first commit
This commit is contained in:
commit
92ac84d542
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*~
|
||||||
|
__pycache__/
|
||||||
|
.git/
|
||||||
|
.ipynb_checkpoints/
|
||||||
33
README.md
Normal file
33
README.md
Normal 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
3
__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
21
__manifest__.py
Normal file
21
__manifest__.py
Normal 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
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>
|
||||||
4
models/__init__.py
Normal file
4
models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import stock_move
|
||||||
|
from . import stock_scrap
|
||||||
|
from . import stock_inventory_backdate
|
||||||
377
models/stock_inventory_backdate.py
Normal file
377
models/stock_inventory_backdate.py
Normal 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
105
models/stock_move.py
Normal 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
33
models/stock_scrap.py
Normal 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
|
||||||
72
report/report_stock_inventory_backdate.xml
Normal file
72
report/report_stock_inventory_backdate.xml
Normal 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>
|
||||||
13
report/stock_inventory_backdate_reports.xml
Normal file
13
report/stock_inventory_backdate_reports.xml
Normal 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>
|
||||||
3
security/ir.model.access.csv
Normal file
3
security/ir.model.access.csv
Normal 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
|
||||||
|
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_backdate
|
||||||
125
tests/test_backdate.py
Normal file
125
tests/test_backdate.py
Normal 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")
|
||||||
143
views/stock_inventory_backdate_views.xml
Normal file
143
views/stock_inventory_backdate_views.xml
Normal 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 < 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"/>
|
||||||
|
</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>
|
||||||
14
views/stock_scrap_views.xml
Normal file
14
views/stock_scrap_views.xml
Normal 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
2
wizard/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import stock_inventory_backdate_export
|
||||||
94
wizard/stock_inventory_backdate_export.py
Normal file
94
wizard/stock_inventory_backdate_export.py
Normal 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',
|
||||||
|
}
|
||||||
31
wizard/stock_inventory_backdate_export_view.xml
Normal file
31
wizard/stock_inventory_backdate_export_view.xml
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user