From eb4694b901bcd2dfb6c9e2f68c79dbd57eebcff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 26 Feb 2023 10:41:40 +0100 Subject: [PATCH] Add stock_first option to supply_on_sale Closes #11955 --- modules/sale_advance_payment/sale.py | 4 +- .../tests/scenario_advance_payment.rst | 2 +- modules/sale_supply/CHANGELOG | 1 + modules/sale_supply/doc/index.rst | 7 +- modules/sale_supply/product.py | 1 + modules/sale_supply/sale.py | 92 ++++++++++--- .../scenario_sale_supply_notifications.rst | 2 +- .../scenario_sale_supply_stock_first.rst | 129 ++++++++++++++++++ .../sale_supply_drop_shipment/doc/index.rst | 8 +- modules/sale_supply_drop_shipment/sale.py | 24 ++-- modules/sale_supply_production/doc/index.rst | 6 +- modules/sale_supply_production/sale.py | 36 ++--- ...rio_sale_supply_production_stock_first.rst | 111 +++++++++++++++ 13 files changed, 357 insertions(+), 66 deletions(-) create mode 100644 modules/sale_supply/tests/scenario_sale_supply_stock_first.rst create mode 100644 modules/sale_supply_production/tests/scenario_sale_supply_production_stock_first.rst diff --git a/modules/sale_advance_payment/sale.py b/modules/sale_advance_payment/sale.py index 90c6c2bd82..becbfebcce 100644 --- a/modules/sale_advance_payment/sale.py +++ b/modules/sale_advance_payment/sale.py @@ -451,8 +451,8 @@ class SaleLine(metaclass=PoolMeta): return None return move - def get_purchase_request(self): - request = super(SaleLine, self).get_purchase_request() + def get_purchase_request(self, product_quantities): + request = super().get_purchase_request(product_quantities) if (self.sale.advance_payment_eligible() and self.sale.supply_blocked): return None diff --git a/modules/sale_advance_payment/tests/scenario_advance_payment.rst b/modules/sale_advance_payment/tests/scenario_advance_payment.rst index fe23a76882..6f7bc1f58c 100644 --- a/modules/sale_advance_payment/tests/scenario_advance_payment.rst +++ b/modules/sale_advance_payment/tests/scenario_advance_payment.rst @@ -285,7 +285,7 @@ Create the product:: >>> sos_template.salable = True >>> sos_template.list_price = Decimal('10') >>> sos_template.account_category = account_category - >>> sos_template.supply_on_sale = True + >>> sos_template.supply_on_sale = 'always' >>> sos_template.save() >>> sos_product, = sos_template.products diff --git a/modules/sale_supply/CHANGELOG b/modules/sale_supply/CHANGELOG index 40f218f908..7d9a9340a9 100644 --- a/modules/sale_supply/CHANGELOG +++ b/modules/sale_supply/CHANGELOG @@ -1,3 +1,4 @@ +* Add stock_first option to supply_on_sale * Change supply_on_sale into selection * Remove support for Python 3.7 * Add support for Python 3.11 diff --git a/modules/sale_supply/doc/index.rst b/modules/sale_supply/doc/index.rst index 1716c98903..8e0bd8b726 100644 --- a/modules/sale_supply/doc/index.rst +++ b/modules/sale_supply/doc/index.rst @@ -2,9 +2,10 @@ Sale Supply Module ################## The Sale Supply module adds a "supply on sale option" to purchasable products. -If checked, it will generate a purchase request for each sale line of this -product regardless of the stock levels. Once the purchased products are -received they are assigned on the customer shipments. +If selected, it will generate a purchase request for each sale line of this +product depending of the option and the stock levels. +Once the purchased products are received they are assigned on the customer +shipments. If the purchase is cancelled the sale goes back to the default supply method. .. warning:: diff --git a/modules/sale_supply/product.py b/modules/sale_supply/product.py index a2da1875bb..0ff3df1ac9 100644 --- a/modules/sale_supply/product.py +++ b/modules/sale_supply/product.py @@ -15,6 +15,7 @@ class Template(metaclass=PoolMeta): supply_on_sale = fields.Selection([ (None, "Never"), + ('stock_first', "Stock First"), ('always', "Always"), ], "Supply On Sale", states={ diff --git a/modules/sale_supply/sale.py b/modules/sale_supply/sale.py index 3f248253d4..405543ce86 100644 --- a/modules/sale_supply/sale.py +++ b/modules/sale_supply/sale.py @@ -1,5 +1,10 @@ # 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 collections import defaultdict +from itertools import groupby +from operator import attrgetter + from trytond.model import Model, fields from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval @@ -19,6 +24,31 @@ class Sale(metaclass=PoolMeta): @classmethod def _process_shipment(cls, sales): + pool = Pool() + Product = pool.get('product.product') + + product_quantities = defaultdict(float) + get_warehouse = attrgetter('warehouse') + for warehouse, sub_sales in groupby( + filter(get_warehouse, sales), key=get_warehouse): + products = { + l.product for s in sub_sales for l in s.lines + if l.product and l.product.supply_on_sale == 'stock_first'} + locations = [warehouse.id] + with Transaction().set_context( + locations=locations, stock_date_end=None): + products = Product.browse(products) + product_quantities.update( + (p, min(p.quantity, p.forecast_quantity)) + for p in Product.browse(products)) + + # purchase requests must be created before shipments to get information + # about requests during the shipments creation like the supplier + cls._process_supply(sales, product_quantities) + super()._process_shipment(sales) + + @classmethod + def _process_supply(cls, sales, product_quantities): pool = Pool() Line = pool.get('sale.line') Move = pool.get('stock.move') @@ -28,10 +58,7 @@ class Sale(metaclass=PoolMeta): requests, lines = [], [] moves_to_draft, shipments_to_wait = [], [] for sale in sales: - # purchase requests must be created before shipments to get - # information about requests during the shipments creation - # like the supplier - reqs, lns = sale.create_purchase_requests() + reqs, lns = sale.create_purchase_requests(product_quantities) requests.extend(reqs) lines.extend(lns) @@ -42,12 +69,11 @@ class Sale(metaclass=PoolMeta): Line.save(lines) Move.draft(moves_to_draft) ShipmentOut.wait(shipments_to_wait) - super()._process_shipment(sales) - def create_purchase_requests(self): + def create_purchase_requests(self, product_quantities): requests, lines = [], [] for line in self.lines: - request = line.get_purchase_request() + request = line.get_purchase_request(product_quantities) if not request: continue requests.append(request) @@ -123,48 +149,72 @@ class Line(metaclass=PoolMeta): return False return bool(self.product.supply_on_sale) + @property + def ready_for_supply(self): + if self.sale.shipment_method == 'invoice': + # Ensure to create the request for the maximum paid + invoice_skips = ( + set(self.sale.invoices_ignored) + | set(self.sale.invoices_recreated)) + invoice_lines = [ + l for l in self.invoice_lines + if l.invoice not in invoice_skips] + if (not invoice_lines + or any( + (not l.invoice) or l.invoice.state != 'paid' + for l in invoice_lines)): + return False + return True + def get_move(self, shipment_type): move = super().get_move(shipment_type) if (move and shipment_type == 'out' - and (self.supply_on_sale or self.has_supply)): + and self.has_supply): if self.supply_state in {'', 'requested'}: move.state = 'staging' return move + def _get_move_quantity(self, shipment_type): + quantity = super()._get_move_quantity(shipment_type) + if self.supply_on_sale and not self.ready_for_supply: + quantity = 0 + return quantity + def _get_purchase_request_product_supplier_pattern(self): return { 'company': self.sale.company.id, } - def get_purchase_request(self): - 'Return purchase request for the sale line' + def get_purchase_request(self, product_quantities): + """Return purchase request for the sale line + depending on the product quantities""" pool = Pool() Uom = pool.get('product.uom') Request = pool.get('purchase.request') if (not self.supply_on_sale or self.purchase_request + or not self.ready_for_supply or not self.product.purchasable): return - # Ensure to create the request for the maximum paid - if self.sale.shipment_method == 'invoice': - invoice_skips = (set(self.sale.invoices_ignored) - | set(self.sale.invoices_recreated)) - invoice_lines = [l for l in self.invoice_lines - if l.invoice not in invoice_skips] - if (not invoice_lines - or any((not l.invoice) or l.invoice.state != 'paid' - for l in invoice_lines)): + product = self.product + quantity = self._get_move_quantity('out') + if product.supply_on_sale == 'stock_first': + available_qty = product_quantities[product] + available_qty = Uom.compute_qty( + product.default_uom, available_qty, self.unit, + round=False) + if available_qty > 0: + product_quantities[product] -= Uom.compute_qty( + self.unit, quantity, product.default_uom, round=False) return - product = self.product supplier, purchase_date = Request.find_best_supplier(product, self.shipping_date, **self._get_purchase_request_product_supplier_pattern()) uom = product.purchase_uom or product.default_uom - quantity = self._get_move_quantity('out') quantity = Uom.compute_qty(self.unit, quantity, uom) return Request( product=product, diff --git a/modules/sale_supply/tests/scenario_sale_supply_notifications.rst b/modules/sale_supply/tests/scenario_sale_supply_notifications.rst index 10e28461f9..af23565a01 100644 --- a/modules/sale_supply/tests/scenario_sale_supply_notifications.rst +++ b/modules/sale_supply/tests/scenario_sale_supply_notifications.rst @@ -36,7 +36,7 @@ Create product:: >>> product_template.default_uom = unit >>> product_template.purchasable = True >>> product_template.salable = True - >>> product_template.supply_on_sale = True + >>> product_template.supply_on_sale = 'always' >>> product_template.save() >>> product, = product_template.products diff --git a/modules/sale_supply/tests/scenario_sale_supply_stock_first.rst b/modules/sale_supply/tests/scenario_sale_supply_stock_first.rst new file mode 100644 index 0000000000..bf19a8dcc2 --- /dev/null +++ b/modules/sale_supply/tests/scenario_sale_supply_stock_first.rst @@ -0,0 +1,129 @@ +================================ +Sale Supply Stock First 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 + >>> from trytond.modules.account.tests.tools import ( + ... create_chart, get_accounts) + +Activate modules:: + + >>> config = activate_modules('sale_supply') + + >>> Inventory = Model.get('stock.inventory') + >>> Location = Model.get('stock.location') + >>> Party = Model.get('party.party') + >>> ProductCategory = Model.get('product.category') + >>> ProductTemplate = Model.get('product.template') + >>> Sale = Model.get('sale.sale') + >>> UoM = Model.get('product.uom') + +Create company:: + + >>> _ = create_company() + +Create chart of accounts:: + + >>> _ = create_chart() + >>> accounts = get_accounts() + +Create parties:: + + >>> supplier = Party(name="Supplier") + >>> supplier.save() + >>> customer = Party(name="Customer") + >>> customer.save() + +Create account 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 product:: + + >>> unit, = UoM.find([('name', '=', "Unit")]) + + >>> template = ProductTemplate(name="Product") + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.purchasable = True + >>> template.salable = True + >>> template.list_price = Decimal('100.0000') + >>> template.supply_on_sale = 'stock_first' + >>> template.account_category = account_category + >>> template.save() + >>> product, = template.products + +Fill warehouse:: + + >>> inventory = Inventory() + >>> inventory.location, = Location.find([('code', '=', 'STO')]) + >>> line = inventory.lines.new() + >>> line.product = product + >>> line.quantity = 5 + >>> inventory.click('confirm') + >>> inventory.state + 'done' + +Sale 3 products with 5 in stock:: + + >>> sale = Sale() + >>> sale.party = customer + >>> line = sale.lines.new() + >>> line.product = product + >>> line.quantity = 3 + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + >>> line, = sale.lines + >>> bool(line.purchase_request) + False + >>> move, = line.moves + >>> move.state + 'draft' + +Sale 3 products with 2 in stock:: + + >>> sale = Sale() + >>> sale.party = customer + >>> line = sale.lines.new() + >>> line.product = product + >>> line.quantity = 3 + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + >>> line, = sale.lines + >>> bool(line.purchase_request) + False + >>> move, = line.moves + >>> move.state + 'draft' + +Sale 4 products with no stock:: + + >>> sale = Sale() + >>> sale.party = customer + >>> line = sale.lines.new() + >>> line.product = product + >>> line.quantity = 4 + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + >>> line, = sale.lines + >>> bool(line.purchase_request) + True + >>> move, = line.moves + >>> move.state + 'staging' diff --git a/modules/sale_supply_drop_shipment/doc/index.rst b/modules/sale_supply_drop_shipment/doc/index.rst index 702cfe1f6a..a32417360b 100644 --- a/modules/sale_supply_drop_shipment/doc/index.rst +++ b/modules/sale_supply_drop_shipment/doc/index.rst @@ -2,10 +2,10 @@ Sale Supply Drop Shipment Model ############################### The Sale Supply Drop Shipment module adds a drop shipment option on product -supplier if "supply on request" is checked. When checked, the purchase request -and the linked purchase have the address of customer as Delivery Address; -at the confirmation of the purchase a drop shipment is created and linked to -both the purchase and the sale. +supplier if "supply on request" is selected. +When selected, the purchase request and the linked purchase have the address of +customer as Delivery Address; at the confirmation of the purchase a drop +shipment is created and linked to both the purchase and the sale. Drop Shipment ************* diff --git a/modules/sale_supply_drop_shipment/sale.py b/modules/sale_supply_drop_shipment/sale.py index 414f3b3bcb..b3758c2b22 100644 --- a/modules/sale_supply_drop_shipment/sale.py +++ b/modules/sale_supply_drop_shipment/sale.py @@ -7,7 +7,6 @@ from trytond.modules.sale.sale import ( from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval from trytond.tools.multivalue import migrate_property -from trytond.transaction import Transaction sale_drop_location = fields.Many2One( 'stock.location', "Sale Drop Location", domain=[('type', '=', 'drop')]) @@ -82,14 +81,13 @@ class Sale(metaclass=PoolMeta): search_drop_shipments = search_shipments_returns('stock.shipment.drop') @classmethod - def _process_shipment(cls, sales): + def _process_supply(cls, sales, product_quantities): pool = Pool() Move = pool.get('stock.move') - super()._process_shipment(sales) + super()._process_supply(sales, product_quantities) moves = [] - with Transaction().set_context(_drop_shipment=True): - for sale in sales: - moves.extend(sale.create_drop_shipment_moves()) + for sale in sales: + moves.extend(sale.create_drop_shipment_moves()) Move.save(moves) def create_drop_shipment_moves(self): @@ -125,15 +123,13 @@ class Line(metaclass=PoolMeta): return True def get_move(self, shipment_type): - result = super().get_move(shipment_type) - if (shipment_type == 'out' - and not Transaction().context.get('_drop_shipment')): - if self.supply_on_sale_drop_move: - return - return result + move = super().get_move(shipment_type) + if self.supply_on_sale_drop_move and not self.purchase_request: + move = None + return move - def get_purchase_request(self): - request = super().get_purchase_request() + def get_purchase_request(self, product_quantities): + request = super().get_purchase_request(product_quantities) if request and request.party: if self.product and self.product.type in ('goods', 'assets'): product_supplier = request.find_best_product_supplier( diff --git a/modules/sale_supply_production/doc/index.rst b/modules/sale_supply_production/doc/index.rst index 8777f63af2..ecb0809ab8 100644 --- a/modules/sale_supply_production/doc/index.rst +++ b/modules/sale_supply_production/doc/index.rst @@ -3,9 +3,9 @@ Sale Supply Production Module The Sale Supply Production module adds a "supply on sale" option to producible products. -If checked, it will generate a production request for each sale line of this -product regardless of the stock levels. Once the products are produced they are -assigned to the customer shipments. +If selected, it will generate a production request for each sale line of this +product depending of the option and the stock levels. +Once the products are produced they are assigned to the customer shipments. If the production request is cancelled, the sale goes back to the default supply method. diff --git a/modules/sale_supply_production/sale.py b/modules/sale_supply_production/sale.py index b946d351ac..0edb3377bb 100644 --- a/modules/sale_supply_production/sale.py +++ b/modules/sale_supply_production/sale.py @@ -11,21 +11,21 @@ class Sale(metaclass=PoolMeta): __name__ = 'sale.sale' @classmethod - def _process_shipment(cls, sales): + def _process_supply(cls, sales, product_quantities): pool = Pool() Production = pool.get('production') productions = [] for sale in sales: - productions.extend(sale.create_productions()) + productions.extend(sale.create_productions(product_quantities)) Production.save(productions) Production.set_moves(productions) - super()._process_shipment(sales) + super()._process_supply(sales, product_quantities) - def create_productions(self): + def create_productions(self, product_quantities): productions = [] for line in self.lines: - production = line.get_production() + production = line.get_production(product_quantities) if not production: continue production.planned_start_date = ( @@ -67,28 +67,32 @@ class Line(metaclass=PoolMeta): default.setdefault('productions', None) return super().copy(lines, default=default) - def get_production(self): + def get_production(self, product_quantities): "Return production for the sale line" pool = Pool() Production = pool.get('production') Date = pool.get('ir.date') + Uom = pool.get('product.uom') + with Transaction().set_context(company=self.sale.company.id): today = Date.today() if (not self.supply_on_sale or self.productions + or not self.ready_for_supply or not self.product.producible): return - # Ensure to create the request for the maximum paid - if self.sale.shipment_method == 'invoice': - invoice_skips = (set(self.sale.invoices_ignored) - | set(self.sale.invoices_recreated)) - invoice_lines = [l for l in self.invoice_lines - if l.invoice not in invoice_skips] - if (not invoice_lines - or any((not l.invoice) or l.invoice.state != 'paid' - for l in invoice_lines)): + product = self.product + quantity = self._get_move_quantity('out') + if product.supply_on_sale == 'stock_first': + available_qty = product_quantities[product] + available_qty = Uom.compute_qty( + product.default_uom, available_qty, self.unit, + round=False) + if quantity < available_qty: + product_quantities[product] -= Uom.compute_qty( + self.unit, quantity, product.default_uom, round=False) return date = self.shipping_date or today @@ -96,8 +100,6 @@ class Line(metaclass=PoolMeta): date = today else: date -= dt.timedelta(1) - product = self.product - quantity = self._get_move_quantity('out') return Production( planned_date=date, company=self.sale.company, diff --git a/modules/sale_supply_production/tests/scenario_sale_supply_production_stock_first.rst b/modules/sale_supply_production/tests/scenario_sale_supply_production_stock_first.rst new file mode 100644 index 0000000000..10d8638cac --- /dev/null +++ b/modules/sale_supply_production/tests/scenario_sale_supply_production_stock_first.rst @@ -0,0 +1,111 @@ +=========================================== +Sale Supply Production Stock First 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_chart, get_accounts) + +Activate modules:: + + >>> config = activate_modules('sale_supply_production') + + >>> Inventory = Model.get('stock.inventory') + >>> Location = Model.get('stock.location') + >>> Party = Model.get('party.party') + >>> ProductCategory = Model.get('product.category') + >>> ProductTemplate = Model.get('product.template') + >>> Sale = Model.get('sale.sale') + >>> UoM = Model.get('product.uom') + +Create company:: + + >>> _ = create_company() + +Create chart of accounts:: + + >>> _ = create_chart() + >>> accounts = get_accounts() + +Create parties:: + + >>> customer = Party(name='Customer') + >>> customer.save() + +Create account category:: + + >>> account_category = ProductCategory(name="Account Category") + >>> account_category.accounting = True + >>> account_category.account_revenue = accounts['revenue'] + >>> account_category.save() + +Create product:: + + >>> unit, = UoM.find([('name', '=', 'Unit')]) + + >>> template = ProductTemplate() + >>> template.name = "Product" + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.producible = True + >>> template.salable = True + >>> template.supply_on_sale = 'stock_first' + >>> template.list_price = Decimal(30) + >>> template.account_category = account_category + >>> product, = template.products + >>> product.cost_price = Decimal(20) + >>> template.save() + >>> product, = template.products + +Fill warehouse:: + + >>> inventory = Inventory() + >>> inventory.location, = Location.find([('code', '=', 'STO')]) + >>> line = inventory.lines.new() + >>> line.product = product + >>> line.quantity = 5 + >>> inventory.click('confirm') + >>> inventory.state + 'done' + +Sale 3 products without production request:: + + >>> sale = Sale() + >>> sale.party = customer + >>> line = sale.lines.new() + >>> line.product = product + >>> line.quantity = 3 + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + >>> line, = sale.lines + >>> len(line.productions) + 0 + >>> move, = line.moves + >>> move.state + 'draft' + +Sale 4 products with production request:: + + >>> sale = Sale() + >>> sale.party = customer + >>> line = sale.lines.new() + >>> line.product = product + >>> line.quantity = 4 + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + >>> line, = sale.lines + >>> len(line.productions) + 1 + >>> move, = line.moves + >>> move.state + 'staging'