diff options
Diffstat (limited to 'addons/purchase_stock/models/stock_rule.py')
| -rw-r--r-- | addons/purchase_stock/models/stock_rule.py | 325 |
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 |
