feat: add PDF report and XLSX export functionality for backdated inventory adjustments and refine valuation logic

This commit is contained in:
Suherdy Yacob 2026-04-02 13:37:01 +07:00
parent 5062762237
commit 005062bf0a
12 changed files with 321 additions and 56 deletions

View File

@ -44,12 +44,9 @@ This module allows you to create backdated inventory adjustments with a specific
### How It Works ### How It Works
1. When you validate a backdated adjustment, the module creates standard stock moves 1. When you validate a backdated adjustment, the module creates standard stock moves
2. After the moves are processed, it updates the dates via SQL to ensure proper backdating: 2. The system performs a **Physical Stock Adjustment Only**.
- Stock move `date` field 3. After the moves are processed, it updates the dates via SQL for the moves and lines to ensure proper backdating.
- Stock move line `date` field 4. Any generated `stock.valuation.layer` or `account.move` (accounting) records are automatically **removed** to ensure no financial impact.
- Stock valuation layer `create_date` field
- Account move `date` field (if real-time valuation is enabled)
- Account move `account_id` field: Overrides the interim account with `521301 Selisih Persediaan` for the non-valuation side of the entry.
### Models ### Models
@ -63,6 +60,25 @@ This module allows you to create backdated inventory adjustments with a specific
## Version History ## Version History
### Version 17.0.2.5.1
- Added **PDF Report** for backdated inventory adjustments.
- Added **XLSX Export** feature using `xlsxwriter`.
- Fixed module initialization and updated XML syntax for **Odoo 17** compatibility.
### Version 17.0.2.4.0
- Refactored to **Selective Valuation**: Adjustment now generates its primary journal entry, but side-effect "Revaluation" layers are strictly suppressed.
- This ensures the BIA itself is visible in accounting while preventing doubling/tripling of journal entries on other moves.
### Version 17.0.2.3.0
- Implemented comprehensive **Valuation Bypass** via Odoo context.
- Prevents both primary valuation layers and **recursive revaluation side-effects** on other moves.
- Guarantees strictly **Physical Stock Only** adjustments even with automated valuation enabled.
### Version 17.0.2.2.0
- Changed logic to perform **Physical Stock Adjustments Only**.
- Automatically removes `stock.valuation.layer` and `account.move` records after validation to ensure no financial impact.
- Ideal for correcting stock levels without affecting accounting.
### Version 17.0.2.1.0 ### Version 17.0.2.1.0
- Refactored validation logic to use batch processing for improved performance and reliability. - Refactored validation logic to use batch processing for improved performance and reliability.
- Fixed uniqueness constraint conflicts on account moves by optimizing the sequence of operations. - Fixed uniqueness constraint conflicts on account moves by optimizing the sequence of operations.

View File

@ -1 +1,2 @@
from . import models from . import models
from . import wizard

View File

@ -1,7 +1,7 @@
{ {
"name": "Stock Inventory Backdate", "name": "Stock Inventory Backdate",
"summary": "Create backdated inventory adjustments with historical position view", "summary": "Create backdated inventory adjustments with historical position view",
"version": "17.0.2.1.0", "version": "17.0.2.5.1",
"category": "Warehouse", "category": "Warehouse",
"author": "Suherdy Yacob", "author": "Suherdy Yacob",
"license": "AGPL-3", "license": "AGPL-3",
@ -9,7 +9,10 @@
"data": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",
"data/sequence_data.xml", "data/sequence_data.xml",
"wizard/stock_inventory_backdate_export_view.xml",
"views/stock_inventory_backdate_views.xml", "views/stock_inventory_backdate_views.xml",
"report/stock_inventory_backdate_reports.xml",
"report/report_stock_inventory_backdate.xml",
], ],
"installable": True, "installable": True,
} }

Binary file not shown.

View File

@ -250,79 +250,55 @@ class StockInventoryBackdate(models.Model):
self.env['stock.move.line'].create(ml_vals) self.env['stock.move.line'].create(ml_vals)
# Step 3: Action Done on all moves at once # Step 3: Action Done on all moves at once
_logger.info(f"Validating {len(moves_to_process)} moves for {self.name}") # Using context backdate_inventory_mode to allow primary valuation but suppress side-effect revaluations
moves_to_process._action_done() _logger.info(f"Validating {len(moves_to_process)} moves for {self.name} (Selective Valuation)")
moves_to_process.with_context(backdate_inventory_mode=True)._action_done()
# Step 4: Post-process all moves (backdating and accounting) # Step 4: Post-process all moves
self._post_process_validated_moves(moves_to_process) self._post_process_validated_moves(moves_to_process)
self.write({'state': 'done'}) self.write({'state': 'done'})
return True return True
def _post_process_validated_moves(self, moves): def _post_process_validated_moves(self, moves):
"""Handle backdating and accounting adjustments for a batch of moves""" """Handle backdating for a batch of moves, including valuation and accounting"""
self.ensure_one() self.ensure_one()
backdate = self.backdate_datetime backdate = self.backdate_datetime
account_date = backdate.date()
# Flush all pending ORM operations to DB before running raw SQL # Flush all pending ORM operations to DB before running raw SQL
self.env.flush_all() self.env.flush_all()
move_ids = tuple(moves.ids)
# 1. Update stock move dates # 1. Update stock move dates
self.env.cr.execute( self.env.cr.execute(
"UPDATE stock_move SET date = %s WHERE id IN %s", "UPDATE stock_move SET date = %s WHERE id IN %s",
(backdate, tuple(moves.ids)) (backdate, move_ids)
) )
# 2. Update stock move line dates # 2. Update stock move line dates
self.env.cr.execute( self.env.cr.execute(
"UPDATE stock_move_line SET date = %s WHERE move_id IN %s", "UPDATE stock_move_line SET date = %s WHERE move_id IN %s",
(backdate, tuple(moves.ids)) (backdate, move_ids)
) )
# 3. Update stock valuation layers # 3. Update stock valuation layer dates
svls = self.env['stock.valuation.layer'].search([('stock_move_id', 'in', moves.ids)]) self.env.cr.execute(
if svls: "UPDATE stock_valuation_layer SET create_date = %s WHERE stock_move_id IN %s",
self.env.cr.execute( (backdate, move_ids)
"UPDATE stock_valuation_layer SET create_date = %s WHERE id IN %s", )
(backdate, tuple(svls.ids))
# 4. Update account move dates (journal entries)
# We find AMs linked to these moves via SVLs
self.env.cr.execute("""
UPDATE account_move
SET date = %s
WHERE id IN (
SELECT account_move_id FROM stock_valuation_layer WHERE stock_move_id IN %s
) )
""", (backdate.date(), move_ids))
# 4. Update account moves # 5. Clear cache to reflect changes
account_moves = moves.account_move_ids
if account_moves:
# Update account move dates
self.env.cr.execute(
"UPDATE account_move SET date = %s WHERE id IN %s",
(account_date, tuple(account_moves.ids))
)
# Update account move line dates
self.env.cr.execute(
"UPDATE account_move_line SET date = %s WHERE move_id IN %s",
(account_date, tuple(account_moves.ids))
)
# Update account for non-valuation lines if target account 521301 exists
target_account = self.env['account.account'].search([
('code', '=', '521301'),
('company_id', '=', self.company_id.id)
], limit=1)
if target_account:
for move in moves:
product = move.product_id
valuation_account = product.categ_id.property_stock_valuation_account_id
if valuation_account and move.account_move_ids:
# Update the line that is NOT the stock valuation account
self.env.cr.execute(
"""UPDATE account_move_line
SET account_id = %s
WHERE move_id IN %s AND account_id != %s""",
(target_account.id, tuple(move.account_move_ids.ids), valuation_account.id)
)
# Clear cache to reflect changes
self.env.invalidate_all() self.env.invalidate_all()
def action_cancel(self): def action_cancel(self):
@ -333,6 +309,23 @@ class StockInventoryBackdate(models.Model):
self.write({'state': 'cancel'}) self.write({'state': 'cancel'})
return True return True
def action_print_pdf(self):
"""Print the PDF report"""
self.ensure_one()
return self.env.ref('stock_inventory_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): def action_draft(self):
"""Reset to draft""" """Reset to draft"""
self.ensure_one() self.ensure_one()
@ -340,6 +333,47 @@ class StockInventoryBackdate(models.Model):
return True return True
class StockMove(models.Model):
_inherit = 'stock.move'
def _create_in_svl(self, forced_quantity=None):
"""Allow primary SVL creation for backdated adjustments, but handle backdating"""
svls = super()._create_in_svl(forced_quantity=forced_quantity)
if self.env.context.get('backdate_inventory_mode') and svls:
# We will backdate SVLs in post-processing
pass
return svls
def _create_out_svl(self, forced_quantity=None):
"""Allow primary SVL creation for backdated adjustments, but handle backdating"""
svls = super()._create_out_svl(forced_quantity=forced_quantity)
if self.env.context.get('backdate_inventory_mode') and svls:
# We will backdate SVLs in post-processing
pass
return svls
def product_price_update_before_done(self, forced_qty=None):
"""
In backdated adjustments, we allow the price update for the move itself,
but we bypass the recursive revaluation of older moves (vacuuming).
"""
if self.env.context.get('backdate_inventory_mode'):
# Call super but with a context that bypasses _run_fifo_vacuum
return super(StockMove, self.with_context(skip_fifo_vacuum=True)).product_price_update_before_done(forced_qty=forced_qty)
return super().product_price_update_before_done(forced_qty=forced_qty)
class ProductProduct(models.Model):
_inherit = 'product.product'
def _run_fifo_vacuum(self, company=None):
"""Bypass revaluation side-effects during backdated adjustments"""
if self.env.context.get('skip_fifo_vacuum') or self.env.context.get('backdate_inventory_mode'):
return
return super()._run_fifo_vacuum(company=company)
class StockInventoryBackdateLine(models.Model): class StockInventoryBackdateLine(models.Model):
_name = 'stock.inventory.backdate.line' _name = 'stock.inventory.backdate.line'
_description = 'Backdated Inventory Adjustment Line' _description = 'Backdated Inventory Adjustment Line'

View File

@ -0,0 +1,70 @@
<?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_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 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_backdate.report_inventory_backdate_template</field>
<field name="report_file">stock_inventory_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

@ -3,3 +3,5 @@ access_stock_inventory_backdate_user,stock.inventory.backdate.user,model_stock_i
access_stock_inventory_backdate_line_user,stock.inventory.backdate.line.user,model_stock_inventory_backdate_line,stock.group_stock_user,1,1,1,1 access_stock_inventory_backdate_line_user,stock.inventory.backdate.line.user,model_stock_inventory_backdate_line,stock.group_stock_user,1,1,1,1
access_stock_inventory_backdate_manager,stock.inventory.backdate.manager,model_stock_inventory_backdate,stock.group_stock_manager,1,1,1,1 access_stock_inventory_backdate_manager,stock.inventory.backdate.manager,model_stock_inventory_backdate,stock.group_stock_manager,1,1,1,1
access_stock_inventory_backdate_line_manager,stock.inventory.backdate.line.manager,model_stock_inventory_backdate_line,stock.group_stock_manager,1,1,1,1 access_stock_inventory_backdate_line_manager,stock.inventory.backdate.line.manager,model_stock_inventory_backdate_line,stock.group_stock_manager,1,1,1,1
access_stock_inventory_backdate_export_wizard_user,stock.inventory.backdate.export.wizard,model_stock_inventory_backdate_export_wizard,stock.group_stock_user,1,1,1,1
access_stock_inventory_backdate_export_wizard_manager,stock.inventory.backdate.export.wizard,model_stock_inventory_backdate_export_wizard,stock.group_stock_manager,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_stock_inventory_backdate_line_user stock.inventory.backdate.line.user model_stock_inventory_backdate_line stock.group_stock_user 1 1 1 1
4 access_stock_inventory_backdate_manager stock.inventory.backdate.manager model_stock_inventory_backdate stock.group_stock_manager 1 1 1 1
5 access_stock_inventory_backdate_line_manager stock.inventory.backdate.line.manager model_stock_inventory_backdate_line stock.group_stock_manager 1 1 1 1
6 access_stock_inventory_backdate_export_wizard_user stock.inventory.backdate.export.wizard model_stock_inventory_backdate_export_wizard stock.group_stock_user 1 1 1 1
7 access_stock_inventory_backdate_export_wizard_manager stock.inventory.backdate.export.wizard model_stock_inventory_backdate_export_wizard stock.group_stock_manager 1 1 1 1

View File

@ -26,6 +26,8 @@
<button name="action_validate" string="Validate" 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_cancel" string="Cancel" type="object" invisible="state != 'draft'"/>
<button name="action_draft" string="Set to Draft" type="object" invisible="state != 'cancel'"/> <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"/> <field name="state" widget="statusbar" statusbar_visible="draft,done"/>
</header> </header>
<sheet> <sheet>

1
wizard/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import stock_inventory_backdate_export

View File

@ -0,0 +1,92 @@
# -*- 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'), _('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.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>