feat: Introduce a dedicated model and UI for backdated inventory adjustments, replacing previous scattered logic.
This commit is contained in:
parent
d4710b2562
commit
31911354f0
85
README.md
Normal file
85
README.md
Normal 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
|
||||
@ -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
13
data/sequence_data.xml
Normal 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>
|
||||
@ -1,2 +1 @@
|
||||
from . import stock_quant
|
||||
from . import stock_move
|
||||
from . import stock_inventory_backdate
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
421
models/stock_inventory_backdate.py
Normal file
421
models/stock_inventory_backdate.py
Normal 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
|
||||
@ -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'])
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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")
|
||||
|
||||
136
views/stock_inventory_backdate_views.xml
Normal file
136
views/stock_inventory_backdate_views.xml
Normal 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 < 0" decoration-success="difference_qty > 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 < 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>
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user