first commit

This commit is contained in:
Suherdy SYC. Yacob 2025-09-29 11:47:29 +07:00
commit 87b5351da2
16 changed files with 268 additions and 0 deletions

1
__init__.py Normal file
View File

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

14
__manifest__.py Normal file
View File

@ -0,0 +1,14 @@
{
'name': 'Product Lot Sequence Per Product',
'version': '1.0',
'depends': [
'stock',
'mrp',
],
'data': [
'views/product_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
}

Binary file not shown.

5
models/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from . import product_template
from . import stock_move
from . import stock_lot
from . import stock_move_line
from . import mrp_production

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

49
models/mrp_production.py Normal file
View File

@ -0,0 +1,49 @@
from odoo import models, _
from odoo.exceptions import UserError
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def _prepare_stock_lot_values(self):
self.ensure_one()
name = False
product = self.product_id
tmpl = product.product_tmpl_id
seq = getattr(tmpl, 'lot_sequence_id', False)
if seq:
tried = set()
# Try generating a unique candidate from the per-product sequence
for _i in range(10):
candidate = seq.next_by_id()
if not candidate:
break
if candidate in tried:
continue
tried.add(candidate)
exist_lot = self.env['stock.lot'].search([
('product_id', '=', product.id),
'|', ('company_id', '=', False), ('company_id', '=', self.company_id.id),
('name', '=', candidate),
], limit=1)
if not exist_lot:
name = candidate
break
# Fallback to default behavior if no per-product candidate was found
if not name:
name = self.env['ir.sequence'].next_by_code('stock.lot.serial')
exist_lot = (not name) or self.env['stock.lot'].search([
('product_id', '=', product.id),
'|', ('company_id', '=', False), ('company_id', '=', self.company_id.id),
('name', '=', name),
], limit=1)
if exist_lot:
name = self.env['stock.lot']._get_next_serial(self.company_id, product)
if not name:
raise UserError(_("Please set the first Serial Number or a default sequence"))
return {
'product_id': product.id,
'name': name,
}

View File

@ -0,0 +1,63 @@
from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
lot_sequence_id = fields.Many2one(
'ir.sequence',
string='Serial/Lot Numbers Sequence',
domain=[('code', '=', 'stock.lot.serial')],
help='Technical Field: The Ir.Sequence record that is used to generate serial/lot numbers for this product'
)
serial_prefix_format = fields.Char(
'Custom Lot/Serial',
compute='_compute_serial_prefix_format',
inverse='_inverse_serial_prefix_format',
help='Set a prefix to generate serial/lot numbers automatically when receiving or producing this product. '
'Use % codes like %(y)s for year, %(month)s for month, etc.'
)
next_serial = fields.Char(
'Next Number',
compute='_compute_next_serial',
help='The next serial/lot number to be generated for this product'
)
@api.depends('lot_sequence_id', 'lot_sequence_id.prefix')
def _compute_serial_prefix_format(self):
for template in self:
template.serial_prefix_format = template.lot_sequence_id.prefix or ""
def _inverse_serial_prefix_format(self):
valid_sequences = self.env['ir.sequence'].search([('prefix', 'in', self.mapped('serial_prefix_format'))])
sequences_by_prefix = {seq.prefix: seq for seq in valid_sequences}
for template in self:
if template.serial_prefix_format:
if template.serial_prefix_format in sequences_by_prefix:
template.lot_sequence_id = sequences_by_prefix[template.serial_prefix_format]
else:
# Create a new sequence with the given prefix
new_sequence = self.env['ir.sequence'].create({
'name': f'{template.name} Serial Sequence',
'code': 'stock.lot.serial',
'prefix': template.serial_prefix_format,
'padding': 7,
'company_id': False, # Global sequence to avoid cross-company conflicts
})
template.lot_sequence_id = new_sequence
sequences_by_prefix[template.serial_prefix_format] = new_sequence
else:
# Reset to default if no prefix
template.lot_sequence_id = self.env.ref('stock.sequence_production_lots', raise_if_not_found=False)
@api.depends('serial_prefix_format', 'lot_sequence_id')
def _compute_next_serial(self):
for template in self:
if template.lot_sequence_id:
template.next_serial = '{:0{}d}{}'.format(
template.lot_sequence_id.number_next_actual,
template.lot_sequence_id.padding,
template.lot_sequence_id.suffix or ""
)
else:
template.next_serial = '00001'

19
models/stock_lot.py Normal file
View File

@ -0,0 +1,19 @@
from odoo import api, models
class StockLot(models.Model):
_inherit = 'stock.lot'
@api.model_create_multi
def create(self, vals_list):
# For each lot being created without a name, assign the next serial from the product's template sequence
for vals in vals_list:
if not vals.get('name') and vals.get('product_id'):
product = self.env['product.product'].browse(vals['product_id'])
seq = getattr(product.product_tmpl_id, 'lot_sequence_id', False)
if seq:
vals['name'] = seq.next_by_id()
else:
# Fallback to global sequence if no product sequence
vals['name'] = self.env['ir.sequence'].next_by_code('stock.lot.serial')
return super().create(vals_list)

71
models/stock_move.py Normal file
View File

@ -0,0 +1,71 @@
from odoo import api, models
class StockMove(models.Model):
_inherit = 'stock.move'
@api.onchange('product_id')
def _onchange_product_id(self):
"""Seed the next_serial field on stock.move when product changes, if product has a sequence."""
res = super()._onchange_product_id()
if self.product_id and getattr(self.product_id.product_tmpl_id, 'lot_sequence_id', False):
self.next_serial = getattr(self.product_id.product_tmpl_id, 'next_serial', False)
return res
def _create_lot_ids_from_move_line_vals(self, vals_list, product_id, company_id=False):
"""
Normalize incoming lot names during 'Generate Serials/Lots' or 'Import Serials/Lots'.
- If user leaves '0' or empty as lot name, create lots without a name to let stock.lot.create()
generate names from the product's per-product sequence (handled by our stock.lot override).
- Otherwise, fallback to the standard behavior for explicit names.
"""
Lot = self.env['stock.lot']
# First handle entries that should be auto-generated (empty or '0')
remaining_vals = []
for vals in vals_list:
lot_name = (vals.get('lot_name') or '').strip()
if not lot_name or lot_name == '0':
lot_vals = {
'product_id': product_id,
}
if company_id:
lot_vals['company_id'] = company_id
# omit 'name' to trigger sequence in stock.lot.create() override
lot = Lot.create([lot_vals])[0]
vals['lot_id'] = lot.id
vals['lot_name'] = False
else:
remaining_vals.append(vals)
# Delegate remaining with explicit names to the standard implementation
if remaining_vals:
return super()._create_lot_ids_from_move_line_vals(remaining_vals, product_id, company_id)
return None
@api.model
def action_generate_lot_line_vals(self, context, mode, first_lot, count, lot_text):
"""
If the 'Generate Serials/Lots' action is invoked with an empty or '0' base,
generate names using the per-product sequence instead of stock.lot.generate_lot_names('0', n),
which would yield 0,1,2...
"""
if mode == 'generate':
product_id = context.get('default_product_id')
if product_id:
product = self.env['product.product'].browse(product_id)
tmpl = product.product_tmpl_id
if (not first_lot or first_lot == '0') and getattr(tmpl, 'lot_sequence_id', False):
seq = tmpl.lot_sequence_id
# Generate count names directly from the sequence
generated_names = [seq.next_by_id() for _ in range(count or 0)]
# Reuse parent implementation for the rest of the processing (locations, uom, etc.)
# by passing a non-zero base and then overriding the names in the returned list.
fake_first = 'SEQDUMMY-1'
vals_list = super().action_generate_lot_line_vals(context, mode, fake_first, count, lot_text)
# Overwrite the lot_name with sequence-based names; keep all other computed values (uom, putaway).
for vals, name in zip(vals_list, generated_names):
vals['lot_name'] = name
return vals_list
# Fallback to standard behavior
return super().action_generate_lot_line_vals(context, mode, first_lot, count, lot_text)

24
models/stock_move_line.py Normal file
View File

@ -0,0 +1,24 @@
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 stock.lot.create() generate the name from
the product's per-product sequence (handled by our stock.lot override).
"""
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, # False => triggers product sequence in stock.lot.create()
'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

22
views/product_views.xml Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend product template form view to add sequence fields on Inventory tab -->
<record id="view_product_template_form_inherit_stock" model="ir.ui.view">
<field name="name">product.template.form.inherit.stock.lot.sequence</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="stock.view_template_property_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='inventory']" position="inside">
<group string="Traceability" name="traceability" groups="stock.group_production_lot" invisible="tracking == 'none'">
<label for="serial_prefix_format" string="Custom Lot/Serial" invisible="tracking == 'none'"/>
<div class="d-flex" invisible="tracking == 'none'">
<field name="serial_prefix_format" style="max-width: 150px;"/>
<field name="next_serial" style="max-width: 150px;"/>
</div>
<!-- Optionally show the technical sequence field -->
<field name="lot_sequence_id" groups="base.group_no_one"/>
</group>
</xpath>
</field>
</record>
</odoo>