[FIX] procurement: when cancelling procurements, cancel only the undone ones
[odoo/odoo.git] / addons / stock / procurement.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from openerp.osv import fields, osv
23 from openerp.tools.translate import _
24
25 from openerp.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
26 from dateutil.relativedelta import relativedelta
27 from datetime import datetime
28 import openerp
29
30 class procurement_group(osv.osv):
31     _inherit = 'procurement.group'
32     _columns = {
33         'partner_id': fields.many2one('res.partner', 'Partner')
34     }
35
36 class procurement_rule(osv.osv):
37     _inherit = 'procurement.rule'
38
39     def _get_action(self, cr, uid, context=None):
40         result = super(procurement_rule, self)._get_action(cr, uid, context=context)
41         return result + [('move', 'Move From Another Location')]
42
43     def _get_rules(self, cr, uid, ids, context=None):
44         res = []
45         for route in self.browse(cr, uid, ids):
46             res += [x.id for x in route.pull_ids]
47         return res
48
49     def _get_route(self, cr, uid, ids, context=None):
50         #WARNING TODO route_id is not required, so a field related seems a bad idea >-< 
51         if context is None:
52             context = {}
53         result = {}
54         if context is None:
55             context = {}
56         context_with_inactive = context.copy()
57         context_with_inactive['active_test']=False
58         for route in self.pool.get('stock.location.route').browse(cr, uid, ids, context=context_with_inactive):
59             for pull_rule in route.pull_ids:
60                 result[pull_rule.id] = True
61         return result.keys()
62
63     _columns = {
64         'location_id': fields.many2one('stock.location', 'Procurement Location'),
65         'location_src_id': fields.many2one('stock.location', 'Source Location',
66             help="Source location is action=move"),
67         'route_id': fields.many2one('stock.location.route', 'Route',
68             help="If route_id is False, the rule is global"),
69         'procure_method': fields.selection([('make_to_stock', 'Make to Stock'), ('make_to_order', 'Make to Order')], 'Procure Method', required=True, help="'Make to Stock': When needed, take from the stock or wait until re-supplying. 'Make to Order': When needed, purchase or produce for the procurement request."),
70         'route_sequence': fields.related('route_id', 'sequence', string='Route Sequence',
71             store={
72                 'stock.location.route': (_get_rules, ['sequence'], 10),
73                 'procurement.rule': (lambda self, cr, uid, ids, c={}: ids, ['route_id'], 10),
74         }),
75         'picking_type_id': fields.many2one('stock.picking.type', 'Picking Type',
76             help="Picking Type determines the way the picking should be shown in the view, reports, ..."),
77         'active': fields.related('route_id', 'active', type='boolean', string='Active', store={
78                     'stock.location.route': (_get_route, ['active'], 20),
79                     'procurement.rule': (lambda self, cr, uid, ids, c={}: ids, ['route_id'], 20)},
80                 help="If the active field is set to False, it will allow you to hide the rule without removing it."),
81         'delay': fields.integer('Number of Days'),
82         'partner_address_id': fields.many2one('res.partner', 'Partner Address'),
83         'propagate': fields.boolean('Propagate cancel and split', help='If checked, when the previous move of the move (which was generated by a next procurement) is cancelled or split, the move generated by this move will too'),
84         'warehouse_id': fields.many2one('stock.warehouse', 'Served Warehouse', help='The warehouse this rule is for'),
85         'propagate_warehouse_id': fields.many2one('stock.warehouse', 'Warehouse to Propagate', help="The warehouse to propagate on the created move/procurement, which can be different of the warehouse this rule is for (e.g for resupplying rules from another warehouse)"),
86     }
87
88     _defaults = {
89         'procure_method': 'make_to_stock',
90         'active': True,
91         'propagate': True,
92         'delay': 0,
93     }
94
95 class procurement_order(osv.osv):
96     _inherit = "procurement.order"
97     _columns = {
98         'location_id': fields.many2one('stock.location', 'Procurement Location'),  # not required because task may create procurements that aren't linked to a location with project_mrp
99         'move_ids': fields.one2many('stock.move', 'procurement_id', 'Moves', help="Moves created by the procurement"),
100         'move_dest_id': fields.many2one('stock.move', 'Destination Move', help="Move which caused (created) the procurement"),
101         'route_ids': fields.many2many('stock.location.route', 'stock_location_route_procurement', 'procurement_id', 'route_id', 'Preferred Routes', help="Preferred route to be followed by the procurement order. Usually copied from the generating document (SO) but could be set up manually."),
102         'warehouse_id': fields.many2one('stock.warehouse', 'Warehouse', help="Warehouse to consider for the route selection"),
103     }
104
105     def propagate_cancel(self, cr, uid, procurement, context=None):
106         if procurement.rule_id.action == 'move' and procurement.move_ids:
107             self.pool.get('stock.move').action_cancel(cr, uid, [m.id for m in procurement.move_ids], context=context)
108
109     def cancel(self, cr, uid, ids, context=None):
110         if context is None:
111             context = {}
112         to_cancel_ids = self.get_cancel_ids(cr, uid, ids, context=context)
113         ctx = context.copy()
114         #set the context for the propagation of the procurement cancelation
115         ctx['cancel_procurement'] = True
116         for procurement in self.browse(cr, uid, to_cancel_ids, context=ctx):
117             if procurement.rule_id and procurement.rule_id.propagate:
118                 self.propagate_cancel(cr, uid, procurement, context=ctx)
119         return super(procurement_order, self).cancel(cr, uid, to_cancel_ids, context=ctx)
120
121     def _find_parent_locations(self, cr, uid, procurement, context=None):
122         location = procurement.location_id
123         res = [location.id]
124         while location.location_id:
125             location = location.location_id
126             res.append(location.id)
127         return res
128
129     def change_warehouse_id(self, cr, uid, ids, warehouse_id, context=None):
130         if warehouse_id:
131             warehouse = self.pool.get('stock.warehouse').browse(cr, uid, warehouse_id, context=context)
132             return {'value': {'location_id': warehouse.lot_stock_id.id}}
133         return {}
134
135     def _search_suitable_rule(self, cr, uid, procurement, domain, context=None):
136         '''we try to first find a rule among the ones defined on the procurement order group and if none is found, we try on the routes defined for the product, and finally we fallback on the default behavior'''
137         pull_obj = self.pool.get('procurement.rule')
138         warehouse_route_ids = []
139         if procurement.warehouse_id:
140             domain += ['|', ('warehouse_id', '=', procurement.warehouse_id.id), ('warehouse_id', '=', False)]
141             warehouse_route_ids = [x.id for x in procurement.warehouse_id.route_ids]
142         product_route_ids = [x.id for x in procurement.product_id.route_ids + procurement.product_id.categ_id.total_route_ids]
143         procurement_route_ids = [x.id for x in procurement.route_ids]
144         res = pull_obj.search(cr, uid, domain + [('route_id', 'in', procurement_route_ids)], order='route_sequence, sequence', context=context)
145         if not res:
146             res = pull_obj.search(cr, uid, domain + [('route_id', 'in', product_route_ids)], order='route_sequence, sequence', context=context)
147             if not res:
148                 res = warehouse_route_ids and pull_obj.search(cr, uid, domain + [('route_id', 'in', warehouse_route_ids)], order='route_sequence, sequence', context=context) or []
149                 if not res:
150                     res = pull_obj.search(cr, uid, domain + [('route_id', '=', False)], order='sequence', context=context)
151         return res
152
153     def _find_suitable_rule(self, cr, uid, procurement, context=None):
154         rule_id = super(procurement_order, self)._find_suitable_rule(cr, uid, procurement, context=context)
155         if not rule_id:
156             #a rule defined on 'Stock' is suitable for a procurement in 'Stock\Bin A'
157             all_parent_location_ids = self._find_parent_locations(cr, uid, procurement, context=context)
158             rule_id = self._search_suitable_rule(cr, uid, procurement, [('location_id', 'in', all_parent_location_ids)], context=context)
159             rule_id = rule_id and rule_id[0] or False
160         return rule_id
161
162     def _run_move_create(self, cr, uid, procurement, context=None):
163         ''' Returns a dictionary of values that will be sued to create a stock move from a procurement.
164         This function assumes that the given procurement has a rule (action == 'move') set on it.
165
166         :param procurement: browse record
167         :rtype: dictionary
168         '''
169         newdate = (datetime.strptime(procurement.date_planned, '%Y-%m-%d %H:%M:%S') - relativedelta(days=procurement.rule_id.delay or 0)).strftime('%Y-%m-%d %H:%M:%S')
170         group_id = False
171         if procurement.rule_id.group_propagation_option == 'propagate':
172             group_id = procurement.group_id and procurement.group_id.id or False
173         elif procurement.rule_id.group_propagation_option == 'fixed':
174             group_id = procurement.rule_id.group_id and procurement.rule_id.group_id.id or False
175         vals = {
176             'name': procurement.name,
177             'company_id': procurement.company_id.id,
178             'product_id': procurement.product_id.id,
179             'product_qty': procurement.product_qty,
180             'product_uom': procurement.product_uom.id,
181             'product_uom_qty': procurement.product_qty,
182             'product_uos_qty': (procurement.product_uos and procurement.product_uos_qty) or procurement.product_qty,
183             'product_uos': (procurement.product_uos and procurement.product_uos.id) or procurement.product_uom.id,
184             'partner_id': procurement.group_id and procurement.group_id.partner_id and procurement.group_id.partner_id.id or False,
185             'location_id': procurement.rule_id.location_src_id.id,
186             'location_dest_id': procurement.rule_id.location_id.id,
187             'move_dest_id': procurement.move_dest_id and procurement.move_dest_id.id or False,
188             'procurement_id': procurement.id,
189             'rule_id': procurement.rule_id.id,
190             'procure_method': procurement.rule_id.procure_method,
191             'origin': procurement.origin,
192             'picking_type_id': procurement.rule_id.picking_type_id.id,
193             'group_id': group_id,
194             'route_ids': [(4, x.id) for x in procurement.route_ids],
195             'warehouse_id': procurement.rule_id.propagate_warehouse_id and procurement.rule_id.propagate_warehouse_id.id or procurement.rule_id.warehouse_id.id,
196             'date': newdate,
197             'date_expected': newdate,
198             'propagate': procurement.rule_id.propagate,
199         }
200         #look if the procurement was in exception (because all its moves were cancelled) and cancel the previously made attempt to avoid duplicates
201         cancelled_moves = [m.id for m in procurement.move_ids if m.state == 'cancel']
202         if cancelled_moves:
203             previous_attempt = self.search(cr, uid, [('move_dest_id', 'in', cancelled_moves)], context=context)
204             if previous_attempt:
205                 self.cancel(cr, uid, previous_attempt, context=context)
206         return vals
207
208     def _run(self, cr, uid, procurement, context=None):
209         if procurement.rule_id and procurement.rule_id.action == 'move':
210             if not procurement.rule_id.location_src_id:
211                 self.message_post(cr, uid, [procurement.id], body=_('No source location defined!'), context=context)
212                 return False
213             move_obj = self.pool.get('stock.move')
214             move_dict = self._run_move_create(cr, uid, procurement, context=context)
215             move_id = move_obj.create(cr, uid, move_dict, context=context)
216             move_obj.action_confirm(cr, uid, [move_id], context=context)
217             return move_id
218         return super(procurement_order, self)._run(cr, uid, procurement, context)
219
220     def _check(self, cr, uid, procurement, context=None):
221         if procurement.rule_id and procurement.rule_id.action == 'move':
222             done_test_list = []
223             done_cancel_test_list = []
224             for move in procurement.move_ids:
225                 done_test_list.append(move.state == 'done')
226                 done_cancel_test_list.append(move.state in ('done', 'cancel'))
227             at_least_one_done = any(done_test_list)
228             all_done_or_cancel = all(done_cancel_test_list)
229             if not all_done_or_cancel:
230                 return False
231             elif at_least_one_done and all_done_or_cancel:
232                 return True
233             else:
234                 #all move are cancelled
235                 self.write(cr, uid, [procurement.id], {'state': 'exception'}, context=context)
236                 self.message_post(cr, uid, [procurement.id], body=_('All stock moves have been cancelled for this procurement.'), context=context)
237                 return False
238
239         return super(procurement_order, self)._check(cr, uid, procurement, context)
240
241     def do_view_pickings(self, cr, uid, ids, context=None):
242         '''
243         This function returns an action that display the pickings of the procurements belonging
244         to the same procurement group of given ids.
245         '''
246         mod_obj = self.pool.get('ir.model.data')
247         act_obj = self.pool.get('ir.actions.act_window')
248         result = mod_obj.get_object_reference(cr, uid, 'stock', 'do_view_pickings')
249         id = result and result[1] or False
250         result = act_obj.read(cr, uid, [id], context=context)[0]
251         group_ids = set([proc.group_id.id for proc in self.browse(cr, uid, ids, context=context) if proc.group_id])
252         result['domain'] = "[('group_id','in',[" + ','.join(map(str, list(group_ids))) + "])]"
253         return result
254
255     #
256     # Scheduler
257     # When stock is installed, it should also check for the different confirmed stock moves
258     # if they can not be installed
259     #
260     #
261     def run_scheduler(self, cr, uid, use_new_cursor=False, context=None):
262         '''
263         Call the scheduler in order to 
264
265         @param self: The object pointer
266         @param cr: The current row, from the database cursor,
267         @param uid: The current user ID for security checks
268         @param ids: List of selected IDs
269         @param use_new_cursor: False or the dbname
270         @param context: A standard dictionary for contextual values
271         @return:  Dictionary of values
272         '''
273
274         super(procurement_order, self).run_scheduler(cr, uid, use_new_cursor=use_new_cursor, context=context)
275         if context is None:
276             context = {}
277         try:
278             if use_new_cursor:
279                 cr = openerp.registry(use_new_cursor).db.cursor()
280
281             company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id
282             move_obj = self.pool.get('stock.move')
283             #Minimum stock rules
284             self. _procure_orderpoint_confirm(cr, uid, automatic=False,use_new_cursor=False, context=context, user_id=False)
285
286             #Search all confirmed stock_moves and try to assign them
287             confirmed_ids = move_obj.search(cr, uid, [('state', '=', 'confirmed'), ('company_id','=', company.id)], limit = None, context=context) #Type  = stockable product?
288             for x in xrange(0, len(confirmed_ids), 100):
289                 move_obj.action_assign(cr, uid, confirmed_ids[x:x+100], context=context)
290                 if use_new_cursor:
291                     cr.commit()
292             
293             
294             if use_new_cursor:
295                 cr.commit()
296         finally:
297             if use_new_cursor:
298                 try:
299                     cr.close()
300                 except Exception:
301                     pass
302         return {}
303
304     def _prepare_automatic_op_procurement(self, cr, uid, product, warehouse, location_id, context=None):
305         return {'name': _('Automatic OP: %s') % (product.name,),
306                 'origin': _('SCHEDULER'),
307                 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
308                 'product_id': product.id,
309                 'product_qty': -product.virtual_available,
310                 'product_uom': product.uom_id.id,
311                 'location_id': location_id,
312                 'company_id': warehouse.company_id.id,
313                 }
314
315     def create_automatic_op(self, cr, uid, context=None):
316         """
317         Create procurement of  virtual stock < 0
318
319         @param self: The object pointer
320         @param cr: The current row, from the database cursor,
321         @param uid: The current user ID for security checks
322         @param context: A standard dictionary for contextual values
323         @return:  Dictionary of values
324         """
325         if context is None:
326             context = {}
327         product_obj = self.pool.get('product.product')
328         proc_obj = self.pool.get('procurement.order')
329         warehouse_obj = self.pool.get('stock.warehouse')
330
331         warehouse_ids = warehouse_obj.search(cr, uid, [], context=context)
332         products_ids = product_obj.search(cr, uid, [], order='id', context=context)
333
334         for warehouse in warehouse_obj.browse(cr, uid, warehouse_ids, context=context):
335             context['warehouse'] = warehouse
336             # Here we check products availability.
337             # We use the method 'read' for performance reasons, because using the method 'browse' may crash the server.
338             for product_read in product_obj.read(cr, uid, products_ids, ['virtual_available'], context=context):
339                 if product_read['virtual_available'] >= 0.0:
340                     continue
341
342                 product = product_obj.browse(cr, uid, [product_read['id']], context=context)[0]
343
344                 location_id = warehouse.lot_stock_id.id
345
346                 proc_id = proc_obj.create(cr, uid,
347                             self._prepare_automatic_op_procurement(cr, uid, product, warehouse, location_id, context=context),
348                             context=context)
349                 self.assign(cr, uid, [proc_id])
350                 self.run(cr, uid, [proc_id])
351         return True
352
353     def _get_orderpoint_date_planned(self, cr, uid, orderpoint, start_date, context=None):
354         date_planned = start_date + \
355                        relativedelta(days=orderpoint.product_id.seller_delay or 0.0)
356         return date_planned.strftime(DEFAULT_SERVER_DATE_FORMAT)
357
358     def _prepare_orderpoint_procurement(self, cr, uid, orderpoint, product_qty, context=None):
359         return {'name': orderpoint.name,
360                 'date_planned': self._get_orderpoint_date_planned(cr, uid, orderpoint, datetime.today(), context=context),
361                 'product_id': orderpoint.product_id.id,
362                 'product_qty': product_qty,
363                 'company_id': orderpoint.company_id.id,
364                 'product_uom': orderpoint.product_uom.id,
365                 'location_id': orderpoint.location_id.id,
366                 'origin': orderpoint.name}
367
368     def _product_virtual_get(self, cr, uid, order_point):
369         product_obj = self.pool.get('product.product')
370         return product_obj._product_available(cr, uid,
371                 [order_point.product_id.id],
372                 {'location': order_point.location_id.id})[order_point.product_id.id]['virtual_available']
373
374     def _procure_orderpoint_confirm(self, cr, uid, automatic=False,\
375             use_new_cursor=False, context=None, user_id=False):
376         '''
377         Create procurement based on Orderpoint
378         use_new_cursor: False or the dbname
379
380         @param self: The object pointer
381         @param cr: The current row, from the database cursor,
382         @param user_id: The current user ID for security checks
383         @param context: A standard dictionary for contextual values
384         @param param: False or the dbname
385         @return:  Dictionary of values
386         """
387         '''
388         if context is None:
389             context = {}
390         if use_new_cursor:
391             cr = openerp.registry(use_new_cursor).db.cursor()
392         orderpoint_obj = self.pool.get('stock.warehouse.orderpoint')
393         
394         procurement_obj = self.pool.get('procurement.order')
395         offset = 0
396         ids = [1]
397         if automatic:
398             self.create_automatic_op(cr, uid, context=context)
399         while ids:
400             ids = orderpoint_obj.search(cr, uid, [], offset=offset, limit=100)
401             for op in orderpoint_obj.browse(cr, uid, ids, context=context):
402                 prods = self._product_virtual_get(cr, uid, op)
403                 if prods is None:
404                     continue
405                 if prods < op.product_min_qty:
406                     qty = max(op.product_min_qty, op.product_max_qty)-prods
407
408                     reste = qty % op.qty_multiple
409                     if reste > 0:
410                         qty += op.qty_multiple - reste
411
412                     if qty <= 0:
413                         continue
414                     if op.product_id.type not in ('consu'):
415                         procurement_draft_ids = orderpoint_obj.get_draft_procurements(cr, uid, op. id, context=context)
416                         if procurement_draft_ids:
417                             # Check draft procurement related to this order point
418                             procure_datas = procurement_obj.read(
419                                 cr, uid, procurement_draft_ids, ['id', 'product_qty'], context=context)
420                             to_generate = qty
421                             for proc_data in procure_datas:
422                                 if to_generate >= proc_data['product_qty']:
423                                     self.signal_button_confirm(cr, uid, [proc_data['id']])
424                                     procurement_obj.write(cr, uid, [proc_data['id']],  {'origin': op.name}, context=context)
425                                     to_generate -= proc_data['product_qty']
426                                 if not to_generate:
427                                     break
428                             qty = to_generate
429
430                     if qty:
431                         proc_id = procurement_obj.create(cr, uid,
432                                                          self._prepare_orderpoint_procurement(cr, uid, op, qty, context=context),
433                                                          context=context)
434                         self.check(cr, uid, [proc_id])
435                         self.run(cr, uid, [proc_id])
436                         #TODO: check if we can remove this field because it doesn't seem used at all
437                         #orderpoint_obj.write(cr, uid, [op.id],
438                         #        {'procurement_id': proc_id}, context=context)
439             offset += len(ids)
440             if use_new_cursor:
441                 cr.commit()
442         if use_new_cursor:
443             cr.commit()
444             cr.close()
445         return {}