feat: Introduce a dedicated model and UI for backdated inventory adjustments, replacing previous scattered logic.

This commit is contained in:
admin.suherdy 2025-12-09 18:10:48 +07:00
parent d4710b2562
commit 31911354f0
14 changed files with 681 additions and 171 deletions

85
README.md Normal file
View File

@ -0,0 +1,85 @@
# Stock Inventory Backdate
## Overview
This module allows you to create backdated inventory adjustments with a specific date and time. Unlike the standard Odoo inventory adjustment, this module creates stock moves with the exact backdated datetime you specify.
## Features
- **Dedicated Backdated Adjustment Form**: Separate interface for creating backdated inventory adjustments
- **Historical Inventory Position**: View the theoretical inventory quantity at the backdated time
- **Full Datetime Support**: Specify both date and time for the adjustment
- **Proper Backdating**: All related records (stock moves, move lines, valuation layers, and account moves) are backdated correctly
- **Multi-line Support**: Adjust multiple products in a single backdated adjustment
- **Lot/Serial Number Support**: Handle products with lot/serial number tracking
- **Package & Owner Support**: Support for advanced tracking features
## Usage
### Creating a Backdated Inventory Adjustment
1. Go to **Inventory > Operations > Backdated Adjustments**
2. Click **Create**
3. Set the **Adjustment Date & Time** to the desired backdate
4. Select the **Location** where you want to adjust inventory
**Option A: Load All Products**
5. Click **Load Products** to automatically load all products with inventory at that location
- The system will calculate the theoretical quantity at the backdated time for each product
6. Adjust the **Counted Quantity** for each product as needed
7. The **Difference** column shows what will be adjusted
8. Click **Validate** to create the backdated stock moves
**Option B: Add Individual Products**
5. In the **Inventory Lines** tab, click **Add a line**
6. Select the **Product** you want to adjust
- The system will automatically calculate the **Theoretical Quantity** at the backdated time
7. Enter the **Counted Quantity**
- The **Difference** is calculated automatically
8. Add more products as needed
9. Click **Validate** to create the backdated stock moves
## Technical Details
### How It Works
1. When you validate a backdated adjustment, the module creates standard stock moves
2. After the moves are processed, it updates the dates via SQL to ensure proper backdating:
- Stock move `date` field
- Stock move line `date` field
- Stock valuation layer `create_date` field
- Account move `date` field (if real-time valuation is enabled)
### Models
- **stock.inventory.backdate**: Main model for backdated adjustments
- **stock.inventory.backdate.line**: Individual product lines in the adjustment
### Security
- Stock Users can create, read, update, and delete backdated adjustments
- Stock Managers have full access
## Version History
### Version 17.0.2.0.0
- Complete redesign with dedicated backdated adjustment form
- Proper backdating of all related records
- Historical inventory position calculation
- Support for lot/serial numbers, packages, and owners
### Version 17.0.1.1.0
- Changed fields from Date to Datetime
- Added time support to inventory adjustments
### Version 17.0.1.0.0
- Initial release
- Basic backdating functionality
## Author
Suherdy Yacob
## License
AGPL-3

View File

@ -1,14 +1,15 @@
{
"name": "Stock Inventory Backdate",
"summary": "Allow backdating of physical stock adjustments and valuations with date and time.",
"version": "17.0.1.1.0",
"summary": "Create backdated inventory adjustments with historical position view",
"version": "17.0.2.0.0",
"category": "Warehouse",
"author": "Suherdy Yacob",
"license": "AGPL-3",
"depends": ["stock_account"],
"depends": ["stock_account", "mail"],
"data": [
"security/ir.model.access.csv",
"views/stock_quant_views.xml",
"data/sequence_data.xml",
"views/stock_inventory_backdate_views.xml",
],
"installable": True,
}

13
data/sequence_data.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sequence for Backdated Inventory Adjustment -->
<record id="sequence_stock_inventory_backdate" model="ir.sequence">
<field name="name">Backdated Inventory Adjustment</field>
<field name="code">stock.inventory.backdate</field>
<field name="prefix">BIA/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@ -1,2 +1 @@
from . import stock_quant
from . import stock_move
from . import stock_inventory_backdate

View File

@ -0,0 +1,421 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
class StockInventoryBackdate(models.Model):
_name = 'stock.inventory.backdate'
_description = 'Backdated Inventory Adjustment'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'backdate_datetime desc, id desc'
name = fields.Char(string='Reference', required=True, default='New', readonly=True, tracking=True)
backdate_datetime = fields.Datetime(
string='Adjustment Date & Time',
required=True,
default=fields.Datetime.now,
help="The date and time for the backdated inventory adjustment",
tracking=True
)
location_id = fields.Many2one(
'stock.location',
string='Location',
required=True,
domain="[('usage', '=', 'internal')]",
tracking=True
)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company
)
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
('cancel', 'Cancelled')
], string='Status', default='draft', readonly=True, tracking=True)
line_ids = fields.One2many(
'stock.inventory.backdate.line',
'inventory_id',
string='Inventory Lines'
)
notes = fields.Text(string='Notes')
@api.model
def create(self, vals):
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('stock.inventory.backdate') or 'New'
return super(StockInventoryBackdate, self).create(vals)
def action_load_products(self):
"""Load products with current inventory at the location"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('You can only load products in draft state.'))
if not self.location_id:
raise UserError(_('Please select a location first.'))
# Get all quants at this location
quants = self.env['stock.quant'].search([
('location_id', '=', self.location_id.id),
('company_id', '=', self.company_id.id),
('quantity', '!=', 0)
])
if not quants:
raise UserError(_('No products found at this location. You can add products manually.'))
# Get existing product IDs to avoid duplicates
existing_product_ids = self.line_ids.mapped('product_id').ids
lines = []
for quant in quants:
# Skip if already in lines
if quant.product_id.id in existing_product_ids:
continue
# Get inventory position at the backdate
historical_qty = self._get_historical_quantity(
quant.product_id,
quant.location_id,
quant.lot_id,
quant.package_id,
quant.owner_id,
self.backdate_datetime
)
lines.append((0, 0, {
'product_id': quant.product_id.id,
'lot_id': quant.lot_id.id,
'package_id': quant.package_id.id,
'owner_id': quant.owner_id.id,
'theoretical_qty': historical_qty,
'counted_qty': historical_qty,
'difference_qty': 0.0,
}))
if lines:
self.line_ids = [(0, 0, line[2]) for line in lines]
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Products Loaded'),
'message': _('%s product(s) loaded successfully.') % len(lines),
'type': 'success',
'sticky': False,
}
}
else:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('No New Products'),
'message': _('All products are already in the list.'),
'type': 'info',
'sticky': False,
}
}
def _get_historical_quantity(self, product, location, lot, package, owner, date):
"""Calculate inventory quantity at a specific date"""
base_domain = [
('product_id', '=', product.id),
('state', '=', 'done'),
('date', '<=', date),
]
if lot:
base_domain.append(('lot_id', '=', lot.id))
if package:
base_domain.append(('package_id', '=', package.id))
if owner:
base_domain.append(('owner_id', '=', owner.id))
# Get all incoming moves (destination = our location)
domain_in = base_domain + [('location_dest_id', '=', location.id)]
moves_in = self.env['stock.move.line'].search(domain_in)
# Get all outgoing moves (source = our location)
domain_out = base_domain + [('location_id', '=', location.id)]
moves_out = self.env['stock.move.line'].search(domain_out)
qty_in = sum(moves_in.mapped('quantity'))
qty_out = sum(moves_out.mapped('quantity'))
return qty_in - qty_out
def action_validate(self):
"""Validate and create backdated stock moves"""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft adjustments can be validated.'))
if not self.line_ids:
raise UserError(_('Please add at least one inventory line.'))
# Create stock moves for each line with differences
for line in self.line_ids:
if line.difference_qty != 0:
line._create_stock_move()
self.write({'state': 'done'})
return True
def action_cancel(self):
"""Cancel the adjustment"""
self.ensure_one()
if self.state == 'done':
raise UserError(_('Cannot cancel a validated adjustment.'))
self.write({'state': 'cancel'})
return True
def action_draft(self):
"""Reset to draft"""
self.ensure_one()
self.write({'state': 'draft'})
return True
class StockInventoryBackdateLine(models.Model):
_name = 'stock.inventory.backdate.line'
_description = 'Backdated Inventory Adjustment Line'
_sql_constraints = [
('unique_product_per_inventory',
'unique(inventory_id, product_id, lot_id, package_id, owner_id)',
'You cannot have duplicate products with the same lot/package/owner in the same adjustment!')
]
inventory_id = fields.Many2one(
'stock.inventory.backdate',
string='Inventory Adjustment',
required=True,
ondelete='cascade'
)
product_id = fields.Many2one(
'product.product',
string='Product',
required=True,
domain="[('type', '=', 'product')]"
)
lot_id = fields.Many2one('stock.lot', string='Lot/Serial Number')
package_id = fields.Many2one('stock.quant.package', string='Package')
owner_id = fields.Many2one('res.partner', string='Owner')
theoretical_qty = fields.Float(
string='Theoretical Quantity',
readonly=True,
help="Quantity at the backdated time (can be negative if there was negative stock)"
)
counted_qty = fields.Float(
string='Counted Quantity',
required=True,
default=0.0
)
difference_qty = fields.Float(
string='Adjustment Qty (+/-)',
default=0.0,
help="Positive value adds stock, negative value removes stock."
)
product_uom_id = fields.Many2one(
'uom.uom',
string='Unit of Measure',
related='product_id.uom_id',
readonly=True
)
state = fields.Selection(related='inventory_id.state', string='Status')
has_negative_theoretical = fields.Boolean(
string='Has Negative Theoretical',
compute='_compute_has_negative_theoretical',
help="Indicates if theoretical quantity is negative"
)
@api.onchange('counted_qty')
def _onchange_counted_qty(self):
for line in self:
line.difference_qty = line.counted_qty - line.theoretical_qty
@api.onchange('difference_qty')
def _onchange_difference_qty(self):
for line in self:
line.counted_qty = line.theoretical_qty + line.difference_qty
@api.depends('theoretical_qty')
def _compute_has_negative_theoretical(self):
for line in self:
line.has_negative_theoretical = line.theoretical_qty < 0
@api.onchange('product_id', 'lot_id', 'package_id', 'owner_id')
def _onchange_product_id(self):
"""Auto-calculate theoretical quantity when product is selected"""
if self.product_id and self.inventory_id.location_id and self.inventory_id.backdate_datetime:
self.theoretical_qty = self.inventory_id._get_historical_quantity(
self.product_id,
self.inventory_id.location_id,
self.lot_id,
self.package_id,
self.owner_id,
self.inventory_id.backdate_datetime
)
# Set counted_qty to theoretical_qty by default (Adjustment 0)
if not self.counted_qty and not self.difference_qty:
self.counted_qty = self.theoretical_qty
self.difference_qty = 0.0
def _create_stock_move(self):
"""Create backdated stock move for this line"""
self.ensure_one()
if self.difference_qty == 0:
return
# Find inventory adjustment location
inventory_location = self.env['stock.location'].search([
('usage', '=', 'inventory'),
('company_id', 'in', [self.inventory_id.company_id.id, False])
], limit=1)
if not inventory_location:
raise UserError(_('Inventory adjustment location not found. Please check your stock configuration.'))
# Determine source and destination based on difference
if self.difference_qty > 0:
# Increase inventory
location_id = inventory_location.id
location_dest_id = self.inventory_id.location_id.id
qty = self.difference_qty
else:
# Decrease inventory
location_id = self.inventory_id.location_id.id
location_dest_id = inventory_location.id
qty = abs(self.difference_qty)
# Create stock move with backdated datetime
backdate = self.inventory_id.backdate_datetime
move_vals = {
'name': _('Backdated Inventory Adjustment: %s') % self.inventory_id.name,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': qty,
'location_id': location_id,
'location_dest_id': location_dest_id,
'company_id': self.inventory_id.company_id.id,
'is_inventory': True,
'origin': self.inventory_id.name,
'date': backdate,
}
move = self.env['stock.move'].create(move_vals)
move._action_confirm()
# Check if move line was already created by _action_confirm (e.g. reservation)
move_line = move.move_line_ids.filtered(lambda ml: ml.product_id.id == self.product_id.id)
move_line_vals = {
'product_id': self.product_id.id,
'product_uom_id': self.product_uom_id.id,
'quantity': qty,
'location_id': location_id,
'location_dest_id': location_dest_id,
'lot_id': self.lot_id.id if self.lot_id else False,
'package_id': self.package_id.id if self.package_id else False,
'owner_id': self.owner_id.id if self.owner_id else False,
'date': backdate,
}
if move_line:
# Update existing line
move_line = move_line[0]
move_line.write(move_line_vals)
_logger.info(f"Updated existing move line {move_line.id} with quantity={qty}")
else:
# Create new line if none exists
move_line_vals['move_id'] = move.id
move_line = self.env['stock.move.line'].create(move_line_vals)
_logger.info(f"Created new move line {move_line.id} with quantity={qty}")
_logger.info(f"Created move line {move_line.id} with quantity_done={qty}")
# Log product valuation settings
product = self.product_id
_logger.info(f"Product: {product.name}, Category: {product.categ_id.name}")
_logger.info(f"Valuation: {product.categ_id.property_valuation}, Cost Method: {product.categ_id.property_cost_method}")
_logger.info(f"Product Cost: {product.standard_price}")
# Mark as picked (required for Odoo 17 _action_done)
move.picked = True
for ml in move.move_line_ids:
ml.picked = True
# Mark as done
_logger.info(f"Move state before _action_done: {move.state}")
result = move._action_done()
_logger.info(f"Move state after _action_done: {move.state}")
_logger.info(f"_action_done returned: {result}")
# Refresh move to get latest data
move = self.env['stock.move'].browse(move.id)
_logger.info(f"Move state after refresh: {move.state}")
# CRITICAL: Update dates via direct SQL after _action_done
# The _action_done method overwrites dates, so we must update after
_logger.info(f"Backdating move {move.id} to {backdate}")
# Flush all pending ORM operations to DB before running raw SQL
self.env.flush_all()
# Update stock move
self.env.cr.execute(
"UPDATE stock_move SET date = %s WHERE id = %s",
(backdate, move.id)
)
_logger.info(f"Updated stock_move {move.id}, rows affected: {self.env.cr.rowcount}")
# Update stock move lines
self.env.cr.execute(
"UPDATE stock_move_line SET date = %s WHERE move_id = %s",
(backdate, move.id)
)
_logger.info(f"Updated stock_move_line for move {move.id}, rows affected: {self.env.cr.rowcount}")
# Update stock valuation layer
# Check if valuation layer exists
svl_count = self.env['stock.valuation.layer'].search_count([('stock_move_id', '=', move.id)])
_logger.info(f"Found {svl_count} stock valuation layers for move {move.id}")
if svl_count > 0:
self.env.cr.execute(
"UPDATE stock_valuation_layer SET create_date = %s WHERE stock_move_id = %s",
(backdate, move.id)
)
_logger.info(f"Updated stock_valuation_layer for move {move.id}, rows affected: {self.env.cr.rowcount}")
else:
_logger.warning(f"No stock valuation layer found for move {move.id}. Product may not use real-time valuation or cost is zero.")
# Update account moves if they exist
# Refresh move to get account_move_ids
move = self.env['stock.move'].browse(move.id)
if move.account_move_ids:
account_date = backdate.date()
for account_move in move.account_move_ids:
self.env.cr.execute(
"UPDATE account_move SET date = %s WHERE id = %s",
(account_date, account_move.id)
)
self.env.cr.execute(
"UPDATE account_move_line SET date = %s WHERE move_id = %s",
(account_date, account_move.id)
)
_logger.info(f"Updated account_move {account_move.id} and lines to {account_date}")
# Invalidate cache to ensure ORM reloads data from DB
self.env.invalidate_all()
# Invalidate cache
self.env.cache.invalidate()
return move

View File

@ -1,85 +0,0 @@
from odoo import api, models, fields
from odoo.tools import float_round
class StockMove(models.Model):
_inherit = 'stock.move'
def _action_done(self, cancel_backorder=False):
moves = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
forced_inventory_date = self.env.context.get('force_inventory_date')
if forced_inventory_date:
for move in moves:
move.write({'date': forced_inventory_date})
# If valuation is real-time, we might need to adjust the account move date too.
# But account move creation usually happens in _action_done -> _create_account_move_line
# which might use the move date.
# Let's check if we need to update account moves.
# Account move date field is Date type, so convert datetime to date
if move.account_move_ids:
account_date = forced_inventory_date.date() if hasattr(forced_inventory_date, 'date') else forced_inventory_date
move.account_move_ids.write({'date': account_date})
return moves
def _create_account_move_line(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
# Override to force date on account move creation if needed.
# However, if we update the move date in _action_done, it might be too late for this method
# if it's called during super()._action_done().
# So we might need to rely on context here too.
forced_inventory_date = self.env.context.get('force_inventory_date')
forced_valuation_date = self.env.context.get('force_valuation_date')
# Use valuation date if present, otherwise inventory date
target_date = forced_valuation_date or forced_inventory_date
if target_date:
# We can't easily change the arguments passed to this method without signature change,
# but we can patch the context or check if we can modify the created move later.
# Actually, this method creates 'account.move'.
# Let's see if we can intercept the creation.
pass
return super(StockMove, self)._create_account_move_line(credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost)
def _prepare_account_move_vals(self, credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost):
# This method prepares the values for account.move.create.
vals = super(StockMove, self)._prepare_account_move_vals(credit_account_id, debit_account_id, journal_id, qty, description, svl_id, cost)
forced_inventory_date = self.env.context.get('force_inventory_date')
forced_valuation_date = self.env.context.get('force_valuation_date')
target_date = forced_valuation_date or forced_inventory_date
if target_date:
# Account move date field is Date type, so convert datetime to date if needed
account_date = target_date.date() if hasattr(target_date, 'date') else target_date
vals['date'] = account_date
return vals
def _create_in_svl(self, forced_quantity=None):
# Override to force date on stock valuation layer
svl = super(StockMove, self)._create_in_svl(forced_quantity=forced_quantity)
self._update_svl_date(svl)
return svl
def _create_out_svl(self, forced_quantity=None):
# Override to force date on stock valuation layer
svl = super(StockMove, self)._create_out_svl(forced_quantity=forced_quantity)
self._update_svl_date(svl)
return svl
def _update_svl_date(self, svl):
forced_inventory_date = self.env.context.get('force_inventory_date')
forced_valuation_date = self.env.context.get('force_valuation_date')
target_date = forced_valuation_date or forced_inventory_date
if target_date and svl:
# create_date is a magic field, we need to update it via SQL
self.env.cr.execute(
"UPDATE stock_valuation_layer SET create_date = %s WHERE id IN %s",
(target_date, tuple(svl.ids))
)
svl.invalidate_recordset(['create_date'])

View File

@ -1,53 +0,0 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class StockQuant(models.Model):
_inherit = 'stock.quant'
force_inventory_date = fields.Datetime(
string="Force Inventory Date",
help="Choose a specific date and time for the inventory adjustment. "
"If set, the stock move will be created with this date and time."
)
force_valuation_date = fields.Datetime(
string="Force Valuation Date",
help="Choose a specific date and time for the stock valuation. "
"If set, the valuation layer will be created with this date and time."
)
@api.model
def _get_inventory_fields_create(self):
""" Allow the new fields to be set during inventory creation """
res = super(StockQuant, self)._get_inventory_fields_create()
res += ['force_inventory_date', 'force_valuation_date']
return res
@api.model
def _get_inventory_fields_write(self):
""" Allow the new fields to be set during inventory write """
res = super(StockQuant, self)._get_inventory_fields_write()
res += ['force_inventory_date', 'force_valuation_date']
return res
def _apply_inventory(self):
"""Override to pass forced dates to the context"""
# We need to handle quants with different forced dates separately
# Group by (force_inventory_date, force_valuation_date)
# If no forced date, key is (False, False)
grouped_quants = {}
for quant in self:
key = (quant.force_inventory_date, quant.force_valuation_date)
if key not in grouped_quants:
grouped_quants[key] = self.env['stock.quant']
grouped_quants[key] |= quant
for (force_inventory_date, force_valuation_date), quants in grouped_quants.items():
ctx = dict(self.env.context)
if force_inventory_date:
ctx['force_inventory_date'] = force_inventory_date
if force_valuation_date:
ctx['force_valuation_date'] = force_valuation_date
super(StockQuant, quants.with_context(ctx))._apply_inventory()

View File

@ -1,2 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_quant_backdate,stock.quant.backdate,stock.model_stock_quant,stock.group_stock_user,1,1,1,1
access_stock_inventory_backdate_user,stock.inventory.backdate.user,model_stock_inventory_backdate,stock.group_stock_user,1,1,1,1
access_stock_inventory_backdate_line_user,stock.inventory.backdate.line.user,model_stock_inventory_backdate_line,stock.group_stock_user,1,1,1,1
access_stock_inventory_backdate_manager,stock.inventory.backdate.manager,model_stock_inventory_backdate,stock.group_stock_manager,1,1,1,1
access_stock_inventory_backdate_line_manager,stock.inventory.backdate.line.manager,model_stock_inventory_backdate_line,stock.group_stock_manager,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_quant_backdate access_stock_inventory_backdate_user stock.quant.backdate stock.inventory.backdate.user stock.model_stock_quant model_stock_inventory_backdate stock.group_stock_user 1 1 1 1
3 access_stock_inventory_backdate_line_user stock.inventory.backdate.line.user model_stock_inventory_backdate_line stock.group_stock_user 1 1 1 1
4 access_stock_inventory_backdate_manager stock.inventory.backdate.manager model_stock_inventory_backdate stock.group_stock_manager 1 1 1 1
5 access_stock_inventory_backdate_line_manager stock.inventory.backdate.line.manager model_stock_inventory_backdate_line stock.group_stock_manager 1 1 1 1

View File

@ -11,34 +11,38 @@ class TestStockBackdate(TransactionCase):
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
# Enable automated valuation for the category if needed,
# but for simplicity we test the move date primarily.
# Enable automated valuation for the category
self.product.categ_id.property_valuation = 'real_time'
self.product.categ_id.property_cost_method = 'average'
self.stock_location = self.env.ref('stock.stock_location_stock')
def test_inventory_backdate(self):
"""Test that inventory adjustment backdating works"""
"""Test that backdated inventory adjustment works"""
backdate = fields.Datetime.now() - timedelta(days=10)
quant = self.env['stock.quant'].create({
'product_id': self.product.id,
# Create backdated inventory adjustment
inventory = self.env['stock.inventory.backdate'].create({
'backdate_datetime': backdate,
'location_id': self.stock_location.id,
'inventory_quantity': 100,
})
# Set forced dates
quant.force_inventory_date = backdate
quant.force_valuation_date = backdate
# Add inventory line
line = self.env['stock.inventory.backdate.line'].create({
'inventory_id': inventory.id,
'product_id': self.product.id,
'theoretical_qty': 0,
'counted_qty': 100,
})
# Apply inventory
quant.action_apply_inventory()
# Validate the adjustment
inventory.action_validate()
# Check stock move date
move = self.env['stock.move'].search([
('product_id', '=', self.product.id),
('is_inventory', '=', True)
('is_inventory', '=', True),
('origin', '=', inventory.name)
], limit=1)
self.assertTrue(move, "Stock move should be created")

View File

@ -0,0 +1,136 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_stock_inventory_backdate_tree" model="ir.ui.view">
<field name="name">stock.inventory.backdate.tree</field>
<field name="model">stock.inventory.backdate</field>
<field name="arch" type="xml">
<tree string="Backdated Inventory Adjustments" decoration-info="state=='draft'" decoration-success="state=='done'" decoration-muted="state=='cancel'">
<field name="name"/>
<field name="backdate_datetime"/>
<field name="location_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge" decoration-info="state=='draft'" decoration-success="state=='done'" decoration-muted="state=='cancel'"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="view_stock_inventory_backdate_form" model="ir.ui.view">
<field name="name">stock.inventory.backdate.form</field>
<field name="model">stock.inventory.backdate</field>
<field name="arch" type="xml">
<form string="Backdated Inventory Adjustment">
<header>
<button name="action_load_products" string="Load Products" type="object" class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_validate" string="Validate" type="object" class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_cancel" string="Cancel" type="object" invisible="state != 'draft'"/>
<button name="action_draft" string="Set to Draft" type="object" invisible="state != 'cancel'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,done"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="backdate_datetime" readonly="state != 'draft'"/>
<field name="location_id" readonly="state != 'draft'"/>
</group>
<group>
<field name="company_id" groups="base.group_multi_company" readonly="state != 'draft'"/>
</group>
</group>
<notebook>
<page string="Inventory Lines">
<div class="alert alert-info" role="alert" invisible="state != 'draft'">
<strong>Tip:</strong> You can either click "Load Products" to load all products from the location,
or manually add individual products by clicking "Add a line" below.
</div>
<div class="alert alert-warning" role="alert" invisible="state != 'draft'">
<strong>Note:</strong> If the theoretical quantity shows as negative (highlighted in orange),
it means there was negative stock at the backdated time. This can happen when products were
sold or moved out before being received. Verify the counted quantity carefully.
</div>
<field name="line_ids" readonly="state != 'draft'">
<tree editable="bottom" decoration-danger="difference_qty &lt; 0" decoration-success="difference_qty &gt; 0" decoration-warning="has_negative_theoretical">
<field name="product_id" required="1"/>
<field name="lot_id" optional="hide" groups="stock.group_production_lot"
domain="[('product_id', '=', product_id)]"/>
<field name="package_id" optional="hide" groups="stock.group_tracking_lot"/>
<field name="owner_id" optional="hide" groups="stock.group_tracking_owner"/>
<field name="theoretical_qty" readonly="1"
decoration-warning="theoretical_qty &lt; 0"
force_save="1"/>
<field name="counted_qty"/>
<field name="difference_qty" sum="Total Adjustment"/>
<field name="product_uom_id" string="UoM" groups="uom.group_uom"/>
<field name="has_negative_theoretical" column_invisible="1"/>
</tree>
</field>
</page>
<page string="Notes">
<field name="notes" placeholder="Add notes here..." readonly="state != 'draft'"/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_stock_inventory_backdate_search" model="ir.ui.view">
<field name="name">stock.inventory.backdate.search</field>
<field name="model">stock.inventory.backdate</field>
<field name="arch" type="xml">
<search string="Backdated Inventory Adjustments">
<field name="name"/>
<field name="location_id"/>
<field name="backdate_datetime"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Done" name="done" domain="[('state', '=', 'done')]"/>
<filter string="Cancelled" name="cancel" domain="[('state', '=', 'cancel')]"/>
<group expand="0" string="Group By">
<filter string="Location" name="location" context="{'group_by': 'location_id'}"/>
<filter string="Status" name="state" context="{'group_by': 'state'}"/>
<filter string="Date" name="date" context="{'group_by': 'backdate_datetime'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_stock_inventory_backdate" model="ir.actions.act_window">
<field name="name">Backdated Inventory Adjustments</field>
<field name="res_model">stock.inventory.backdate</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a backdated inventory adjustment
</p>
<p>
This allows you to adjust inventory with a specific past date and time.
The system will automatically calculate the inventory position at that date.
</p>
<p>
<strong>Two ways to add products:</strong><br/>
1. Click "Load Products" to load all products from a location<br/>
2. Manually add individual products by clicking "Add a line"
</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_stock_inventory_backdate"
name="Backdated Adjustments"
parent="stock.menu_stock_warehouse_mgmt"
action="action_stock_inventory_backdate"
sequence="100"/>
</odoo>

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_quant_tree_inventory_editable_inherit_backdate" model="ir.ui.view">
<field name="name">stock.quant.inventory.tree.editable.inherit.backdate</field>
<field name="model">stock.quant</field>
<field name="inherit_id" ref="stock.view_stock_quant_tree_inventory_editable"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='inventory_date']" position="after">
<field name="force_inventory_date" optional="show"/>
<field name="force_valuation_date" optional="hide"/>
</xpath>
</field>
</record>
</odoo>