[IMP] stock: Improve lots with serial numbers, define lot tracking by picking type and convert stock with lot False to a lot when necessary

Instead of having only outgoing, manufacturing, incoming, all... traceability on the product,
we can define on the product if there is no tracking, lot tracking or unique serial number tracking.
On the picking type we can define whether we will only create lots for the picking type or use existing lots.

The lots are required when the product and the picking type demand it.

The option for serial number will check that each piece (quantity 1) has its
own serial number.

A lot splitting wizard is added on the pack operations in the picking form.

We want to be able to handle the case where we receive a lot of iPads e.g. and we
only assign a serial number when the product is delivered.  For this, when the product is
delivered, we see that we only have products with no lots.  When we see that we take something
without lot for something that needed one, we assign the lot to that piece of stock.

For negative quants: a positive quant with no lot can reconcile with a negative quant with/without lot,
a positive quant with lot, will preferably reconcile with a negative quant with the same lot, afterwards
with that without lot.
This commit is contained in:
Josse Colpaert
2015-07-03 17:50:35 +02:00
parent 4d19428685
commit d2b4b3909e
17 changed files with 309 additions and 194 deletions
-3
View File
@@ -555,9 +555,6 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="stock.view_template_property_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='lot']" position="inside">
<field name="track_production" groups="stock.group_production_lot" attrs="{'invisible': [('track_all', '=', True)]}"/>
</xpath>
<group name="sale_condition" position="inside">
<label for="produce_delay" attrs="{'invisible':[('type','=','service')]}"/>
<div attrs="{'invisible':[('type','=','service')]}">
-1
View File
@@ -27,7 +27,6 @@ class product_template(osv.osv):
'bom_count': fields.function(_bom_orders_count, string='# Bill of Material', type='integer', multi="_bom_order_count"),
'mo_count': fields.function(_bom_orders_count_mo, string='# Manufacturing Orders', type='integer'),
'produce_delay': fields.float('Manufacturing Lead Time', help="Average delay in days to produce this product. In the case of multi-level BOM, the manufacturing lead times of the components will be added."),
'track_production': fields.boolean('Track Manufacturing Lots', help="Forces to specify a Serial Number for all moves containing this product and generated by a Manufacturing Order"),
}
_defaults = {
+1 -20
View File
@@ -21,28 +21,9 @@ class StockMove(osv.osv):
def check_tracking(self, cr, uid, move, lot_id, context=None):
super(StockMove, self).check_tracking(cr, uid, move, lot_id, context=context)
if move.product_id.track_production and (move.location_id.usage == 'production' or move.location_dest_id.usage == 'production') and not lot_id:
raise UserError(_('You must assign a serial number for the product %s') % (move.product_id.name))
if move.raw_material_production_id and move.location_dest_id.usage == 'production' and move.raw_material_production_id.product_id.track_production and not move.consumed_for:
if move.raw_material_production_id and move.product_id.tracking!='none' and move.location_dest_id.usage == 'production' and move.raw_material_production_id.product_id.tracking != 'none' and not move.consumed_for:
raise UserError(_("Because the product %s requires it, you must assign a serial number to your raw material %s to proceed further in your production. Please use the 'Produce' button to do so.") % (move.raw_material_production_id.product_id.name, move.product_id.name))
# TODO master: remove me, no longer used
def _check_phantom_bom(self, cr, uid, move, context=None):
"""check if product associated to move has a phantom bom
return list of ids of mrp.bom for that product """
user_company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
#doing the search as SUPERUSER because a user with the permission to write on a stock move should be able to explode it
#without giving him the right to read the boms.
domain = [
'|', ('product_id', '=', move.product_id.id),
'&', ('product_id', '=', False), ('product_tmpl_id.product_variant_ids', '=', move.product_id.id),
('type', '=', 'phantom'),
'|', ('date_start', '=', False), ('date_start', '<=', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)),
'|', ('date_stop', '=', False), ('date_stop', '>=', time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)),
('company_id', '=', user_company)]
return self.pool.get('mrp.bom').search(cr, SUPERUSER_ID, domain, context=context)
def _action_explode(self, cr, uid, move, context=None):
""" Explodes pickings.
@param move: Stock moves
+4 -4
View File
@@ -14,7 +14,6 @@ class mrp_product_produce_line(osv.osv_memory):
'product_qty': fields.float('Quantity (in default UoM)', digits_compute=dp.get_precision('Product Unit of Measure')),
'lot_id': fields.many2one('stock.production.lot', string='Lot'),
'produce_id': fields.many2one('mrp.product.produce', string="Produce"),
'track_production': fields.related('product_id', 'track_production', type='boolean', string="Track Production"),
}
class mrp_product_produce(osv.osv_memory):
@@ -31,7 +30,8 @@ class mrp_product_produce(osv.osv_memory):
"and it will finish the production order when total ordered quantities are produced."),
'lot_id': fields.many2one('stock.production.lot', 'Lot'), #Should only be visible when it is consume and produce mode
'consume_lines': fields.one2many('mrp.product.produce.line', 'produce_id', 'Products Consumed'),
'track_production': fields.boolean('Track production'),
'tracking': fields.related('product_id', 'tracking', type='selection',
selection=[('serial', 'By Unique Serial Number'), ('lot', 'By Lots'), ('none', 'No Tracking')]),
}
def on_change_qty(self, cr, uid, ids, product_qty, consume_lines, context=None):
@@ -88,13 +88,13 @@ class mrp_product_produce(osv.osv_memory):
def _get_track(self, cr, uid, context=None):
prod = self._get_product_id(cr, uid, context=context)
prod_obj = self.pool.get("product.product")
return prod and prod_obj.browse(cr, uid, prod, context=context).track_production or False
return prod and prod_obj.browse(cr, uid, prod, context=context).tracking or 'none'
_defaults = {
'product_qty': _get_product_qty,
'mode': lambda *x: 'consume_produce',
'product_id': _get_product_id,
'track_production': _get_track,
'tracking': _get_track,
}
def do_produce(self, cr, uid, ids, context=None):
@@ -14,10 +14,10 @@
<field name="mode"/>
<field name="product_qty" colspan="2" on_change="on_change_qty(product_qty, consume_lines, context)"/>
<field name="product_id" invisible="1"/>
<field name="track_production" invisible="1"/>
<field name="tracking" invisible="1"/>
<field name="lot_id" domain="[('product_id', '=', product_id)]"
context="{'default_product_id':product_id}"
attrs="{'required': [('track_production', '=', True), ('mode', '=', 'consume_produce')]}"
attrs="{'required': [('tracking', '!=', 'none'), ('mode', '=', 'consume_produce')]}"
groups="stock.group_production_lot"/>
</group>
<group string="To Consume">
+1 -1
View File
@@ -268,7 +268,7 @@ class mrp_repair(osv.osv):
else:
self.write(cr, uid, [o.id], {'state': 'confirmed'})
for line in o.operations:
if line.product_id.track_production:
if line.product_id.tracking != 'none':
raise UserError(_("Serial number is required for operation line with product '%s'") % (line.product_id.name))
mrp_line_obj.write(cr, uid, [l.id for l in o.operations], {'state': 'confirmed'})
return True
+2 -1
View File
@@ -58,6 +58,7 @@ Dashboard / Reports for Warehouse Management will include:
'wizard/make_procurement_view.xml',
'wizard/orderpoint_procurement_view.xml',
'wizard/stock_pack_details.xml',
'wizard/stock_lot_split.xml',
'stock_incoterms.xml',
'stock_report.xml',
'stock_view.xml',
@@ -74,7 +75,7 @@ Dashboard / Reports for Warehouse Management will include:
'report/report_stock_forecast.xml',
'stock_dashboard.xml',
'wizard/stock_immediate_transfer.xml',
'wizard/stock_backorder_confirmation.xml'
'wizard/stock_backorder_confirmation.xml',
],
'test': [
'test/inventory.yml',
+8 -10
View File
@@ -312,14 +312,14 @@ class product_product(osv.osv):
templ_ids = list(set([x.product_tmpl_id.id for x in self.browse(cr, uid, ids, context=context)]))
return template_obj.action_view_routes(cr, uid, templ_ids, context=context)
def onchange_track_all(self, cr, uid, ids, track_all, context=None):
if not track_all:
def onchange_tracking(self, cr, uid, ids, tracking, context=None):
if not tracking or tracking == 'none':
return {}
unassigned_quants = self.pool['stock.quant'].search_count(cr, uid, [('product_id','in', ids), ('lot_id','=', False), ('location_id.usage','=', 'internal')], context=context)
if unassigned_quants:
return {'warning' : {
'title': _('Warning!'),
'message' : _("Lots are not defined for all the existing inventory of this product. You should assign serial numbers (e.g. by creating an inventory) first.")
'message' : _("You have products in stock that have no lot number. You can assign serial numbers by doing an inventory. ")
}}
return {}
@@ -392,10 +392,7 @@ class product_template(osv.osv):
'loc_rack': fields.char('Rack', size=16),
'loc_row': fields.char('Row', size=16),
'loc_case': fields.char('Case', size=16),
'track_incoming': fields.boolean('Track Incoming Lots', help="Forces to specify a Serial Number for all moves containing this product and coming from a Supplier Location"),
'track_outgoing': fields.boolean('Track Outgoing Lots', help="Forces to specify a Serial Number for all moves containing this product and going to a Customer Location"),
'track_all': fields.boolean('Full Lots Traceability', help="Forces to specify a Serial Number on each and every operation related to this product"),
'tracking': fields.selection(selection=[('serial', 'By Unique Serial Number'), ('lot', 'By Lots'), ('none', 'No Tracking')], string="Tracking", required=True),
# sum of product variant qty
# 'reception_count': fields.function(_product_available, multi='qty_available',
# fnct_search=_search_product_quantity, type='float', string='Quantity On Hand'),
@@ -416,6 +413,7 @@ class product_template(osv.osv):
_defaults = {
'sale_delay': 7,
'tracking': 'none',
}
def action_view_routes(self, cr, uid, ids, context=None):
@@ -432,12 +430,12 @@ class product_template(osv.osv):
result['domain'] = "[('id','in',[" + ','.join(map(str, route_ids)) + "])]"
return result
def onchange_track_all(self, cr, uid, ids, track_all, context=None):
if not track_all:
def onchange_tracking(self, cr, uid, ids, tracking, context=None):
if not tracking:
return {}
product_product = self.pool['product.product']
variant_ids = product_product.search(cr, uid, [('product_tmpl_id', 'in', ids)], context=context)
return product_product.onchange_track_all(cr, uid, variant_ids, track_all, context=context)
return product_product.onchange_tracking(cr, uid, variant_ids, tracking, context=context)
def _get_products(self, cr, uid, ids, context=None):
products = []
+1 -3
View File
@@ -135,9 +135,7 @@
</group>
<group name="status" position="before">
<group name="lot" groups="stock.group_production_lot" string="Lots" attrs="{'invisible':[('type','=','service')]}">
<field name="track_all" groups="stock.group_production_lot" on_change="onchange_track_all(track_all)"/>
<field name="track_incoming" groups="stock.group_production_lot" attrs="{'invisible': [('track_all', '=', True)]}"/>
<field name="track_outgoing" groups="stock.group_production_lot" attrs="{'invisible': [('track_all', '=', True)]}"/>
<field name="tracking" on_change="onchange_tracking(tracking)" widget="radio"/>
</group>
</group>
<group name="weight" position="before">
+136 -117
View File
@@ -396,7 +396,7 @@ class stock_quant(osv.osv):
quants_reconcile.append(quant)
if to_move_quants:
to_recompute_move_ids = [x.reservation_id.id for x in to_move_quants if x.reservation_id and x.reservation_id.id != move.id]
self.move_quants_write(cr, uid, to_move_quants, move, location_to, dest_package_id, context=context)
self.move_quants_write(cr, uid, to_move_quants, move, location_to, dest_package_id, lot_id=lot_id, context=context)
self.pool.get('stock.move').recalculate_move_state(cr, uid, to_recompute_move_ids, context=context)
if location_to.usage == 'internal':
# Do manual search for quant to avoid full table scan (order by id)
@@ -408,11 +408,13 @@ class stock_quant(osv.osv):
for quant in quants_reconcile:
self._quant_reconcile_negative(cr, uid, quant, move, context=context)
def move_quants_write(self, cr, uid, quants, move, location_dest_id, dest_package_id, context=None):
def move_quants_write(self, cr, uid, quants, move, location_dest_id, dest_package_id, lot_id = False, context=None):
context=context or {}
vals = {'location_id': location_dest_id.id,
'history_ids': [(4, move.id)],
'reservation_id': False}
if lot_id and any(x.id for x in quants if not x.lot_id.id):
vals['lot_id'] = lot_id
if not context.get('entire_pack'):
vals.update({'package_id': dest_package_id})
self.write(cr, SUPERUSER_ID, [q.id for q in quants], vals, context=context)
@@ -430,6 +432,19 @@ class stock_quant(osv.osv):
if location.usage in ['inventory', 'production', 'supplier']:
return quants
res_qty = qty
if restrict_lot_id:
if not prefered_domain_list:
prefered_domain_list = [[('lot_id', '=', restrict_lot_id)],[('lot_id', '=', False)]]
else:
lot_list = []
no_lot_list = []
for pref_domain in prefered_domain_list:
pref_lot_domain = pref_domain + [('lot_id', '=', restrict_lot_id)]
pref_no_lot_domain = pref_domain + [('lot_id', '=', False)]
lot_list.append(pref_lot_domain)
no_lot_list.append(pref_no_lot_domain)
prefered_domain_list = lot_list + no_lot_list
if not prefered_domain_list:
return self.quants_get(cr, uid, location, product, qty, domain=domain, restrict_lot_id=restrict_lot_id, restrict_partner_id=restrict_partner_id, context=context)
for prefered_domain in prefered_domain_list:
@@ -458,7 +473,7 @@ class stock_quant(osv.osv):
if restrict_partner_id:
domain += [('owner_id', '=', restrict_partner_id)]
if restrict_lot_id:
domain += [('lot_id', '=', restrict_lot_id)]
domain += ['|', ('lot_id', '=', restrict_lot_id), ('lot_id', '=', False)]
if location:
removal_strategy = self.pool.get('stock.location').get_removal_strategy(cr, uid, location, product, context=context)
result += self.apply_removal_strategy(cr, uid, location, product, qty, domain, removal_strategy, context=context)
@@ -507,6 +522,16 @@ class stock_quant(osv.osv):
negative_quant_id = self.create(cr, SUPERUSER_ID, negative_vals, context=context)
vals.update({'propagated_from_id': negative_quant_id})
# In case of serial tracking, check if the product does not exist somewhere internally already
picking_type = move.picking_id and move.picking_id.picking_type_id or False
if move.product_id.tracking == 'serial' and (not picking_type or (picking_type.use_create_lots or picking_type.use_existing_lots)):
if qty != 1.0:
raise UserError(_('You should only receive by the piece with the same serial number'))
other_quants = self.search(cr, uid, [('product_id', '=', move.product_id.id), ('lot_id', '=', lot_id),
('qty', '>', 0.0), ('location_id.usage', '=', 'internal')], context=context)
if other_quants:
raise UserError(_('The serial number %s is already in stock') % lot_id.name)
#create the quant as superuser, because we want to restrict the creation of quant manually: we should always use this method to create quants
quant_id = self.create(cr, SUPERUSER_ID, vals, context=context)
return self.browse(cr, uid, quant_id, context=context)
@@ -547,12 +572,11 @@ class stock_quant(osv.osv):
"""
solving_quant = quant
dom = [('qty', '<', 0)]
if quant.lot_id:
dom += [('lot_id', '=', quant.lot_id.id)]
dom += [('owner_id', '=', quant.owner_id.id)]
dom += [('package_id', '=', quant.package_id.id)]
if quant.package_id.id:
dom += [('package_id', '=', quant.package_id.id)]
dom += [('id', '!=', quant.propagated_from_id.id)]
quants = self.quants_get(cr, uid, quant.location_id, quant.product_id, quant.qty, dom, context=context)
quants = self.quants_get_prefered_domain(cr, uid, quant.location_id, quant.product_id, quant.qty, dom,
restrict_lot_id=quant.lot_id.id, restrict_partner_id=quant.owner_id.id, context=context)
product_uom_rounding = quant.product_id.uom_id.rounding
for quant_neg, qty in quants:
if not quant_neg or not solving_quant:
@@ -790,7 +814,8 @@ class stock_picking(models.Model):
if picking_type_id:
picking_type = self.pool['stock.picking.type'].browse(cr, uid, picking_type_id)
res['value'] = {'location_id': picking_type.default_location_src_id.id,
'location_dest_id': picking_type.default_location_dest_id.id}
'location_dest_id': picking_type.default_location_dest_id.id,
'lots_visible': picking_type.use_create_lots or picking_type.use_existing_lots,}
return res
def _default_location_destination(self):
@@ -1521,6 +1546,7 @@ class stock_picking(models.Model):
stock_move_obj = self.pool["stock.move"]
stock_operation_obj = self.pool["stock.pack.operation"]
package_obj = self.pool["stock.quant.package"]
package_id = False
for pick in self.browse(cr, uid, ids, context=context):
operations = [x for x in pick.pack_operation_ids if x.qty_done > 0 and (not x.result_package_id)]
pack_operation_ids = []
@@ -1532,11 +1558,13 @@ class stock_picking(models.Model):
stock_operation_obj.write(cr, uid, operation.id, {'product_qty': operation.product_qty - operation.qty_done,'qty_done': 0, 'lot_id': False}, context=context)
op = stock_operation_obj.browse(cr, uid, new_operation, context=context)
pack_operation_ids.append(op.id)
if op.product_id and op.location_id and op.location_dest_id:
stock_move_obj.check_tracking_product(cr, uid, op.product_id, op.lot_id.id, op.location_id, op.location_dest_id, context=context)
if operations:
stock_operation_obj.check_tracking(cr, uid, pack_operation_ids, context=context)
package_id = package_obj.create(cr, uid, {}, context=context)
stock_operation_obj.write(cr, uid, pack_operation_ids, {'result_package_id': package_id}, context=context)
else:
raise UserError(_('Please process some quantities to put in the pack first!'))
return package_id
class stock_production_lot(osv.osv):
@@ -2168,22 +2196,13 @@ class stock_move(osv.osv):
self.check_recompute_pack_op(cr, uid, ids, context=context)
return res
def check_tracking_product(self, cr, uid, product, lot_id, location, location_dest, context=None):
check = False
if product.track_all and not location_dest.usage == 'inventory':
check = True
elif product.track_incoming and location.usage in ('supplier', 'transit', 'inventory') and location_dest.usage == 'internal':
check = True
elif product.track_outgoing and location_dest.usage in ('customer', 'transit') and location.usage == 'internal':
check = True
if check and not lot_id:
raise UserError(_('You must assign a serial number for the product %s') % (product.name))
def check_tracking(self, cr, uid, move, lot_id, context=None):
""" Checks if serial number is assigned to stock move or not and raise an error if it had to.
"""
self.check_tracking_product(cr, uid, move.product_id, lot_id, move.location_id, move.location_dest_id, context=context)
if move.picking_id and (move.picking_id.picking_type_id.use_existing_lots or move.picking_id.picking_type_id.use_create_lots) and \
move.product_id.tracking != 'none':
if not lot_id:
raise UserError(_('You need to provide a Lot/Serial Number for product %s') % move.product_id.name)
def check_recompute_pack_op(self, cr, uid, ids, context=None):
pickings = list(set([x.picking_id for x in self.browse(cr, uid, ids, context=context) if x.picking_id]))
@@ -2373,7 +2392,7 @@ class stock_move(osv.osv):
prefered_domain_list = [prefered_domain] + [fallback_domain] + [fallback_domain2]
dom = main_domain + self.pool.get('stock.move.operation.link').get_specific_domain(cr, uid, record, context=context)
quants = quant_obj.quants_get_prefered_domain(cr, uid, ops.location_id, move.product_id, record.qty, domain=dom, prefered_domain_list=prefered_domain_list,
restrict_lot_id=move.restrict_lot_id.id, restrict_partner_id=move.restrict_partner_id.id, context=context)
restrict_lot_id=ops.lot_id.id, restrict_partner_id=ops.owner_id.id, context=context)
if ops.product_id:
#If a product is given, the result is always put immediately in the result package (if it is False, they are without package)
quant_dest_package_id = ops.result_package_id.id
@@ -3383,6 +3402,8 @@ class stock_warehouse(osv.osv):
'name': _('Receipts'),
'warehouse_id': warehouse.id,
'code': 'incoming',
'use_create_lots': True,
'use_existing_lots': False,
'sequence_id': in_seq_id,
'default_location_src_id': supplier_loc.id,
'default_location_dest_id': input_loc.id,
@@ -3392,6 +3413,8 @@ class stock_warehouse(osv.osv):
'name': _('Delivery Orders'),
'warehouse_id': warehouse.id,
'code': 'outgoing',
'use_create_lots': False,
'use_existing_lots': True,
'sequence_id': out_seq_id,
'return_picking_type_id': in_type_id,
'default_location_src_id': output_loc.id,
@@ -3403,6 +3426,8 @@ class stock_warehouse(osv.osv):
'name': _('Internal Transfers'),
'warehouse_id': warehouse.id,
'code': 'internal',
'use_create_lots': False,
'use_existing_lots': True,
'sequence_id': int_seq_id,
'default_location_src_id': wh_stock_loc.id,
'default_location_dest_id': wh_stock_loc.id,
@@ -3413,6 +3438,8 @@ class stock_warehouse(osv.osv):
'name': _('Pack'),
'warehouse_id': warehouse.id,
'code': 'internal',
'use_create_lots': False,
'use_existing_lots': True,
'sequence_id': pack_seq_id,
'default_location_src_id': wh_pack_stock_loc.id,
'default_location_dest_id': output_loc.id,
@@ -3423,6 +3450,8 @@ class stock_warehouse(osv.osv):
'name': _('Pick'),
'warehouse_id': warehouse.id,
'code': 'internal',
'use_create_lots': False,
'use_existing_lots': True,
'sequence_id': pick_seq_id,
'default_location_src_id': wh_stock_loc.id,
'default_location_dest_id': wh_pack_stock_loc.id,
@@ -4029,6 +4058,27 @@ class stock_pack_operation(osv.osv):
self.write(cr, uid, [id], {'qty_done': 1.0}, context=context)
return True
def _compute_lots_visible(self, cr, uid, ids, field_name, arg, context=None):
res = {}
for pack in self.browse(cr, uid, ids, context=context):
pick = pack.picking_id
product_requires = (pack.product_id.tracking != 'none')
if pick.picking_type_id:
res[pack.id] = pick.picking_type_id.use_existing_lots or pick.picking_type_id.use_create_lots and product_requires
else:
res[pack.id] = product_requires
return res
def _get_default_from_loc(self, cr, uid, context=None):
default_loc = context.get('default_location_id')
if default_loc:
return self.pool['stock.location'].browse(cr, uid, default_loc, context=context).name
def _get_default_to_loc(self, cr, uid, context=None):
default_loc = context.get('default_location_dest_id')
if default_loc:
return self.pool['stock.location'].browse(cr, uid, default_loc, context=context).name
_columns = {
'picking_id': fields.many2one('stock.picking', 'Stock Picking', help='The stock operation where the packing has been made', required=True),
'product_id': fields.many2one('product.product', 'Product', ondelete="CASCADE"), # 1
@@ -4051,6 +4101,7 @@ class stock_pack_operation(osv.osv):
'from_loc': fields.function(_compute_location_description, type='char', string='From', multi='loc', readonly=True),
'to_loc': fields.function(_compute_location_description, type='char', string='To', multi='loc', readonly=True),
'fresh_record': fields.boolean('Newly created pack operation'),
'lots_visible': fields.function(_compute_lots_visible, type='boolean'),
'state': fields.related('picking_id', 'state', type='selection', selection=[
('draft', 'Draft'),
('cancel', 'Cancelled'),
@@ -4068,6 +4119,8 @@ class stock_pack_operation(osv.osv):
'product_qty': 0.0,
'processed_boolean': lambda *a: False,
'fresh_record': True,
'from_loc': _get_default_from_loc,
'to_loc': _get_default_to_loc,
}
def write(self, cr, uid, ids, vals, context=None):
@@ -4093,97 +4146,59 @@ class stock_pack_operation(osv.osv):
raise UserError(_('You can not delete pack operations of a done picking'))
return super(stock_pack_operation, self).unlink(cr, uid, ids, context=context)
def action_drop_down(self, cr, uid, ids, context=None):
''' Used by barcode interface to say that pack_operation has been moved from src location
to destination location, if qty_done is less than product_qty than we have to split the
operation in two to process the one with the qty moved
'''
processed_ids = []
move_obj = self.pool.get("stock.move")
for pack_op in self.browse(cr, uid, ids, context=None):
if pack_op.product_id and pack_op.location_id and pack_op.location_dest_id:
move_obj.check_tracking_product(cr, uid, pack_op.product_id, pack_op.lot_id.id, pack_op.location_id, pack_op.location_dest_id, context=context)
op = pack_op.id
if pack_op.qty_done < pack_op.product_qty:
# we split the operation in two
op = self.copy(cr, uid, pack_op.id, {'product_qty': pack_op.qty_done, 'qty_done': pack_op.qty_done}, context=context)
self.write(cr, uid, [pack_op.id], {'product_qty': pack_op.product_qty - pack_op.qty_done, 'qty_done': 0, 'lot_id': False}, context=context)
processed_ids.append(op)
self.write(cr, uid, processed_ids, {'processed': 'true'}, context=context)
def check_tracking(self, cr, uid, ids, context=None):
""" Checks if serial number is assigned to stock move or not and raise an error if it had to.
"""
operations = self.browse(cr, uid, ids, context=context)
for ops in operations:
if ops.picking_id and (ops.picking_id.picking_type_id.use_existing_lots or ops.picking_id.picking_type_id.use_create_lots) and \
ops.product_id and ops.product_id.tracking != 'none':
if not ops.lot_id:
raise UserError(_('You need to provide a Lot/Serial Number for product %s') % ops.product_id.name)
if ops.product_id.tracking == 'serial' and ops.qty_done != 1.0:
raise UserError(_('You should provide a different Lot for each piece'))
def create_and_assign_lot(self, cr, uid, id, name, context=None):
''' Used by barcode interface to create a new lot and assign it to the operation
'''
obj = self.browse(cr,uid,id,context)
product_id = obj.product_id.id
val = {'product_id': product_id}
new_lot_id = False
if name:
lots = self.pool.get('stock.production.lot').search(cr, uid, ['&', ('name', '=', name), ('product_id', '=', product_id)], context=context)
if lots:
new_lot_id = lots[0]
val.update({'name': name})
if not new_lot_id:
new_lot_id = self.pool.get('stock.production.lot').create(cr, uid, val, context=context)
self.write(cr, uid, id, {'lot_id': new_lot_id}, context=context)
def _search_and_increment(self, cr, uid, picking_id, domain, filter_visible=False, visible_op_ids=False, increment=1, context=None):
'''Search for an operation with given 'domain' in a picking, if it exists increment the qty by the value of increment otherwise create it
:param domain: list of tuple directly reusable as a domain
context can receive a key 'current_package_id' with the package to consider for this operation
returns True
'''
if context is None:
context = {}
#if current_package_id is given in the context, we increase the number of items in this package
package_clause = [('result_package_id', '=', context.get('current_package_id', False))]
existing_operation_ids = self.search(cr, uid, [('picking_id', '=', picking_id)] + domain + package_clause, context=context)
todo_operation_ids = []
if existing_operation_ids:
if filter_visible:
todo_operation_ids = [val for val in existing_operation_ids if val in visible_op_ids]
def split_lot(self, cr, uid, ids, context=None):
assert len(ids) > 0
data_obj = self.pool['ir.model.data']
pack = self.browse(cr, uid, ids[0], context=context)
picking_type = pack.picking_id.picking_type_id
serial = (pack.product_id.tracking == 'serial')
view = data_obj.xmlid_to_res_id(cr, uid, 'stock.view_lot_split')
only_create = picking_type.use_create_lots and not picking_type.use_existing_lots
line_ids = []
if pack.qty_done > 0 and pack.lot_id:
if pack.product_id.tracking == 'serial':
product_qty = 1.0
else:
todo_operation_ids = existing_operation_ids
if todo_operation_ids:
#existing operation found for the given domain and picking => increment its quantity
operation_id = todo_operation_ids[0]
op_obj = self.browse(cr, uid, operation_id, context=context)
qty = op_obj.qty_done
if increment > 0:
qty += increment
elif increment < 0:
if qty == 0 and op_obj.product_qty == 0:
#we have a line with 0 qty set, so delete it
self.unlink(cr, uid, [operation_id], context=context)
return False
else:
qty = max(0, qty-1)
self.write(cr, uid, [operation_id], {'qty_done': qty}, context=context)
else:
#no existing operation found for the given domain and picking => create a new one
picking_obj = self.pool.get("stock.picking")
picking = picking_obj.browse(cr, uid, picking_id, context=context)
values = {
'picking_id': picking_id,
'product_qty': 0,
'location_id': picking.location_id.id,
'location_dest_id': picking.location_dest_id.id,
'qty_done': increment,
}
for key in domain:
var_name, dummy, value = key
uom_id = False
if var_name == 'product_id':
uom_id = self.pool.get('product.product').browse(cr, uid, value, context=context).uom_id.id
update_dict = {var_name: value}
if uom_id:
update_dict['product_uom_id'] = uom_id
values.update(update_dict)
operation_id = self.create(cr, uid, values, context=context)
return operation_id
product_qty = pack.qty_done
line_ids = [(0, 0, {'lot_id': pack.lot_id.id,
'lot_name': pack.lot_id.name if only_create else '',
'product_qty': product_qty,
})]
values = {
'pack_id': pack.id,
'product_id': pack.product_id.id,
'product_uom_id': pack.product_uom_id.id,
'product_qty': pack.product_qty,
'qty_done': pack.qty_done,
'only_create': only_create,
'line_ids': line_ids,
}
wiz_id = self.pool['stock.lot.split'].create(cr, uid, values, context=context)
return {
'name': _('Split Lot'),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'stock.lot.split',
'views': [(view, 'form')],
'view_id': view,
'target': 'new',
'res_id': wiz_id,
'context': {'serial': serial,
'only_create': only_create},
}
class stock_move_operation_link(osv.osv):
@@ -4216,8 +4231,8 @@ class stock_move_operation_link(osv.osv):
#if not given any information about package, we don't open boxes
domain.append(('package_id', '=', False))
#if lot info is given, we restrict choice to this lot otherwise we can take any
if op.lot_id:
domain.append(('lot_id', '=', op.lot_id.id))
#if op.lot_id:
# domain.append(('lot_id', '=', op.lot_id.id))
#if owner info is given, we restrict to this owner otherwise we restrict to no owner
if op.owner_id:
domain.append(('owner_id', '=', op.owner_id.id))
@@ -4471,9 +4486,11 @@ class stock_picking_type(osv.osv):
'default_location_dest_id': fields.many2one('stock.location', 'Default Destination Location'),
'code': fields.selection([('incoming', 'Suppliers'), ('outgoing', 'Customers'), ('internal', 'Internal')], 'Type of Operation', required=True),
'return_picking_type_id': fields.many2one('stock.picking.type', 'Picking Type for Returns'),
'show_entire_packs': fields.boolean('Show entire packs to move'),
'show_entire_packs': fields.boolean('Allow moving packs'),
'warehouse_id': fields.many2one('stock.warehouse', 'Warehouse', ondelete='cascade'),
'active': fields.boolean('Active'),
'use_create_lots': fields.boolean('Create New Lots'),
'use_existing_lots': fields.boolean('Use Existing Lots'),
# Statistics for the kanban view
'last_done_picking': fields.function(_get_tristate_values,
@@ -4505,6 +4522,8 @@ class stock_picking_type(osv.osv):
_defaults = {
'warehouse_id': _default_warehouse,
'active': True,
'use_existing_lots': True,
'use_create_lots': True,
}
class barcode_rule(models.Model):
+17 -9
View File
@@ -729,13 +729,16 @@
<button name="do_prepare_partial" type="object" string="Recompute" attrs="{'invisible': [('recompute_pack_op','=', False)]}"/>
<field name="recompute_pack_op" invisible="1"/>
<field name="pack_operation_ids" invisible="1"/>
<field name="pack_operation_product_ids" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}">
<field name="pack_operation_product_ids" options="{'reload_on_button': True}" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}">
<tree editable="bottom" colors="grey:result_package_id;red:qty_done&gt;product_qty;green:qty_done==product_qty and state!='done'">
<field name="package_id" groups="stock.group_tracking_lot" invisible="1"/>
<field name="product_id" on_change="product_id_change(product_id, product_uom_id, product_qty)" required="1" attrs="{'readonly': [('fresh_record', '=', False)]}"/>
<field name="fresh_record" invisible="1"/>
<field name="product_uom_id" attrs="{'readonly': [('fresh_record', '=', False)]}" groups="product.group_uom"/>
<field name="lot_id" domain="[('product_id','=?', product_id)]" context="{'product_id': product_id}" groups="stock.group_production_lot" attrs="{'readonly': [('fresh_record', '=', False)]}"/>
<field name="lot_id" domain="[('product_id','=', product_id)]" context="{'product_id': product_id}" groups="stock.group_production_lot" attrs="{'readonly': [('fresh_record', '=', False)]}"/>
<button name="split_lot" string="Lot Split" type="object" icon="STOCK_INDENT" groups="stock.group_production_lot"
attrs="{'invisible': ['|', ('lots_visible', '=', False), ('state', 'not in', ['confirmed', 'assigned', 'waiting', 'partially_available'])]}"/>
<field name="lots_visible" invisible="1"/>
<field name="picking_id" invisible="1"/>
<field name="owner_id" groups="stock.group_tracking_owner"/>
<field name="location_id" domain="[('id', 'child_of', parent.location_id)]" invisible="1"/>
@@ -751,7 +754,7 @@
</tree>
</field>
<field name="picking_type_entire_packs" invisible="1"/>
<field name="pack_operation_pack_ids" attrs="{'invisible': [('pack_operation_pack_ids', '=', []), ('picking_type_entire_packs', '=', False)]}" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_picking_id': id}" groups="stock.group_tracking_lot">
<field name="pack_operation_pack_ids" options="{'reload_on_button': True}" attrs="{'invisible': [('pack_operation_pack_ids', '=', []), ('picking_type_entire_packs', '=', False)]}" context="{'default_picking_id': id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_picking_id': id}" groups="stock.group_tracking_lot">
<tree editable="bottom" colors="grey:qty_done&gt;0;">
<field name="package_id" required="1" string="Package To Move"/>
<field name="picking_id" invisible="1"/>
@@ -764,13 +767,13 @@
<field name="qty_done" invisible="1"/>
<field name="processed_boolean"/>
<button name="%(stock.pack_details)d" string="Modify" type="action" icon="STOCK_EXECUTE"
states="confirmed,assigned,waiting,partially_available"/>
states="confirmed,assigned,waiting,partially_available" groups="base.group_no_one"/>
</tree>
</field>
<button class="oe_link oe_right" name="put_in_pack" type="object" string="&#8627;Put in Pack" attrs="{'invisible': [('state', 'in', ('done', 'cancel'))]}" groups="stock.group_tracking_lot"/>
</page>
<page string="Initial Demand" attrs="{'invisible': [('state', 'not in', ('draft', 'confirmed', 'waiting'))]}">
<field name="move_lines" context="{'address_in_id': partner_id, 'form_view_ref':'stock.view_move_picking_form', 'tree_view_ref':'stock.view_move_picking_tree', 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}">
<field name="move_lines" options="{'reload_on_button': True}" context="{'address_in_id': partner_id, 'form_view_ref':'stock.view_move_picking_form', 'tree_view_ref':'stock.view_move_picking_tree', 'default_picking_type_id': picking_type_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id}">
<kanban class="o_kanban_mobile o_kanban_one2many">
<field name="product_id"/>
<field name="availability"/>
@@ -1449,13 +1452,18 @@
<field name="code" on_change="onchange_picking_code(code)"/>
<field name="return_picking_type_id"/>
<field name="barcode_nomenclature_id"/>
<field name="show_entire_packs" groups="stock.group_tracking_lot"/>
</group>
</group>
<separator string="Locations"/>
<group>
<field name="default_location_src_id" attrs="{'required': [('code', '=', 'internal')]}"/>
<field name="default_location_dest_id" attrs="{'required': [('code', 'in', ('internal', 'incoming'))]}"/>
<group string="Packs and Lots">
<field name="show_entire_packs" groups="stock.group_tracking_lot"/>
<field name="use_create_lots" groups="stock.group_production_lot"/>
<field name="use_existing_lots" groups="stock.group_production_lot"/>
</group>
<group string="Locations">
<field name="default_location_src_id" attrs="{'required': [('code', '=', 'internal')]}"/>
<field name="default_location_dest_id" attrs="{'required': [('code', 'in', ('internal', 'incoming'))]}"/>
</group>
</group>
</sheet>
</form>
+1
View File
@@ -9,3 +9,4 @@ import orderpoint_procurement
import stock_pack_details
import stock_immediate_transfer
import stock_backorder_confirmation
import stock_lot_split
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp import models, fields, api, _
import openerp.addons.decimal_precision as dp
from openerp.exceptions import UserError
class stock_lot_split(models.TransientModel):
_name = 'stock.lot.split'
_description = 'Lot split'
@api.one
@api.depends('line_ids')
def _compute_qty_done(self):
self.qty_done = sum([x.product_qty for x in self.line_ids])
pack_id = fields.Many2one('stock.pack.operation', 'Pack operation')
product_qty = fields.Float('Quantity', digits=dp.get_precision('Product Unit of Measure'), readonly=True)
product_id = fields.Many2one('product.product', 'Product', readonly=True)
product_uom_id = fields.Many2one('product.uom', 'Product Unit of Measure', readonly=True)
line_ids = fields.One2many('stock.lot.split.line', 'split_id')
qty_done = fields.Float('Processed Qty', digits=dp.get_precision('Product Unit of Measure'), compute='_compute_qty_done')
picking_type_id = fields.Many2one('stock.picking.type', related='pack_id.picking_id.picking_type_id')
@api.multi
def process(self):
self.ensure_one()
if not self.line_ids:
raise UserError (_('Please provide at least one line'))
# Split pack operations
firstline = True
totals_other = 0.0
for line in self.line_ids:
if not line.lot_name and not line.lot_id:
raise UserError(_('Please provide a lot/serial number for every line'))
# In case of only creating lots, we will have the text in lot_name, otherwise the lot_id
if line.lot_name:
if self.pack_id.lot_id and line.lot_name == self.pack_id.lot_id.name:
lot = self.pack_id.lot_id
else:
lot = self.env['stock.production.lot'].create({'name': line.lot_name, 'product_id': self.pack_id.product_id.id})
else:
lot = line.lot_id
if firstline:
self.pack_id.write({'lot_id': lot.id,
'qty_done': line.product_qty})
firstline = False
else:
pack_new = self.pack_id.copy()
pack_new.write({'lot_id': lot.id,
'qty_done': line.product_qty,
'product_qty': line.product_qty})
totals_other += line.product_qty
old_qty = self.pack_id.product_qty
if old_qty - totals_other > 0:
self.pack_id.product_qty = self.pack_id.product_qty - totals_other
else:
self.pack_id.product_qty = 0.0
return True
class stock_lot_split_line(models.TransientModel):
_name = 'stock.lot.split.line'
_description = 'Lot split line'
split_id = fields.Many2one('stock.lot.split')
lot_id = fields.Many2one('stock.production.lot', string="Lot/Serial Number")
lot_name = fields.Char('Name')
product_qty = fields.Float('Quantity', digits=dp.get_precision('Product Unit of Measure'), default=1.0)
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_lot_split" model="ir.ui.view">
<field name="name">Split Lot</field>
<field name="model">stock.lot.split</field>
<field name="arch" type="xml">
<form string="Lot Tracker">
<group>
<field name="product_id"/>
<label for="qty_done"/>
<span>
<field name="qty_done" class="oe_inline"/> of
<field name="product_qty" class="oe_inline"/>
<field name="product_uom_id" class="oe_inline" groups="product.group_uom"/>
</span>
</group>
<group>
<field name="line_ids" nolabel="1">
<tree editable="bottom">
<field name="lot_name" invisible="not context.get('only_create', False)"/>
<field name="lot_id" invisible="context.get('only_create', False)"
domain="[('product_id','=', parent.product_id)]"
context="{'product_id': parent.product_id}"/>
<field name="product_qty" invisible="context.get('serial')"/>
</tree>
</field>
</group>
<footer>
<button name="process" string="_Apply" type="object" class="oe_highlight"/>
or
<button string="_Cancel" class="oe_link" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="lot_split" model="ir.actions.act_window">
<field name="name">Lot Split</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">stock.lot.split</field>
<field name="view_id" ref="view_lot_split"/>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{}</field>
</record>
</odoo>
+7 -10
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp import models, fields, api
from openerp import models, fields, api, _
import openerp.addons.decimal_precision as dp
from openerp.exceptions import UserError
class stock_pack_details(models.TransientModel):
_name = 'stock.pack.details'
@@ -44,17 +45,13 @@ class stock_pack_details(models.TransientModel):
@api.multi
def split_quantities(self):
for wiz in self:
if wiz.quantity>1 and wiz.qty_done < wiz.quantity:
if wiz.quantity>0.0 and wiz.qty_done < wiz.quantity:
pack2 = self.pack_id.copy({'qty_done': 0.0, 'product_qty': wiz.quantity - wiz.qty_done})
wiz.quantity = wiz.qty_done
self.pack_id.write({'qty_done': wiz.qty_done, 'product_qty': wiz.quantity})
return {
'view_type': 'form',
'view_mode': 'form',
'res_model': 'stock.picking',
'type': 'ir.actions.act_window',
'res_id': self.pack_id.picking_id.id,
}
return True
else:
raise UserError(_('Can not split 0 quantity'))
@api.one
def process(self):
@@ -69,4 +66,4 @@ class stock_pack_details(models.TransientModel):
'location_dest_id': self.location_dest_id.id,
'result_package_id': self.result_package_id.id,
})
return {}
return True
+10 -11
View File
@@ -6,13 +6,12 @@
<field name="arch" type="xml">
<form string="Enter Pack Details">
<group>
<group string="#Products">
<group string="Products">
<field name="pack_id" invisible="1"/>
<field name="picking_source_location_id" invisible="True"/>
<field name="picking_destination_location_id" invisible="True"/>
<field name="product_id" attrs="{'required': [('package_id', '=', False)]}"/>
<field name="lot_id" groups="stock.group_production_lot" domain="[('product_id','=?', product_id)]" context="{'product_id': product_id}"/>
<field name="lot_id" groups="stock.group_production_lot" domain="[('product_id','=', product_id)]" context="{'product_id': product_id}"/>
</group>
<group string="From">
<field name="location_id" domain="[('id', 'child_of', picking_source_location_id)]"/>
@@ -20,15 +19,15 @@
</group>
</group>
<group>
<group string="Actions">
<label for="qty_done" string="Done"/>
<div class="oe_inline">
<field name="qty_done"/>
OF
<group string="Quantities">
<label for="qty_done"/>
<span class="oe_inline">
<field name="qty_done" nolabel="1"/>
/
<field name="quantity"/>
<field name="product_uom_id" options="{&quot;no_open&quot;: True}" groups="product.group_uom"/>
<button name="split_quantities" string="Split" type="object" icon="STOCK_PREFERENCES" attrs="{'invisible': ['|', ('quantity', '=', 1), ('qty_done', '=', 0.0)]}"/>
</div>
</span>
<button name="split_quantities" class="oe_link" string="Split" type="object"/>
</group>
<group string="To">
<field name="location_dest_id" domain="[('id', 'child_of', picking_destination_location_id)]"/>
@@ -36,7 +35,7 @@
</group>
</group>
<footer>
<button name="process" string="_Apply" type="object" class="oe_highlight"/>
<button name="process" string="Save" type="object" class="oe_highlight"/>
or
<button string="_Cancel" class="oe_link" special="cancel" />
</footer>
+2 -2
View File
@@ -250,8 +250,8 @@ class stock_quant(osv.osv):
self._account_entry_move(cr, uid, [quant], move, context)
return quant
def move_quants_write(self, cr, uid, quants, move, location_dest_id, dest_package_id, context=None):
res = super(stock_quant, self).move_quants_write(cr, uid, quants, move, location_dest_id, dest_package_id, context=context)
def move_quants_write(self, cr, uid, quants, move, location_dest_id, dest_package_id, lot_id=False, context=None):
res = super(stock_quant, self).move_quants_write(cr, uid, quants, move, location_dest_id, dest_package_id, lot_id=lot_id, context=context)
if move.product_id.valuation == 'real_time':
self._account_entry_move(cr, uid, quants, move, context=context)
return res