summaryrefslogtreecommitdiff
path: root/addons/purchase_stock/models/stock_rule.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/purchase_stock/models/stock_rule.py')
-rw-r--r--addons/purchase_stock/models/stock_rule.py325
1 files changed, 325 insertions, 0 deletions
diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py
new file mode 100644
index 00000000..33144e9e
--- /dev/null
+++ b/addons/purchase_stock/models/stock_rule.py
@@ -0,0 +1,325 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+from itertools import groupby
+
+from odoo import api, fields, models, SUPERUSER_ID, _
+from odoo.addons.stock.models.stock_rule import ProcurementException
+
+
+class StockRule(models.Model):
+ _inherit = 'stock.rule'
+
+ action = fields.Selection(selection_add=[
+ ('buy', 'Buy')
+ ], ondelete={'buy': 'cascade'})
+
+ def _get_message_dict(self):
+ message_dict = super(StockRule, self)._get_message_dict()
+ dummy, destination, dummy = self._get_message_values()
+ message_dict.update({
+ 'buy': _('When products are needed in <b>%s</b>, <br/> a request for quotation is created to fulfill the need.') % (destination)
+ })
+ return message_dict
+
+ @api.depends('action')
+ def _compute_picking_type_code_domain(self):
+ remaining = self.browse()
+ for rule in self:
+ if rule.action == 'buy':
+ rule.picking_type_code_domain = 'incoming'
+ else:
+ remaining |= rule
+ super(StockRule, remaining)._compute_picking_type_code_domain()
+
+ @api.onchange('action')
+ def _onchange_action(self):
+ if self.action == 'buy':
+ self.location_src_id = False
+
+ @api.model
+ def _run_buy(self, procurements):
+ procurements_by_po_domain = defaultdict(list)
+ errors = []
+ for procurement, rule in procurements:
+
+ # Get the schedule date in order to find a valid seller
+ procurement_date_planned = fields.Datetime.from_string(procurement.values['date_planned'])
+ schedule_date = (procurement_date_planned - relativedelta(days=procurement.company_id.po_lead))
+
+ supplier = False
+ if procurement.values.get('supplierinfo_id'):
+ supplier = procurement.values['supplierinfo_id']
+ else:
+ supplier = procurement.product_id.with_company(procurement.company_id.id)._select_seller(
+ partner_id=procurement.values.get("supplierinfo_name"),
+ quantity=procurement.product_qty,
+ date=schedule_date.date(),
+ uom_id=procurement.product_uom)
+
+ # Fall back on a supplier for which no price may be defined. Not ideal, but better than
+ # blocking the user.
+ supplier = supplier or procurement.product_id._prepare_sellers(False).filtered(
+ lambda s: not s.company_id or s.company_id == procurement.company_id
+ )[:1]
+
+ if not supplier:
+ msg = _('There is no matching vendor price to generate the purchase order for product %s (no vendor defined, minimum quantity not reached, dates not valid, ...). Go on the product form and complete the list of vendors.') % (procurement.product_id.display_name)
+ errors.append((procurement, msg))
+
+ partner = supplier.name
+ # we put `supplier_info` in values for extensibility purposes
+ procurement.values['supplier'] = supplier
+ procurement.values['propagate_cancel'] = rule.propagate_cancel
+
+ domain = rule._make_po_get_domain(procurement.company_id, procurement.values, partner)
+ procurements_by_po_domain[domain].append((procurement, rule))
+
+ if errors:
+ raise ProcurementException(errors)
+
+ for domain, procurements_rules in procurements_by_po_domain.items():
+ # Get the procurements for the current domain.
+ # Get the rules for the current domain. Their only use is to create
+ # the PO if it does not exist.
+ procurements, rules = zip(*procurements_rules)
+
+ # Get the set of procurement origin for the current domain.
+ origins = set([p.origin for p in procurements])
+ # Check if a PO exists for the current domain.
+ po = self.env['purchase.order'].sudo().search([dom for dom in domain], limit=1)
+ company_id = procurements[0].company_id
+ if not po:
+ # We need a rule to generate the PO. However the rule generated
+ # the same domain for PO and the _prepare_purchase_order method
+ # should only uses the common rules's fields.
+ vals = rules[0]._prepare_purchase_order(company_id, origins, [p.values for p in procurements])
+ # The company_id is the same for all procurements since
+ # _make_po_get_domain add the company in the domain.
+ # We use SUPERUSER_ID since we don't want the current user to be follower of the PO.
+ # Indeed, the current user may be a user without access to Purchase, or even be a portal user.
+ po = self.env['purchase.order'].with_company(company_id).with_user(SUPERUSER_ID).create(vals)
+ else:
+ # If a purchase order is found, adapt its `origin` field.
+ if po.origin:
+ missing_origins = origins - set(po.origin.split(', '))
+ if missing_origins:
+ po.write({'origin': po.origin + ', ' + ', '.join(missing_origins)})
+ else:
+ po.write({'origin': ', '.join(origins)})
+
+ procurements_to_merge = self._get_procurements_to_merge(procurements)
+ procurements = self._merge_procurements(procurements_to_merge)
+
+ po_lines_by_product = {}
+ grouped_po_lines = groupby(po.order_line.filtered(lambda l: not l.display_type and l.product_uom == l.product_id.uom_po_id).sorted(lambda l: l.product_id.id), key=lambda l: l.product_id.id)
+ for product, po_lines in grouped_po_lines:
+ po_lines_by_product[product] = self.env['purchase.order.line'].concat(*list(po_lines))
+ po_line_values = []
+ for procurement in procurements:
+ po_lines = po_lines_by_product.get(procurement.product_id.id, self.env['purchase.order.line'])
+ po_line = po_lines._find_candidate(*procurement)
+
+ if po_line:
+ # If the procurement can be merge in an existing line. Directly
+ # write the new values on it.
+ vals = self._update_purchase_order_line(procurement.product_id,
+ procurement.product_qty, procurement.product_uom, company_id,
+ procurement.values, po_line)
+ po_line.write(vals)
+ else:
+ # If it does not exist a PO line for current procurement.
+ # Generate the create values for it and add it to a list in
+ # order to create it in batch.
+ partner = procurement.values['supplier'].name
+ po_line_values.append(self.env['purchase.order.line']._prepare_purchase_order_line_from_procurement(
+ procurement.product_id, procurement.product_qty,
+ procurement.product_uom, procurement.company_id,
+ procurement.values, po))
+ self.env['purchase.order.line'].sudo().create(po_line_values)
+
+ def _get_lead_days(self, product):
+ """Add the company security lead time, days to purchase and the supplier
+ delay to the cumulative delay and cumulative description. The days to
+ purchase and company lead time are always displayed for onboarding
+ purpose in order to indicate that those options are available.
+ """
+ delay, delay_description = super()._get_lead_days(product)
+ bypass_delay_description = self.env.context.get('bypass_delay_description')
+ buy_rule = self.filtered(lambda r: r.action == 'buy')
+ seller = product.with_company(buy_rule.company_id)._select_seller()
+ if not buy_rule or not seller:
+ return delay, delay_description
+ buy_rule.ensure_one()
+ supplier_delay = seller[0].delay
+ if supplier_delay and not bypass_delay_description:
+ delay_description += '<tr><td>%s</td><td class="text-right">+ %d %s</td></tr>' % (_('Vendor Lead Time'), supplier_delay, _('day(s)'))
+ security_delay = buy_rule.picking_type_id.company_id.po_lead
+ if not bypass_delay_description:
+ delay_description += '<tr><td>%s</td><td class="text-right">+ %d %s</td></tr>' % (_('Purchase Security Lead Time'), security_delay, _('day(s)'))
+ days_to_purchase = buy_rule.company_id.days_to_purchase
+ if not bypass_delay_description:
+ delay_description += '<tr><td>%s</td><td class="text-right">+ %d %s</td></tr>' % (_('Days to Purchase'), days_to_purchase, _('day(s)'))
+ return delay + supplier_delay + security_delay + days_to_purchase, delay_description
+
+ @api.model
+ def _get_procurements_to_merge_groupby(self, procurement):
+ # Do not group procument from different orderpoint. 1. _quantity_in_progress
+ # directly depends from the orderpoint_id on the line. 2. The stock move
+ # generated from the order line has the orderpoint's location as
+ # destination location. In case of move_dest_ids those two points are not
+ # necessary anymore since those values are taken from destination moves.
+ return procurement.product_id, procurement.product_uom, procurement.values['propagate_cancel'],\
+ procurement.values.get('product_description_variants'),\
+ (procurement.values.get('orderpoint_id') and not procurement.values.get('move_dest_ids')) and procurement.values['orderpoint_id']
+
+ @api.model
+ def _get_procurements_to_merge_sorted(self, procurement):
+ return procurement.product_id.id, procurement.product_uom.id, procurement.values['propagate_cancel'],\
+ procurement.values.get('product_description_variants'),\
+ (procurement.values.get('orderpoint_id') and not procurement.values.get('move_dest_ids')) and procurement.values['orderpoint_id']
+
+ @api.model
+ def _get_procurements_to_merge(self, procurements):
+ """ Get a list of procurements values and create groups of procurements
+ that would use the same purchase order line.
+ params procurements_list list: procurements requests (not ordered nor
+ sorted).
+ return list: procurements requests grouped by their product_id.
+ """
+ procurements_to_merge = []
+
+ for k, procurements in groupby(sorted(procurements, key=self._get_procurements_to_merge_sorted), key=self._get_procurements_to_merge_groupby):
+ procurements_to_merge.append(list(procurements))
+ return procurements_to_merge
+
+ @api.model
+ def _merge_procurements(self, procurements_to_merge):
+ """ Merge the quantity for procurements requests that could use the same
+ order line.
+ params similar_procurements list: list of procurements that have been
+ marked as 'alike' from _get_procurements_to_merge method.
+ return a list of procurements values where values of similar_procurements
+ list have been merged.
+ """
+ merged_procurements = []
+ for procurements in procurements_to_merge:
+ quantity = 0
+ move_dest_ids = self.env['stock.move']
+ orderpoint_id = self.env['stock.warehouse.orderpoint']
+ for procurement in procurements:
+ if procurement.values.get('move_dest_ids'):
+ move_dest_ids |= procurement.values['move_dest_ids']
+ if not orderpoint_id and procurement.values.get('orderpoint_id'):
+ orderpoint_id = procurement.values['orderpoint_id']
+ quantity += procurement.product_qty
+ # The merged procurement can be build from an arbitrary procurement
+ # since they were mark as similar before. Only the quantity and
+ # some keys in values are updated.
+ values = dict(procurement.values)
+ values.update({
+ 'move_dest_ids': move_dest_ids,
+ 'orderpoint_id': orderpoint_id,
+ })
+ merged_procurement = self.env['procurement.group'].Procurement(
+ procurement.product_id, quantity, procurement.product_uom,
+ procurement.location_id, procurement.name, procurement.origin,
+ procurement.company_id, values
+ )
+ merged_procurements.append(merged_procurement)
+ return merged_procurements
+
+ def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line):
+ partner = values['supplier'].name
+ procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
+ seller = product_id.with_company(company_id)._select_seller(
+ partner_id=partner,
+ quantity=line.product_qty + procurement_uom_po_qty,
+ date=line.order_id.date_order and line.order_id.date_order.date(),
+ uom_id=product_id.uom_po_id)
+
+ price_unit = self.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, company_id) if seller else 0.0
+ if price_unit and seller and line.order_id.currency_id and seller.currency_id != line.order_id.currency_id:
+ price_unit = seller.currency_id._convert(
+ price_unit, line.order_id.currency_id, line.order_id.company_id, fields.Date.today())
+
+ res = {
+ 'product_qty': line.product_qty + procurement_uom_po_qty,
+ 'price_unit': price_unit,
+ 'move_dest_ids': [(4, x.id) for x in values.get('move_dest_ids', [])]
+ }
+ orderpoint_id = values.get('orderpoint_id')
+ if orderpoint_id:
+ res['orderpoint_id'] = orderpoint_id.id
+ return res
+
+ def _prepare_purchase_order(self, company_id, origins, values):
+ """ Create a purchase order for procuremets that share the same domain
+ returned by _make_po_get_domain.
+ params values: values of procurements
+ params origins: procuremets origins to write on the PO
+ """
+ dates = [fields.Datetime.from_string(value['date_planned']) for value in values]
+
+ procurement_date_planned = min(dates)
+ schedule_date = (procurement_date_planned - relativedelta(days=company_id.po_lead))
+ supplier_delay = max([int(value['supplier'].delay) for value in values])
+
+ # Since the procurements are grouped if they share the same domain for
+ # PO but the PO does not exist. In this case it will create the PO from
+ # the common procurements values. The common values are taken from an
+ # arbitrary procurement. In this case the first.
+ values = values[0]
+ partner = values['supplier'].name
+ purchase_date = schedule_date - relativedelta(days=supplier_delay)
+
+ fpos = self.env['account.fiscal.position'].with_company(company_id).get_fiscal_position(partner.id)
+
+ gpo = self.group_propagation_option
+ group = (gpo == 'fixed' and self.group_id.id) or \
+ (gpo == 'propagate' and values.get('group_id') and values['group_id'].id) or False
+
+ return {
+ 'partner_id': partner.id,
+ 'user_id': False,
+ 'picking_type_id': self.picking_type_id.id,
+ 'company_id': company_id.id,
+ 'currency_id': partner.with_company(company_id).property_purchase_currency_id.id or company_id.currency_id.id,
+ 'dest_address_id': values.get('partner_id', False),
+ 'origin': ', '.join(origins),
+ 'payment_term_id': partner.with_company(company_id).property_supplier_payment_term_id.id,
+ 'date_order': purchase_date,
+ 'fiscal_position_id': fpos.id,
+ 'group_id': group
+ }
+
+ def _make_po_get_domain(self, company_id, values, partner):
+ gpo = self.group_propagation_option
+ group = (gpo == 'fixed' and self.group_id) or \
+ (gpo == 'propagate' and 'group_id' in values and values['group_id']) or False
+
+ domain = (
+ ('partner_id', '=', partner.id),
+ ('state', '=', 'draft'),
+ ('picking_type_id', '=', self.picking_type_id.id),
+ ('company_id', '=', company_id.id),
+ ('user_id', '=', False),
+ )
+ if values.get('orderpoint_id'):
+ procurement_date = fields.Date.to_date(values['date_planned']) - relativedelta(days=int(values['supplier'].delay) + company_id.po_lead)
+ delta_days = int(self.env['ir.config_parameter'].sudo().get_param('purchase_stock.delta_days_merge') or 0)
+ domain += (
+ ('date_order', '<=', datetime.combine(procurement_date + relativedelta(days=delta_days), datetime.max.time())),
+ ('date_order', '>=', datetime.combine(procurement_date - relativedelta(days=delta_days), datetime.min.time()))
+ )
+ if group:
+ domain += (('group_id', '=', group.id),)
+ return domain
+
+ def _push_prepare_move_copy_values(self, move_to_copy, new_date):
+ res = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date)
+ res['purchase_line_id'] = None
+ return res