Add stock_first option to supply_on_sale

Closes #11955
This commit is contained in:
Cédric Krier
2023-02-26 10:41:40 +01:00
parent d90aeed235
commit eb4694b901
13 changed files with 357 additions and 66 deletions
+2 -2
View File
@@ -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
@@ -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
+1
View File
@@ -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
+4 -3
View File
@@ -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::
+1
View File
@@ -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={
+71 -21
View File
@@ -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,
@@ -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
@@ -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'
@@ -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
*************
+10 -14
View File
@@ -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(
+3 -3
View File
@@ -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.
+19 -17
View File
@@ -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,
@@ -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'