first commit

This commit is contained in:
Suherdy Yacob 2026-01-28 13:25:23 +07:00
commit 56cd97ef27
10 changed files with 627 additions and 0 deletions

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Odoo
*.log
.vscode/
.idea/
*.DS_Store

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# Subcontract Generate Lot
## Overview
This Odoo 19 module provides automated lot/serial number generation specifically for subcontracting receipts. It isolates the subcontracting automation feature from legacy modules, ensuring compatibility with Odoo 19's native "Lot Sequence per Product" functionality.
## Features
- **Auto-Generate Lots**: Automatically generates lot/serial numbers for subcontract receipt moves based on the product's configured sequence.
- **Wizard Support**: Includes a "Generate Subcontract Lots" wizard for manual control and batch generation.
- **Validation Hook**: Automatically triggers lot generation when validating a subcontracting receipt (Stock Picking).
- **Native Compatibility**: Uses Odoo 19's standard `lot_sequence_id` on the product template, ensuring no conflict with standard features.
## Prerequisites
- **Odoo Version**: 19.0
- **Dependencies**:
- `stock`
- `mrp`
- `mrp_subcontracting`
## Installation
1. Place the module in your custom addons path.
2. Update the **App List** in Odoo.
3. Install **Subcontract Generate Lot**.
4. **Important**: If you are migrating from `product_lot_sequence_per_product`, you MUST uninstall that module after installing this one to prevent conflicts.
## Usage
1. **Configure Product**:
- Go to a Product form -> **Inventory** tab.
- Set **Tracking** to 'By Lots' or 'By Unique Serial Number'.
- Configure the **Lot/Serial Numbers Sequence** (Standard Odoo field).
2. **Subcontract Receipt**:
- Create a Purchase Order for a subcontracted product.
- Confirm the PO to create a Receipt (Stock Picking).
- On the Receipt, use the **Auto Generate Lots** button or validate the transfer to trigger generation.

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import models
from . import wizard

21
__manifest__.py Normal file
View File

@ -0,0 +1,21 @@
{
'name': 'Subcontract Generate Lot',
'version': '1.0',
'category': 'Inventory/Manufacturing',
'summary': 'Auto-generate lot numbers for subcontract moves',
'description': """
Auto-generates lot numbers for subcontract moves using the standard product lot sequence.
This functionality was extracted from the legacy 'product_lot_sequence_per_product' module
to coexist with Odoo 19's native lot per product feature.
""",
'depends': ['base', 'stock', 'mrp', 'mrp_subcontracting'],
'data': [
'security/ir.model.access.csv',
'views/stock_picking_views.xml',
'views/stock_move_views.xml',
'wizard/subcontract_lot_generator_views.xml',
],
'installable': True,
'application': False,
'license': 'LGPL-3',
}

3
models/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from . import stock_move
from . import stock_picking
from . import stock_move_line

126
models/stock_move.py Normal file
View File

@ -0,0 +1,126 @@
from odoo import api, models, _
import logging
_logger = logging.getLogger(__name__)
class StockMove(models.Model):
_inherit = 'stock.move'
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
# Use standard Odoo 19 lot_sequence_id field
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 standard sequence
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
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 getattr(self, 'is_subcontract', False):
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,
},
}

83
models/stock_move_line.py Normal file
View File

@ -0,0 +1,83 @@
from odoo import api, models
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
def _prepare_new_lot_vals(self):
"""
Ensure that bogus base like '0' or empty lot_name does not create a lot literally named '0'.
If lot_name is empty or equals '0', let standard Odoo logic handles it
(or if previously relied on custom overrides, we ensure we pass False to trigger sequence usage if available).
"""
self.ensure_one()
# Normalize lot_name
lot_name = (self.lot_name or '').strip()
normalized_name = lot_name if lot_name and lot_name != '0' else False
vals = {
'name': normalized_name,
'product_id': self.product_id.id,
}
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
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,
}
}
# Odoo 19 standardizing on 'quantity' for stock.move.line
quantity_value = self.quantity or 0.0
src_uom = self.product_uom_id
if quantity_value <= 0:
# Fallback logic if line has no quantity
if self.move_id:
done = self.move_id.quantity
demand = self.move_id.product_uom_qty
remaining = max(demand - done, 0.0)
quantity_value = remaining if remaining > 0 else 1.0
src_uom = self.move_id.product_uom
else:
quantity_value = 1.0
src_uom = self.product_uom_id
# Return action to open wizard
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,
},
}

185
models/stock_picking.py Normal file
View File

@ -0,0 +1,185 @@
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')
def _compute_has_subcontract_moves(self):
"""Compute if this picking has any subcontract moves."""
for picking in self:
# Check for 'is_subcontract' field safely using getattr/mapped in case other modules affect it
subcontract_moves = picking.move_ids.filtered(lambda m: getattr(m, 'is_subcontract', False))
picking.has_subcontract_moves = bool(subcontract_moves)
@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(lambda m: getattr(m, 'is_subcontract', False))
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(lambda m: getattr(m, 'is_subcontract', False))
lot_ids = subcontract_moves.move_line_ids.mapped('lot_id').ids
if not lot_ids:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Lots Found',
'message': 'No lots have been generated for subcontract moves yet.',
'type': 'info',
'sticky': False,
}
}
return {
'name': f'Generated Lots ({len(lot_ids)})',
'type': 'ir.actions.act_window',
'res_model': 'stock.lot',
'view_mode': 'list,form',
'views': [(False, 'list'), (False, 'form')],
'domain': [('id', 'in', lot_ids)],
'context': {
'create': False,
'edit': False,
},
'target': 'current',
}
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(lambda m: getattr(m, 'is_subcontract', False))
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(lambda m: getattr(m, 'is_subcontract', False))
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(lambda m: getattr(m, 'is_subcontract', False))[: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()

1
wizard/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import subcontract_lot_generator

View File

@ -0,0 +1,109 @@
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)
@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:
# Use standard Odoo sequence generation
# Removed custom batch allocation and unsafe prefix overrides
lot_names = [lot_sequence.next_by_id() for _ in range(self.lot_count)]
else:
# Generate simple sequential names fallback
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)
# Calculate quantity in Product's Base UOM
# self.quantity is in self.move_id.product_uom (or whatever was passed)
base_uom = self.product_id.uom_id
move_uom = self.move_id.product_uom
total_base_qty = self.quantity
if move_uom and base_uom and move_uom != base_uom:
total_base_qty = move_uom._compute_quantity(self.quantity, base_uom)
if self.tracking == 'serial':
# One move line per lot for serial tracking, usually 1 unit of base UOM per serial
qty_per_lot = 1.0
else:
# Distribute quantity across lots for lot tracking
qty_per_lot = total_base_qty / self.lot_count
for lot in lots:
vals = {
'move_id': self.move_id.id,
'product_id': self.product_id.id,
'lot_id': lot.id,
'quantity': qty_per_lot, # Odoo 19 uses 'quantity' instead of product_uom_qty/qty_done in stock.move.line for initial demand?
# Let's check standard stock.move.line fields. Usually it's quantity (done) or reserved_uom_qty.
# In Odoo 19 stock.move.line has 'quantity'.
'product_uom_id': base_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,
}
self.env['stock.move.line'].create(vals)
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,
}
}