1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
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
|