From 56cd97ef279c299f74b5bb5399dfad0de4612ec6 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 28 Jan 2026 13:25:23 +0700 Subject: [PATCH] first commit --- .gitignore | 64 ++++++++++ README.md | 33 +++++ __init__.py | 2 + __manifest__.py | 21 ++++ models/__init__.py | 3 + models/stock_move.py | 126 +++++++++++++++++++ models/stock_move_line.py | 83 +++++++++++++ models/stock_picking.py | 185 ++++++++++++++++++++++++++++ wizard/__init__.py | 1 + wizard/subcontract_lot_generator.py | 109 ++++++++++++++++ 10 files changed, 627 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/stock_move.py create mode 100644 models/stock_move_line.py create mode 100644 models/stock_picking.py create mode 100644 wizard/__init__.py create mode 100644 wizard/subcontract_lot_generator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59ca567 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc43187 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..0c7a4e0 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..1c5f544 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_picking +from . import stock_move_line diff --git a/models/stock_move.py b/models/stock_move.py new file mode 100644 index 0000000..cec1df0 --- /dev/null +++ b/models/stock_move.py @@ -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, + }, + } diff --git a/models/stock_move_line.py b/models/stock_move_line.py new file mode 100644 index 0000000..e73882b --- /dev/null +++ b/models/stock_move_line.py @@ -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, + }, + } diff --git a/models/stock_picking.py b/models/stock_picking.py new file mode 100644 index 0000000..bbc432b --- /dev/null +++ b/models/stock_picking.py @@ -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() diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..af6a7e5 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1 @@ +from . import subcontract_lot_generator diff --git a/wizard/subcontract_lot_generator.py b/wizard/subcontract_lot_generator.py new file mode 100644 index 0000000..e8b33d2 --- /dev/null +++ b/wizard/subcontract_lot_generator.py @@ -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, + } + }