feat: add PDF report and XLSX export functionality for backdated inventory adjustments and refine valuation logic
This commit is contained in:
parent
5062762237
commit
005062bf0a
28
README.md
28
README.md
@ -44,12 +44,9 @@ This module allows you to create backdated inventory adjustments with a specific
|
||||
### How It Works
|
||||
|
||||
1. When you validate a backdated adjustment, the module creates standard stock moves
|
||||
2. After the moves are processed, it updates the dates via SQL to ensure proper backdating:
|
||||
- Stock move `date` field
|
||||
- Stock move line `date` field
|
||||
- Stock valuation layer `create_date` field
|
||||
- Account move `date` field (if real-time valuation is enabled)
|
||||
- Account move `account_id` field: Overrides the interim account with `521301 Selisih Persediaan` for the non-valuation side of the entry.
|
||||
2. The system performs a **Physical Stock Adjustment Only**.
|
||||
3. After the moves are processed, it updates the dates via SQL for the moves and lines to ensure proper backdating.
|
||||
4. Any generated `stock.valuation.layer` or `account.move` (accounting) records are automatically **removed** to ensure no financial impact.
|
||||
|
||||
### Models
|
||||
|
||||
@ -63,6 +60,25 @@ This module allows you to create backdated inventory adjustments with a specific
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
|
||||
@ -1 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Stock Inventory Backdate",
|
||||
"summary": "Create backdated inventory adjustments with historical position view",
|
||||
"version": "17.0.2.1.0",
|
||||
"version": "17.0.2.5.1",
|
||||
"category": "Warehouse",
|
||||
"author": "Suherdy Yacob",
|
||||
"license": "AGPL-3",
|
||||
@ -9,7 +9,10 @@
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/sequence_data.xml",
|
||||
"wizard/stock_inventory_backdate_export_view.xml",
|
||||
"views/stock_inventory_backdate_views.xml",
|
||||
"report/stock_inventory_backdate_reports.xml",
|
||||
"report/report_stock_inventory_backdate.xml",
|
||||
],
|
||||
"installable": True,
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -250,79 +250,55 @@ class StockInventoryBackdate(models.Model):
|
||||
self.env['stock.move.line'].create(ml_vals)
|
||||
|
||||
# Step 3: Action Done on all moves at once
|
||||
_logger.info(f"Validating {len(moves_to_process)} moves for {self.name}")
|
||||
moves_to_process._action_done()
|
||||
# Using context backdate_inventory_mode to allow primary valuation but suppress side-effect revaluations
|
||||
_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.write({'state': 'done'})
|
||||
return True
|
||||
|
||||
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()
|
||||
backdate = self.backdate_datetime
|
||||
account_date = backdate.date()
|
||||
|
||||
# Flush all pending ORM operations to DB before running raw SQL
|
||||
self.env.flush_all()
|
||||
|
||||
move_ids = tuple(moves.ids)
|
||||
|
||||
# 1. Update stock move dates
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_move SET date = %s WHERE id IN %s",
|
||||
(backdate, tuple(moves.ids))
|
||||
(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, tuple(moves.ids))
|
||||
(backdate, move_ids)
|
||||
)
|
||||
|
||||
# 3. Update stock valuation layers
|
||||
svls = self.env['stock.valuation.layer'].search([('stock_move_id', 'in', moves.ids)])
|
||||
if svls:
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE id IN %s",
|
||||
(backdate, tuple(svls.ids))
|
||||
# 3. Update stock valuation layer dates
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE stock_move_id IN %s",
|
||||
(backdate, move_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
|
||||
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
|
||||
# 5. Clear cache to reflect changes
|
||||
self.env.invalidate_all()
|
||||
|
||||
def action_cancel(self):
|
||||
@ -333,6 +309,23 @@ class StockInventoryBackdate(models.Model):
|
||||
self.write({'state': 'cancel'})
|
||||
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):
|
||||
"""Reset to draft"""
|
||||
self.ensure_one()
|
||||
@ -340,6 +333,47 @@ class StockInventoryBackdate(models.Model):
|
||||
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):
|
||||
_name = 'stock.inventory.backdate.line'
|
||||
_description = 'Backdated Inventory Adjustment Line'
|
||||
|
||||
70
report/report_stock_inventory_backdate.xml
Normal file
70
report/report_stock_inventory_backdate.xml
Normal 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>
|
||||
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_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>
|
||||
@ -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_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_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
|
||||
|
||||
|
@ -26,6 +26,8 @@
|
||||
<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>
|
||||
|
||||
1
wizard/__init__.py
Normal file
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import stock_inventory_backdate_export
|
||||
92
wizard/stock_inventory_backdate_export.py
Normal file
92
wizard/stock_inventory_backdate_export.py
Normal 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',
|
||||
}
|
||||
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