first commit

This commit is contained in:
Suherdy Yacob 2026-06-17 14:59:07 +07:00
commit 92ac84d542
19 changed files with 1094 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*.pyc
*.pyo
*~
__pycache__/
.git/
.ipynb_checkpoints/

33
README.md Normal file
View File

@ -0,0 +1,33 @@
Stock Inventory and Scrap Backdate
==================================
This module enables backdating for stock inventory adjustments and scrap orders in Odoo 19.
Features
--------
- Backdate physical inventory adjustments using a custom wizard/document flow.
- Backdate scrap orders directly from the scrap document form view.
- Automatically handles shifting dates of moves, move lines, product valuation layers (product.value), and journal entries.
- Schema-aware SQL queries ensure compatibility with Odoo 19 database structure.
Installation
------------
1. Copy this module to your custom addons directory.
2. Update the app list in Odoo.
3. Install the module.
Usage
-----
Inventory Backdate:
1. Go to Inventory > Operations > Backdated Adjustments.
2. Create a new adjustment, set the target location and the past datetime.
3. Add lines manually or click Load Products to load quantities at that historical moment.
4. Set the Counted Qty and validate.
Scrap Backdate:
1. Go to Inventory > Operations > Scrap.
2. Create a scrap, set the date to a past date.
3. Validate the scrap order.

3
__init__.py Normal file
View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard

21
__manifest__.py Normal file
View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
{
"name": "Stock Inventory and Scrap Backdate",
"summary": "Backdate stock inventory adjustments and scrap orders in Odoo 19",
"version": "19.0.1.0.0",
"category": "Warehouse",
"author": "Suherdy Yacob",
"license": "AGPL-3",
"depends": ["stock_account", "mail"],
"data": [
"security/ir.model.access.csv",
"data/sequence_data.xml",
"wizard/stock_inventory_backdate_export_view.xml",
"views/stock_inventory_backdate_views.xml",
"views/stock_scrap_views.xml",
"report/stock_inventory_backdate_reports.xml",
"report/report_stock_inventory_backdate.xml",
],
"installable": True,
"application": False,
}

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>

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import stock_move
from . import stock_scrap
from . import stock_inventory_backdate

View File

@ -0,0 +1,377 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_is_zero, float_compare
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_create_multi
def create(self, vals_list):
for vals in vals_list:
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_list)
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 = 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 in batch"""
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.'))
# Find inventory adjustment location
inventory_location = self.env['stock.location'].search([
('usage', '=', 'inventory'),
('company_id', 'in', [self.company_id.id, False])
], limit=1)
if not inventory_location:
raise UserError(_('Inventory adjustment location not found. Please check your stock configuration.'))
moves_to_process = self.env['stock.move']
move_line_data = [] # To store move line info linked to moves
# Step 1: Create all stock moves
for line in self.line_ids:
if line.difference_qty == 0:
continue
# Determine source and destination based on difference
if line.difference_qty > 0:
location_id = inventory_location.id
location_dest_id = self.location_id.id
qty = line.difference_qty
else:
location_id = self.location_id.id
location_dest_id = inventory_location.id
qty = abs(line.difference_qty)
backdate = self.backdate_datetime
move_vals = {
'description_picking': _('Backdated Inventory Adjustment: %s') % self.name,
'product_id': line.product_id.id,
'product_uom': line.product_uom_id.id,
'product_uom_qty': qty,
'location_id': location_id,
'location_dest_id': location_dest_id,
'company_id': self.company_id.id,
'is_inventory': True,
'origin': self.name,
'date': backdate,
'picked': True,
}
move = self.env['stock.move'].create(move_vals)
moves_to_process |= move
# Store data for move line creation
move_line_data.append({
'move': move,
'line': line,
'qty': qty,
'location_id': location_id,
'location_dest_id': location_dest_id,
'backdate': backdate
})
if not moves_to_process:
self.write({'state': 'done'})
return True
# Step 2: Confirm moves and handle move lines
moves_to_process._action_confirm()
for data in move_line_data:
move = data['move']
line = data['line']
# Check if move line was already created by _action_confirm (e.g. reservation)
existing_ml = move.move_line_ids.filtered(lambda ml: ml.product_id.id == line.product_id.id)
ml_vals = {
'product_id': line.product_id.id,
'product_uom_id': line.product_uom_id.id,
'quantity': data['qty'],
'location_id': data['location_id'],
'location_dest_id': data['location_dest_id'],
'lot_id': line.lot_id.id if line.lot_id else False,
'package_id': line.package_id.id if line.package_id else False,
'owner_id': line.owner_id.id if line.owner_id else False,
'date': data['backdate'],
'picked': True,
}
if existing_ml:
existing_ml[0].write(ml_vals)
else:
ml_vals['move_id'] = move.id
self.env['stock.move.line'].create(ml_vals)
# Step 3: Action Done on all moves at once
moves_to_process.with_context(backdate_inventory_mode=True)._action_done()
# Step 4: Post-process all moves
moves_to_process._post_process_validated_moves(self.backdate_datetime)
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_print_pdf(self):
"""Print the PDF report"""
self.ensure_one()
return self.env.ref('stock_inventory_scrap_backdate.action_report_inventory_backdate').report_action(self)
def action_export_xlsx(self):
"""Open XLSX export wizard"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Export Inventory Backdate'),
'res_model': 'stock.inventory.backdate.export.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_inventory_id': self.id}
}
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', '=', 'consu')]"
)
lot_id = fields.Many2one('stock.lot', string='Lot/Serial Number')
package_id = fields.Many2one('stock.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 (+/-)',
compute='_compute_difference_qty',
inverse='_inverse_difference_qty',
store=True,
readonly=False,
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.depends('counted_qty', 'theoretical_qty')
def _compute_difference_qty(self):
for line in self:
line.difference_qty = line.counted_qty - line.theoretical_qty
def _inverse_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

105
models/stock_move.py Normal file
View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class StockMove(models.Model):
_inherit = 'stock.move'
def _post_process_validated_moves(self, backdate):
"""Handle backdating for a batch of moves, including valuation and accounting"""
if not self:
return True
# Flush all pending ORM operations to DB before running raw SQL
self.env.flush_all()
move_ids = tuple(self.ids)
# 1. Update stock move dates
self.env.cr.execute(
"UPDATE stock_move SET date = %s WHERE id IN %s",
(backdate, move_ids)
)
# 2. Update stock move line dates
self.env.cr.execute(
"UPDATE stock_move_line SET date = %s WHERE move_id IN %s",
(backdate, move_ids)
)
# 3. Update stock valuation layer (if the table stock_valuation_layer exists)
self.env.cr.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'stock_valuation_layer'
);
""")
if self.env.cr.fetchone()[0]:
self.env.cr.execute(
"UPDATE stock_valuation_layer SET create_date = %s WHERE stock_move_id IN %s",
(backdate, move_ids)
)
# 4. Update product value (Odoo 19) (if the table product_value exists)
self.env.cr.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'product_value'
);
""")
if self.env.cr.fetchone()[0]:
self.env.cr.execute(
"UPDATE product_value SET date = %s, create_date = %s WHERE move_id IN %s",
(backdate, backdate, move_ids)
)
# 5. Update account move dates (journal entries)
account_move_ids = []
# Check if stock_move has account_move_id column
self.env.cr.execute("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'stock_move'
AND column_name = 'account_move_id'
);
""")
if self.env.cr.fetchone()[0]:
self.env.cr.execute(
"SELECT account_move_id FROM stock_move WHERE id IN %s AND account_move_id IS NOT NULL",
(move_ids,)
)
account_move_ids.extend([row[0] for row in self.env.cr.fetchall()])
# Also check stock_valuation_layer if table exists
self.env.cr.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'stock_valuation_layer'
);
""")
if self.env.cr.fetchone()[0]:
self.env.cr.execute(
"SELECT account_move_id FROM stock_valuation_layer WHERE stock_move_id IN %s AND account_move_id IS NOT NULL",
(move_ids,)
)
account_move_ids.extend([row[0] for row in self.env.cr.fetchall()])
account_move_ids = list(set(account_move_ids))
if account_move_ids:
# Update account_move date
self.env.cr.execute(
"UPDATE account_move SET date = %s WHERE id IN %s",
(backdate.date(), tuple(account_move_ids))
)
# Update account_move_line date
self.env.cr.execute(
"UPDATE account_move_line SET date = %s WHERE move_id IN %s",
(backdate.date(), tuple(account_move_ids))
)
# 6. Clear cache to reflect changes
self.env.invalidate_all()
return True

33
models/stock_scrap.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class StockScrap(models.Model):
_inherit = 'stock.scrap'
date_done = fields.Datetime(
'Date',
readonly=False,
default=fields.Datetime.now,
copy=False,
)
def do_scrap(self):
# Capture the custom date_done for each scrap record in draft state
backdates = {}
for scrap in self:
if scrap.state == 'draft' and scrap.date_done:
backdates[scrap.id] = scrap.date_done
else:
backdates[scrap.id] = fields.Datetime.now()
res = super().do_scrap()
# Shifting date_done and generated moves
for scrap in self:
backdate = backdates.get(scrap.id)
if backdate:
# We need to write date_done to the custom date because super().do_scrap() sets it to Datetime.now()
scrap.write({'date_done': backdate})
if scrap.move_ids:
scrap.move_ids._post_process_validated_moves(backdate)
return res

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_inventory_backdate_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<div class="oe_structure"/>
<h2>Backdated Inventory Adjustment: <span t-field="o.name"/></h2>
<div class="row mt32 mb32">
<div class="col-3">
<strong>Adjustment Date:</strong>
<p t-field="o.backdate_datetime"/>
</div>
<div class="col-3">
<strong>Location:</strong>
<p t-field="o.location_id"/>
</div>
<div class="col-3">
<strong>Company:</strong>
<p t-field="o.company_id"/>
</div>
<div class="col-3">
<strong>Status:</strong>
<p t-field="o.state"/>
</div>
</div>
<table class="table table-sm o_main_table mt16">
<thead>
<tr>
<th name="th_product"><strong>Product</strong></th>
<th name="th_barcode"><strong>Barcode</strong></th>
<th name="th_lot" t-if="any(l.lot_id for l in o.line_ids)"><strong>Lot/Serial</strong></th>
<th name="th_package" t-if="any(l.package_id for l in o.line_ids)"><strong>Package</strong></th>
<th class="text-end"><strong>Theoretical</strong></th>
<th class="text-end"><strong>Counted</strong></th>
<th class="text-end"><strong>Difference</strong></th>
<th class="text-end"><strong>UoM</strong></th>
</tr>
</thead>
<tbody>
<t t-foreach="o.line_ids" t-as="line">
<tr>
<td><span t-field="line.product_id"/></td>
<td><span t-field="line.product_id.barcode"/></td>
<td t-if="any(l.lot_id for l in o.line_ids)"><span t-field="line.lot_id"/></td>
<td t-if="any(l.package_id for l in o.line_ids)"><span t-field="line.package_id"/></td>
<td class="text-end"><span t-field="line.theoretical_qty"/></td>
<td class="text-end"><span t-field="line.counted_qty"/></td>
<td class="text-end"><span t-field="line.difference_qty"/></td>
<td class="text-end"><span t-field="line.product_uom_id"/></td>
</tr>
</t>
</tbody>
</table>
<p t-if="o.notes" class="mt32">
<strong>Notes:</strong>
<br/>
<span t-field="o.notes"/>
</p>
<div class="oe_structure"/>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_inventory_backdate" model="ir.actions.report">
<field name="name">Inventory Backdate Report</field>
<field name="model">stock.inventory.backdate</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">stock_inventory_scrap_backdate.report_inventory_backdate_template</field>
<field name="report_file">stock_inventory_scrap_backdate.report_inventory_backdate</field>
<field name="print_report_name">'Inventory Adjustment - %s' % (object.name)</field>
<field name="binding_model_id" ref="model_stock_inventory_backdate"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_inventory_backdate_user stock.inventory.backdate.user 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

2
tests/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_backdate

125
tests/test_backdate.py Normal file
View File

@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
from odoo import fields
from datetime import timedelta
class TestStockBackdate(TransactionCase):
def setUp(self):
super(TestStockBackdate, self).setUp()
# Find a suitable company
self.company = self.env.company
# Create a product with type 'consu' (Odoo 19 physical product type)
category = self.env['product.category'].search([], limit=1)
self.product = self.env['product.product'].create({
'name': 'Test Backdated Product',
'type': 'consu',
'categ_id': category.id,
})
# Try to set automated valuation. If accounts are missing, it might raise/warn, so handle it.
try:
self.product.categ_id.write({
'property_valuation': 'real_time',
'property_cost_method': 'average',
})
except Exception:
pass # Fallback to standard valuation if real_time fails due to accounts configuration
self.stock_location = self.env['stock.location'].search([('usage', '=', 'internal')], limit=1)
def test_scrap_backdate(self):
"""Test that backdated scrap order works and backdates all valuation/moves"""
backdate = fields.Datetime.now() - timedelta(days=5)
scrap = self.env['stock.scrap'].create({
'product_id': self.product.id,
'product_uom_id': self.product.uom_id.id,
'scrap_qty': 5.0,
'location_id': self.stock_location.id,
'date_done': backdate,
})
# Verify initial state
self.assertEqual(scrap.state, 'draft')
self.assertEqual(scrap.date_done, backdate)
# Confirm scrap
scrap.action_validate()
self.assertEqual(scrap.state, 'done')
# Verify stock move backdated
move = scrap.move_ids
self.assertTrue(move, "Stock move should be created for the scrap order")
self.assertEqual(move.date, backdate, "Stock move date should match the scrap backdate")
# Verify stock move line backdated
move_line = move.move_line_ids
self.assertTrue(move_line, "Stock move line should be created")
self.assertEqual(move_line.date, backdate, "Stock move line date should match the scrap backdate")
# Verify product value backdated
product_value = self.env['product.value'].search([('move_id', '=', move.id)], limit=1)
if product_value:
self.assertEqual(product_value.date, backdate, "Product value date should match the scrap backdate")
self.assertEqual(product_value.create_date, backdate, "Product value create_date should match the scrap backdate")
# Verify account move backdated if exists
if move.account_move_id:
self.assertEqual(move.account_move_id.date, backdate.date(), "Account move date should match the scrap backdate date")
for aml in move.account_move_id.line_ids:
self.assertEqual(aml.date, backdate.date(), "Account move line date should match the scrap backdate date")
def test_inventory_backdate(self):
"""Test that backdated inventory adjustment works and backdates all valuation/moves"""
backdate = fields.Datetime.now() - timedelta(days=10)
# Create backdated inventory adjustment
inventory = self.env['stock.inventory.backdate'].create({
'backdate_datetime': backdate,
'location_id': self.stock_location.id,
})
# Add inventory line
line = self.env['stock.inventory.backdate.line'].create({
'inventory_id': inventory.id,
'product_id': self.product.id,
'theoretical_qty': 0.0,
'counted_qty': 50.0,
})
self.assertEqual(line.difference_qty, 50.0)
# Validate the adjustment
inventory.action_validate()
self.assertEqual(inventory.state, 'done')
# Find created moves
move = self.env['stock.move'].search([
('product_id', '=', self.product.id),
('is_inventory', '=', True),
('origin', '=', inventory.name)
], limit=1)
self.assertTrue(move, "Stock move should be created for backdated inventory adjustment")
self.assertEqual(move.date, backdate, "Stock move date should match the adjustment backdate")
# Verify stock move line backdated
move_line = move.move_line_ids
self.assertTrue(move_line, "Stock move line should be created")
self.assertEqual(move_line.date, backdate, "Stock move line date should match the adjustment backdate")
# Verify product value backdated
product_value = self.env['product.value'].search([('move_id', '=', move.id)], limit=1)
if product_value:
self.assertEqual(product_value.date, backdate, "Product value date should match the adjustment backdate")
self.assertEqual(product_value.create_date, backdate, "Product value create_date should match the adjustment backdate")
# Verify account move backdated if exists
if move.account_move_id:
self.assertEqual(move.account_move_id.date, backdate.date(), "Account move date should match the adjustment backdate date")
for aml in move.account_move_id.line_ids:
self.assertEqual(aml.date, backdate.date(), "Account move line date should match the adjustment backdate date")

View File

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- List View -->
<record id="view_stock_inventory_backdate_list" model="ir.ui.view">
<field name="name">stock.inventory.backdate.list</field>
<field name="model">stock.inventory.backdate</field>
<field name="arch" type="xml">
<list 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'"/>
</list>
</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'"/>
<button name="action_print_pdf" string="Print PDF" type="object" class="btn-secondary" invisible="state != 'done'"/>
<button name="action_export_xlsx" string="Export XLSX" type="object" class="btn-secondary" invisible="state != 'done'"/>
<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'">
<list 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"/>
</list>
</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>
<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">list,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 under Inventory Adjustments -->
<menuitem id="menu_stock_inventory_backdate"
name="Backdated Adjustments"
parent="stock.menu_stock_adjustments"
action="action_stock_inventory_backdate"
sequence="10"/>
<!-- Hide legacy stock inventory adjustment menu -->
<record id="stock.menu_action_inventory_tree" model="ir.ui.menu">
<field name="active" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_scrap_form_inherit_backdate" model="ir.ui.view">
<field name="name">stock.scrap.form.inherit.backdate</field>
<field name="model">stock.scrap</field>
<field name="inherit_id" ref="stock.stock_scrap_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='date_done']" position="attributes">
<attribute name="invisible">False</attribute>
<attribute name="readonly">state != 'draft'</attribute>
</xpath>
</field>
</record>
</odoo>

2
wizard/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import stock_inventory_backdate_export

View File

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
import io
import base64
from odoo import models, fields, api, _
from odoo.exceptions import UserError
try:
import xlsxwriter
except ImportError:
xlsxwriter = None
class StockInventoryBackdateExportWizard(models.TransientModel):
_name = 'stock.inventory.backdate.export.wizard'
_description = 'Export Inventory Backdate to XLSX'
inventory_id = fields.Many2one('stock.inventory.backdate', string='Inventory Adjustment', readonly=True)
file_data = fields.Binary(string='Download File', readonly=True)
file_name = fields.Char(string='File Name', readonly=True)
state = fields.Selection([
('choose', 'Choose'),
('get', 'Get'),
], default='choose')
def action_export(self):
self.ensure_one()
if not xlsxwriter:
raise UserError(_('The xlsxwriter library is not installed. Please contact your administrator.'))
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet('Inventory Adjustment')
# Formats
header_format = workbook.add_format({
'bold': True, 'bg_color': '#D3D3D3', 'border': 1, 'align': 'center'
})
title_format = workbook.add_format({'bold': True, 'font_size': 14})
date_format = workbook.add_format({'num_format': 'yyyy-mm-dd hh:mm:ss'})
number_format = workbook.add_format({'num_format': '#,##0.00'})
# Title
sheet.write(0, 0, _('Backdated Inventory Adjustment: %s') % self.inventory_id.name, title_format)
sheet.write(2, 0, _('Date:'), header_format)
sheet.write(2, 1, self.inventory_id.backdate_datetime.strftime('%Y-%m-%d %H:%M:%S') if self.inventory_id.backdate_datetime else '', date_format)
sheet.write(3, 0, _('Location:'), header_format)
sheet.write(3, 1, self.inventory_id.location_id.display_name or '')
# Table Header
row = 5
col = 0
headers = [_('Product'), _('Barcode'), _('Lot/Serial'), _('Package'), _('Theoretical Qty'), _('Counted Qty'), _('Difference'), _('UoM')]
for h in headers:
sheet.write(row, col, h, header_format)
col += 1
# Data
row += 1
for line in self.inventory_id.line_ids:
col = 0
sheet.write(row, col, line.product_id.display_name)
col += 1
sheet.write(row, col, line.product_id.barcode or '')
col += 1
sheet.write(row, col, line.lot_id.name or '')
col += 1
sheet.write(row, col, line.package_id.name or '')
col += 1
sheet.write(row, col, line.theoretical_qty, number_format)
col += 1
sheet.write(row, col, line.counted_qty, number_format)
col += 1
sheet.write(row, col, line.difference_qty, number_format)
col += 1
sheet.write(row, col, line.product_uom_id.name or '')
row += 1
workbook.close()
output.seek(0)
file_name = 'Inventory_Backdate_%s.xlsx' % self.inventory_id.name.replace('/', '_')
self.write({
'state': 'get',
'file_data': base64.b64encode(output.read()),
'file_name': file_name
})
return {
'type': 'ir.actions.act_window',
'res_model': 'stock.inventory.backdate.export.wizard',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_inventory_backdate_export_wizard_form" model="ir.ui.view">
<field name="name">stock.inventory.backdate.export.wizard.form</field>
<field name="model">stock.inventory.backdate.export.wizard</field>
<field name="arch" type="xml">
<form string="Export Inventory Backdate">
<field name="state" invisible="1"/>
<div invisible="state != 'choose'">
<p>Click "Export" to generate the XLSX file for this adjustment.</p>
</div>
<div invisible="state != 'get'">
<p>Your file is ready for download.</p>
<field name="file_name" invisible="1"/>
<field name="file_data" filename="file_name" readonly="1"/>
</div>
<footer>
<button name="action_export" string="Export" type="object" class="btn-primary" invisible="state != 'choose'"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_stock_inventory_backdate_export" model="ir.actions.act_window">
<field name="name">Export Inventory Backdate</field>
<field name="res_model">stock.inventory.backdate.export.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>