add feature to auto generate lot number on resupply subcontract receipt
This commit is contained in:
parent
2ee5a11d1a
commit
e912961a2d
@ -1 +1,2 @@
|
||||
from . import models
|
||||
from . import models
|
||||
from . import wizard
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Product Lot Sequence Per Product',
|
||||
'version': '1.1.1',
|
||||
'version': '1.2.1',
|
||||
'category': 'Inventory/Inventory',
|
||||
'summary': 'Per-product lot/serial sequences with performance optimization for large batches',
|
||||
'description': """
|
||||
@ -15,6 +15,8 @@
|
||||
* 8-10x speedup for large batch operations
|
||||
* Support for receipts, manufacturing orders, and manual generation
|
||||
* Date format codes support (%(y)s, %(month)s, %(day)s, etc.)
|
||||
* Automatic lot generation for subcontracting moves
|
||||
* Auto-generate button (+ icon) for subcontract receipts
|
||||
|
||||
Performance:
|
||||
-----------
|
||||
@ -23,20 +25,25 @@
|
||||
* Automatic optimization for quantities > 10 units
|
||||
* Tested with up to 500,000 units
|
||||
|
||||
New in v1.1:
|
||||
New in v1.2.1:
|
||||
-----------
|
||||
* Major performance improvements for large batches
|
||||
* Date format code support in sequences
|
||||
* Comprehensive test suites
|
||||
* Detailed performance documentation
|
||||
* Working automatic lot generation for subcontracting moves
|
||||
* Clean UI with essential buttons only
|
||||
* Robust field compatibility for Odoo 18
|
||||
* Enhanced error handling and user feedback
|
||||
""",
|
||||
'author': 'Suherdy Yacob',
|
||||
'depends': [
|
||||
'stock',
|
||||
'mrp',
|
||||
'mrp_subcontracting',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/product_views.xml',
|
||||
'wizard/subcontract_lot_generator_views.xml',
|
||||
'views/stock_picking_views.xml',
|
||||
'views/stock_move_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
|
||||
@ -2,4 +2,5 @@ from . import product_template
|
||||
from . import stock_move
|
||||
from . import stock_lot
|
||||
from . import stock_move_line
|
||||
from . import mrp_production
|
||||
from . import mrp_production
|
||||
from . import stock_picking
|
||||
@ -174,4 +174,125 @@ class StockMove(models.Model):
|
||||
return vals_list
|
||||
|
||||
# Fallback to standard behavior
|
||||
return super().action_generate_lot_line_vals(context, mode, first_lot, count, lot_text)
|
||||
return super().action_generate_lot_line_vals(context, mode, first_lot, count, lot_text)
|
||||
|
||||
def _auto_generate_lots_for_subcontract(self):
|
||||
"""
|
||||
Auto-generate lot numbers for subcontracting moves.
|
||||
This method handles the automatic lot generation for subcontracted products.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.product_id.tracking in ['lot', 'serial']:
|
||||
return []
|
||||
|
||||
product = self.product_id
|
||||
tmpl = product.product_tmpl_id
|
||||
lot_sequence = getattr(tmpl, 'lot_sequence_id', False)
|
||||
|
||||
if not lot_sequence:
|
||||
_logger.warning(f"No lot sequence configured for product {product.display_name}")
|
||||
return []
|
||||
|
||||
# Check if we already have move lines with lots
|
||||
existing_lots = self.move_line_ids.filtered(lambda ml: ml.lot_id)
|
||||
if existing_lots:
|
||||
_logger.info(f"Move already has lots assigned: {[lot.lot_id.name for lot in existing_lots]}")
|
||||
return existing_lots.mapped('lot_id')
|
||||
|
||||
# Calculate how many lots we need to generate
|
||||
remaining_qty = self.product_uom_qty
|
||||
|
||||
# For serial tracking, create one lot per unit
|
||||
if product.tracking == 'serial':
|
||||
lots_needed = int(remaining_qty)
|
||||
else:
|
||||
# For lot tracking, create one lot for the entire quantity
|
||||
lots_needed = 1
|
||||
|
||||
if lots_needed <= 0:
|
||||
return []
|
||||
|
||||
# Generate lot names using the optimized batch method
|
||||
if lots_needed > 10:
|
||||
lot_names = self._allocate_sequence_batch(lot_sequence, lots_needed)
|
||||
else:
|
||||
lot_names = [lot_sequence.next_by_id() for _ in range(lots_needed)]
|
||||
|
||||
# Create the lots
|
||||
Lot = self.env['stock.lot']
|
||||
lot_vals_list = []
|
||||
for lot_name in lot_names:
|
||||
lot_vals = {
|
||||
'name': lot_name,
|
||||
'product_id': product.id,
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
lot_vals_list.append(lot_vals)
|
||||
|
||||
lots = Lot.create(lot_vals_list)
|
||||
|
||||
# Assign lots to existing move lines or create new ones
|
||||
existing_move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id)
|
||||
|
||||
if existing_move_lines and product.tracking == 'lot':
|
||||
# For lot tracking, assign the first lot to the first available move line
|
||||
if lots:
|
||||
existing_move_lines[0].lot_id = lots[0].id
|
||||
_logger.info(f"Assigned lot {lots[0].name} to existing move line")
|
||||
elif product.tracking == 'serial':
|
||||
# For serial tracking, we need one move line per lot
|
||||
# If we don't have enough move lines, let Odoo handle the creation
|
||||
# by using the standard lot assignment mechanism
|
||||
for i, lot in enumerate(lots):
|
||||
if i < len(existing_move_lines):
|
||||
existing_move_lines[i].lot_id = lot.id
|
||||
else:
|
||||
# Let Odoo create additional move lines as needed
|
||||
# This is safer than trying to create them manually
|
||||
break
|
||||
|
||||
_logger.info(f"Auto-generated {len(lots)} lots for subcontract move of product {product.display_name}")
|
||||
return lots
|
||||
|
||||
def action_generate_lots_for_move(self):
|
||||
"""Open the lot generator wizard for this specific move."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.is_subcontract:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Not a Subcontract Move',
|
||||
'message': 'This action is only available for subcontract moves.',
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
if self.product_id.tracking == 'none':
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Tracking Required',
|
||||
'message': 'This product does not require lot/serial tracking.',
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'name': 'Generate Lots for Move',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'subcontract.lot.generator',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_move_id': self.id,
|
||||
'default_picking_id': self.picking_id.id,
|
||||
'default_product_id': self.product_id.id,
|
||||
'default_quantity': self.product_uom_qty,
|
||||
},
|
||||
}
|
||||
@ -21,4 +21,56 @@ class StockMoveLine(models.Model):
|
||||
}
|
||||
if self.product_id.company_id and self.company_id in (self.product_id.company_id.all_child_ids | self.product_id.company_id):
|
||||
vals['company_id'] = self.company_id.id
|
||||
return vals
|
||||
return vals
|
||||
|
||||
def action_open_lot_generator(self):
|
||||
"""Open the lot generator wizard for this move line."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.product_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Product',
|
||||
'message': 'Please select a product first.',
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
if self.product_id.tracking == 'none':
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Tracking Required',
|
||||
'message': 'This product does not require lot/serial tracking.',
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
# Use safe field access for quantity fields (field names may vary in different Odoo versions)
|
||||
MoveLine = self.env['stock.move.line']
|
||||
quantity_value = 1.0 # default fallback
|
||||
|
||||
# Try different quantity field names
|
||||
for field_name in ['reserved_uom_qty', 'product_qty', 'product_uom_qty']:
|
||||
if field_name in MoveLine._fields and hasattr(self, field_name):
|
||||
quantity_value = getattr(self, field_name, 1.0) or 1.0
|
||||
break
|
||||
|
||||
return {
|
||||
'name': 'Generate Lots for Move Line',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'subcontract.lot.generator',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_move_id': self.move_id.id,
|
||||
'default_picking_id': self.picking_id.id,
|
||||
'default_product_id': self.product_id.id,
|
||||
'default_quantity': quantity_value,
|
||||
},
|
||||
}
|
||||
166
models/stock_picking.py
Normal file
166
models/stock_picking.py
Normal file
@ -0,0 +1,166 @@
|
||||
from odoo import api, fields, models
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
subcontract_lot_count = fields.Integer(
|
||||
'Subcontract Lots Count',
|
||||
compute='_compute_subcontract_lot_count'
|
||||
)
|
||||
has_subcontract_moves = fields.Boolean(
|
||||
'Has Subcontract Moves',
|
||||
compute='_compute_has_subcontract_moves'
|
||||
)
|
||||
|
||||
@api.depends('move_ids.is_subcontract')
|
||||
def _compute_has_subcontract_moves(self):
|
||||
"""Compute if this picking has any subcontract moves."""
|
||||
for picking in self:
|
||||
picking.has_subcontract_moves = any(move.is_subcontract for move in picking.move_ids)
|
||||
|
||||
@api.depends('move_ids.move_line_ids.lot_id')
|
||||
def _compute_subcontract_lot_count(self):
|
||||
"""Compute the number of lots generated for subcontract moves."""
|
||||
for picking in self:
|
||||
if picking.picking_type_code == 'incoming':
|
||||
subcontract_moves = picking.move_ids.filtered('is_subcontract')
|
||||
lot_ids = subcontract_moves.move_line_ids.mapped('lot_id')
|
||||
picking.subcontract_lot_count = len(lot_ids)
|
||||
else:
|
||||
picking.subcontract_lot_count = 0
|
||||
|
||||
def action_view_generated_lots(self):
|
||||
"""Action to view all lots generated for subcontract moves in this picking."""
|
||||
self.ensure_one()
|
||||
subcontract_moves = self.move_ids.filtered('is_subcontract')
|
||||
lot_ids = subcontract_moves.move_line_ids.mapped('lot_id').ids
|
||||
|
||||
return {
|
||||
'name': 'Generated Lots',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'stock.lot',
|
||||
'view_mode': 'tree,form',
|
||||
'domain': [('id', 'in', lot_ids)],
|
||||
'context': {'create': False},
|
||||
}
|
||||
|
||||
def action_auto_generate_lots_subcontract(self):
|
||||
"""
|
||||
Action to auto-generate lot numbers for subcontracting moves.
|
||||
Similar to the "+" icon functionality in MO forms.
|
||||
"""
|
||||
generated_count = 0
|
||||
generated_lots = []
|
||||
|
||||
for picking in self:
|
||||
if picking.picking_type_code == 'incoming':
|
||||
subcontract_moves = picking.move_ids.filtered('is_subcontract')
|
||||
if not subcontract_moves:
|
||||
continue
|
||||
for move in subcontract_moves:
|
||||
if move.product_id.tracking in ['lot', 'serial']:
|
||||
try:
|
||||
lots = move._auto_generate_lots_for_subcontract()
|
||||
if lots:
|
||||
generated_count += len(lots)
|
||||
generated_lots.extend([lot.name for lot in lots])
|
||||
except Exception as e:
|
||||
_logger.error(f"Error generating lots for move {move.id}: {e}")
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Generation Error',
|
||||
'message': f'Error generating lots: {str(e)}',
|
||||
'type': 'danger',
|
||||
'sticky': True,
|
||||
}
|
||||
}
|
||||
|
||||
if generated_count > 0:
|
||||
message = f'Generated {generated_count} lots: {", ".join(generated_lots[:5])}'
|
||||
if len(generated_lots) > 5:
|
||||
message += f' and {len(generated_lots) - 5} more...'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Success',
|
||||
'message': message,
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Lots Generated',
|
||||
'message': 'No lots were generated. Check that products have lot/serial tracking and custom sequences configured.',
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
def _auto_assign_lots_on_subcontract_receipt(self):
|
||||
"""
|
||||
Automatically assign lot numbers when validating subcontract receipts.
|
||||
This is called during the validation process.
|
||||
"""
|
||||
for picking in self:
|
||||
if picking.picking_type_code == 'incoming':
|
||||
subcontract_moves = picking.move_ids.filtered('is_subcontract')
|
||||
for move in subcontract_moves:
|
||||
if move.product_id.tracking in ['lot', 'serial'] and move.state not in ['done', 'cancel']:
|
||||
move._auto_generate_lots_for_subcontract()
|
||||
|
||||
def action_open_subcontract_lot_wizard(self):
|
||||
"""Open the subcontract lot generator wizard."""
|
||||
self.ensure_one()
|
||||
|
||||
# Find the first subcontract move for default values
|
||||
subcontract_move = self.move_ids.filtered('is_subcontract')[:1]
|
||||
|
||||
if not subcontract_move:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'No Subcontract Moves',
|
||||
'message': 'This picking does not contain any subcontract moves.',
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
context = {
|
||||
'default_picking_id': self.id,
|
||||
}
|
||||
|
||||
if subcontract_move:
|
||||
context.update({
|
||||
'default_move_id': subcontract_move.id,
|
||||
'default_product_id': subcontract_move.product_id.id,
|
||||
'default_quantity': subcontract_move.product_uom_qty,
|
||||
})
|
||||
|
||||
return {
|
||||
'name': 'Generate Subcontract Lots',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'subcontract.lot.generator',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': context,
|
||||
}
|
||||
|
||||
def button_validate(self):
|
||||
"""Override to auto-generate lots for subcontract moves before validation."""
|
||||
# Auto-generate lots for subcontract moves if needed
|
||||
self._auto_assign_lots_on_subcontract_receipt()
|
||||
return super().button_validate()
|
||||
2
security/ir.model.access.csv
Normal file
2
security/ir.model.access.csv
Normal file
@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_subcontract_lot_generator,subcontract.lot.generator,model_subcontract_lot_generator,stock.group_stock_user,1,1,1,1
|
||||
|
189
tests/test_subcontract_lots.py
Normal file
189
tests/test_subcontract_lots.py
Normal file
@ -0,0 +1,189 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestSubcontractLots(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a product with lot tracking
|
||||
self.product = self.env['product.product'].create({
|
||||
'name': 'Subcontract Test Product',
|
||||
'type': 'product',
|
||||
'tracking': 'lot',
|
||||
})
|
||||
|
||||
# Create a custom sequence for the product
|
||||
self.sequence = self.env['ir.sequence'].create({
|
||||
'name': 'Test Subcontract Sequence',
|
||||
'code': 'stock.lot.serial',
|
||||
'prefix': 'SUB-%(y)s-',
|
||||
'padding': 4,
|
||||
})
|
||||
|
||||
self.product.product_tmpl_id.lot_sequence_id = self.sequence
|
||||
|
||||
# Create a subcontractor
|
||||
self.subcontractor = self.env['res.partner'].create({
|
||||
'name': 'Test Subcontractor',
|
||||
'is_company': True,
|
||||
})
|
||||
|
||||
# Create locations
|
||||
self.location_stock = self.env.ref('stock.stock_location_stock')
|
||||
self.location_subcontractor = self.env['stock.location'].create({
|
||||
'name': 'Subcontractor Location',
|
||||
'usage': 'supplier',
|
||||
'partner_id': self.subcontractor.id,
|
||||
})
|
||||
|
||||
def test_auto_generate_lots_subcontract(self):
|
||||
"""Test automatic lot generation for subcontract moves."""
|
||||
|
||||
# Create a subcontract receipt
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': self.env.ref('stock.picking_type_in').id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'partner_id': self.subcontractor.id,
|
||||
})
|
||||
|
||||
# Create a subcontract move
|
||||
move = self.env['stock.move'].create({
|
||||
'name': 'Test Subcontract Move',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 5.0,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'picking_id': picking.id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'is_subcontract': True,
|
||||
})
|
||||
|
||||
# Test auto-generation
|
||||
move._auto_generate_lots_for_subcontract()
|
||||
|
||||
# Check that a lot was created
|
||||
self.assertEqual(len(move.move_line_ids), 1)
|
||||
self.assertTrue(move.move_line_ids[0].lot_id)
|
||||
self.assertTrue(move.move_line_ids[0].lot_id.name.startswith('SUB-'))
|
||||
|
||||
def test_subcontract_lot_generator_wizard(self):
|
||||
"""Test the subcontract lot generator wizard."""
|
||||
|
||||
# Create a picking and move
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': self.env.ref('stock.picking_type_in').id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'partner_id': self.subcontractor.id,
|
||||
})
|
||||
|
||||
move = self.env['stock.move'].create({
|
||||
'name': 'Test Move',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 10.0,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'picking_id': picking.id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'is_subcontract': True,
|
||||
})
|
||||
|
||||
# Create wizard
|
||||
wizard = self.env['subcontract.lot.generator'].create({
|
||||
'picking_id': picking.id,
|
||||
'move_id': move.id,
|
||||
'product_id': self.product.id,
|
||||
'quantity': 10.0,
|
||||
'lot_count': 2,
|
||||
'use_sequence': True,
|
||||
})
|
||||
|
||||
# Generate lots
|
||||
wizard.action_generate_lots()
|
||||
|
||||
# Check results
|
||||
self.assertEqual(len(move.move_line_ids), 2)
|
||||
for line in move.move_line_ids:
|
||||
self.assertTrue(line.lot_id)
|
||||
self.assertTrue(line.lot_id.name.startswith('SUB-'))
|
||||
|
||||
def test_serial_tracking_subcontract(self):
|
||||
"""Test serial tracking for subcontract products."""
|
||||
|
||||
# Change product to serial tracking
|
||||
self.product.tracking = 'serial'
|
||||
|
||||
# Create picking and move
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': self.env.ref('stock.picking_type_in').id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'partner_id': self.subcontractor.id,
|
||||
})
|
||||
|
||||
move = self.env['stock.move'].create({
|
||||
'name': 'Test Serial Move',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 3.0,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'picking_id': picking.id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'is_subcontract': True,
|
||||
})
|
||||
|
||||
# Test auto-generation for serial tracking
|
||||
move._auto_generate_lots_for_subcontract()
|
||||
|
||||
# Should create 3 move lines (one per serial)
|
||||
self.assertEqual(len(move.move_line_ids), 3)
|
||||
for line in move.move_line_ids:
|
||||
self.assertEqual(line.product_uom_qty, 1.0)
|
||||
self.assertTrue(line.lot_id)
|
||||
self.assertTrue(line.lot_id.name.startswith('SUB-'))
|
||||
|
||||
def test_picking_auto_generate_action(self):
|
||||
"""Test the picking-level auto-generate action."""
|
||||
|
||||
# Create picking with multiple subcontract moves
|
||||
picking = self.env['stock.picking'].create({
|
||||
'picking_type_id': self.env.ref('stock.picking_type_in').id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'partner_id': self.subcontractor.id,
|
||||
})
|
||||
|
||||
# Create two moves
|
||||
move1 = self.env['stock.move'].create({
|
||||
'name': 'Test Move 1',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 2.0,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'picking_id': picking.id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'is_subcontract': True,
|
||||
})
|
||||
|
||||
move2 = self.env['stock.move'].create({
|
||||
'name': 'Test Move 2',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 3.0,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'picking_id': picking.id,
|
||||
'location_id': self.location_subcontractor.id,
|
||||
'location_dest_id': self.location_stock.id,
|
||||
'is_subcontract': True,
|
||||
})
|
||||
|
||||
# Test picking-level auto-generation
|
||||
picking.action_auto_generate_lots_subcontract()
|
||||
|
||||
# Check that both moves have lots generated
|
||||
self.assertTrue(move1.move_line_ids)
|
||||
self.assertTrue(move2.move_line_ids)
|
||||
self.assertTrue(all(line.lot_id for line in move1.move_line_ids))
|
||||
self.assertTrue(all(line.lot_id for line in move2.move_line_ids))
|
||||
20
views/stock_move_views.xml
Normal file
20
views/stock_move_views.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add menu item to stock move -->
|
||||
<record id="view_stock_move_form_inherit_lot_generation" model="ir.ui.view">
|
||||
<field name="name">stock.move.form.inherit.lot.generation</field>
|
||||
<field name="model">stock.move</field>
|
||||
<field name="inherit_id" ref="stock.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_generate_lots_for_move"
|
||||
type="object"
|
||||
string="Generate Lots"
|
||||
class="btn-secondary"
|
||||
icon="fa-plus"
|
||||
invisible="not is_subcontract"
|
||||
help="Generate lot/serial numbers for this subcontract move"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
69
views/stock_picking_views.xml
Normal file
69
views/stock_picking_views.xml
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add auto-generate lots button to subcontracting receipt forms -->
|
||||
<record id="view_picking_form_inherit_subcontract_lots" model="ir.ui.view">
|
||||
<field name="name">stock.picking.form.inherit.subcontract.lots</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Add invisible fields for computed values -->
|
||||
<xpath expr="//form" position="inside">
|
||||
<field name="has_subcontract_moves" invisible="1"/>
|
||||
</xpath>
|
||||
<!-- Add the auto-generate button in the header for subcontract receipts -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_auto_generate_lots_subcontract"
|
||||
type="object"
|
||||
string="Auto Generate Lots"
|
||||
class="btn-secondary"
|
||||
icon="fa-plus"
|
||||
invisible="picking_type_code != 'incoming' or not has_subcontract_moves"
|
||||
help="Automatically generate lot/serial numbers for subcontracted products"/>
|
||||
<button name="action_open_subcontract_lot_wizard"
|
||||
type="object"
|
||||
string="Advanced Lot Generation"
|
||||
class="btn-secondary"
|
||||
icon="fa-cogs"
|
||||
invisible="picking_type_code != 'incoming' or not has_subcontract_moves"
|
||||
help="Advanced lot generation with custom options"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Enhance the move lines view to show auto-generate option -->
|
||||
<record id="view_stock_move_line_detailed_operation_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.move.line.detailed.operation.inherit.subcontract</field>
|
||||
<field name="model">stock.move.line</field>
|
||||
<field name="inherit_id" ref="stock.view_stock_move_line_detailed_operation_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Add a button in the move line tree for individual lot generation -->
|
||||
<xpath expr="//field[@name='lot_name']" position="after">
|
||||
<button name="action_open_lot_generator"
|
||||
type="object"
|
||||
icon="fa-plus"
|
||||
class="btn-link"
|
||||
context="{'default_product_id': product_id, 'default_move_id': move_id}"
|
||||
invisible="not product_id"
|
||||
help="Generate lot/serial numbers"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add a smart button to show generated lots for subcontract moves -->
|
||||
<record id="view_picking_form_subcontract_lots_smart_button" model="ir.ui.view">
|
||||
<field name="name">stock.picking.form.subcontract.lots.smart.button</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button type="object"
|
||||
name="action_view_generated_lots"
|
||||
class="oe_stat_button"
|
||||
icon="fa-barcode"
|
||||
invisible="picking_type_code != 'incoming' or not has_subcontract_moves">
|
||||
<field name="subcontract_lot_count" widget="statinfo" string="Generated Lots"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
wizard/__init__.py
Normal file
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import subcontract_lot_generator
|
||||
111
wizard/subcontract_lot_generator.py
Normal file
111
wizard/subcontract_lot_generator.py
Normal file
@ -0,0 +1,111 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SubcontractLotGenerator(models.TransientModel):
|
||||
_name = 'subcontract.lot.generator'
|
||||
_description = 'Subcontract Lot Generator Wizard'
|
||||
|
||||
picking_id = fields.Many2one('stock.picking', string='Picking', required=True)
|
||||
move_id = fields.Many2one('stock.move', string='Move', required=True)
|
||||
product_id = fields.Many2one('product.product', string='Product', required=True)
|
||||
quantity = fields.Float('Quantity', required=True, default=1.0)
|
||||
lot_count = fields.Integer('Number of Lots', default=1, help='Number of lots to generate')
|
||||
tracking = fields.Selection(related='product_id.tracking')
|
||||
use_sequence = fields.Boolean('Use Product Sequence', default=True)
|
||||
custom_prefix = fields.Char('Custom Prefix', help='Override the product sequence prefix')
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
if self.product_id.tracking == 'serial':
|
||||
self.lot_count = int(self.quantity)
|
||||
else:
|
||||
self.lot_count = 1
|
||||
|
||||
@api.onchange('quantity', 'tracking')
|
||||
def _onchange_quantity(self):
|
||||
if self.tracking == 'serial':
|
||||
self.lot_count = int(self.quantity)
|
||||
|
||||
def action_generate_lots(self):
|
||||
"""Generate lots based on wizard configuration."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.product_id.tracking in ['lot', 'serial']:
|
||||
raise UserError(_('Product must have lot or serial tracking enabled.'))
|
||||
|
||||
if self.lot_count <= 0:
|
||||
raise UserError(_('Number of lots must be greater than 0.'))
|
||||
|
||||
# Get the sequence
|
||||
tmpl = self.product_id.product_tmpl_id
|
||||
lot_sequence = getattr(tmpl, 'lot_sequence_id', False)
|
||||
|
||||
if not lot_sequence and self.use_sequence:
|
||||
raise UserError(_('No lot sequence configured for product %s') % self.product_id.display_name)
|
||||
|
||||
# Generate lot names
|
||||
if self.use_sequence and lot_sequence:
|
||||
if self.custom_prefix:
|
||||
# Temporarily override the sequence prefix
|
||||
original_prefix = lot_sequence.prefix
|
||||
lot_sequence.prefix = self.custom_prefix
|
||||
|
||||
# Use the optimized batch generation
|
||||
if self.lot_count > 10:
|
||||
lot_names = self.move_id._allocate_sequence_batch(lot_sequence, self.lot_count)
|
||||
else:
|
||||
lot_names = [lot_sequence.next_by_id() for _ in range(self.lot_count)]
|
||||
|
||||
if self.custom_prefix:
|
||||
# Restore original prefix
|
||||
lot_sequence.prefix = original_prefix
|
||||
else:
|
||||
# Generate simple sequential names
|
||||
lot_names = [f"LOT-{i+1:04d}" for i in range(self.lot_count)]
|
||||
|
||||
# Create the lots
|
||||
Lot = self.env['stock.lot']
|
||||
lot_vals_list = []
|
||||
for lot_name in lot_names:
|
||||
lot_vals = {
|
||||
'name': lot_name,
|
||||
'product_id': self.product_id.id,
|
||||
'company_id': self.picking_id.company_id.id,
|
||||
}
|
||||
lot_vals_list.append(lot_vals)
|
||||
|
||||
lots = Lot.create(lot_vals_list)
|
||||
|
||||
# Create move lines
|
||||
if self.tracking == 'serial':
|
||||
# One move line per lot for serial tracking
|
||||
qty_per_lot = 1.0
|
||||
else:
|
||||
# Distribute quantity across lots for lot tracking
|
||||
qty_per_lot = self.quantity / self.lot_count
|
||||
|
||||
for lot in lots:
|
||||
self.env['stock.move.line'].create({
|
||||
'move_id': self.move_id.id,
|
||||
'product_id': self.product_id.id,
|
||||
'lot_id': lot.id,
|
||||
'product_uom_qty': qty_per_lot,
|
||||
'qty_done': 0.0,
|
||||
'product_uom_id': self.move_id.product_uom.id,
|
||||
'location_id': self.move_id.location_id.id,
|
||||
'location_dest_id': self.move_id.location_dest_id.id,
|
||||
'picking_id': self.picking_id.id,
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Success'),
|
||||
'message': _('Generated %d lots for %s') % (len(lots), self.product_id.display_name),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
41
wizard/subcontract_lot_generator_views.xml
Normal file
41
wizard/subcontract_lot_generator_views.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Subcontract Lot Generator Wizard Form -->
|
||||
<record id="view_subcontract_lot_generator_form" model="ir.ui.view">
|
||||
<field name="name">subcontract.lot.generator.form</field>
|
||||
<field name="model">subcontract.lot.generator</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Generate Lots for Subcontract">
|
||||
<group>
|
||||
<group>
|
||||
<field name="picking_id" readonly="1"/>
|
||||
<field name="move_id" readonly="1"/>
|
||||
<field name="product_id" readonly="1"/>
|
||||
<field name="tracking" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="quantity"/>
|
||||
<field name="lot_count" readonly="tracking == 'serial'"/>
|
||||
<field name="use_sequence"/>
|
||||
<field name="custom_prefix" invisible="not use_sequence" placeholder="e.g., SUB-2024-"/>
|
||||
</group>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_generate_lots" type="object" string="Generate Lots" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action for the wizard -->
|
||||
<record id="action_subcontract_lot_generator" model="ir.actions.act_window">
|
||||
<field name="name">Generate Subcontract Lots</field>
|
||||
<field name="res_model">subcontract.lot.generator</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="context">{
|
||||
'default_picking_id': active_id,
|
||||
}</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue
Block a user