335 lines
18 KiB
Python
335 lines
18 KiB
Python
|
# -*- 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 odoo.tools import float_compare
|
||
|
|
||
|
from odoo import api, fields, models, SUPERUSER_ID, _
|
||
|
from odoo.addons.stock.models.stock_rule import ProcurementException
|
||
|
from odoo.tools import groupby
|
||
|
|
||
|
|
||
|
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()
|
||
|
__, destination, __, __ = 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.<br/>'
|
||
|
'Note: This rule will be used in combination with the rules<br/>'
|
||
|
'of the reception route(s)', 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'])
|
||
|
|
||
|
supplier = False
|
||
|
if procurement.values.get('supplierinfo_id'):
|
||
|
supplier = procurement.values['supplierinfo_id']
|
||
|
elif procurement.values.get('orderpoint_id') and procurement.values['orderpoint_id'].supplier_id:
|
||
|
supplier = procurement.values['orderpoint_id'].supplier_id
|
||
|
else:
|
||
|
supplier = procurement.product_id.with_company(procurement.company_id.id)._select_seller(
|
||
|
partner_id=self._get_partner_id(procurement.values, rule),
|
||
|
quantity=procurement.product_qty,
|
||
|
date=max(procurement_date_planned.date(), fields.Date.today()),
|
||
|
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.partner_id
|
||
|
# 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:
|
||
|
positive_values = [p.values for p in procurements if float_compare(p.product_qty, 0.0, precision_rounding=p.product_uom.rounding) >= 0]
|
||
|
if positive_values:
|
||
|
# 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, positive_values)
|
||
|
# 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), 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(*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.sudo().write(vals)
|
||
|
else:
|
||
|
if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) <= 0:
|
||
|
# If procurement contains negative quantity, don't create a new line that would contain negative qty
|
||
|
continue
|
||
|
# 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'].partner_id
|
||
|
po_line_values.append(self.env['purchase.order.line']._prepare_purchase_order_line_from_procurement(
|
||
|
*procurement, po))
|
||
|
# Check if we need to advance the order date for the new line
|
||
|
order_date_planned = procurement.values['date_planned'] - relativedelta(
|
||
|
days=procurement.values['supplier'].delay)
|
||
|
if fields.Date.to_date(order_date_planned) < fields.Date.to_date(po.date_order):
|
||
|
po.date_order = order_date_planned
|
||
|
self.env['purchase.order.line'].sudo().create(po_line_values)
|
||
|
|
||
|
def _get_lead_days(self, product, **values):
|
||
|
"""Add the company security lead time and the supplier delay to the cumulative delay
|
||
|
and cumulative description. The company lead time is always displayed for onboarding
|
||
|
purpose in order to indicate that those options are available.
|
||
|
"""
|
||
|
delays, delay_description = super()._get_lead_days(product, **values)
|
||
|
bypass_delay_description = self.env.context.get('bypass_delay_description')
|
||
|
buy_rule = self.filtered(lambda r: r.action == 'buy')
|
||
|
seller = 'supplierinfo' in values and values['supplierinfo'] or product.with_company(buy_rule.company_id)._select_seller(quantity=None)
|
||
|
if not buy_rule or not seller:
|
||
|
return delays, delay_description
|
||
|
buy_rule.ensure_one()
|
||
|
if not self.env.context.get('ignore_vendor_lead_time'):
|
||
|
supplier_delay = seller[0].delay
|
||
|
delays['total_delay'] += supplier_delay
|
||
|
delays['purchase_delay'] += supplier_delay
|
||
|
if not bypass_delay_description:
|
||
|
delay_description.append((_('Vendor Lead Time'), _('+ %d day(s)', supplier_delay)))
|
||
|
security_delay = buy_rule.picking_type_id.company_id.po_lead
|
||
|
delays['total_delay'] += security_delay
|
||
|
delays['security_lead_days'] += security_delay
|
||
|
if not bypass_delay_description:
|
||
|
delay_description.append((_('Purchase Security Lead Time'), _('+ %d day(s)', security_delay)))
|
||
|
days_to_order = buy_rule.company_id.days_to_purchase
|
||
|
delays['total_delay'] += days_to_order
|
||
|
if not bypass_delay_description:
|
||
|
delay_description.append((_('Days to Purchase'), _('+ %d day(s)', days_to_order)))
|
||
|
return delays, 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(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.
|
||
|
"""
|
||
|
return [pro_g for __, pro_g in groupby(procurements, key=self._get_procurements_to_merge_groupby)]
|
||
|
|
||
|
@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'].partner_id
|
||
|
procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id, rounding_method='HALF-UP')
|
||
|
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.sudo().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
|
||
|
"""
|
||
|
purchase_date = min([value.get('date_order') or fields.Datetime.from_string(value['date_planned']) - relativedelta(days=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'].partner_id
|
||
|
|
||
|
fpos = self.env['account.fiscal.position'].with_company(company_id)._get_fiscal_position(partner)
|
||
|
|
||
|
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': partner.buyer_id.id,
|
||
|
'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', '=', partner.buyer_id.id),
|
||
|
)
|
||
|
delta_days = self.env['ir.config_parameter'].sudo().get_param('purchase_stock.delta_days_merge')
|
||
|
if values.get('orderpoint_id') and delta_days is not False:
|
||
|
procurement_date = fields.Date.to_date(values['date_planned']) - relativedelta(days=int(values['supplier'].delay))
|
||
|
delta_days = int(delta_days)
|
||
|
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
|
||
|
if self.location_dest_id.usage == "supplier":
|
||
|
res['purchase_line_id'], res['partner_id'] = move_to_copy._get_purchase_line_and_partner_from_chain()
|
||
|
return res
|
||
|
|
||
|
def _get_partner_id(self, values, rule):
|
||
|
return values.get("supplierinfo_name") or (values.get("group_id") and values.get("group_id").partner_id)
|