commit 92ac84d542194b9ec7d3132ebe388e2d6a587bff Author: Suherdy Yacob Date: Wed Jun 17 14:59:07 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1630958 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +*.pyo +*~ +__pycache__/ +.git/ +.ipynb_checkpoints/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec66faf --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..408a600 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..d193df0 --- /dev/null +++ b/__manifest__.py @@ -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, +} diff --git a/data/sequence_data.xml b/data/sequence_data.xml new file mode 100644 index 0000000..d477db4 --- /dev/null +++ b/data/sequence_data.xml @@ -0,0 +1,13 @@ + + + + + + Backdated Inventory Adjustment + stock.inventory.backdate + BIA/ + 5 + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..0d958f9 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import stock_move +from . import stock_scrap +from . import stock_inventory_backdate diff --git a/models/stock_inventory_backdate.py b/models/stock_inventory_backdate.py new file mode 100644 index 0000000..b220277 --- /dev/null +++ b/models/stock_inventory_backdate.py @@ -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 diff --git a/models/stock_move.py b/models/stock_move.py new file mode 100644 index 0000000..40ebd61 --- /dev/null +++ b/models/stock_move.py @@ -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 diff --git a/models/stock_scrap.py b/models/stock_scrap.py new file mode 100644 index 0000000..44553e7 --- /dev/null +++ b/models/stock_scrap.py @@ -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 diff --git a/report/report_stock_inventory_backdate.xml b/report/report_stock_inventory_backdate.xml new file mode 100644 index 0000000..48e4b5c --- /dev/null +++ b/report/report_stock_inventory_backdate.xml @@ -0,0 +1,72 @@ + + + + diff --git a/report/stock_inventory_backdate_reports.xml b/report/stock_inventory_backdate_reports.xml new file mode 100644 index 0000000..4173a1d --- /dev/null +++ b/report/stock_inventory_backdate_reports.xml @@ -0,0 +1,13 @@ + + + + Inventory Backdate Report + stock.inventory.backdate + qweb-pdf + stock_inventory_scrap_backdate.report_inventory_backdate_template + stock_inventory_scrap_backdate.report_inventory_backdate + 'Inventory Adjustment - %s' % (object.name) + + report + + diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..6f38533 --- /dev/null +++ b/security/ir.model.access.csv @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f30947e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_backdate diff --git a/tests/test_backdate.py b/tests/test_backdate.py new file mode 100644 index 0000000..dd3c1e4 --- /dev/null +++ b/tests/test_backdate.py @@ -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") diff --git a/views/stock_inventory_backdate_views.xml b/views/stock_inventory_backdate_views.xml new file mode 100644 index 0000000..09fb590 --- /dev/null +++ b/views/stock_inventory_backdate_views.xml @@ -0,0 +1,143 @@ + + + + + stock.inventory.backdate.list + stock.inventory.backdate + + + + + + + + + + + + + + stock.inventory.backdate.form + stock.inventory.backdate + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + + stock.inventory.backdate.search + stock.inventory.backdate + + + + + + + + + + + + + + + + + + + + Backdated Inventory Adjustments + stock.inventory.backdate + list,form + {'search_default_draft': 1} + +

+ Create a backdated inventory adjustment +

+

+ This allows you to adjust inventory with a specific past date and time. + The system will automatically calculate the inventory position at that date. +

+

+ Two ways to add products:
+ 1. Click "Load Products" to load all products from a location
+ 2. Manually add individual products by clicking "Add a line" +

+
+
+ + + + + + + + +
diff --git a/views/stock_scrap_views.xml b/views/stock_scrap_views.xml new file mode 100644 index 0000000..c435ea5 --- /dev/null +++ b/views/stock_scrap_views.xml @@ -0,0 +1,14 @@ + + + + stock.scrap.form.inherit.backdate + stock.scrap + + + + False + state != 'draft' + + + + diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..88f7a32 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import stock_inventory_backdate_export diff --git a/wizard/stock_inventory_backdate_export.py b/wizard/stock_inventory_backdate_export.py new file mode 100644 index 0000000..223b326 --- /dev/null +++ b/wizard/stock_inventory_backdate_export.py @@ -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', + } diff --git a/wizard/stock_inventory_backdate_export_view.xml b/wizard/stock_inventory_backdate_export_view.xml new file mode 100644 index 0000000..bc9e320 --- /dev/null +++ b/wizard/stock_inventory_backdate_export_view.xml @@ -0,0 +1,31 @@ + + + + stock.inventory.backdate.export.wizard.form + stock.inventory.backdate.export.wizard + +
+ +
+

Click "Export" to generate the XLSX file for this adjustment.

+
+
+

Your file is ready for download.

+ + +
+
+
+ +
+
+ + + Export Inventory Backdate + stock.inventory.backdate.export.wizard + form + new + +