Compare commits

...

64 Commits

Author SHA1 Message Date
Alexis de Lattre
b973dd2b07 mrp_average_cost : fix bug in multi-uom and when bom qty != 1 2024-03-24 14:54:04 +01:00
Alexis de Lattre
bdb7e04929 sale_stock_usability: only show picking_status on confirmed SO 2023-09-07 18:25:57 +02:00
Alexis de Lattre
04d5b4c5ab sale_down_payment: auto-reconcile when validating invoice 2023-04-25 19:11:58 +02:00
Alexis de Lattre
b2e9f0ecc3 stock_usability: improve stock.move.line form when accessed from menu or product form shortcut 2023-01-10 19:51:44 +01:00
Alexis de Lattre
61c3aaa532 stock_usability: backport improvements from v14 (show reservations on quants tree view)
Also add button to access stock.move.line from lots
2022-12-02 15:37:56 +01:00
Alexis de Lattre
f861685ae8 sale_usability: add client_order_ref in tree view 2022-10-18 15:02:38 +02:00
Alexis de Lattre
0cd6ecd958 purchase_usability: warning when price and/or delay is auto-updated following a qty change
It is similar to the feature present in sale_usability when the qty is
updated and the price unit changes
2022-06-16 18:07:53 +02:00
Alexis de Lattre
0d689b1e95 stock_valuation_xlsx: improve multi-company support
Other small minor improvements/fixes
2022-05-13 17:39:32 +02:00
Alexis de Lattre
8fe2cac892 sale_report: add button to send order acknowledgement 2022-05-06 14:56:12 +02:00
Alexis de Lattre
3b2efaab37 [ADD] module base_dynamic_list 2022-05-06 11:13:03 +02:00
Alexis de Lattre
38a6b1221e account_usability: add name_search on account.incoterms
base_usability: Small improvement in script on res.users
2022-03-04 17:01:38 +01:00
Alexis de Lattre
af806f7bcc stock_usability: usability improvements on inventory and picking form view 2022-01-21 11:24:59 +01:00
Alexis de Lattre
b72d8c44d4 stock_valuation_xlsx: fix crash with using depreciation rules 2021-11-15 16:20:59 +01:00
Alexis de Lattre
49226e814c stock_valuation_xlsx: add depreciation ratios 2021-09-26 23:39:07 +02:00
Benoit
d05b75dbe4 fix migraiton of mail_usability 2021-09-13 21:56:42 +02:00
Chafique
8449c3de80 [FIX] remove notify_email option in README.rst 2021-09-13 21:56:42 +02:00
Chafique
f752fbc9f3 [12.0][MIG] mail_usability 2021-09-13 21:56:42 +02:00
Alexis de Lattre
70d7cbdfda New module link_tracker_usability 2021-09-13 21:56:42 +02:00
Sébastien BEAU
ea4c6df7d6 [FIX] fix helper 2021-09-13 21:56:42 +02:00
Sébastien BEAU
b62f3a3570 [IMP] by default do not send an email when user_id is fill on object 2021-09-13 21:56:42 +02:00
Sébastien BEAU
39855d7b12 [REF] refactor the code in order to split it in several file 2021-09-13 21:56:42 +02:00
Sébastien BEAU
e644b27b7a [IMP] add record_id on mail.message to be able to access to the record 2021-09-13 21:56:42 +02:00
Sébastien BEAU
39bee6e5f7 [IMP] add some extra style css support and add a debugger mode. Update readme 2021-09-13 21:56:42 +02:00
Sébastien BEAU
aec5bb9c91 [IMP] remove the fucking auto_delete!!! 2021-09-13 21:56:42 +02:00
Sébastien BEAU
0f35f8e92b [REF] refactor the code to make it simplifier and avoid hacking the _notify method 2021-09-13 21:56:42 +02:00
Sébastien BEAU
7f64d3c33e [IMP] add readme, remove auto following when sending an email, use light version of email notification to avoid injecting useless link in the mail sent 2021-09-13 21:56:42 +02:00
Sébastien BEAU
b26b3de7ae [IMP] improve the wizard for testing email, allow to search on object and to send email for real check 2021-09-13 21:56:42 +02:00
David Beal
c3f4775a58 UPD Branding 2021-09-13 21:56:42 +02:00
Alexis de Lattre
d34f273fdb mail_usability: add intermediary level to notify_email parameter of res.partner 2021-09-13 21:56:42 +02:00
Alexis de Lattre
1a8588ea24 Port mail_usability to v10 2021-09-13 21:56:42 +02:00
David Beal
453b6ba626 IMP add icons 2021-09-13 21:56:42 +02:00
Alexis de Lattre
6dbbb98774 Mass rename from __openerp__.py to __manifest__.py 2021-09-13 21:56:42 +02:00
Alexis de Lattre
81b78b83ce Set all modules as uninstallable 2021-09-13 21:56:42 +02:00
Benoit
bb4e5d377c [ADD] mail_usability module for improvements on mails 2021-09-13 21:56:42 +02:00
Alexis de Lattre
8da01d0665 Add script to fix old rounding data bug 2021-09-13 21:50:21 +02:00
Alexis de Lattre
9a67836c50 Improve stock inventory tree view 2021-07-20 13:07:36 +02:00
Alexis de Lattre
8e01d32fe6 Add tracking on some fields 2021-07-20 12:23:41 +02:00
Alexis de Lattre
25a177eb76 Add module sale_mrp_usability (backport of a feature of v14) 2021-05-27 23:58:03 +02:00
Alexis de Lattre
dbbd14f58a sale_down_payment: add a hook that can be used for notifications 2021-05-27 23:08:17 +02:00
Raphaël Valyi
592a82a417 fix message_post v12 API in unreserve (#145)
* fix message_post v12 API in unreserve

* import missing UserError
2021-05-12 20:16:19 +02:00
Pierrick Brun (Akretion)
e56654058e Merge pull request #116 from akretion/12.0-fix_account_type_liquidity_for_stripe
[12.0][FIX] account_usability - Fix stripe installation issue due to wrong account_type on transfert …
2021-04-26 16:15:02 +02:00
Alexis de Lattre
9b89661545 Fix crash in sale_down_payment 2021-03-22 12:10:34 +01:00
Alexis de Lattre
97fe8b463d mrp_usability: add tracking on date fields 2021-03-13 00:24:04 +01:00
David Beal
93272fb571 Update menu_view.xml 2021-03-09 10:50:54 +01:00
Alexis de Lattre
872801fd29 stock_valuation_xlsx: add stock.variation.xlsx report (Up-port from v10)
Up-port from v10 other fixes and improvements up to 2021-02-26
2021-02-26 11:09:51 +01:00
Alexis de Lattre
09b73d1994 mrp_usability: remove readonly on location field 2021-01-29 21:55:26 +01:00
Alexis de Lattre
2fe0ad51c7 mrp_usability: add source and dest loc on stock.move.line form view of finished product 2021-01-29 21:54:23 +01:00
Alexis de Lattre
aa804e2dfd Add track_visibility='onchange' on all importants fields of mrp.bom 2021-01-13 15:19:29 +01:00
Alexis de Lattre
05ac567c7a Show location_id in tree view of stock.move.line in raw materials tab 2021-01-11 19:01:24 +01:00
Alexis de Lattre
4076946f48 mrp_usability: add location_dest_id in the tree view of finished product on MO 2020-12-04 18:29:54 +01:00
Alexis de Lattre
2a3ca2d35c sale_usability: up-port warning on price update when qty is changed 2020-12-04 10:49:35 +01:00
Alexis de Lattre
614c1a7ac2 Remove dead file 2020-12-02 20:59:41 +01:00
Alexis de Lattre
03a57798b6 Add drill-through on purchase.report 2020-11-27 10:05:36 +01:00
Florian da Costa
22e9234d23 [FIX] Import failure if element is not part of the domain 2020-11-25 15:44:13 +01:00
Alexis de Lattre
ac89271d5b mrp_average_cost: Remove ACL that depend on sales_team (this module doesn't
depend on sales_team)
2020-11-15 14:36:10 +01:00
Alexis de Lattre
261442d903 Work it work with mrp_subcontracting (without depending on it) 2020-11-12 18:21:04 +01:00
Alexis de Lattre
d63c4b2433 [FIX] stock_valuation_xlsx: bad context key for past stock level 2020-11-03 17:24:36 +01:00
Florian
4dadc8047e Merge pull request #132 from akretion/12-account-fiscal-pos-pay-receivable
12 account fiscal pos pay receivable
2020-11-02 11:54:23 +01:00
Florian da Costa
34ec0dfa27 Black on account_fiscal_position_payable_receivable 2020-11-02 11:33:07 +01:00
Florian da Costa
7a6600431c Migrate account_fiscal_position_payable_receivable to v12 2020-11-02 11:32:38 +01:00
Alexis de Lattre
341717b75d Port account_fiscal_position_payable_receivable to v10 2020-11-02 11:25:52 +01:00
Raphaël Valyi
1061111f9b Merge pull request #130 from akretion/12.0-remove_oe_title_width
[FIX] Remove hard-coded width on product's title
2020-10-31 21:58:41 -03:00
Alexis de Lattre
d28a40e035 Update comment 2020-10-31 17:50:54 +01:00
hparfr
d2b63236e3 Fix stripe installation issue due to wrong account_type on transfert account
template
2020-04-29 23:00:08 +02:00
67 changed files with 2117 additions and 193 deletions

View File

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

View File

@@ -0,0 +1,23 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Account Fiscal Position Payable Receivable",
"version": "12.0.1.0.0",
"category": "Accounting & Finance",
"license": "AGPL-3",
"summary": "Configure payable/receivable accounts on fiscal positions",
"description": """
Account Fiscal Position Payable Receivable
==========================================
This module allows to configure a special *Partner Receivable Account* and a special *Partner Payable Account* on fiscal positions. This is used in the onchange of the fiscal position of partners.
This module has been written by Alexis de Lattre from Akretion <alexis.delattre@akretion.com>.
""",
"author": "Akretion",
"website": "http://www.akretion.com",
"depends": ["account"],
"data": ["views/account_fiscal_position_view.xml"],
"installable": True,
}

View File

@@ -0,0 +1,2 @@
from . import account_fiscal_position
from . import res_partner

View File

@@ -0,0 +1,21 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields
class AccountFiscalPosition(models.Model):
_inherit = "account.fiscal.position"
receivable_account_id = fields.Many2one(
"account.account",
string="Partner Receivable Account",
company_dependent=True,
domain=[("internal_type", "=", "receivable")],
)
payable_account_id = fields.Many2one(
"account.account",
string="Partner Payable Account",
company_dependent=True,
domain=[("internal_type", "=", "payable")],
)

View File

@@ -0,0 +1,25 @@
# © 2016-2017 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, api
class ResPartner(models.Model):
_inherit = "res.partner"
@api.onchange("property_account_position_id")
def fiscal_position_receivable_payable_change(self):
fp = self.property_account_position_id
ipo = self.env["ir.property"]
if fp.receivable_account_id:
self.property_account_receivable_id = fp.receivable_account_id
else:
self.property_account_receivable_id = ipo.get(
"property_account_receivable_id", "res.partner"
)
if fp.payable_account_id:
self.property_account_payable_id = fp.payable_account_id
else:
self.property_account_payable_id = ipo.get(
"property_account_payable_id", "res.partner"
)

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
© 2016 Akretion (Alexis de Lattre <alexis.delattre@akretion.com>)
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="view_account_position_form" model="ir.ui.view">
<field name="name">receivable_payable.fiscal_position_form</field>
<field name="model">account.fiscal.position</field>
<field name="inherit_id" ref="account.view_account_position_form" />
<field name="arch" type="xml">
<field name="company_id" position="after">
<field name="receivable_account_id"/>
<field name="payable_account_id"/>
</field>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _
from odoo.tools import float_compare, float_is_zero
from odoo.tools import float_compare, float_is_zero, float_round
from odoo.tools.misc import formatLang
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
@@ -318,7 +318,6 @@ class AccountAccount(models.Model):
logger.info("END of the script 'fix bank and cash account types'")
return True
# TODO mig to v12
@api.model
def create_account_groups(self, level=2, name_prefix=u'Comptes '):
'''Should be launched by a script. Make sure the account_group module is installed
@@ -408,6 +407,58 @@ class AccountMove(models.Model):
move.default_credit = default_credit
move.default_debit = default_debit
@api.model
def _fix_debit_credit_round_bug(self):
logger.info('START script _fix_debit_credit_round_bug')
moves = self.sudo().search([]) # sudo to search in all companies
bug_move_ids = []
for move in moves:
buggy = False
for l in move.line_ids:
if not float_is_zero(l.debit, precision_digits=2):
debit_rounded = float_round(l.debit, precision_digits=2)
if float_compare(l.debit, debit_rounded, precision_digits=6):
logger.info('Bad move to fix ID %d company_id %d name %s ref %s date %s journal %s (line ID %d debit=%s)', move.id, move.company_id.id, move.name, move.ref, move.date, move.journal_id.code, l.id, l.debit)
buggy = True
break
else:
credit_rounded = float_round(l.credit, precision_digits=2)
if float_compare(l.credit, credit_rounded, precision_digits=6):
logger.info('Bad move to fix ID %d company_id %d name %s ref %s date %s journal %s (line ID %d credit=%s)', move.id, move.company_id.id, move.name, move.ref, move.date, move.journal_id.code, l.id, l.credit)
buggy = True
break
if buggy:
bug_move_ids.append(move.id)
bal = 0.0
max_credit = (False, 0)
for l in move.line_ids:
if not float_is_zero(l.debit, precision_digits=2):
new_debit = float_round(l.debit, precision_digits=2)
self._cr.execute(
'UPDATE account_move_line set debit=%s, balance=%s where id=%s',
(new_debit, new_debit, l.id))
bal -= new_debit
elif not float_is_zero(l.credit, precision_digits=2):
new_credit = float_round(l.credit, precision_digits=2)
self._cr.execute(
'UPDATE account_move_line set credit=%s, balance=%s where id=%s',
(new_credit, new_credit * -1, l.id))
bal += new_credit
if new_credit > max_credit[1]:
max_credit = (l, new_credit)
if not float_is_zero(bal, precision_digits=2):
assert abs(bal) < 0.05
l = max_credit[0]
new_credit = max_credit[1]
new_new_credit = float_round(new_credit - bal, precision_digits=2)
assert new_new_credit > 0
self._cr.execute(
'UPDATE account_move_line set credit=%s, balance=%s where id=%s',
(new_new_credit, new_new_credit * -1, l.id))
logger.info('Move ID %d fixed', move.id)
logger.info('%d buggy moves fixed (IDs: %s)', len(bug_move_ids), bug_move_ids)
logger.info('END _fix_debit_credit_round_bug')
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@@ -673,6 +724,16 @@ class AccountIncoterms(models.Model):
res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search([('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(name=name, args=args, operator=operator, limit=limit)
class AccountReconciliation(models.AbstractModel):
_inherit = 'account.reconciliation.widget'
@@ -694,8 +755,9 @@ class AccountReconciliation(models.AbstractModel):
st_line, aml_accounts, partner_id,
excluded_ids=excluded_ids, search_str=search_str)
# We want to replace a domain item by another one
position = domain.index(('payment_id', '<>', False))
domain[position] = ['journal_id', '=', st_line.journal_id.id]
if ('payment_id', '<>', False) in domain:
position = domain.index(('payment_id', '<>', False))
domain[position] = ['journal_id', '=', st_line.journal_id.id]
return domain
@@ -704,3 +766,22 @@ class ResConfigSettings(models.TransientModel):
transfer_account_id = fields.Many2one(
related='company_id.transfer_account_id', readonly=False)
class AccountChartTemplate(models.Model):
_inherit = "account.chart.template"
@api.model
def _prepare_transfer_account_template(self):
"""Change the type of default account in order to be
compliant with _check_account_type_on_bank_journal
Used at installation of payment modules like stripe
See https://github.com/akretion/odoo-usability/issues/115
"""
vals = super()._prepare_transfer_account_template()
current_assets_type = self.env.ref(
'account.data_account_type_liquidity', raise_if_not_found=False)
vals.update({
'user_type_id': current_assets_type and current_assets_type.id or False,
})
return vals

View File

@@ -31,6 +31,7 @@ This modules adds the following functions:
* don't attach PDF upon invoice report generation on supplier invoices/refunds
* Add filter on debit and credit amount for Move Lines
* Add supplier invoice number in invoice tree view
* Change type from current_assets to liquidity for transfert account template.
Together with this module, I recommend the use of the following modules:

View File

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

View File

@@ -0,0 +1,62 @@
# Copyright 2020-2022 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': 'Base Dynamic List',
'version': '12.0.1.0.0',
'category': 'Tools',
'license': 'AGPL-3',
'summary': 'Dynamic lists',
'description': """
Base Dynamic List
=================
Very often during an Odoo implementation, we need to add selection fields on a native objet, and we don't want to have a hard-coded selection list (fields.Selection), but a selection list that can be changed by users (Many2one field). For that, the developper needs to add a new object (with just a 'name' and 'sequence' field) with a form/tree view. The goal of this module is to speed-up this process by defining a dynamic list object that already has all the required views.
This module provides several ready-to-go objects:
* simple list : fields *name*, *sequence* and *active*
* translatable list : fields *name* with translate=True, *sequence* and *active*
* code list : fields *code* (unique), *name*, *sequence* and *active*
* translatable code list : fields *code* (unique), *name* with translate=True, *sequence* and *active*
These objects are readable by the employee group. The system group has full rights on it.
To use it, you need to do 2 or 3 things :
1) Add an entry in the domain field and the object you selected:
domain = fields.Selection(selection_add=[('risk.type', "Risk Type")], ondelete={"risk.type": "cascade"})
2) Add the many2one field on your object:
risk_type_id = fields.Many2one(
'dynamic.list', string="Risk Type",
ondelete='restrict', domain=[('domain', '=', 'risk.type')])
3) Optionally, you can add a dedicated action and a menu entry (otherwize, you can use the generic menu entry under *Settings > Technical > Dynamic Lists*:
<record id="dynamic_list_risk_type_action" model="ir.actions.act_window">
<field name="name">Risk Type</field>
<field name="res_model">dynamic.list</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('domain', '=', 'risk.type')]</field>
<field name="context">{'default_domain': 'risk.type'}</field>
</record>
<menuitem id="dynamic_list_risk_type_menu" action="dynamic_list_risk_type_action"
parent="parent_menu_xmlid"/>
Limitation: when you want to have different access rights on these lists depending on the source object, you should prefer to use dedicated objects.
""",
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['base'],
'data': [
'security/ir.model.access.csv',
'views/dynamic_list.xml',
],
'installable': True,
}

View File

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

View File

@@ -0,0 +1,115 @@
# Copyright 2020-2022 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class DynamicList(models.Model):
_name = 'dynamic.list'
_description = 'Dynamic List (non translatable)'
_order = 'sequence, id'
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_name_uniq',
'unique(domain, name)',
'This entry already exists!'
)]
class DynamicListTranslate(models.Model):
_name = 'dynamic.list.translate'
_description = 'Translatable Dynamic List'
_order = 'sequence, id'
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_name_uniq',
'unique(domain, name)',
'This entry already exists!'
)]
class DynamicListCode(models.Model):
_name = 'dynamic.list.code'
_description = 'Dynamic list with code'
_order = 'sequence, id'
code = fields.Char(required=True)
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_code_uniq',
'unique(domain, code)',
'This code already exists!'
)]
@api.depends('code', 'name')
def name_get(self):
res = []
for rec in self:
res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res
@api.model
def name_search(
self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search(
[('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(
name=name, args=args, operator=operator, limit=limit)
class DynamicListCodeTranslate(models.Model):
_name = 'dynamic.list.code.translate'
_description = 'Translatable dynamic list with code'
_order = 'sequence, id'
code = fields.Char(required=True)
name = fields.Char(translate=True, required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
domain = fields.Selection([], string='Domain', required=True, index=True)
_sql_constraint = [(
'domain_code_uniq',
'unique(domain, code)',
'This code already exists!'
)]
@api.depends('code', 'name')
def name_get(self):
res = []
for rec in self:
res.append((rec.id, '[%s] %s' % (rec.code, rec.name)))
return res
@api.model
def name_search(
self, name='', args=None, operator='ilike', limit=80):
if args is None:
args = []
if name and operator == 'ilike':
recs = self.search(
[('code', '=', name)] + args, limit=limit)
if recs:
return recs.name_get()
return super().name_search(
name=name, args=args, operator=operator, limit=limit)

View File

@@ -0,0 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dynamic_list_read,Read access on dynamic.list to employees,model_dynamic_list,base.group_user,1,0,0,0
access_dynamic_list_full,Full access to dynamic.list to System group,model_dynamic_list,base.group_system,1,1,1,1
access_dynamic_list_translate_read,Read access on dynamic.list.translate to employees,model_dynamic_list_translate,base.group_user,1,0,0,0
access_dynamic_list_translate_full,Full access to dynamic.list.translate to System group,model_dynamic_list_translate,base.group_system,1,1,1,1
access_dynamic_list_code_read,Read access on dynamic.list.code to employees,model_dynamic_list_code,base.group_user,1,0,0,0
access_dynamic_list_code_full,Full access to dynamic.list.code to System group,model_dynamic_list_code,base.group_system,1,1,1,1
access_dynamic_list_code_translate_read,Read access on dynamic.list.code.translate to employees,model_dynamic_list_code_translate,base.group_user,1,0,0,0
access_dynamic_list_code_translate_full,Full access to dynamic.list.code.translate to System group,model_dynamic_list_code_translate,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dynamic_list_read Read access on dynamic.list to employees model_dynamic_list base.group_user 1 0 0 0
3 access_dynamic_list_full Full access to dynamic.list to System group model_dynamic_list base.group_system 1 1 1 1
4 access_dynamic_list_translate_read Read access on dynamic.list.translate to employees model_dynamic_list_translate base.group_user 1 0 0 0
5 access_dynamic_list_translate_full Full access to dynamic.list.translate to System group model_dynamic_list_translate base.group_system 1 1 1 1
6 access_dynamic_list_code_read Read access on dynamic.list.code to employees model_dynamic_list_code base.group_user 1 0 0 0
7 access_dynamic_list_code_full Full access to dynamic.list.code to System group model_dynamic_list_code base.group_system 1 1 1 1
8 access_dynamic_list_code_translate_read Read access on dynamic.list.code.translate to employees model_dynamic_list_code_translate base.group_user 1 0 0 0
9 access_dynamic_list_code_translate_full Full access to dynamic.list.code.translate to System group model_dynamic_list_code_translate base.group_system 1 1 1 1

View File

@@ -0,0 +1,240 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2020 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<menuitem id="dynamic_list_root_menu" name="Dynamic Lists" parent="base.menu_custom" sequence="100"/>
<record id="dynamic_list_form" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_tree" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_search" model="ir.ui.view">
<field name="model">dynamic.list</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_action" model="ir.actions.act_window">
<field name="name">Simple List</field>
<field name="res_model">dynamic.list</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_menu" action="dynamic_list_action" parent="dynamic_list_root_menu" sequence="10"/>
<record id="dynamic_list_translate_form" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_translate_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_translate_tree" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_translate_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_translate_search" model="ir.ui.view">
<field name="model">dynamic.list.translate</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_translate_action" model="ir.actions.act_window">
<field name="name">Translatable Simple List</field>
<field name="res_model">dynamic.list.translate</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_translate_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_translate_menu" action="dynamic_list_translate_action" parent="dynamic_list_root_menu" sequence="20"/>
<record id="dynamic_list_code_form" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_code_tree" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_code_search" model="ir.ui.view">
<field name="model">dynamic.list.code</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Name or Code" filter_domain="['|', ('name', 'ilike', self), ('code', 'ilike', self)]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<field name="code"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_code_action" model="ir.actions.act_window">
<field name="name">Code List</field>
<field name="res_model">dynamic.list.code</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_code_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_code_menu" action="dynamic_list_code_action" parent="dynamic_list_root_menu" sequence="30"/>
<record id="dynamic_list_code_translate_form" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object"
class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"
options='{"terminology": "archive"}'/>
</button>
</div>
<group name="main">
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_translate_main_view')"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="dynamic_list_code_translate_tree" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="domain" invisible="not context.get('dynamic_list_code_translate_main_view')"/>
</tree>
</field>
</record>
<record id="dynamic_list_code_translate_search" model="ir.ui.view">
<field name="model">dynamic.list.code.translate</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Name or Code" filter_domain="['|', ('name', 'ilike', self), ('code', 'ilike', self)]"/>
<field name="code"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group string="Group By" name="groupby">
<filter name="domain_groupby" string="Domain" context="{'group_by': 'domain'}"/>
</group>
</search>
</field>
</record>
<record id="dynamic_list_code_translate_action" model="ir.actions.act_window">
<field name="name">Translatable Code List</field>
<field name="res_model">dynamic.list.code.translate</field>
<field name="view_mode">tree,form</field>
<field name="context">{'dynamic_list_code_translate_main_view': True, 'search_default_domain_groupby': True}</field>
</record>
<menuitem id="dynamic_list_code_translate_menu" action="dynamic_list_code_translate_action" parent="dynamic_list_root_menu" sequence="40"/>
</odoo>

View File

@@ -23,12 +23,9 @@ class ResUsers(models.Model):
@api.model
def _script_partners_linked_to_users_no_company(self):
if self.env.user.id != SUPERUSER_ID:
raise UserError(_('You must run this script as admin user'))
logger.info(
'START to set company_id=False on partners related to users')
users = self.search(
['|', ('active', '=', True), ('active', '=', False)])
users = self.sudo().with_context(active_test=False).search([])
for user in users:
if user.partner_id.company_id:
user.partner_id.company_id = False
@@ -37,4 +34,3 @@ class ResUsers(models.Model):
user.login, user.id)
logger.info(
'END setting company_id=False on partners related to users')
return True

View File

@@ -2,7 +2,7 @@
<odoo>
<menuitem id="conf_tech" parent="base.menu_administration" name="🧰" groups="base.group_erp_manager" sequence="100"/>
<menuitem id="conf_tech" parent="base.menu_administration" name="🧰" groups="base.group_erp_manager" sequence="1"/>
<menuitem id="model" name="Model" parent="conf_tech" action="base.action_model_model" sequence="10"/>
<menuitem id="view" name="View" parent="conf_tech" action="base.action_ui_view" sequence="20" />
<menuitem id="rec_rule" name="Record Rule" parent="conf_tech" action="base.action_rule" sequence="30" />

19
mail_usability/README.rst Normal file
View File

@@ -0,0 +1,19 @@
# Mail Usability
Take back the control on your email
## Feature
- do not follow automatically a object when sending an email
- better email preview, allow to select between the whole database object and not only the last 10
- use a light template version for notification without link (link should be explicit)
- add some additional style in the white list when santizing html field (see tools.py)
- make the email template by default not 'auto_delete'
## TIPS
Never, never tick the 'auto_delete' on mail template because it fucking hard to debug
and understand what have been sent (we should create a module with a crontask, that drop them latter)
If the template of mail do not look like the same when saving it in odoo, maybe the sanitize style have drop some balise
please run odoo with "LOG_STYLE_SANITIZE=True odoo" to understand what have been drop, magic warning logger will tell you everthing

View File

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

View File

@@ -0,0 +1,33 @@
# Copyright 2020 Akretion France (http://www.akretion.com)
# @author Benoît Guillot <benoit.guillot@akretion.com>
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': 'Mail Usability',
'version': '12.0.1.0.0',
'category': 'Base',
'license': 'AGPL-3',
'summary': 'Usability improvements on mails',
'description': """
Mail Usability
==============
Small usability improvements on mails:
* remove link in mail footer
* remove 'sent by' in notification footer
* add a new entry *All Messages Except Notifications* to the field *Receive Inbox Notifications by Email* of partners (becomes the default value)
""",
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['mail'],
'data': [
'views/mail_view.xml',
'data/mail_data.xml',
'wizard/email_template_preview_view.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!--Default Notification Email template -->
<record id="mail_template_notification" model="mail.template">
<field name="name">Notification Email</field>
<field name="subject">${object.subject}</field>
<field name="model_id" ref="mail.model_mail_message"/>
<field name="auto_delete" eval="True"/>
<field name="body_html">${object.body | safe}</field>
</record>
<template id="message_notification_email_usability">
<div t-raw="message.body"/>
</template>
</odoo>

View File

@@ -0,0 +1,5 @@
from . import mail
from . import tools
from . import mail_template
from . import mail_message
from . import res_partner

View File

@@ -0,0 +1,36 @@
# Copyright 2016-2017 Akretion France (http://www.akretion.com)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, api
import logging
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _active_message_auto_subscribe_notify(self):
_logger.debug('Skip automatic subscribe notification')
return False
def _message_auto_subscribe_notify(self, partner_ids, template):
if self._active_message_auto_subscribe_notify():
return super(MailThread, self)._message_auto_subscribe_notify(
partner_ids, template)
else:
return True
@api.multi
@api.returns('self', lambda value: value.id)
def message_post(self, body='', subject=None, message_type='notification',
subtype=None, parent_id=False, attachments=None,
content_subtype='html', **kwargs):
if not 'mail_create_nosubscribe' in self._context:
# Do not implicitly follow an object by just sending a message
self = self.with_context(mail_create_nosubscribe=True)
return super(MailThread, self).message_post(
body=body, subject=subject, message_type=message_type,
subtype=subtype, parent_id=parent_id, attachments=attachments,
content_subtype=content_subtype, **kwargs)

View File

@@ -0,0 +1,19 @@
# Copyright 2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class MailMessage(models.Model):
_inherit = 'mail.message'
@property
def record_id(self):
# we do not use a reference field here as mail message
# are used everywhere and many model are not yet loaded
# so odoo raise exception
if self:
self.ensure_one()
return self.env[self.model].browse(self.res_id)
return None

View File

@@ -0,0 +1,11 @@
# Copyright 2018 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp import fields, models
class MailTemplate(models.Model):
_inherit = 'mail.template'
auto_delete = fields.Boolean(default=False)

View File

@@ -0,0 +1,27 @@
# Copyright 2016-2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api
class ResPartner(models.Model):
_inherit = 'res.partner'
opt_out = fields.Boolean(track_visibility='onchange')
@api.model
def _notify(self, message, rdata, record, force_send=False,
send_after_commit=True, model_description=False,
mail_auto_delete=True):
# use an empty layout for notification by default
if not message.layout:
message.layout = 'mail_usability.message_notification_email_usability'
# Never auto delete notification email
# fucking to hard to debug when message have been delete
mail_auto_delete = False
return super(ResPartner, self)._notify(
message=message, rdata=rdata, record=record,
force_send=force_send, send_after_commit=send_after_commit,
model_description=model_description, mail_auto_delete=mail_auto_delete)

View File

@@ -0,0 +1,43 @@
# Copyright 2018 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tools.mail import _Cleaner
import os
import logging
_logger = logging.getLogger(__name__)
_Cleaner._style_whitelist += [
'word-wrap',
'display'
'border-top',
'border-bottom',
'border-left',
'border-right',
'text-transform',
]
if os.getenv('LOG_STYLE_SANITIZE'):
# Monkey patch the parse style method to debug
# the missing style
def parse_style(self, el):
attributes = el.attrib
styling = attributes.get('style')
if styling:
valid_styles = {}
styles = self._style_re.findall(styling)
for style in styles:
if style[0].lower() in self._style_whitelist:
valid_styles[style[0].lower()] = style[1]
# START HACK
else:
_logger.warning('Remove style %s %s', *style)
# END HACK
if valid_styles:
el.attrib['style'] = '; '.join(
'%s:%s' % (key, val)
for (key, val) in valid_styles.iteritems())
else:
del el.attrib['style']
_Cleaner.parse_style = parse_style

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mail_tree" model="ir.ui.view">
<field name="model">mail.mail</field>
<field name="inherit_id" ref="mail.view_mail_tree"/>
<field name="arch" type="xml">
<field name="email_from" position="replace"/>
<field name="date" position="after">
<field name="email_from"/>
<field name="email_to"/>
</field>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,43 @@
# Copyright 2019 Akretion France (http://www.akretion.com)
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp import api, fields, models
class TemplatePreview(models.TransientModel):
_inherit = "email_template.preview"
res_id = fields.Integer(compute='_compute_res_id')
object_id = fields.Reference(selection='_reference_models')
@api.model
def default_get(self, fields):
result = super(TemplatePreview, self).default_get(fields)
if result.get('model_id'):
model = self.env['ir.model'].browse(result['model_id'])
result['object_id'] = model.model
return result
def _reference_models(self):
result = self.default_get(['model_id'])
if result.get('model_id'):
model = self.env['ir.model'].browse(result['model_id'])
return [(model.model, model.name)]
else:
ir_models = self.env['ir.model'].search([('state', '!=', 'manual')])
return [(ir_model.model, ir_model.name)
for ir_model in ir_models
if not ir_model.model.startswith('ir.')]
@api.depends('object_id')
def _compute_res_id(self):
for record in self:
if self.object_id:
record.res_id = self.object_id.id
def send(self):
template = self.env['mail.template'].browse(
self._context['template_id'])
template.send_mail(
self.res_id, force_send=True, raise_exception=True)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="email_template_preview_form" model="ir.ui.view">
<field name="model">email_template.preview</field>
<field name="inherit_id" ref="mail.email_template_preview_form"/>
<field name="arch" type="xml">
<field name="res_id" position="attributes">
<attribute name="invisible">True</attribute>
</field>
<field name="res_id" position="after">
<field name="object_id"/>
</field>
<footer position="inside">
<button
string="Send"
name="send"
class="btn-primary"
type='object'/>
</footer>
</field>
</record>
</odoo>

View File

@@ -17,7 +17,7 @@ class MrpBomLabourLine(models.Model):
bom_id = fields.Many2one(
comodel_name='mrp.bom',
string='Labour Lines',
string='Bill of Material',
ondelete='cascade')
labour_time = fields.Float(
@@ -75,7 +75,7 @@ class MrpBom(models.Model):
total_labour_cost = fields.Float(
compute='_compute_total_labour_cost', readonly=True,
digits=dp.get_precision('Product Price'),
string="Total Labour Cost", store=True)
string="Total Labour Cost", store=True, track_visibility='onchange')
extra_cost = fields.Float(
string='Extra Cost', track_visibility='onchange',
digits=dp.get_precision('Product Price'),
@@ -117,15 +117,18 @@ class MrpBom(models.Model):
wproduct = bom.product_id
if not wproduct:
wproduct = bom.product_tmpl_id
bom_cost_per_unit_in_product_uom = 0
qty_product_uom = bom.product_uom_id._compute_quantity(bom.product_qty, wproduct.uom_id)
if qty_product_uom:
bom_cost_per_unit_in_product_uom = bom.total_cost / qty_product_uom
if float_compare(
wproduct.standard_price, bom.total_cost,
wproduct.standard_price, bom_cost_per_unit_in_product_uom,
precision_digits=precision):
wproduct.with_context().write(
{'standard_price': bom.total_cost})
{'standard_price': bom_cost_per_unit_in_product_uom})
logger.info(
'Cost price updated to %s on product %s',
bom.total_cost, wproduct.display_name)
return True
bom_cost_per_unit_in_product_uom, wproduct.display_name)
class MrpBomLine(models.Model):
@@ -196,6 +199,7 @@ class MrpProduction(models.Model):
mo_total_price = 0.0 # In the UoM of the M0
labor_cost_per_unit = 0.0 # In the UoM of the product
extra_cost_per_unit = 0.0 # In the UoM of the product
subcontract_cost_per_unit = 0.0
# I read the raw materials MO, not on BOM, in order to make
# it work with the "dynamic" BOMs (few raw material are auto-added
# on the fly on MO)
@@ -231,6 +235,13 @@ class MrpProduction(models.Model):
assert bom_qty_product_uom > 0, 'BoM qty should be positive'
labor_cost_per_unit = bom.total_labour_cost / bom_qty_product_uom
extra_cost_per_unit = bom.extra_cost / bom_qty_product_uom
if bom.type == 'subcontract':
one_finished_move = self.env['stock.move'].search([
('production_id', '=', self.id),
('product_id', '=', self.product_id.id),
('move_dest_ids', '!=', False)], limit=1)
if one_finished_move:
subcontract_cost_per_unit = one_finished_move.move_dest_ids[0].price_unit
# mo_standard_price and labor_cost_per_unit are
# in the UoM of the product (not of the MO/BOM)
mo_qty_product_uom = self.product_uom_id._compute_quantity(
@@ -238,10 +249,13 @@ class MrpProduction(models.Model):
assert mo_qty_product_uom > 0, 'MO qty should be positive'
mo_standard_price = mo_total_price / mo_qty_product_uom
logger.info(
'MO %s: labor_cost_per_unit=%s extra_cost_per_unit=%s',
self.name, labor_cost_per_unit, extra_cost_per_unit)
'MO %s: labor_cost_per_unit=%s extra_cost_per_unit=%s '
'subcontract_cost_per_unit=%s',
self.name, labor_cost_per_unit, extra_cost_per_unit,
subcontract_cost_per_unit)
mo_standard_price += labor_cost_per_unit
mo_standard_price += extra_cost_per_unit
mo_standard_price += subcontract_cost_per_unit
return mo_standard_price
def post_inventory(self):

View File

@@ -1,9 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_labour_cost_profile_read,Read access on labour.cost.profile to MRP user,model_labour_cost_profile,mrp.group_mrp_user,1,0,0,0
access_labour_cost_profile_read_sale,Read access on labour.cost.profile to Sale user,model_labour_cost_profile,sales_team.group_sale_salesman,1,0,0,0
access_labour_cost_profile_read_stock,Read access on labour.cost.profile to Stock user,model_labour_cost_profile,stock.group_stock_user,1,0,0,0
access_labour_cost_profile_full,Full access on labour.cost.profile to MRP manager,model_labour_cost_profile,mrp.group_mrp_manager,1,1,1,1
access_mrp_bom_labour_line_read,Read access on mrp.bom.labour.line to MRP user,model_mrp_bom_labour_line,mrp.group_mrp_user,1,0,0,0
access_mrp_bom_labour_line_read_sale,Read access on mrp.bom.labour.line to Sale user,model_mrp_bom_labour_line,sales_team.group_sale_salesman,1,0,0,0
access_mrp_bom_labour_line_read_stock,Read access on mrp.bom.labour.line to Stock user,model_mrp_bom_labour_line,stock.group_stock_user,1,0,0,0
access_mrp_bom_labour_line_full,Full access on mrp.bom.labour.line to MRP manager,model_mrp_bom_labour_line,mrp.group_mrp_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_labour_cost_profile_read Read access on labour.cost.profile to MRP user model_labour_cost_profile mrp.group_mrp_user 1 0 0 0
access_labour_cost_profile_read_sale Read access on labour.cost.profile to Sale user model_labour_cost_profile sales_team.group_sale_salesman 1 0 0 0
3 access_labour_cost_profile_read_stock Read access on labour.cost.profile to Stock user model_labour_cost_profile stock.group_stock_user 1 0 0 0
4 access_labour_cost_profile_full Full access on labour.cost.profile to MRP manager model_labour_cost_profile mrp.group_mrp_manager 1 1 1 1
5 access_mrp_bom_labour_line_read Read access on mrp.bom.labour.line to MRP user model_mrp_bom_labour_line mrp.group_mrp_user 1 0 0 0
access_mrp_bom_labour_line_read_sale Read access on mrp.bom.labour.line to Sale user model_mrp_bom_labour_line sales_team.group_sale_salesman 1 0 0 0
6 access_mrp_bom_labour_line_read_stock Read access on mrp.bom.labour.line to Stock user model_mrp_bom_labour_line stock.group_stock_user 1 0 0 0
7 access_mrp_bom_labour_line_full Full access on mrp.bom.labour.line to MRP manager model_mrp_bom_labour_line mrp.group_mrp_manager 1 1 1 1

View File

@@ -1,15 +1,18 @@
# © 2015-2016 Akretion (http://www.akretion.com)
# Copyright 2015-2021 Akretion (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
from odoo import api, fields, models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
_order = 'id desc'
date_planned_start = fields.Datetime(track_visibility='onchange')
date_planned_finished = fields.Datetime(track_visibility='onchange')
@api.model
def get_stock_move_sold_out_report(self, move):
lines = move.active_move_line_ids
@@ -18,3 +21,17 @@ class MrpProduction(models.Model):
if diff == 0.0:
return ""
return diff
class MrpBom(models.Model):
_inherit = 'mrp.bom'
code = fields.Char(track_visibility='onchange')
type = fields.Selection(track_visibility='onchange')
product_tmpl_id = fields.Many2one(track_visibility='onchange')
product_id = fields.Many2one(track_visibility='onchange')
product_qty = fields.Float(track_visibility='onchange')
product_uom_id = fields.Many2one(track_visibility='onchange')
routing_id = fields.Many2one(track_visibility='onchange')
ready_to_produce = fields.Selection(track_visibility='onchange')
picking_type_id = fields.Many2one(track_visibility='onchange')

View File

@@ -26,6 +26,26 @@
<button name="action_cancel" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to cancel this manufacturing order?</attribute>
</button>
<xpath expr="//field[@name='finished_move_line_ids']/tree/field[@name='product_uom_id']" position="replace"/>
<xpath expr="//field[@name='finished_move_line_ids']/tree/field[@name='qty_done']" position="after">
<field name="product_uom_id" groups="uom.group_uom"/> <!-- Move after qty -->
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
</xpath>
</field>
</record>
<record id="view_stock_move_lots" model="ir.ui.view">
<field name="model">stock.move</field>
<field name="inherit_id" ref="mrp.view_stock_move_lots" />
<field name="arch" type="xml">
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='lot_id']" position="before">
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='location_id']" position="move"/>
</xpath>
<xpath expr="//field[@name='active_move_line_ids']/tree/field[@name='location_id']" position="attributes">
<attribute name="invisible">0</attribute>
<attribute name="readonly">0</attribute>
<attribute name="groups">stock.group_stock_multi_locations</attribute>
</xpath>
</field>
</record>
@@ -40,6 +60,17 @@
</field>
</record>
<record id="view_finisehd_move_line" model="ir.ui.view">
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="mrp.view_finisehd_move_line" />
<field name="arch" type="xml">
<field name="lot_id" position="after">
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
</field>
</field>
</record>
<record id="mrp_bom_tree_view" model="ir.ui.view">
<field name="model">mrp.bom</field>
<field name="inherit_id" ref="mrp.mrp_bom_tree_view"/>

View File

@@ -3,8 +3,9 @@
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api
from odoo import models, fields, api, _
from odoo.tools.misc import formatLang
from odoo.tools import float_compare
class PurchaseOrder(models.Model):
@@ -42,6 +43,51 @@ class PurchaseOrder(models.Model):
if po.partner_ref:
name += ' (' + po.partner_ref + ')'
if self.env.context.get('show_total_amount') and po.amount_total:
name += ': ' + formatLang(self.env, po.amount_untaxed, currency_obj=po.currency_id)
name += ': ' + formatLang(
self.env, po.amount_untaxed, currency_obj=po.currency_id)
result.append((po.id, name))
return result
class PurchaseOrderLine(models.Model):
_inherit = 'purchase.order.line'
@api.onchange('product_qty', 'product_uom')
def _onchange_quantity(self):
# When the user has manually set a price and/or planned_date
# he is often upset when Odoo changes it when he changes the qty
# So we add a warning...
res = {}
old_price = self.price_unit
old_date_planned = self.date_planned
super()._onchange_quantity()
new_price = self.price_unit
new_date_planned = self.date_planned
prec = self.env['decimal.precision'].precision_get('Product Price')
price_compare = float_compare(old_price, new_price, precision_digits=prec)
if price_compare or old_date_planned != new_date_planned:
res['warning'] = {
'title': _('Updates'),
'message': _(
"Due to the update of the ordered quantity on line '%s', "
"the following data has been updated using the supplier info "
"of the product:"
) % self.name
}
if price_compare:
res['warning']['message'] += _(
"\nOld price: %s\nNew price: %s") % (
formatLang(
self.env, old_price,
currency_obj=self.order_id.currency_id),
formatLang(
self.env, new_price,
currency_obj=self.order_id.currency_id))
if old_date_planned != new_date_planned:
res['warning']['message'] += _(
"\nOld delivery date: %s\nNew delivery date: %s") % (
old_date_planned,
new_date_planned,
)
return res

View File

@@ -172,5 +172,40 @@
<!-- The menu entry should be added in customer-specific module -->
<record id="purchase.action_purchase_order_report_all" model="ir.actions.act_window">
<field name="view_mode">pivot,graph,tree</field> <!--- native order is graph,pivot. Switch order and add tree -->
</record>
<record id="view_purchase_order_pivot" model="ir.ui.view">
<field name="model">purchase.report</field>
<field name="inherit_id" ref="purchase.view_purchase_order_pivot"/>
<field name="arch" type="xml">
<pivot position="attributes">
<attribute name="disable_linking"></attribute>
</pivot>
<field name="unit_quantity" position="replace"/> <!-- it's stupid to display a unit_quantity by default... it will sum qty of different products, which doesn't make a lot of sense -->
<field name="price_average" position="replace"/> <!-- it's stupid to display a price_average by default... it will average between different products, which is a non-sense -->
</field>
</record>
<record id="view_purchase_order_tree" model="ir.ui.view">
<field name="name">purchase.report.tree</field>
<field name="model">purchase.report</field>
<field name="arch" type="xml">
<tree>
<field name="commercial_partner_id"/>
<field name="date_order"/>
<field name="date_approve"/>
<field name="product_id"/>
<field name="unit_quantity" sum="1"/>
<field name="product_uom"/>
<field name="price_total" sum="1"/>
<field name="account_analytic_id"/>
<field name="currency_id" invisible="1"/>
<field name="user_id"/>
<field name="state"/>
</tree>
</field>
</record>
</odoo>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2017 Akretion (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<openerp>
<data>
<record id="view_picking_form" model="ir.ui.view">
<field name="name">purchase_usability.stock.picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<field name="origin" position="after">
<field name="purchase_id" attrs="{'invisible': [('picking_type_code', '!=', 'incoming')]}" context="{'show_purchase': True}"/>
</field>
</field>
</record>
</data>
</openerp>

View File

@@ -0,0 +1,27 @@
# Copyright 2023 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, models
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
@api.multi
def action_invoice_open(self):
res = super().action_invoice_open()
amlo = self.env['account.move.line']
for inv in self:
if inv.state == 'open' and inv.type == 'out_invoice':
sales = inv.invoice_line_ids.mapped('sale_line_ids').\
mapped('order_id')
if sales:
mlines = amlo.search([('sale_id', 'in', sales.ids)])
if mlines:
receivable_lines = inv.move_id.mapped('line_ids').filtered(
lambda l: l.account_id == inv.account_id)
mlines |= receivable_lines
mlines.remove_move_reconcile()
mlines.reconcile()
return res

View File

@@ -30,3 +30,8 @@ class AccountMoveLine(models.Model):
def sale_advance_payement_account_id_change(self):
if self.sale_id and self.account_id.user_type_id.type != 'receivable':
self.sale_id = False
def _sale_down_payment_hook(self):
# can be used for notifications
self.ensure_one()

View File

@@ -25,6 +25,14 @@ class AccountPayment(models.Model):
res['sale_id'] = self.sale_id.id
return res
def _create_payment_entry(self, amount):
move = super()._create_payment_entry(amount)
if hasattr(self, 'sale_id') and self.sale_id:
for line in move.line_ids:
if line.sale_id and line.account_id.internal_type == 'receivable':
line._sale_down_payment_hook()
return move
class AccountAbstractPayment(models.AbstractModel):
_inherit = "account.abstract.payment"
@@ -49,7 +57,7 @@ class AccountAbstractPayment(models.AbstractModel):
def _compute_payment_amount(self, invoices=None, currency=None):
amount = super(AccountAbstractPayment, self)._compute_payment_amount(
invoices=invoices, currency=currency)
if self.sale_id:
if hasattr(self, 'sale_id') and self.sale_id:
payment_currency = currency
if not payment_currency:
payment_currency = self.sale_id.currency_id

View File

@@ -64,7 +64,9 @@ class AccountBankStatementSale(models.TransientModel):
self.ensure_one()
for line in self.line_ids:
if line.move_line_id.sale_id != line.sale_id:
line.move_line_id.sale_id = line.sale_id.id
line.move_line_id.write({'sale_id': line.sale_id.id or False})
if line.sale_id:
line.move_line_id._sale_down_payment_hook()
class AccountBankStatementSaleLine(models.TransientModel):

View File

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

View File

@@ -0,0 +1,23 @@
# Copyright 2021 Akretion France (http://www.akretion.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': 'Sale MRP Usability',
'version': '12.0.1.0.0',
'category': 'Sales',
'license': 'AGPL-3',
'summary': 'Usability improvements on sale_mrp module',
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': [
'sale_mrp',
'stock_usability',
],
'data': [
# Native in v14. Do no up-port to v14
'views/mrp_production.xml',
'views/sale_order.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,2 @@
from . import sale
from . import mrp_production

View File

@@ -0,0 +1,39 @@
# Backport from Odoo v14
# Copyright Odoo SA
# Same licence as Odoo (LGPL)
from odoo import api, fields, models, _
class MrpProduction(models.Model):
_inherit = 'mrp.production'
sale_order_count = fields.Integer(
"Count of Source SO",
compute='_compute_sale_order_count',
groups='sales_team.group_sale_salesman')
@api.depends('move_dest_ids.group_id.sale_id')
def _compute_sale_order_count(self):
for production in self:
production.sale_order_count = len(production.move_dest_ids.mapped('group_id').mapped('sale_id'))
def action_view_sale_orders(self):
self.ensure_one()
sale_order_ids = self.move_dest_ids.mapped('group_id').mapped('sale_id').ids
action = {
'res_model': 'sale.order',
'type': 'ir.actions.act_window',
}
if len(sale_order_ids) == 1:
action.update({
'view_mode': 'form',
'res_id': sale_order_ids[0],
})
else:
action.update({
'name': _("Sources Sale Orders of %s" % self.name),
'domain': [('id', 'in', sale_order_ids)],
'view_mode': 'tree,form',
})
return action

View File

@@ -0,0 +1,39 @@
# This code is a backport from odoo v14
# Copyright Odoo SA
# Same licence as Odoo (LGPL)
from odoo import api, fields, models, _
class SaleOrder(models.Model):
_inherit = 'sale.order'
mrp_production_count = fields.Integer(
"Count of MO generated",
compute='_compute_mrp_production_count',
groups='mrp.group_mrp_user')
@api.depends('procurement_group_id.stock_move_ids.created_production_id')
def _compute_mrp_production_count(self):
for sale in self:
sale.mrp_production_count = len(sale.procurement_group_id.stock_move_ids.mapped('created_production_id'))
def action_view_mrp_production(self):
self.ensure_one()
mrp_production_ids = self.procurement_group_id.stock_move_ids.mapped('created_production_id').ids
action = {
'res_model': 'mrp.production',
'type': 'ir.actions.act_window',
}
if len(mrp_production_ids) == 1:
action.update({
'view_mode': 'form',
'res_id': mrp_production_ids[0],
})
else:
action.update({
'name': _("Manufacturing Orders Generated by %s" % self.name),
'domain': [('id', 'in', mrp_production_ids)],
'view_mode': 'tree,form',
})
return action

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backport from odoo v14
Copyright Odoo SA
Same licence as Odoo (LGPL) -->
<odoo>
<record id="mrp_production_form_view" model="ir.ui.view">
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="groups_id" eval="[(6, 0, [ref('sales_team.group_sale_salesman_all_leads')])]"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_sale_orders" type="object" icon="fa-dollar" attrs="{'invisible': [('sale_order_count', '=', 0)]}" groups="sales_team.group_sale_salesman">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="sale_order_count"/></span>
<span class="o_stat_text">Sales</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backport from Odoo v14
Copyright Odoo SA
Same licence as Odoo (LGPL)
-->
<odoo>
<record id="view_order_form" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="groups_id" eval="[(6, 0, [ref('mrp.group_mrp_user')])]"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" name="action_view_mrp_production" type="object" icon="fa-wrench" attrs="{'invisible': [('mrp_production_count', '=', 0)]}" groups="mrp.group_mrp_user">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="mrp_production_count"/></span>
<span class="o_stat_text">Manufacturing</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,106 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * sale_stock_usability
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 8.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-07-13 12:36+0000\n"
"PO-Revision-Date: 2016-07-13 14:37+0200\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"Language: fr\n"
"X-Generator: Poedit 1.8.7.1\n"
#. module: sale_stock_usability
#: view:sale.order:sale_stock_usability.view_order_form_inherit
msgid "Delivery Orders"
msgstr "Livraisons"
#. module: sale_stock_usability
#: view:procurement.group:sale_stock_usability.procurement_group_form_view
#: field:procurement.group,picking_ids:0
msgid "Pickings"
msgstr "Préparations"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_procurement_group
msgid "Procurement Requisition"
msgstr "Demande d'approvisionnement"
#. module: sale_stock_usability
#: view:procurement.group:sale_stock_usability.procurement_group_form_view
#: field:procurement.group,sale_ids:0
msgid "Sale Orders"
msgstr "Sale Orders"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order
msgid "Sales Order"
msgstr "Bon de commande"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order_line
msgid "Sales Order Line"
msgstr "Ligne de commandes de vente"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Delivery Cancelled"
msgstr "Livraison annulée"
#. module: sale_stock_usability
#: model_terms:ir.ui.view,arch_db:sale_stock_usability.view_sales_order_filter
#: selection:sale.order,picking_status:0
msgid "Fully Delivered"
msgstr "Entierement Livré "
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__incoterm
msgid "Incoterms"
msgstr "Incoterms"
#. module: sale_stock_usability
#: model:ir.model.fields,help:sale_stock_usability.field_sale_order__incoterm
msgid "International Commercial Terms are a series of predefined commercial terms used in international transactions."
msgstr "Les Incoterms sont une série de termes commerciaux prédéfinie utilisés dans les transactions internationales."
#. module: sale_stock_usability
#: model_terms:ir.ui.view,arch_db:sale_stock_usability.view_sales_order_filter
msgid "Not Fully Delivered"
msgstr "Livraison à faire"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Nothing to Deliver"
msgstr "Rien à livrer"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "Partially Delivered"
msgstr "Livré partielement"
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__picking_status
msgid "Picking Status"
msgstr "Status de BL"
#. module: sale_stock_usability
#: model:ir.model,name:sale_stock_usability.model_sale_order
msgid "Sale Order"
msgstr "Bon de commande"
#. module: sale_stock_usability
#: selection:sale.order,picking_status:0
msgid "To Deliver"
msgstr "Prêt à livrer"
#. module: sale_stock_usability
#: model:ir.model.fields,field_description:sale_stock_usability.field_sale_order__warehouse_id
msgid "Warehouse"
msgstr "Entrepôt"

View File

@@ -36,7 +36,7 @@
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<field name="pricelist_id" position="after">
<field name="picking_status"/>
<field name="picking_status" states="sale,done"/>
</field>
</field>
</record>

View File

@@ -2,8 +2,9 @@
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api
from odoo.tools import float_is_zero
from odoo import models, fields, api, _
from odoo.tools import float_is_zero, float_compare
from odoo.tools.misc import formatLang
class SaleOrder(models.Model):
@@ -11,6 +12,7 @@ class SaleOrder(models.Model):
date_order = fields.Datetime(track_visibility='onchange')
confirmation_date = fields.Datetime(track_visibility='onchange')
commitment_date = fields.Datetime(track_visibility='onchange')
client_order_ref = fields.Char(track_visibility='onchange')
# for partner_id, the 'sale' module sets track_visibility='always'
partner_id = fields.Many2one(track_visibility='onchange')
@@ -63,3 +65,35 @@ class SaleOrder(models.Model):
# {'subtotal': 8932.23},
# ]
return res
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@api.onchange('product_uom', 'product_uom_qty')
def product_uom_change(self):
# When the user has manually set a custom price
# he is often upset when Odoo changes it when he changes the qty
# So we add a warning in which we recall the old price.
res = {}
old_price = self.price_unit
super().product_uom_change()
new_price = self.price_unit
prec = self.env['decimal.precision'].precision_get('Product Price')
if float_compare(old_price, new_price, precision_digits=prec):
pricelist = self.order_id.pricelist_id
res['warning'] = {
'title': _('Price updated'),
'message': _(
"Due to the update of the ordered quantity on line '%s', "
"the price has been updated according to pricelist '%s'.\n"
"Old price: %s\n"
"New price: %s") % (
self.name,
pricelist.display_name,
formatLang(
self.env, old_price, currency_obj=pricelist.currency_id),
formatLang(
self.env, new_price, currency_obj=pricelist.currency_id))
}
return res

View File

@@ -26,6 +26,9 @@
<field name="confirmation_date" position="after">
<field name="client_order_ref"/>
</field>
<button name="preview_sale_order" position="after">
<button name="action_quotation_send" type="object" string="Send Order Acknowledgement" attrs="{'invisible': ['|', ('state', 'not in', ('sale', 'done')), ('invoice_count','&gt;=',1)]}"/>
</button>
</field>
</record>
@@ -51,6 +54,9 @@
<field name="state" position="attributes">
<attribute name="invisible">0</attribute>
</field>
<field name="partner_id" position="after">
<field name="client_order_ref"/>
</field>
</field>
</record>

View File

@@ -12,6 +12,9 @@ logger = logging.getLogger(__name__)
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
# this field stock_move_ids is native in v14
stock_move_ids = fields.One2many('stock.move', 'group_id', string="Related Stock Moves")
@api.model
def _procure_orderpoint_confirm(
self, use_new_cursor=False, company_id=False):

View File

@@ -3,6 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
logger = logging.getLogger(__name__)
@@ -98,7 +99,7 @@ class StockMove(models.Model):
picking = move.picking_id
if picking:
product = move.product_id
picking.message_post(_(
picking.message_post(body=_(
"Product <a href=# data-oe-model=product.product "
"data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>")
% (product.id, product.display_name,
@@ -120,7 +121,7 @@ class StockMoveLine(models.Model):
picking = moveline.move_id.picking_id
if picking:
product = moveline.product_id
picking.message_post(_(
picking.message_post(body=_(
"Product <a href=# data-oe-model=product.product "
"data-oe-id=%d>%s</a> qty %s %s <b>unreserved</b>")
% (product.id, product.display_name,
@@ -145,3 +146,31 @@ class StockQuant(models.Model):
action = self.action_view_stock_moves()
action['context'] = {'search_default_todo': True}
return action
def action_stock_move_lines_reserved(self):
self.ensure_one()
action = self.env['ir.actions.act_window'].for_xml_id('stock', 'stock_move_line_action')
action['domain'] = [
('state', 'not in', ('draft', 'done')),
('product_id', '=', self.product_id.id),
('location_id', '=', self.location_id.id),
('lot_id', '=', self.lot_id.id or False),
'|',
('package_id', '=', self.package_id.id or False),
('result_package_id', '=', self.package_id.id or False),
]
action['context'] = {'create': 0}
return action
class StockInventoryLine(models.Model):
_inherit = 'stock.inventory.line'
state = fields.Selection(store=True)
partner_id = fields.Many2one(states={'done': [('readonly', True)]})
product_id = fields.Many2one(states={'done': [('readonly', True)]})
product_uom_id = fields.Many2one(states={'done': [('readonly', True)]})
product_qty = fields.Float(states={'done': [('readonly', True)]})
location_id = fields.Many2one(states={'done': [('readonly', True)]})
package_id = fields.Many2one(states={'done': [('readonly', True)]})
prod_lot_id = fields.Many2one(states={'done': [('readonly', True)]})

View File

@@ -23,6 +23,11 @@
<button name="action_cancel" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to cancel this picking?</attribute>
</button>
<!-- picking_type_id updates location_id and location_dest_id, we it's
better to put it BEFORE in the view, not AFTER ! -->
<field name="location_id" position="before">
<field name="picking_type_id" position="move"/>
</field>
<!-- STOCK MOVE -->
<!-- This sum is useful to check the 'number of items' to transfer... -->
<xpath expr="//field[@name='move_ids_without_package']/tree/field[@name='product_uom_qty']" position="attributes">
@@ -262,8 +267,22 @@
</field>
</record>
<record id="view_move_line_form" model="ir.ui.view">
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_move_line_form" />
<field name="arch" type="xml">
<field name="picking_id" position="replace"/>
<xpath expr="//field[@name='date']/.." position="before">
<group invisible="not context.get('stock_move_line_main_view')" name="origin" colspan="2">
<field name="picking_id" attrs="{'invisible': [('picking_id', '=', False)]}"/>
<field name="move_id"/>
</group>
</xpath>
</field>
</record>
<record id="stock.stock_move_line_action" model="ir.actions.act_window">
<field name="context">{'search_default_done': 1}</field>
<field name="context">{'search_default_done': 1, 'stock_move_line_main_view': True}</field>
</record>
<record id="view_warehouse" model="ir.ui.view">
@@ -350,7 +369,9 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<button name="action_reset_product_qty" type="object" position="attributes">
<attribute name="confirm">Are you sure you want to reset all quantities to 0 ?</attribute>
</button>
<button name="action_inventory_line_tree" position="attributes">
<attribute name="states">confirm,done</attribute>
</button>
</field>
</record>
@@ -361,6 +382,7 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<field name="arch" type="xml">
<field name="date" position="after">
<field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="filter"/>
</field>
<tree position="attributes">
<attribute name="decoration-info">state == 'draft'</attribute>
@@ -369,6 +391,16 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
</field>
</record>
<record id="stock_inventory_line_tree2" model="ir.ui.view">
<field name="model">stock.inventory.line</field>
<field name="inherit_id" ref="stock.stock_inventory_line_tree2"/>
<field name="arch" type="xml">
<field name="prod_lot_id" position="attributes">
<attribute name="attrs">{'readonly': ['|', ('product_tracking', '=', 'none'), ('state', '=', 'done')]}</attribute>
</field>
</field>
</record>
<record id="view_stock_quant_tree" model="ir.ui.view">
<field name="name">stock.usability.quant.tree</field>
<field name="model">stock.quant</field>
@@ -380,6 +412,11 @@ should be able to access it. So I add a menu entry under Inventory Control. -->
<field name="reserved_quantity" position="attributes">
<attribute name="sum">1</attribute>
</field>
<!-- Move available_quantity AFTER quantity -->
<field name="quantity" position="after">
<field name="reserved_quantity" position="move"/>
<button type="object" name="action_stock_move_lines_reserved" string="Reservations" attrs="{'invisible': [('reserved_quantity', '=', 0)]}"/>
</field>
</field>
</record>
@@ -414,6 +451,24 @@ So I create another "regular" Quants" menu entry -->
parent="stock.menu_stock_inventory_control"
sequence="135"/>
<record id="stock_move_line_from_lot_action" model="ir.actions.act_window">
<field name="name">Product Moves</field>
<field name="res_model">stock.move.line</field>
<field name="view_mode">tree,kanban,pivot,form</field>
<field name="domain">[('lot_id', '=', active_id)]</field>
<field name="context">{'search_default_done': 1, 'create': 0}</field>
</record>
<record id="view_production_lot_form" model="ir.ui.view">
<field name="model">stock.production.lot</field>
<field name="inherit_id" ref="stock.view_production_lot_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="%(stock_move_line_from_lot_action)d" string="Product Moves" type="action" icon="fa-exchange"/>
</div>
</field>
</record>
<record id="stock.action_production_lot_form" model="ir.actions.act_window">
<field name="context">{}</field> <!-- remove group by product -->
</record>

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Stock Valuation XLSX',
'version': '12.0.1.0.0',
'version': '12.0.1.0.1',
'category': 'Tools',
'license': 'AGPL-3',
'summary': 'Generate XLSX reports for past or present stock levels',
@@ -37,8 +37,11 @@ This module has been written by Alexis de Lattre from Akretion <alexis.delattre@
'website': 'http://www.akretion.com',
'depends': ['stock_account'],
'data': [
'security/ir.model.access.csv',
'wizard/stock_valuation_xlsx_view.xml',
'wizard/stock_variation_xlsx_view.xml',
'views/stock_inventory.xml',
'views/stock_expiry_depreciation_rule.xml',
],
'installable': True,
}

View File

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

View File

@@ -0,0 +1,35 @@
# Copyright 2021 Akretion France (http://www.akretion.com/)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
class StockExpiryDepreciationRule(models.Model):
_name = 'stock.expiry.depreciation.rule'
_description = 'Stock Expiry Depreciation Rule'
_order = 'company_id, start_limit_days'
company_id = fields.Many2one(
'res.company', string='Company',
ondelete='cascade', required=True,
default=lambda self: self.env['res.company']._company_default_get())
start_limit_days = fields.Integer(
string='Days Before/After Expiry', required=True,
help="Enter negative value for days before expiry. Enter positive values for days after expiry. This value is the START of the time interval when going from future to past.")
ratio = fields.Integer(string='Depreciation Ratio (%)', required=True)
name = fields.Char(string='Label')
_sql_constraints = [(
'ratio_positive',
'CHECK(ratio >= 0)',
'The depreciation ratio must be positive.'
), (
'ratio_max',
'CHECK(ratio <= 100)',
'The depreciation ratio cannot be above 100%.'
), (
'start_limit_days_unique',
'unique(company_id, start_limit_days)',
'This depreciation rule already exists in this company.'
)]

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_expiry_depreciation_rule_full,Full access on stock.expiry.depreciation.rule to account manager,model_stock_expiry_depreciation_rule,account.group_account_manager,1,1,1,1
access_stock_expiry_depreciation_rule_read,Read access on stock.expiry.depreciation.rule to stock manager,model_stock_expiry_depreciation_rule,stock.group_stock_manager,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_expiry_depreciation_rule_full Full access on stock.expiry.depreciation.rule to account manager model_stock_expiry_depreciation_rule account.group_account_manager 1 1 1 1
3 access_stock_expiry_depreciation_rule_read Read access on stock.expiry.depreciation.rule to stock manager model_stock_expiry_depreciation_rule stock.group_stock_manager 1 0 0 0

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<data>
<record id="stock_expiry_depreciation_rule_tree" model="ir.ui.view">
<field name="model">stock.expiry.depreciation.rule</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="start_limit_days"/>
<field name="ratio"/>
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="stock_expiry_depreciation_rule_action" model="ir.actions.act_window">
<field name="name">Stock Depreciation Rules</field>
<field name="res_model">stock.expiry.depreciation.rule</field>
<field name="view_mode">tree</field>
</record>
<menuitem id="stock_expiry_depreciation_rule_menu"
action="stock_expiry_depreciation_rule_action"
parent="account.account_management_menu"
sequence="100"/>
</data>
</odoo>

View File

@@ -1 +1,2 @@
from . import stock_valuation_xlsx
from . import stock_variation_xlsx

View File

@@ -4,6 +4,7 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from dateutil.relativedelta import relativedelta
from odoo.tools import float_is_zero, float_round
from io import BytesIO
from datetime import datetime
@@ -18,7 +19,7 @@ class StockValuationXlsx(models.TransientModel):
_name = 'stock.valuation.xlsx'
_description = 'Generate XLSX report for stock valuation'
export_file = fields.Binary(string='XLSX Report', readonly=True)
export_file = fields.Binary(string='XLSX Report', readonly=True, attachment=True)
export_filename = fields.Char(readonly=True)
# I don't use ir.actions.url on v12, because it renders
# the wizard unusable after the first report generation, which creates
@@ -38,8 +39,10 @@ class StockValuationXlsx(models.TransientModel):
help="The childen locations of the selected locations will "
u"be taken in the valuation.")
categ_ids = fields.Many2many(
'product.category', string='Product Categories',
states={'done': [('readonly', True)]})
'product.category', string='Product Category Filter',
help="Leave this field empty to have a stock valuation for all your products.",
states={'done': [('readonly', True)]},
)
source = fields.Selection([
('inventory', 'Physical Inventory'),
('stock', 'Stock Levels'),
@@ -59,17 +62,33 @@ class StockValuationXlsx(models.TransientModel):
categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]},
help="Show a subtotal per product category")
help="Show a subtotal per product category.")
standard_price_date = fields.Selection([
('past', 'Past Date or Inventory Date'),
('present', 'Current'),
], default='past', string='Cost Price Date',
states={'done': [('readonly', True)]})
# I can't put a compute field for has_expiry_date
# because I want to have the value when the wizard is started,
# and not wait until run
has_expiry_date = fields.Boolean(
default=lambda self: self._default_has_expiry_date(), readonly=True)
apply_depreciation = fields.Boolean(
string='Apply Depreciation Rules', default=True,
states={'done': [('readonly', True)]})
split_by_lot = fields.Boolean(
string='Display Lots', states={'done': [('readonly', True)]})
split_by_location = fields.Boolean(
string='Display Stock Locations', states={'done': [('readonly', True)]})
@api.model
def _default_has_expiry_date(self):
splo = self.env['stock.production.lot']
has_expiry_date = False
if hasattr(splo, 'expiry_date'):
has_expiry_date = True
return has_expiry_date
@api.model
def _default_location(self):
wh = self.env.ref('stock.warehouse0')
@@ -123,11 +142,22 @@ class StockValuationXlsx(models.TransientModel):
def _prepare_product_fields(self):
return ['uom_id', 'name', 'default_code', 'categ_id']
def _prepare_expiry_depreciation_rules(self, company_id, past_date):
rules = self.env['stock.expiry.depreciation.rule'].search_read([('company_id', '=', company_id)], ['start_limit_days', 'ratio'], order='start_limit_days desc')
if past_date:
date_dt = fields.Date.to_date(past_date) # convert datetime to date
else:
date_dt = fields.Date.context_today(self)
for rule in rules:
rule['start_date'] = date_dt - relativedelta(days=rule['start_limit_days'])
logger.debug('depreciation_rules=%s', rules)
return rules
def compute_product_data(
self, company_id, in_stock_product_ids, standard_price_past_date=False):
self.ensure_one()
logger.debug('Start compute_product_data')
ppo = self.env['product.product']
ppo = self.env['product.product'].with_context(force_company=company_id)
ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields()
if not standard_price_past_date:
@@ -156,38 +186,56 @@ class StockValuationXlsx(models.TransientModel):
logger.debug('End compute_product_data')
return product_id2data
def id2name(self, product_ids):
logger.debug('Start id2name')
@api.model
def product_categ_id2name(self, categories):
pco = self.env['product.category']
splo = self.env['stock.production.lot']
slo = self.env['stock.location'].with_context(active_test=False)
puo = self.env['uom.uom'].with_context(active_test=False)
categ_id2name = {}
categ_domain = []
if self.categ_ids:
categ_domain = [('id', 'child_of', self.categ_ids.ids)]
if categories:
categ_domain = [('id', 'child_of', categories.ids)]
for categ in pco.search_read(categ_domain, ['display_name']):
categ_id2name[categ['id']] = categ['display_name']
return categ_id2name
@api.model
def uom_id2name(self):
puo = self.env['uom.uom'].with_context(active_test=False)
uom_id2name = {}
uoms = puo.search_read([], ['name'])
for uom in uoms:
uom_id2name[uom['id']] = uom['name']
return uom_id2name
@api.model
def prodlot_id2data(self, product_ids, has_expiry_date, depreciation_rules):
splo = self.env['stock.production.lot']
lot_id2data = {}
lot_fields = ['name']
if hasattr(splo, 'expiry_date'):
if has_expiry_date:
lot_fields.append('expiry_date')
lots = splo.search_read(
[('product_id', 'in', product_ids)], lot_fields)
for lot in lots:
lot_id2data[lot['id']] = lot
lot_id2data[lot['id']]['depreciation_ratio'] = 0
if depreciation_rules and lot.get('expiry_date'):
expiry_date = lot['expiry_date']
for rule in depreciation_rules:
if expiry_date <= rule['start_date']:
lot_id2data[lot['id']]['depreciation_ratio'] = rule['ratio'] / 100.0
break
return lot_id2data
@api.model
def stock_location_id2name(self, location):
slo = self.env['stock.location'].with_context(active_test=False)
loc_id2name = {}
locs = slo.search_read(
[('id', 'child_of', self.location_id.id)], ['display_name'])
for loc in locs:
loc_id2name[loc['id']] = loc['display_name']
logger.debug('End id2name')
return categ_id2name, uom_id2name, lot_id2data, loc_id2name
return loc_id2name
def compute_data_from_inventory(self, product_ids, prec_qty):
self.ensure_one()
@@ -239,7 +287,7 @@ class StockValuationXlsx(models.TransientModel):
self.ensure_one()
logger.debug('Start compute_data_from_past_stock past_date=%s', past_date)
ppo = self.env['product.product']
products = ppo.with_context(to_date=past_date, location_id=self.location_id.id).browse(product_ids)
products = ppo.with_context(to_date=past_date, location=self.location_id.id).browse(product_ids)
res = []
in_stock_products = {}
for product in products:
@@ -275,7 +323,7 @@ class StockValuationXlsx(models.TransientModel):
def stringify_and_sort_result(
self, product_ids, product_id2data, data,
prec_qty, prec_price, prec_cur_rounding, categ_id2name,
uom_id2name, lot_id2data, loc_id2name):
uom_id2name, lot_id2data, loc_id2name, apply_depreciation):
logger.debug('Start stringify_and_sort_result')
res = []
for l in data:
@@ -284,17 +332,27 @@ class StockValuationXlsx(models.TransientModel):
standard_price = float_round(
product_id2data[product_id]['standard_price'],
precision_digits=prec_price)
subtotal = float_round(
subtotal_before_depreciation = float_round(
standard_price * qty, precision_rounding=prec_cur_rounding)
depreciation_ratio = 0
if apply_depreciation and l['lot_id']:
depreciation_ratio = lot_id2data[l['lot_id']].get('depreciation_ratio', 0)
subtotal = float_round(
subtotal_before_depreciation * (1 - depreciation_ratio),
precision_rounding=prec_cur_rounding)
else:
subtotal = subtotal_before_depreciation
res.append(dict(
product_id2data[product_id],
product_name=product_id2data[product_id]['name'],
loc_name=l['location_id'] and loc_id2name[l['location_id']] or '',
lot_name=l['lot_id'] and lot_id2data[l['lot_id']]['name'] or '',
expiry_date=l['lot_id'] and lot_id2data[l['lot_id']].get('expiry_date'),
depreciation_ratio=depreciation_ratio,
qty=qty,
uom_name=uom_id2name[product_id2data[product_id]['uom_id']],
standard_price=standard_price,
subtotal_before_depreciation=subtotal_before_depreciation,
subtotal=subtotal,
categ_name=categ_id2name[product_id2data[product_id]['categ_id']],
))
@@ -313,6 +371,12 @@ class StockValuationXlsx(models.TransientModel):
prec_cur_rounding = company.currency_id.rounding
self._check_config(company_id)
apply_depreciation = self.apply_depreciation
if (
(self.source == 'stock' and self.stock_date_type == 'past') or
not self.split_by_lot or
not self.has_expiry_date):
apply_depreciation = False
product_ids = self.get_product_ids()
if not product_ids:
raise UserError(_("There are no products to analyse."))
@@ -332,18 +396,32 @@ class StockValuationXlsx(models.TransientModel):
elif self.source == 'inventory':
past_date = self.inventory_id.date
data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty)
standard_price_past_date = past_date
if not (self.source == 'stock' and self.stock_date_type == 'present') and self.standard_price_date == 'present':
if self.source == 'stock' and self.stock_date_type == 'present':
standard_price_past_date = False
else: # field standard_price_date is shown on screen
if self.standard_price_date == 'present':
standard_price_past_date = False
else:
standard_price_past_date = past_date
depreciation_rules = []
if apply_depreciation:
depreciation_rules = self._prepare_expiry_depreciation_rules(company_id, past_date)
if not depreciation_rules:
raise UserError(_(
"The are not stock depreciation rule for company '%s'.")
% company.display_name)
in_stock_product_ids = list(in_stock_products.keys())
product_id2data = self.compute_product_data(
company_id, in_stock_product_ids,
standard_price_past_date=standard_price_past_date)
data_res = self.group_result(data, split_by_lot, split_by_location)
categ_id2name, uom_id2name, lot_id2data, loc_id2name = self.id2name(product_ids)
categ_id2name = self.product_categ_id2name(self.categ_ids)
uom_id2name = self.uom_id2name()
lot_id2data = self.prodlot_id2data(in_stock_product_ids, self.has_expiry_date, depreciation_rules)
loc_id2name = self.stock_location_id2name(self.location_id)
res = self.stringify_and_sort_result(
product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name, lot_id2data, loc_id2name)
categ_id2name, uom_id2name, lot_id2data, loc_id2name, apply_depreciation)
logger.debug('Start create XLSX workbook')
file_data = BytesIO()
@@ -356,12 +434,15 @@ class StockValuationXlsx(models.TransientModel):
if not split_by_lot:
cols.pop('lot_name', None)
cols.pop('expiry_date', None)
if not hasattr(splo, 'expiry_date'):
if not self.has_expiry_date:
cols.pop('expiry_date', None)
if not split_by_location:
cols.pop('loc_name', None)
if not categ_subtotal:
cols.pop('categ_subtotal', None)
if not apply_depreciation:
cols.pop('depreciation_ratio', None)
cols.pop('subtotal_before_depreciation', None)
j = 0
for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']):
@@ -417,6 +498,9 @@ class StockValuationXlsx(models.TransientModel):
letter_qty = cols['qty']['pos_letter']
letter_price = cols['standard_price']['pos_letter']
letter_subtotal = cols['subtotal']['pos_letter']
if apply_depreciation:
letter_subtotal_before_depreciation = cols['subtotal_before_depreciation']['pos_letter']
letter_depreciation_ratio = cols['depreciation_ratio']['pos_letter']
crow = 0
lines = res
for categ_id in categ_ids:
@@ -432,12 +516,20 @@ class StockValuationXlsx(models.TransientModel):
total += l['subtotal']
ctotal += l['subtotal']
categ_has_line = True
subtotal_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1)
qty_by_price_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1)
if apply_depreciation:
sheet.write_formula(i, cols['subtotal_before_depreciation']['pos'], qty_by_price_formula, styles['regular_currency'], l['subtotal_before_depreciation'])
subtotal_formula = '=%s%d*(1 - %s%d)' % (letter_subtotal_before_depreciation, i + 1, letter_depreciation_ratio, i + 1)
else:
subtotal_formula = qty_by_price_formula
sheet.write_formula(i, cols['subtotal']['pos'], subtotal_formula, styles['regular_currency'], l['subtotal'])
for col_name, col in cols.items():
if not col.get('formula'):
if col.get('type') == 'date' and l[col_name]:
l[col_name] = fields.Date.from_string(l[col_name])
if col.get('type') == 'date':
if l[col_name]:
l[col_name] = fields.Date.from_string(l[col_name])
else:
l[col_name] = '' # to avoid display of 31/12/1899
sheet.write(i, col['pos'], l[col_name], styles[col['style']])
if categ_subtotal:
if categ_has_line:
@@ -503,6 +595,7 @@ class StockValuationXlsx(models.TransientModel):
'regular_date': workbook.add_format({'num_format': 'dd/mm/yyyy'}),
'regular_currency': workbook.add_format({'num_format': currency_num_format}),
'regular_price_currency': workbook.add_format({'num_format': price_currency_num_format}),
'regular_int_percent': workbook.add_format({'num_format': u'0.%'}),
'regular': workbook.add_format({}),
'regular_small': workbook.add_format({'font_size': regular_font_size - 2}),
'categ_title': workbook.add_format({
@@ -527,8 +620,10 @@ class StockValuationXlsx(models.TransientModel):
'qty': {'width': 8, 'style': 'regular', 'sequence': 60, 'title': _('Qty')},
'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 70, 'title': _('UoM')},
'standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 80, 'title': _('Cost Price')},
'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 90, 'title': _('Sub-total'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 100, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 110, 'title': _('Product Category')},
'subtotal_before_depreciation': {'width': 16, 'style': 'regular_currency', 'sequence': 90, 'title': _('Sub-total'), 'formula': True},
'depreciation_ratio': {'width': 10, 'style': 'regular_int_percent', 'sequence': 100, 'title': _('Depreciation')},
'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 110, 'title': _('Sub-total'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 120, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 130, 'title': _('Product Category')},
}
return cols

View File

@@ -27,8 +27,10 @@
<field name="past_date" attrs="{'invisible': ['|', ('source', '!=', 'stock'), ('stock_date_type', '!=', 'past')], 'required': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
<field name="standard_price_date" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'present')]}" widget="radio"/>
<field name="categ_subtotal" />
<field name="has_expiry_date" invisible="1"/>
<field name="split_by_lot" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}" groups="stock.group_production_lot"/>
<field name="split_by_location" attrs="{'invisible': [('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
<field name="apply_depreciation" groups="stock.group_production_lot" attrs="{'invisible': ['|', '|', ('split_by_lot', '=', False), ('has_expiry_date', '=', False), '&amp;', ('source', '=', 'stock'), ('stock_date_type', '=', 'past')]}"/>
</group>
<group name="done" states="done" string="Result">
<field name="export_file" filename="export_filename"/>
@@ -55,6 +57,7 @@
<record id="stock_account.menu_valuation" model="ir.ui.menu">
<field name="action" ref="stock_valuation_xlsx.stock_valuation_xlsx_action"/>
<field name="name">Stock Valuation XLSX</field>
<field name="sequence">0</field>
</record>
</odoo>

View File

@@ -0,0 +1,459 @@
# Copyright 2020-2021 Akretion France (http://www.akretion.com/)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.tools import float_is_zero, float_round
from io import BytesIO
import base64
from datetime import datetime
import xlsxwriter
import logging
logger = logging.getLogger(__name__)
class StockVariationXlsx(models.TransientModel):
_name = 'stock.variation.xlsx'
_description = 'Generate XLSX report for stock valuation variation between 2 dates'
export_file = fields.Binary(string='XLSX Report', readonly=True, attachment=True)
export_filename = fields.Char(readonly=True)
state = fields.Selection([
('setup', 'Setup'),
('done', 'Done'),
], string='State', default='setup', readonly=True)
warehouse_id = fields.Many2one(
'stock.warehouse', string='Warehouse',
states={'done': [('readonly', True)]})
location_id = fields.Many2one(
'stock.location', string='Root Stock Location', required=True,
domain=[('usage', 'in', ('view', 'internal'))],
default=lambda self: self._default_location(),
states={'done': [('readonly', True)]},
help="The childen locations of the selected locations will "
"be taken in the valuation.")
categ_ids = fields.Many2many(
'product.category', string='Product Category Filter',
help="Leave this fields empty to have a stock valuation for all your products.",
states={'done': [('readonly', True)]})
start_date = fields.Datetime(
string='Start Date', required=True,
states={'done': [('readonly', True)]})
standard_price_start_date_type = fields.Selection([
('start', 'Start Date'),
('present', 'Current'),
], default='start', required=True,
string='Cost Price for Start Date',
states={'done': [('readonly', True)]})
end_date_type = fields.Selection([
('present', 'Present'),
('past', 'Past'),
], string='End Date Type', default='present', required=True,
states={'done': [('readonly', True)]})
end_date = fields.Datetime(
string='End Date', states={'done': [('readonly', True)]},
default=fields.Datetime.now)
standard_price_end_date_type = fields.Selection([
('end', 'End Date'),
('present', 'Current'),
], default='end', string='Cost Price for End Date', required=True,
states={'done': [('readonly', True)]})
categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]},
help="Show a subtotal per product category.")
@api.model
def _default_location(self):
wh = self.env.ref('stock.warehouse0')
return wh.lot_stock_id
@api.onchange('warehouse_id')
def warehouse_id_change(self):
if self.warehouse_id:
self.location_id = self.warehouse_id.view_location_id.id
def _check_config(self, company_id):
self.ensure_one()
present = fields.Datetime.now()
if self.end_date_type == 'past':
if not self.end_date:
raise UserError(_("End Date is missing."))
if self.end_date > present:
raise UserError(_("The end date must be in the past."))
if self.end_date <= self.start_date:
raise UserError(_("The start date must be before the end date."))
else:
if self.start_date >= present:
raise UserError(_("The start date must be in the past."))
cost_method_real_count = self.env['ir.property'].search([
('company_id', '=', company_id),
('name', '=', 'property_cost_method'),
('value_text', '=', 'real'),
('type', '=', 'selection'),
], count=True)
if cost_method_real_count:
raise UserError(_(
"There are %d properties that have "
"'Costing Method' = 'Real Price'. This costing "
"method is not supported by this module.")
% cost_method_real_count)
def _prepare_product_domain(self):
self.ensure_one()
domain = [('type', '=', 'product')]
if self.categ_ids:
domain += [('categ_id', 'child_of', self.categ_ids.ids)]
return domain
def get_product_ids(self):
self.ensure_one()
domain = self._prepare_product_domain()
# Should we also add inactive products ??
products = self.env['product.product'].search(domain)
return products.ids
def _prepare_product_fields(self):
return ['uom_id', 'name', 'default_code', 'categ_id']
def compute_product_data(
self, company_id, filter_product_ids,
standard_price_start_date=False, standard_price_end_date=False):
self.ensure_one()
logger.debug('Start compute_product_data')
ppo = self.env['product.product'].with_context(force_company=company_id)
ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields()
if not standard_price_start_date or not standard_price_end_date:
fields_list.append('standard_price')
products = ppo.search_read([('id', 'in', filter_product_ids)], fields_list)
product_id2data = {}
for p in products:
logger.debug('p=%d', p['id'])
# I don't call the native method get_history_price()
# because it requires a browse record and it is too slow
if standard_price_start_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_start_date)],
['cost'], order='datetime desc, id desc', limit=1)
start_standard_price = history and history[0]['cost'] or 0.0
else:
start_standard_price = p['standard_price']
if standard_price_end_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_end_date)],
['cost'], order='datetime desc, id desc', limit=1)
end_standard_price = history and history[0]['cost'] or 0.0
else:
end_standard_price = p['standard_price']
product_id2data[p['id']] = {
'start_standard_price': start_standard_price,
'end_standard_price': end_standard_price,
}
for pfield in fields_list:
if pfield.endswith('_id'):
product_id2data[p['id']][pfield] = p[pfield][0]
else:
product_id2data[p['id']][pfield] = p[pfield]
logger.debug('End compute_product_data')
return product_id2data
def compute_data_from_stock(self, product_ids, prec_qty, start_date, end_date_type, end_date, company_id):
self.ensure_one()
logger.debug('Start compute_data_from_stock past_date=%s end_date_type=%s, end_date=%s', start_date, end_date_type, end_date)
ppo = self.env['product.product']
smo = self.env['stock.move']
sqo = self.env['stock.quant']
ppo_loc = ppo.with_context(location=self.location_id.id, force_company=company_id)
# Inspired by odoo/addons/stock/models/product.py
# method _compute_quantities_dict()
domain_quant_loc, domain_move_in_loc, domain_move_out_loc = ppo_loc._get_domain_locations()
domain_quant = [('product_id', 'in', product_ids)] + domain_quant_loc
domain_move_in = [('product_id', 'in', product_ids), ('state', '=', 'done')] + domain_move_in_loc
domain_move_out = [('product_id', 'in', product_ids), ('state', '=', 'done')] + domain_move_out_loc
quants_res = dict((item['product_id'][0], item['quantity']) for item in sqo.read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id'))
domain_move_in_start_to_end = [('date', '>', start_date)] + domain_move_in
domain_move_out_start_to_end = [('date', '>', start_date)] + domain_move_out
if end_date_type == 'past':
domain_move_in_end_to_present = [('date', '>', end_date)] + domain_move_in
domain_move_out_end_to_present = [('date', '>', end_date)] + domain_move_out
moves_in_res_end_to_present = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_in_end_to_present, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res_end_to_present = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_out_end_to_present, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
domain_move_in_start_to_end += [('date', '<', end_date)]
domain_move_out_start_to_end += [('date', '<', end_date)]
moves_in_res_start_to_end = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_in_start_to_end, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
moves_out_res_start_to_end = dict((item['product_id'][0], item['product_qty']) for item in smo.read_group(domain_move_out_start_to_end, ['product_id', 'product_qty'], ['product_id'], orderby='id'))
product_data = {} # key = product_id , value = dict
for product in ppo.browse(product_ids):
end_qty = quants_res.get(product.id, 0.0)
if end_date_type == 'past':
end_qty += moves_out_res_end_to_present.get(product.id, 0.0) - moves_in_res_end_to_present.get(product.id, 0.0)
in_qty = moves_in_res_start_to_end.get(product.id, 0.0)
out_qty = moves_out_res_start_to_end.get(product.id, 0.0)
start_qty = end_qty - in_qty + out_qty
if (
not float_is_zero(start_qty, precision_digits=prec_qty) or
not float_is_zero(in_qty, precision_digits=prec_qty) or
not float_is_zero(out_qty, precision_digits=prec_qty) or
not float_is_zero(end_qty, precision_digits=prec_qty)):
product_data[product.id] = {
'product_id': product.id,
'start_qty': start_qty,
'in_qty': in_qty,
'out_qty': out_qty,
'end_qty': end_qty,
}
logger.debug('End compute_data_from_stock')
return product_data
def stringify_and_sort_result(
self, product_data, product_id2data, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name):
logger.debug('Start stringify_and_sort_result')
res = []
for product_id, l in product_data.items():
start_qty = float_round(l['start_qty'], precision_digits=prec_qty)
in_qty = float_round(l['in_qty'], precision_digits=prec_qty)
out_qty = float_round(l['out_qty'], precision_digits=prec_qty)
end_qty = float_round(l['end_qty'], precision_digits=prec_qty)
start_standard_price = float_round(
product_id2data[product_id]['start_standard_price'],
precision_digits=prec_price)
end_standard_price = float_round(
product_id2data[product_id]['end_standard_price'],
precision_digits=prec_price)
start_subtotal = float_round(
start_standard_price * start_qty, precision_rounding=prec_cur_rounding)
end_subtotal = float_round(
end_standard_price * end_qty, precision_rounding=prec_cur_rounding)
variation = float_round(
end_subtotal - start_subtotal, precision_rounding=prec_cur_rounding)
res.append(dict(
product_id2data[product_id],
product_name=product_id2data[product_id]['name'],
start_qty=start_qty,
start_standard_price=start_standard_price,
start_subtotal=start_subtotal,
in_qty=in_qty,
out_qty=out_qty,
end_qty=end_qty,
end_standard_price=end_standard_price,
end_subtotal=end_subtotal,
variation=variation,
uom_name=uom_id2name[product_id2data[product_id]['uom_id']],
categ_name=categ_id2name[product_id2data[product_id]['categ_id']],
))
sort_res = sorted(res, key=lambda x: x['product_name'])
logger.debug('End stringify_and_sort_result')
return sort_res
def generate(self):
self.ensure_one()
logger.debug('Start generate XLSX stock variation report')
svxo = self.env['stock.valuation.xlsx']
prec_qty = self.env['decimal.precision'].precision_get('Product Unit of Measure')
prec_price = self.env['decimal.precision'].precision_get('Product Price')
company = self.env.user.company_id
company_id = company.id
prec_cur_rounding = company.currency_id.rounding
self._check_config(company_id)
product_ids = self.get_product_ids()
if not product_ids:
raise UserError(_("There are no products to analyse."))
product_data = self.compute_data_from_stock(
product_ids, prec_qty, self.start_date, self.end_date_type, self.end_date,
company_id)
standard_price_start_date = standard_price_end_date = False
if self.standard_price_start_date_type == 'start':
standard_price_start_date = self.start_date
if self.standard_price_end_date_type == 'end' and self.end_date_type == 'past':
standard_price_end_date = self.end_date
product_id2data = self.compute_product_data(
company_id, list(product_data.keys()),
standard_price_start_date, standard_price_end_date)
categ_id2name = svxo.product_categ_id2name(self.categ_ids)
uom_id2name = svxo.uom_id2name()
res = self.stringify_and_sort_result(
product_data, product_id2data, prec_qty, prec_price, prec_cur_rounding,
categ_id2name, uom_id2name)
logger.debug('Start create XLSX workbook')
file_data = BytesIO()
workbook = xlsxwriter.Workbook(file_data)
sheet = workbook.add_worksheet('Stock_Variation')
styles = svxo._prepare_styles(workbook, company, prec_price)
cols = self._prepare_cols()
categ_subtotal = self.categ_subtotal
# remove cols that we won't use
if not categ_subtotal:
cols.pop('categ_subtotal', None)
j = 0
for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']):
cols[col]['pos'] = j
cols[col]['pos_letter'] = chr(j + 97).upper()
sheet.set_column(j, j, cols[col]['width'])
j += 1
# HEADER
now_dt = fields.Datetime.context_timestamp(self, datetime.now())
now_str = fields.Datetime.to_string(now_dt)
start_time_utc_dt = self.start_date
start_time_dt = fields.Datetime.context_timestamp(self, start_time_utc_dt)
start_time_str = fields.Datetime.to_string(start_time_dt)
if self.end_date_type == 'past':
end_time_utc_dt = self.end_date
end_time_dt = fields.Datetime.context_timestamp(self, end_time_utc_dt)
end_time_str = fields.Datetime.to_string(end_time_dt)
else:
end_time_str = now_str
if standard_price_start_date:
standard_price_start_date_str = start_time_str
else:
standard_price_start_date_str = now_str
if standard_price_end_date:
standard_price_end_date_str = end_time_str
else:
standard_price_end_date_str = now_str
i = 0
sheet.write(i, 0, 'Odoo - Stock Valuation Variation', styles['doc_title'])
sheet.set_row(0, 26)
i += 1
sheet.write(i, 0, 'Start Date: %s' % start_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price Start Date: %s' % standard_price_start_date_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'End Date: %s' % end_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price End Date: %s' % standard_price_end_date_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Stock location (children included): %s' % self.location_id.complete_name, styles['doc_subtitle'])
if self.categ_ids:
i += 1
sheet.write(i, 0, 'Product Categories: %s' % ', '.join([categ.display_name for categ in self.categ_ids]), styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Generated on %s by %s' % (now_str, self.env.user.name), styles['regular_small'])
# TITLE of COLS
i += 2
for col in cols.values():
sheet.write(i, col['pos'], col['title'], styles['col_title'])
i += 1
sheet.write(i, 0, _("TOTALS:"), styles['total_title'])
total_row = i
# LINES
if categ_subtotal:
categ_ids = categ_id2name.keys()
else:
categ_ids = [0]
start_total = end_total = variation_total = 0.0
letter_start_qty = cols['start_qty']['pos_letter']
letter_in_qty = cols['in_qty']['pos_letter']
letter_out_qty = cols['out_qty']['pos_letter']
letter_end_qty = cols['end_qty']['pos_letter']
letter_start_price = cols['start_standard_price']['pos_letter']
letter_end_price = cols['end_standard_price']['pos_letter']
letter_start_subtotal = cols['start_subtotal']['pos_letter']
letter_end_subtotal = cols['end_subtotal']['pos_letter']
letter_variation = cols['variation']['pos_letter']
crow = 0
lines = res
for categ_id in categ_ids:
ctotal = 0.0
categ_has_line = False
if categ_subtotal:
# skip a line and save it's position as crow
i += 1
crow = i
lines = filter(lambda x: x['categ_id'] == categ_id, res)
for l in lines:
i += 1
start_total += l['start_subtotal']
end_total += l['end_subtotal']
variation_total += l['variation']
ctotal += l['variation']
categ_has_line = True
end_qty_formula = '=%s%d+%s%d-%s%d' % (letter_start_qty, i + 1, letter_in_qty, i + 1, letter_out_qty, i + 1)
sheet.write_formula(i, cols['end_qty']['pos'], end_qty_formula, styles[cols['end_qty']['style']], l['end_qty'])
start_subtotal_formula = '=%s%d*%s%d' % (letter_start_qty, i + 1, letter_start_price, i + 1)
sheet.write_formula(i, cols['start_subtotal']['pos'], start_subtotal_formula, styles[cols['start_subtotal']['style']], l['start_subtotal'])
end_subtotal_formula = '=%s%d*%s%d' % (letter_end_qty, i + 1, letter_end_price, i + 1)
sheet.write_formula(i, cols['end_subtotal']['pos'], end_subtotal_formula, styles[cols['end_subtotal']['style']], l['end_subtotal'])
variation_formula = '=%s%d-%s%d' % (letter_end_subtotal, i + 1, letter_start_subtotal, i + 1)
sheet.write_formula(i, cols['variation']['pos'], variation_formula, styles[cols['variation']['style']], l['variation'])
sheet.write_formula(i, cols['end_subtotal']['pos'], end_subtotal_formula, styles[cols['end_subtotal']['style']], l['end_subtotal'])
for col_name, col in cols.items():
if not col.get('formula'):
if col.get('type') == 'date' and l[col_name]:
l[col_name] = fields.Date.from_string(l[col_name])
sheet.write(i, col['pos'], l[col_name], styles[col['style']])
if categ_subtotal:
if categ_has_line:
sheet.write(crow, 0, categ_id2name[categ_id], styles['categ_title'])
for x in range(cols['categ_subtotal']['pos'] - 1):
sheet.write(crow, x + 1, '', styles['categ_title'])
cformula = '=SUM(%s%d:%s%d)' % (letter_variation, crow + 2, letter_variation, i + 1)
sheet.write_formula(crow, cols['categ_subtotal']['pos'], cformula, styles['categ_currency'], float_round(ctotal, precision_rounding=prec_cur_rounding))
else:
i -= 1 # go back to skipped line
# Write total
start_total_formula = '=SUM(%s%d:%s%d)' % (letter_start_subtotal, total_row + 2, letter_start_subtotal, i + 1)
sheet.write_formula(total_row, cols['start_subtotal']['pos'], start_total_formula, styles['total_currency'], float_round(start_total, precision_rounding=prec_cur_rounding))
end_total_formula = '=SUM(%s%d:%s%d)' % (letter_end_subtotal, total_row + 2, letter_end_subtotal, i + 1)
sheet.write_formula(total_row, cols['end_subtotal']['pos'], end_total_formula, styles['total_currency'], float_round(end_total, precision_rounding=prec_cur_rounding))
variation_total_formula = '=SUM(%s%d:%s%d)' % (letter_variation, total_row + 2, letter_variation, i + 1)
sheet.write_formula(total_row, cols['variation']['pos'], variation_total_formula, styles['total_currency'], float_round(variation_total, precision_rounding=prec_cur_rounding))
workbook.close()
logger.debug('End create XLSX workbook')
file_data.seek(0)
filename = 'Odoo_stock_%s_%s.xlsx' % (
start_time_str.replace(' ', '-').replace(':', '_'),
end_time_str.replace(' ', '-').replace(':', '_'))
export_file_b64 = base64.b64encode(file_data.read())
self.write({
'state': 'done',
'export_filename': filename,
'export_file': export_file_b64,
})
action = self.env['ir.actions.act_window'].for_xml_id(
'stock_valuation_xlsx', 'stock_variation_xlsx_action')
action['res_id'] = self.id
return action
def _prepare_cols(self):
cols = {
'default_code': {'width': 18, 'style': 'regular', 'sequence': 10, 'title': _('Product Code')},
'product_name': {'width': 40, 'style': 'regular', 'sequence': 20, 'title': _('Product Name')},
'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 30, 'title': _('UoM')},
'start_qty': {'width': 8, 'style': 'regular', 'sequence': 40, 'title': _('Start Qty')},
'start_standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 50, 'title': _('Start Cost Price')},
'start_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 60, 'title': _('Start Value'), 'formula': True},
'in_qty': {'width': 8, 'style': 'regular', 'sequence': 70, 'title': _('In Qty')},
'out_qty': {'width': 8, 'style': 'regular', 'sequence': 80, 'title': _('Out Qty')},
'end_qty': {'width': 8, 'style': 'regular', 'sequence': 90, 'title': _('End Qty'), 'formula': True},
'end_standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 100, 'title': _('End Cost Price')},
'end_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 110, 'title': _('End Value'), 'formula': True},
'variation': {'width': 16, 'style': 'regular_currency', 'sequence': 120, 'title': _('Variation'), 'formula': True},
'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 130, 'title': _('Categ Sub-total'), 'formula': True},
'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 140, 'title': _('Product Category')},
}
return cols

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 Akretion France (http://www.akretion.com/)
@author: Alexis de Lattre <alexis.delattre@akretion.com>
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-->
<odoo>
<record id="stock_variation_xlsx_form" model="ir.ui.view">
<field name="name">stock.variation.xlsx.form</field>
<field name="model">stock.variation.xlsx</field>
<field name="arch" type="xml">
<form string="Stock variation XLSX">
<div name="help">
<p>The generated XLSX report has the valuation of stockable products located on the selected stock locations (and their childrens).</p>
</div>
<group name="setup">
<field name="state" invisible="1"/>
<field name="categ_ids" widget="many2many_tags"/>
<field name="warehouse_id"/>
<field name="location_id"/>
<field name="categ_subtotal" />
</group>
<group name="start_end">
<group name="start" string="Start">
<field name="start_date"/>
<field name="standard_price_start_date_type"/>
</group>
<group name="end" string="End">
<field name="end_date_type"/>
<field name="end_date" attrs="{'invisible': [('end_date_type', '!=', 'past')], 'required': [('end_date_type', '=', 'past')]}"/>
<field name="standard_price_end_date_type"/>
</group>
</group>
<group name="done" states="done" string="Result">
<field name="export_file" filename="export_filename"/>
<field name="export_filename" invisible="1"/>
</group>
<footer>
<button name="generate" type="object" states="setup"
class="btn-primary" string="Generate"/>
<button special="cancel" string="Cancel" class="btn-default" states="setup"/>
<button special="cancel" string="Close" class="btn-default" states="done"/>
</footer>
</form>
</field>
</record>
<record id="stock_variation_xlsx_action" model="ir.actions.act_window">
<field name="name">Stock Variation XLSX</field>
<field name="res_model">stock.variation.xlsx</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Replace native menu, to avoid user confusion -->
<menuitem id="stock_variation_xlsx_menu" action="stock_variation_xlsx_action" parent="stock.menu_warehouse_report" sequence="1"/>
</odoo>