ddafbebad79f2d1a6d8df9c7f80e6e4b314a1ea1
[odoo/odoo.git] / addons / mrp / mrp.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 datetime import datetime
23 from osv import osv, fields
24 import decimal_precision as dp
25 from tools.translate import _
26 import netsvc
27 import time
28 import tools
29
30
31 #----------------------------------------------------------
32 # Work Centers
33 #----------------------------------------------------------
34 # capacity_hour : capacity per hour. default: 1.0.
35 #          Eg: If 5 concurrent operations at one time: capacity = 5 (because 5 employees)
36 # unit_per_cycle : how many units are produced for one cycle
37
38 class mrp_workcenter(osv.osv):
39     _name = 'mrp.workcenter'
40     _description = 'Work Center'
41     _inherits = {'resource.resource':"resource_id"}
42     _columns = {
43         'note': fields.text('Description', help="Description of the Work Center. Explain here what's a cycle according to this Work Center."),
44         'capacity_per_cycle': fields.float('Capacity per Cycle', help="Number of operations this Work Center can do in parallel. If this Work Center represents a team of 5 workers, the capacity per cycle is 5."),
45         'time_cycle': fields.float('Time for 1 cycle (hour)', help="Time in hours for doing one cycle."),
46         'time_start': fields.float('Time before prod.', help="Time in hours for the setup."),
47         'time_stop': fields.float('Time after prod.', help="Time in hours for the cleaning."),
48         'costs_hour': fields.float('Cost per hour', help="Specify Cost of Work Center per hour."),
49         'costs_hour_account_id': fields.many2one('account.analytic.account', 'Hour Account', domain=[('type','<>','view')],
50             help="Complete this only if you want automatic analytic accounting entries on production orders."),
51         'costs_cycle': fields.float('Cost per cycle', help="Specify Cost of Work Center per cycle."),
52         'costs_cycle_account_id': fields.many2one('account.analytic.account', 'Cycle Account', domain=[('type','<>','view')],
53             help="Complete this only if you want automatic analytic accounting entries on production orders."),
54         'costs_journal_id': fields.many2one('account.analytic.journal', 'Analytic Journal'),
55         'costs_general_account_id': fields.many2one('account.account', 'General Account', domain=[('type','<>','view')]),
56         'resource_id': fields.many2one('resource.resource','Resource', ondelete='cascade', required=True),
57         'product_id': fields.many2one('product.product','Work Center Product', help="Fill this product to track easily your production costs in the analytic accounting."),
58     }
59     _defaults = {
60         'capacity_per_cycle': 1.0,
61         'resource_type': 'material',
62      }
63
64     def on_change_product_cost(self, cr, uid, ids, product_id, context=None):
65         value = {}
66
67         if product_id:
68             cost = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
69             value = {'costs_hour': cost.standard_price}
70         return {'value': value}
71
72 mrp_workcenter()
73
74
75 class mrp_routing(osv.osv):
76     """
77     For specifying the routings of Work Centers.
78     """
79     _name = 'mrp.routing'
80     _description = 'Routing'
81     _columns = {
82         'name': fields.char('Name', size=64, required=True),
83         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the routing without removing it."),
84         'code': fields.char('Code', size=8),
85
86         'note': fields.text('Description'),
87         'workcenter_lines': fields.one2many('mrp.routing.workcenter', 'routing_id', 'Work Centers'),
88
89         'location_id': fields.many2one('stock.location', 'Production Location',
90             help="Keep empty if you produce at the location where the finished products are needed." \
91                 "Set a location if you produce at a fixed location. This can be a partner location " \
92                 "if you subcontract the manufacturing operations."
93         ),
94         'company_id': fields.many2one('res.company', 'Company'),
95     }
96     _defaults = {
97         'active': lambda *a: 1,
98         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'mrp.routing', context=context)
99     }
100 mrp_routing()
101
102 class mrp_routing_workcenter(osv.osv):
103     """
104     Defines working cycles and hours of a Work Center using routings.
105     """
106     _name = 'mrp.routing.workcenter'
107     _description = 'Work Center Usage'
108     _columns = {
109         'workcenter_id': fields.many2one('mrp.workcenter', 'Work Center', required=True),
110         'name': fields.char('Name', size=64, required=True),
111         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of routing Work Centers."),
112         'cycle_nbr': fields.float('Number of Cycles', required=True,
113             help="Number of iterations this work center has to do in the specified operation of the routing."),
114         'hour_nbr': fields.float('Number of Hours', required=True, help="Time in hours for this Work Center to achieve the operation of the specified routing."),
115         'routing_id': fields.many2one('mrp.routing', 'Parent Routing', select=True, ondelete='cascade',
116              help="Routing indicates all the Work Centers used, for how long and/or cycles." \
117                 "If Routing is indicated then,the third tab of a production order (Work Centers) will be automatically pre-completed."),
118         'note': fields.text('Description'),
119         'company_id': fields.related('routing_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True),
120     }
121     _defaults = {
122         'cycle_nbr': lambda *a: 1.0,
123         'hour_nbr': lambda *a: 0.0,
124     }
125 mrp_routing_workcenter()
126
127 class mrp_bom(osv.osv):
128     """
129     Defines bills of material for a product.
130     """
131     _name = 'mrp.bom'
132     _description = 'Bill of Material'
133
134     def _child_compute(self, cr, uid, ids, name, arg, context=None):
135         """ Gets child bom.
136         @param self: The object pointer
137         @param cr: The current row, from the database cursor,
138         @param uid: The current user ID for security checks
139         @param ids: List of selected IDs
140         @param name: Name of the field
141         @param arg: User defined argument
142         @param context: A standard dictionary for contextual values
143         @return:  Dictionary of values
144         """
145         result = {}
146         if context is None:
147             context = {}
148         bom_obj = self.pool.get('mrp.bom')
149         bom_id = context and context.get('active_id', False) or False
150         cr.execute('select id from mrp_bom')
151         if all(bom_id != r[0] for r in cr.fetchall()):
152             ids.sort()
153             bom_id = ids[0]
154         bom_parent = bom_obj.browse(cr, uid, bom_id, context=context)
155         for bom in self.browse(cr, uid, ids, context=context):
156             if (bom_parent) or (bom.id == bom_id):
157                 result[bom.id] = map(lambda x: x.id, bom.bom_lines)
158             else:
159                 result[bom.id] = []
160             if bom.bom_lines:
161                 continue
162             ok = ((name=='child_complete_ids') and (bom.product_id.supply_method=='produce'))
163             if (bom.type=='phantom' or ok):
164                 sids = bom_obj.search(cr, uid, [('bom_id','=',False),('product_id','=',bom.product_id.id)])
165                 if sids:
166                     bom2 = bom_obj.browse(cr, uid, sids[0], context=context)
167                     result[bom.id] += map(lambda x: x.id, bom2.bom_lines)
168
169         return result
170
171     def _compute_type(self, cr, uid, ids, field_name, arg, context=None):
172         """ Sets particular method for the selected bom type.
173         @param field_name: Name of the field
174         @param arg: User defined argument
175         @return:  Dictionary of values
176         """
177         res = dict.fromkeys(ids, False)
178         for line in self.browse(cr, uid, ids, context=context):
179             if line.type == 'phantom' and not line.bom_id:
180                 res[line.id] = 'set'
181                 continue
182             if line.bom_lines or line.type == 'phantom':
183                 continue
184             if line.product_id.supply_method == 'produce':
185                 if line.product_id.procure_method == 'make_to_stock':
186                     res[line.id] = 'stock'
187                 else:
188                     res[line.id] = 'order'
189         return res
190
191     _columns = {
192         'name': fields.char('Name', size=64, required=True),
193         'code': fields.char('Reference', size=16),
194         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the bills of material without removing it."),
195         'type': fields.selection([('normal','Normal BoM'),('phantom','Sets / Phantom')], 'BoM Type', required=True,
196                                  help= "If a sub-product is used in several products, it can be useful to create its own BoM. "\
197                                  "Though if you don't want separated production orders for this sub-product, select Set/Phantom as BoM type. "\
198                                  "If a Phantom BoM is used for a root product, it will be sold and shipped as a set of components, instead of being produced."),
199         'method': fields.function(_compute_type, string='Method', type='selection', selection=[('',''),('stock','On Stock'),('order','On Order'),('set','Set / Pack')]),
200         'date_start': fields.date('Valid From', help="Validity of this BoM or component. Keep empty if it's always valid."),
201         'date_stop': fields.date('Valid Until', help="Validity of this BoM or component. Keep empty if it's always valid."),
202         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of bills of material."),
203         'position': fields.char('Internal Reference', size=64, help="Reference to a position in an external plan."),
204         'product_id': fields.many2one('product.product', 'Product', required=True),
205         'product_uos_qty': fields.float('Product UOS Qty'),
206         'product_uos': fields.many2one('product.uom', 'Product UOS', help="Product UOS (Unit of Sale) is the unit of measurement for the invoicing and promotion of stock."),
207         'product_qty': fields.float('Product Qty', required=True, digits_compute=dp.get_precision('Product UoM')),
208         'product_uom': fields.many2one('product.uom', 'Product UOM', required=True, help="UoM (Unit of Measure) is the unit of measurement for the inventory control"),
209         'product_rounding': fields.float('Product Rounding', help="Rounding applied on the product quantity."),
210         'product_efficiency': fields.float('Manufacturing Efficiency', required=True, help="A factor of 0.9 means a loss of 10% within the production process."),
211         'bom_lines': fields.one2many('mrp.bom', 'bom_id', 'BoM Lines'),
212         'bom_id': fields.many2one('mrp.bom', 'Parent BoM', ondelete='cascade', select=True),
213         'routing_id': fields.many2one('mrp.routing', 'Routing', help="The list of operations (list of work centers) to produce the finished product. The routing is mainly used to compute work center costs during operations and to plan future loads on work centers based on production planning."),
214         'property_ids': fields.many2many('mrp.property', 'mrp_bom_property_rel', 'bom_id','property_id', 'Properties'),
215         'revision_ids': fields.one2many('mrp.bom.revision', 'bom_id', 'BoM Revisions'),
216         'child_complete_ids': fields.function(_child_compute, relation='mrp.bom', string="BoM Hierarchy", type='many2many'),
217         'company_id': fields.many2one('res.company','Company',required=True),
218     }
219     _defaults = {
220         'active': lambda *a: 1,
221         'product_efficiency': lambda *a: 1.0,
222         'product_qty': lambda *a: 1.0,
223         'product_rounding': lambda *a: 0.0,
224         'type': lambda *a: 'normal',
225         'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'mrp.bom', context=c),
226     }
227     _order = "sequence"
228     _sql_constraints = [
229         ('bom_qty_zero', 'CHECK (product_qty>0)',  'All product quantities must be greater than 0.\n' \
230             'You should install the mrp_subproduct module if you want to manage extra products on BoMs !'),
231     ]
232
233     def _check_recursion(self, cr, uid, ids, context=None):
234         level = 100
235         while len(ids):
236             cr.execute('select distinct bom_id from mrp_bom where id IN %s',(tuple(ids),))
237             ids = filter(None, map(lambda x:x[0], cr.fetchall()))
238             if not level:
239                 return False
240             level -= 1
241         return True
242
243     def _check_product(self, cr, uid, ids, context=None):
244         all_prod = []
245         boms = self.browse(cr, uid, ids, context=context)
246         def check_bom(boms):
247             res = True
248             for bom in boms:
249                 if bom.product_id.id in all_prod:
250                     res = res and False
251                 all_prod.append(bom.product_id.id)
252                 lines = bom.bom_lines
253                 if lines:
254                     res = res and check_bom([bom_id for bom_id in lines if bom_id not in boms])
255             return res
256         return check_bom(boms)
257
258     _constraints = [
259         (_check_recursion, 'Error ! You cannot create recursive BoM.', ['parent_id']),
260         (_check_product, 'BoM line product should not be same as BoM product.', ['product_id']),
261     ]
262
263     def onchange_product_id(self, cr, uid, ids, product_id, name, context=None):
264         """ Changes UoM and name if product_id changes.
265         @param name: Name of the field
266         @param product_id: Changed product_id
267         @return:  Dictionary of changed values
268         """
269         if product_id:
270             prod = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
271             return {'value': {'name': prod.name, 'product_uom': prod.uom_id.id}}
272         return {}
273
274     def _bom_find(self, cr, uid, product_id, product_uom, properties=[]):
275         """ Finds BoM for particular product and product uom.
276         @param product_id: Selected product.
277         @param product_uom: Unit of measure of a product.
278         @param properties: List of related properties.
279         @return: False or BoM id.
280         """
281         cr.execute('select id from mrp_bom where product_id=%s and bom_id is null order by sequence', (product_id,))
282         ids = map(lambda x: x[0], cr.fetchall())
283         max_prop = 0
284         result = False
285         for bom in self.pool.get('mrp.bom').browse(cr, uid, ids):
286             prop = 0
287             for prop_id in bom.property_ids:
288                 if prop_id.id in properties:
289                     prop += 1
290             if (prop > max_prop) or ((max_prop == 0) and not result):
291                 result = bom.id
292                 max_prop = prop
293         return result
294
295     def _bom_explode(self, cr, uid, bom, factor, properties=[], addthis=False, level=0, routing_id=False):
296         """ Finds Products and Work Centers for related BoM for manufacturing order.
297         @param bom: BoM of particular product.
298         @param factor: Factor of product UoM.
299         @param properties: A List of properties Ids.
300         @param addthis: If BoM found then True else False.
301         @param level: Depth level to find BoM lines starts from 10.
302         @return: result: List of dictionaries containing product details.
303                  result2: List of dictionaries containing Work Center details.
304         """
305         routing_obj = self.pool.get('mrp.routing')
306         factor = factor / (bom.product_efficiency or 1.0)
307         factor = rounding(factor, bom.product_rounding)
308         if factor < bom.product_rounding:
309             factor = bom.product_rounding
310         result = []
311         result2 = []
312         phantom = False
313         if bom.type == 'phantom' and not bom.bom_lines:
314             newbom = self._bom_find(cr, uid, bom.product_id.id, bom.product_uom.id, properties)
315             
316             if newbom:
317                 res = self._bom_explode(cr, uid, self.browse(cr, uid, [newbom])[0], factor*bom.product_qty, properties, addthis=True, level=level+10)
318                 result = result + res[0]
319                 result2 = result2 + res[1]
320                 phantom = True
321             else:
322                 phantom = False
323         if not phantom:
324             if addthis and not bom.bom_lines:
325                 result.append(
326                 {
327                     'name': bom.product_id.name,
328                     'product_id': bom.product_id.id,
329                     'product_qty': bom.product_qty * factor,
330                     'product_uom': bom.product_uom.id,
331                     'product_uos_qty': bom.product_uos and bom.product_uos_qty * factor or False,
332                     'product_uos': bom.product_uos and bom.product_uos.id or False,
333                 })
334             routing = (routing_id and routing_obj.browse(cr, uid, routing_id)) or bom.routing_id or False
335             if routing:
336                 for wc_use in routing.workcenter_lines:
337                     wc = wc_use.workcenter_id
338                     d, m = divmod(factor, wc_use.workcenter_id.capacity_per_cycle)
339                     mult = (d + (m and 1.0 or 0.0))
340                     cycle = mult * wc_use.cycle_nbr
341                     result2.append({
342                         'name': tools.ustr(wc_use.name) + ' - '  + tools.ustr(bom.product_id.name),
343                         'workcenter_id': wc.id,
344                         'sequence': level+(wc_use.sequence or 0),
345                         'cycle': cycle,
346                         'hour': float(wc_use.hour_nbr*mult + ((wc.time_start or 0.0)+(wc.time_stop or 0.0)+cycle*(wc.time_cycle or 0.0)) * (wc.time_efficiency or 1.0)),
347                     })
348             for bom2 in bom.bom_lines:
349                 res = self._bom_explode(cr, uid, bom2, factor, properties, addthis=True, level=level+10)
350                 result = result + res[0]
351                 result2 = result2 + res[1]
352         return result, result2
353
354     def copy_data(self, cr, uid, id, default=None, context=None):
355         if default is None:
356             default = {}
357         bom_data = self.read(cr, uid, id, [], context=context)
358         default.update({'name': bom_data['name'] + ' ' + _('Copy'), 'bom_id':False})
359         return super(mrp_bom, self).copy_data(cr, uid, id, default, context=context)
360
361 mrp_bom()
362
363 class mrp_bom_revision(osv.osv):
364     _name = 'mrp.bom.revision'
365     _description = 'Bill of Material Revision'
366     _columns = {
367         'name': fields.char('Modification name', size=64, required=True),
368         'description': fields.text('Description'),
369         'date': fields.date('Modification Date'),
370         'indice': fields.char('Revision', size=16),
371         'last_indice': fields.char('last indice', size=64),
372         'author_id': fields.many2one('res.users', 'Author'),
373         'bom_id': fields.many2one('mrp.bom', 'BoM', select=True),
374     }
375
376     _defaults = {
377         'author_id': lambda x, y, z, c: z,
378         'date': lambda *a: time.strftime('%Y-%m-%d'),
379     }
380
381 mrp_bom_revision()
382
383 def rounding(f, r):
384     import math
385     if not r:
386         return f
387     return math.ceil(f / r) * r
388
389 class mrp_production(osv.osv):
390     """
391     Production Orders / Manufacturing Orders
392     """
393     _name = 'mrp.production'
394     _description = 'Manufacturing Order'
395     _date_name  = 'date_planned'
396
397     def _production_calc(self, cr, uid, ids, prop, unknow_none, context=None):
398         """ Calculates total hours and total no. of cycles for a production order.
399         @param prop: Name of field.
400         @param unknow_none:
401         @return: Dictionary of values.
402         """
403         result = {}
404         for prod in self.browse(cr, uid, ids, context=context):
405             result[prod.id] = {
406                 'hour_total': 0.0,
407                 'cycle_total': 0.0,
408             }
409             for wc in prod.workcenter_lines:
410                 result[prod.id]['hour_total'] += wc.hour
411                 result[prod.id]['cycle_total'] += wc.cycle
412         return result
413
414     def _production_date_end(self, cr, uid, ids, prop, unknow_none, context=None):
415         """ Finds production end date.
416         @param prop: Name of field.
417         @param unknow_none:
418         @return: Dictionary of values.
419         """
420         result = {}
421         for prod in self.browse(cr, uid, ids, context=context):
422             result[prod.id] = prod.date_planned
423         return result
424
425     def _production_date(self, cr, uid, ids, prop, unknow_none, context=None):
426         """ Finds production planned date.
427         @param prop: Name of field.
428         @param unknow_none:
429         @return: Dictionary of values.
430         """
431         result = {}
432         for prod in self.browse(cr, uid, ids, context=context):
433             result[prod.id] = prod.date_planned[:10]
434         return result
435
436     _columns = {
437         'name': fields.char('Reference', size=64, required=True),
438         'origin': fields.char('Source Document', size=64, help="Reference of the document that generated this production order request."),
439         'priority': fields.selection([('0','Not urgent'),('1','Normal'),('2','Urgent'),('3','Very Urgent')], 'Priority', select=True),
440
441         'product_id': fields.many2one('product.product', 'Product', required=True, readonly=True, states={'draft':[('readonly',False)]}),
442         'product_qty': fields.float('Product Qty', digits_compute=dp.get_precision('Product UoM'), required=True, states={'draft':[('readonly',False)]}, readonly=True),
443         'product_uom': fields.many2one('product.uom', 'Product UOM', required=True, states={'draft':[('readonly',False)]}, readonly=True),
444         'product_uos_qty': fields.float('Product UoS Qty', states={'draft':[('readonly',False)]}, readonly=True),
445         'product_uos': fields.many2one('product.uom', 'Product UoS', states={'draft':[('readonly',False)]}, readonly=True),
446
447         'location_src_id': fields.many2one('stock.location', 'Raw Materials Location', required=True,
448             readonly=True, states={'draft':[('readonly',False)]}, help="Location where the system will look for components."),
449         'location_dest_id': fields.many2one('stock.location', 'Finished Products Location', required=True,
450             readonly=True, states={'draft':[('readonly',False)]}, help="Location where the system will stock the finished products."),
451
452         'date_planned_end': fields.function(_production_date_end, type='date', string='Scheduled End Date'),
453         'date_planned_date': fields.function(_production_date, type='date', string='Scheduled Date'),
454         'date_planned': fields.datetime('Scheduled date', required=True, select=1),
455         'date_start': fields.datetime('Start Date', select=True),
456         'date_finished': fields.datetime('End Date', select=True),
457
458         'bom_id': fields.many2one('mrp.bom', 'Bill of Material', domain=[('bom_id','=',False)], readonly=True, states={'draft':[('readonly',False)]}),
459         'routing_id': fields.many2one('mrp.routing', string='Routing', on_delete='set null', readonly=True, states={'draft':[('readonly',False)]}, help="The list of operations (list of work centers) to produce the finished product. The routing is mainly used to compute work center costs during operations and to plan future loads on work centers based on production plannification."),
460         'picking_id': fields.many2one('stock.picking', 'Picking list', readonly=True, ondelete="restrict",
461             help="This is the Internal Picking List that brings the finished product to the production plan"),
462         'move_prod_id': fields.many2one('stock.move', 'Move product', readonly=True),
463         'move_lines': fields.many2many('stock.move', 'mrp_production_move_ids', 'production_id', 'move_id', 'Products to Consume', domain=[('state','not in', ('done', 'cancel'))], states={'done':[('readonly',True)]}),
464         'move_lines2': fields.many2many('stock.move', 'mrp_production_move_ids', 'production_id', 'move_id', 'Consumed Products', domain=[('state','in', ('done', 'cancel'))]),
465         'move_created_ids': fields.one2many('stock.move', 'production_id', 'Products to Produce', domain=[('state','not in', ('done', 'cancel'))], states={'done':[('readonly',True)]}),
466         'move_created_ids2': fields.one2many('stock.move', 'production_id', 'Produced Products', domain=[('state','in', ('done', 'cancel'))]),
467         'product_lines': fields.one2many('mrp.production.product.line', 'production_id', 'Scheduled goods'),
468         'workcenter_lines': fields.one2many('mrp.production.workcenter.line', 'production_id', 'Work Centers Utilisation'),
469         'state': fields.selection([('draft','New'),('picking_except', 'Picking Exception'),('confirmed','Waiting Goods'),('ready','Ready to Produce'),('in_production','Production Started'),('cancel','Cancelled'),('done','Done')],'State', readonly=True,
470                                     help='When the production order is created the state is set to \'Draft\'.\n If the order is confirmed the state is set to \'Waiting Goods\'.\n If any exceptions are there, the state is set to \'Picking Exception\'.\
471                                     \nIf the stock is available then the state is set to \'Ready to Produce\'.\n When the production gets started then the state is set to \'In Production\'.\n When the production is over, the state is set to \'Done\'.'),
472         'hour_total': fields.function(_production_calc, type='float', string='Total Hours', multi='workorder', store=True),
473         'cycle_total': fields.function(_production_calc, type='float', string='Total Cycles', multi='workorder', store=True),
474         'user_id':fields.many2one('res.users', 'Responsible'),
475         'company_id': fields.many2one('res.company','Company',required=True),
476     }
477     _defaults = {
478         'priority': lambda *a: '1',
479         'state': lambda *a: 'draft',
480         'date_planned': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S'),
481         'product_qty':  lambda *a: 1.0,
482         'name': lambda x, y, z, c: x.pool.get('ir.sequence').get(y, z, 'mrp.production') or '/',
483         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'mrp.production', context=c),
484     }
485     _sql_constraints = [
486         ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'),
487     ]
488     _order = 'priority desc, date_planned asc';
489
490     def _check_qty(self, cr, uid, ids, context=None):
491         for order in self.browse(cr, uid, ids, context=context):
492             if order.product_qty <= 0:
493                 return False
494         return True
495
496     _constraints = [
497         (_check_qty, 'Order quantity cannot be negative or zero!', ['product_qty']),
498     ]
499
500     def unlink(self, cr, uid, ids, context=None):
501         for production in self.browse(cr, uid, ids, context=context):
502             if production.state not in ('draft', 'cancel'):
503                 raise osv.except_osv(_('Invalid action !'), _('Cannot delete a manufacturing order in state \'%s\'') % production.state)
504         return super(mrp_production, self).unlink(cr, uid, ids, context=context)
505
506     def copy(self, cr, uid, id, default=None, context=None):
507         if default is None:
508             default = {}
509         default.update({
510             'name': self.pool.get('ir.sequence').get(cr, uid, 'mrp.production'),
511             'move_lines' : [],
512             'move_lines2' : [],
513             'move_created_ids' : [],
514             'move_created_ids2' : [],
515             'product_lines' : [],
516             'picking_id': False
517         })
518         return super(mrp_production, self).copy(cr, uid, id, default, context)
519
520     def location_id_change(self, cr, uid, ids, src, dest, context=None):
521         """ Changes destination location if source location is changed.
522         @param src: Source location id.
523         @param dest: Destination location id.
524         @return: Dictionary of values.
525         """
526         if dest:
527             return {}
528         if src:
529             return {'value': {'location_dest_id': src}}
530         return {}
531
532     def product_id_change(self, cr, uid, ids, product_id, context=None):
533         """ Finds UoM of changed product.
534         @param product_id: Id of changed product.
535         @return: Dictionary of values.
536         """
537         if not product_id:
538             return {'value': {
539                 'product_uom': False,
540                 'bom_id': False,
541                 'routing_id': False
542             }}
543         bom_obj = self.pool.get('mrp.bom')
544         product = self.pool.get('product.product').browse(cr, uid, product_id, context=context)
545         bom_id = bom_obj._bom_find(cr, uid, product.id, product.uom_id and product.uom_id.id, [])
546         routing_id = False
547         if bom_id:
548             bom_point = bom_obj.browse(cr, uid, bom_id, context=context)
549             routing_id = bom_point.routing_id.id or False
550         result = {
551             'product_uom': product.uom_id and product.uom_id.id or False,
552             'bom_id': bom_id,
553             'routing_id': routing_id
554         }
555         return {'value': result}
556
557     def bom_id_change(self, cr, uid, ids, bom_id, context=None):
558         """ Finds routing for changed BoM.
559         @param product: Id of product.
560         @return: Dictionary of values.
561         """
562         if not bom_id:
563             return {'value': {
564                 'routing_id': False
565             }}
566         bom_point = self.pool.get('mrp.bom').browse(cr, uid, bom_id, context=context)
567         routing_id = bom_point.routing_id.id or False
568         result = {
569             'routing_id': routing_id
570         }
571         return {'value': result}
572
573     def action_picking_except(self, cr, uid, ids):
574         """ Changes the state to Exception.
575         @return: True
576         """
577         self.write(cr, uid, ids, {'state': 'picking_except'})
578         return True
579
580     def action_compute(self, cr, uid, ids, properties=[], context=None):
581         """ Computes bills of material of a product.
582         @param properties: List containing dictionaries of properties.
583         @return: No. of products.
584         """
585         results = []
586         bom_obj = self.pool.get('mrp.bom')
587         uom_obj = self.pool.get('product.uom')
588         prod_line_obj = self.pool.get('mrp.production.product.line')
589         workcenter_line_obj = self.pool.get('mrp.production.workcenter.line')
590         for production in self.browse(cr, uid, ids):
591             cr.execute('delete from mrp_production_product_line where production_id=%s', (production.id,))
592             cr.execute('delete from mrp_production_workcenter_line where production_id=%s', (production.id,))
593             bom_point = production.bom_id
594             bom_id = production.bom_id.id
595             if not bom_point:
596                 bom_id = bom_obj._bom_find(cr, uid, production.product_id.id, production.product_uom.id, properties)
597                 if bom_id:
598                     bom_point = bom_obj.browse(cr, uid, bom_id)
599                     routing_id = bom_point.routing_id.id or False
600                     self.write(cr, uid, [production.id], {'bom_id': bom_id, 'routing_id': routing_id})
601
602             if not bom_id:
603                 raise osv.except_osv(_('Error'), _("Couldn't find a bill of material for this product."))
604             factor = uom_obj._compute_qty(cr, uid, production.product_uom.id, production.product_qty, bom_point.product_uom.id)
605             res = bom_obj._bom_explode(cr, uid, bom_point, factor / bom_point.product_qty, properties, routing_id=production.routing_id.id)
606             results = res[0]
607             results2 = res[1]
608             for line in results:
609                 line['production_id'] = production.id
610                 prod_line_obj.create(cr, uid, line)
611             for line in results2:
612                 line['production_id'] = production.id
613                 workcenter_line_obj.create(cr, uid, line)
614         return len(results)
615
616     def action_cancel(self, cr, uid, ids, context=None):
617         """ Cancels the production order and related stock moves.
618         @return: True
619         """
620         if context is None:
621             context = {}
622         move_obj = self.pool.get('stock.move')
623         for production in self.browse(cr, uid, ids, context=context):
624             if production.state == 'confirmed' and production.picking_id.state not in ('draft', 'cancel'):
625                 raise osv.except_osv(
626                     _('Could not cancel manufacturing order !'),
627                     _('You must first cancel related internal picking attached to this manufacturing order.'))
628             if production.move_created_ids:
629                 move_obj.action_cancel(cr, uid, [x.id for x in production.move_created_ids])
630             move_obj.action_cancel(cr, uid, [x.id for x in production.move_lines])
631         self.write(cr, uid, ids, {'state': 'cancel'})
632         return True
633
634     def action_ready(self, cr, uid, ids):
635         """ Changes the production state to Ready and location id of stock move.
636         @return: True
637         """
638         move_obj = self.pool.get('stock.move')
639         self.write(cr, uid, ids, {'state': 'ready'})
640
641         for (production_id,name) in self.name_get(cr, uid, ids):
642             production = self.browse(cr, uid, production_id)
643             if production.move_prod_id:
644                 move_obj.write(cr, uid, [production.move_prod_id.id],
645                         {'location_id': production.location_dest_id.id})
646
647             message = _("Manufacturing order '%s' is ready to produce.") % ( name,)
648             self.log(cr, uid, production_id, message)
649         return True
650
651     def action_production_end(self, cr, uid, ids):
652         """ Changes production state to Finish and writes finished date.
653         @return: True
654         """
655         for production in self.browse(cr, uid, ids):
656             self._costs_generate(cr, uid, production)
657         return self.write(cr, uid, ids, {'state': 'done', 'date_finished': time.strftime('%Y-%m-%d %H:%M:%S')})
658
659     def test_production_done(self, cr, uid, ids):
660         """ Tests whether production is done or not.
661         @return: True or False
662         """
663         res = True
664         for production in self.browse(cr, uid, ids):
665             if production.move_lines:
666                 res = False
667
668             if production.move_created_ids:
669                 res = False
670         return res
671
672     def _get_subproduct_factor(self, cr, uid, production_id, move_id=None, context=None):
673         """ Compute the factor to compute the qty of procucts to produce for the given production_id. By default, 
674             it's always equal to the quantity encoded in the production order or the production wizard, but if the 
675             module mrp_subproduct is installed, then we must use the move_id to identify the product to produce 
676             and its quantity.
677         :param production_id: ID of the mrp.order
678         :param move_id: ID of the stock move that needs to be produced. Will be used in mrp_subproduct.
679         :return: The factor to apply to the quantity that we should produce for the given production order.
680         """
681         return 1
682
683     def action_produce(self, cr, uid, production_id, production_qty, production_mode, context=None):
684         """ To produce final product based on production mode (consume/consume&produce).
685         If Production mode is consume, all stock move lines of raw materials will be done/consumed.
686         If Production mode is consume & produce, all stock move lines of raw materials will be done/consumed
687         and stock move lines of final product will be also done/produced.
688         @param production_id: the ID of mrp.production object
689         @param production_qty: specify qty to produce
690         @param production_mode: specify production mode (consume/consume&produce).
691         @return: True
692         """
693         stock_mov_obj = self.pool.get('stock.move')
694         production = self.browse(cr, uid, production_id, context=context)
695
696         produced_qty = 0
697         for produced_product in production.move_created_ids2:
698             if (produced_product.scrapped) or (produced_product.product_id.id <> production.product_id.id):
699                 continue
700             produced_qty += produced_product.product_qty
701
702         if production_mode in ['consume','consume_produce']:
703             consumed_data = {}
704
705             # Calculate already consumed qtys
706             for consumed in production.move_lines2:
707                 if consumed.scrapped:
708                     continue
709                 if not consumed_data.get(consumed.product_id.id, False):
710                     consumed_data[consumed.product_id.id] = 0
711                 consumed_data[consumed.product_id.id] += consumed.product_qty
712
713             # Find product qty to be consumed and consume it
714             for scheduled in production.product_lines:
715
716                 # total qty of consumed product we need after this consumption
717                 total_consume = ((production_qty + produced_qty) * scheduled.product_qty / production.product_qty)
718
719                 # qty available for consume and produce
720                 qty_avail = scheduled.product_qty - consumed_data.get(scheduled.product_id.id, 0.0)
721
722                 if qty_avail <= 0.0:
723                     # there will be nothing to consume for this raw material
724                     continue
725
726                 raw_product = [move for move in production.move_lines if move.product_id.id==scheduled.product_id.id]
727                 if raw_product:
728                     # qtys we have to consume
729                     qty = total_consume - consumed_data.get(scheduled.product_id.id, 0.0)
730
731                     if qty > qty_avail:
732                         # if qtys we have to consume is more than qtys available to consume
733                         prod_name = scheduled.product_id.name_get()[0][1]
734                         raise osv.except_osv(_('Warning!'), _('You are going to consume total %s quantities of "%s".\nBut you can consume upto total %s quantities.' % (qty, prod_name, qty_avail)))
735                     if qty <= 0.0:
736                         # we already have more qtys consumed than we need 
737                         continue
738
739                     raw_product[0].action_consume(qty, raw_product[0].location_id.id, context=context)
740
741         if production_mode == 'consume_produce':
742             # To produce remaining qty of final product
743             #vals = {'state':'confirmed'}
744             #final_product_todo = [x.id for x in production.move_created_ids]
745             #stock_mov_obj.write(cr, uid, final_product_todo, vals)
746             #stock_mov_obj.action_confirm(cr, uid, final_product_todo, context)
747             produced_products = {}
748             for produced_product in production.move_created_ids2:
749                 if produced_product.scrapped:
750                     continue
751                 if not produced_products.get(produced_product.product_id.id, False):
752                     produced_products[produced_product.product_id.id] = 0
753                 produced_products[produced_product.product_id.id] += produced_product.product_qty
754
755             for produce_product in production.move_created_ids:
756                 produced_qty = produced_products.get(produce_product.product_id.id, 0)
757                 subproduct_factor = self._get_subproduct_factor(cr, uid, production.id, produce_product.id, context=context)
758                 rest_qty = (subproduct_factor * production.product_qty) - produced_qty
759
760                 if rest_qty < production_qty:
761                     prod_name = produce_product.product_id.name_get()[0][1]
762                     raise osv.except_osv(_('Warning!'), _('You are going to produce total %s quantities of "%s".\nBut you can produce upto total %s quantities.' % (production_qty, prod_name, rest_qty)))
763                 if rest_qty > 0 :
764                     stock_mov_obj.action_consume(cr, uid, [produce_product.id], (subproduct_factor * production_qty), context=context)
765
766         for raw_product in production.move_lines2:
767             new_parent_ids = []
768             parent_move_ids = [x.id for x in raw_product.move_history_ids]
769             for final_product in production.move_created_ids2:
770                 if final_product.id not in parent_move_ids:
771                     new_parent_ids.append(final_product.id)
772             for new_parent_id in new_parent_ids:
773                 stock_mov_obj.write(cr, uid, [raw_product.id], {'move_history_ids': [(4,new_parent_id)]})
774
775         wf_service = netsvc.LocalService("workflow")
776         wf_service.trg_validate(uid, 'mrp.production', production_id, 'button_produce_done', cr)
777         return True
778
779     def _costs_generate(self, cr, uid, production):
780         """ Calculates total costs at the end of the production.
781         @param production: Id of production order.
782         @return: Calculated amount.
783         """
784         amount = 0.0
785         analytic_line_obj = self.pool.get('account.analytic.line')
786         for wc_line in production.workcenter_lines:
787             wc = wc_line.workcenter_id
788             if wc.costs_journal_id and wc.costs_general_account_id:
789                 value = wc_line.hour * wc.costs_hour
790                 account = wc.costs_hour_account_id.id
791                 if value and account:
792                     amount += value
793                     analytic_line_obj.create(cr, uid, {
794                         'name': wc_line.name + ' (H)',
795                         'amount': value,
796                         'account_id': account,
797                         'general_account_id': wc.costs_general_account_id.id,
798                         'journal_id': wc.costs_journal_id.id,
799                         'ref': wc.code,
800                         'product_id': wc.product_id.id,
801                         'unit_amount': wc_line.hour,
802                         'product_uom_id': wc.product_id.uom_id.id
803                     } )
804             if wc.costs_journal_id and wc.costs_general_account_id:
805                 value = wc_line.cycle * wc.costs_cycle
806                 account = wc.costs_cycle_account_id.id
807                 if value and account:
808                     amount += value
809                     analytic_line_obj.create(cr, uid, {
810                         'name': wc_line.name+' (C)',
811                         'amount': value,
812                         'account_id': account,
813                         'general_account_id': wc.costs_general_account_id.id,
814                         'journal_id': wc.costs_journal_id.id,
815                         'ref': wc.code,
816                         'product_id': wc.product_id.id,
817                         'unit_amount': wc_line.cycle,
818                         'product_uom_id': wc.product_id.uom_id.id
819                     } )
820         return amount
821
822     def action_in_production(self, cr, uid, ids):
823         """ Changes state to In Production and writes starting date.
824         @return: True
825         """
826         self.write(cr, uid, ids, {'state': 'in_production', 'date_start': time.strftime('%Y-%m-%d %H:%M:%S')})
827         return True
828
829     def test_if_product(self, cr, uid, ids):
830         """
831         @return: True or False
832         """
833         res = True
834         for production in self.browse(cr, uid, ids):
835             if not production.product_lines:
836                 if not self.action_compute(cr, uid, [production.id]):
837                     res = False
838         return res
839
840     def _get_auto_picking(self, cr, uid, production):
841         return True
842
843     def _make_production_line_procurement(self, cr, uid, production_line, shipment_move_id, context=None):
844         wf_service = netsvc.LocalService("workflow")
845         procurement_order = self.pool.get('procurement.order')
846         production = production_line.production_id
847         location_id = production.location_src_id.id
848         date_planned = production.date_planned
849         procurement_name = (production.origin or '').split(':')[0] + ':' + production.name
850         procurement_id = procurement_order.create(cr, uid, {
851                     'name': procurement_name,
852                     'origin': procurement_name,
853                     'date_planned': date_planned,
854                     'product_id': production_line.product_id.id,
855                     'product_qty': production_line.product_qty,
856                     'product_uom': production_line.product_uom.id,
857                     'product_uos_qty': production_line.product_uos and production_line.product_qty or False,
858                     'product_uos': production_line.product_uos and production_line.product_uos.id or False,
859                     'location_id': location_id,
860                     'procure_method': production_line.product_id.procure_method,
861                     'move_id': shipment_move_id,
862                     'company_id': production.company_id.id,
863                 })
864         wf_service.trg_validate(uid, procurement_order._name, procurement_id, 'button_confirm', cr)
865         return procurement_id
866
867     def _make_production_internal_shipment_line(self, cr, uid, production_line, shipment_id, parent_move_id, destination_location_id=False, context=None):
868         stock_move = self.pool.get('stock.move')
869         production = production_line.production_id
870         date_planned = production.date_planned
871         # Internal shipment is created for Stockable and Consumer Products
872         if production_line.product_id.type not in ('product', 'consu'):
873             return False
874         move_name = _('PROD: %s') % production.name
875         source_location_id = production.location_src_id.id
876         if not destination_location_id:
877             destination_location_id = source_location_id
878         return stock_move.create(cr, uid, {
879                         'name': move_name,
880                         'picking_id': shipment_id,
881                         'product_id': production_line.product_id.id,
882                         'product_qty': production_line.product_qty,
883                         'product_uom': production_line.product_uom.id,
884                         'product_uos_qty': production_line.product_uos and production_line.product_uos_qty or False,
885                         'product_uos': production_line.product_uos and production_line.product_uos.id or False,
886                         'date': date_planned,
887                         'move_dest_id': parent_move_id,
888                         'location_id': source_location_id,
889                         'location_dest_id': destination_location_id,
890                         'state': 'waiting',
891                         'company_id': production.company_id.id,
892                 })
893           
894     def _make_production_internal_shipment(self, cr, uid, production, context=None):
895         ir_sequence = self.pool.get('ir.sequence')
896         stock_picking = self.pool.get('stock.picking')
897         routing_loc = None
898         pick_type = 'internal'
899         address_id = False
900         
901         # Take routing address as a Shipment Address.
902         # If usage of routing location is a internal, make outgoing shipment otherwise internal shipment
903         if production.bom_id.routing_id and production.bom_id.routing_id.location_id:
904             routing_loc = production.bom_id.routing_id.location_id
905             if routing_loc.usage <> 'internal':
906                 pick_type = 'out'
907             address_id = routing_loc.address_id and routing_loc.address_id.id or False
908
909         # Take next Sequence number of shipment base on type
910         pick_name = ir_sequence.get(cr, uid, 'stock.picking.' + pick_type)
911
912         picking_id = stock_picking.create(cr, uid, {
913             'name': pick_name,
914             'origin': (production.origin or '').split(':')[0] + ':' + production.name,
915             'type': pick_type,
916             'move_type': 'one',
917             'state': 'auto',
918             'address_id': address_id,
919             'auto_picking': self._get_auto_picking(cr, uid, production),
920             'company_id': production.company_id.id,
921         })
922         production.write({'picking_id': picking_id}, context=context)
923         return picking_id
924
925     def _make_production_produce_line(self, cr, uid, production, context=None):
926         stock_move = self.pool.get('stock.move')
927         source_location_id = production.product_id.product_tmpl_id.property_stock_production.id
928         destination_location_id = production.location_dest_id.id
929         move_name = _('PROD: %s') + production.name 
930         data = {
931             'name': move_name,
932             'date': production.date_planned,
933             'product_id': production.product_id.id,
934             'product_qty': production.product_qty,
935             'product_uom': production.product_uom.id,
936             'product_uos_qty': production.product_uos and production.product_uos_qty or False,
937             'product_uos': production.product_uos and production.product_uos.id or False,
938             'location_id': source_location_id,
939             'location_dest_id': destination_location_id,
940             'move_dest_id': production.move_prod_id.id,
941             'state': 'waiting',
942             'company_id': production.company_id.id,
943         }
944         move_id = stock_move.create(cr, uid, data, context=context)
945         production.write({'move_created_ids': [(6, 0, [move_id])]}, context=context)
946         return move_id
947
948     def _make_production_consume_line(self, cr, uid, production_line, parent_move_id, source_location_id=False, context=None):
949         stock_move = self.pool.get('stock.move')
950         production = production_line.production_id
951         # Internal shipment is created for Stockable and Consumer Products
952         if production_line.product_id.type not in ('product', 'consu'):
953             return False
954         move_name = _('PROD: %s') % production.name
955         destination_location_id = production.product_id.product_tmpl_id.property_stock_production.id
956         if not source_location_id:
957             source_location_id = production.location_src_id.id
958         move_id = stock_move.create(cr, uid, {
959             'name': move_name,
960             'date': production.date_planned,
961             'product_id': production_line.product_id.id,
962             'product_qty': production_line.product_qty,
963             'product_uom': production_line.product_uom.id,
964             'product_uos_qty': production_line.product_uos and production_line.product_uos_qty or False,
965             'product_uos': production_line.product_uos and production_line.product_uos.id or False,
966             'location_id': source_location_id,
967             'location_dest_id': destination_location_id,
968             'move_dest_id': parent_move_id,
969             'state': 'waiting',
970             'company_id': production.company_id.id,
971         })
972         production.write({'move_lines': [(4, move_id)]}, context=context)
973         return move_id
974
975     def action_confirm(self, cr, uid, ids, context=None):
976         """ Confirms production order.
977         @return: Newly generated Shipment Id.
978         """
979         shipment_id = False
980         wf_service = netsvc.LocalService("workflow")
981         uncompute_ids = filter(lambda x:x, [not x.product_lines and x.id or False for x in self.browse(cr, uid, ids, context=context)])
982         self.action_compute(cr, uid, uncompute_ids, context=context)
983         for production in self.browse(cr, uid, ids, context=context):
984             shipment_id = self._make_production_internal_shipment(cr, uid, production, context=context)
985             produce_move_id = self._make_production_produce_line(cr, uid, production, context=context)
986             
987             # Take routing location as a Source Location.
988             source_location_id = production.location_src_id.id
989             if production.bom_id.routing_id and production.bom_id.routing_id.location_id:
990                 source_location_id = production.bom_id.routing_id.location_id.id
991
992             for line in production.product_lines:
993                 consume_move_id = self._make_production_consume_line(cr, uid, line, produce_move_id, source_location_id=source_location_id, context=context)
994                 shipment_move_id = self._make_production_internal_shipment_line(cr, uid, line, shipment_id, consume_move_id,\
995                                  destination_location_id=source_location_id, context=context)
996                 self._make_production_line_procurement(cr, uid, line, shipment_move_id, context=context)
997                     
998             wf_service.trg_validate(uid, 'stock.picking', shipment_id, 'button_confirm', cr)
999             production.write({'state':'confirmed'}, context=context)
1000             message = _("Manufacturing order '%s' is scheduled for the %s.") % (
1001                 production.name,
1002                 datetime.strptime(production.date_planned,'%Y-%m-%d %H:%M:%S').strftime('%m/%d/%Y'),
1003             )
1004             self.log(cr, uid, production.id, message)
1005         return shipment_id
1006
1007     def force_production(self, cr, uid, ids, *args):
1008         """ Assigns products.
1009         @param *args: Arguments
1010         @return: True
1011         """
1012         pick_obj = self.pool.get('stock.picking')
1013         pick_obj.force_assign(cr, uid, [prod.picking_id.id for prod in self.browse(cr, uid, ids)])
1014         return True
1015
1016 mrp_production()
1017
1018 class mrp_production_workcenter_line(osv.osv):
1019     _name = 'mrp.production.workcenter.line'
1020     _description = 'Work Order'
1021     _order = 'sequence'
1022
1023     _columns = {
1024         'name': fields.char('Work Order', size=64, required=True),
1025         'workcenter_id': fields.many2one('mrp.workcenter', 'Work Center', required=True),
1026         'cycle': fields.float('Nbr of cycles', digits=(16,2)),
1027         'hour': fields.float('Nbr of hours', digits=(16,2)),
1028         'sequence': fields.integer('Sequence', required=True, help="Gives the sequence order when displaying a list of work orders."),
1029         'production_id': fields.many2one('mrp.production', 'Production Order', select=True, ondelete='cascade', required=True),
1030     }
1031     _defaults = {
1032         'sequence': lambda *a: 1,
1033         'hour': lambda *a: 0,
1034         'cycle': lambda *a: 0,
1035     }
1036 mrp_production_workcenter_line()
1037
1038 class mrp_production_product_line(osv.osv):
1039     _name = 'mrp.production.product.line'
1040     _description = 'Production Scheduled Product'
1041     _columns = {
1042         'name': fields.char('Name', size=64, required=True),
1043         'product_id': fields.many2one('product.product', 'Product', required=True),
1044         'product_qty': fields.float('Product Qty', digits_compute=dp.get_precision('Product UoM'), required=True),
1045         'product_uom': fields.many2one('product.uom', 'Product UOM', required=True),
1046         'product_uos_qty': fields.float('Product UOS Qty'),
1047         'product_uos': fields.many2one('product.uom', 'Product UOS'),
1048         'production_id': fields.many2one('mrp.production', 'Production Order', select=True),
1049     }
1050 mrp_production_product_line()
1051
1052 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: