Compare commits

..

77 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
clementmbr
3f06231c22 [FIX] Remove hard-coded width on product's title 2020-10-20 21:16:37 -03:00
Alexis de Lattre
67d31f9658 Display advanced fields in stock move form views 2020-10-13 17:22:34 +02:00
Alexis de Lattre
1b931d066b stock_usability: add tracking on is_locked field of pickings 2020-10-13 10:07:36 +02:00
David Beal
f605b56a5e FIX mrp_usability: define sold out in bottom page 2020-10-06 16:28:43 +02:00
David Beal
58f01d9673 FIX mrp_usability: round rupture value 2020-10-02 18:23:46 +02:00
David Beal
8878ab5bd1 IMP mrp_usability: define stock move in rupture 2020-10-02 18:13:59 +02:00
Alexis de Lattre
80f5341da0 [FIX] stock_valuation_xlsx: fix report when categ_subtotal is false 2020-09-25 23:57:56 +02:00
Alexis de Lattre
a4ca584e90 stock_valuation_xlsx: Add ability to force cost price to current
Improve headers in XLSX
Improve code
2020-09-25 22:51:39 +02:00
Alexis de Lattre
4d81dee7b4 stock_valuation_xlsx: Replace the right menu 2020-09-25 16:28:24 +02:00
Alexis de Lattre
140217da6e Port module stock_valuation_xlsx from v10 to v12 2020-09-25 16:16:14 +02:00
Sébastien BEAU
a9a0a2a999 [IMP] add module for hiding unwanted feature 2020-09-17 01:03:53 +02:00
Alexis de Lattre
d7f3a70d48 mrp_average_cost: improve code perf 2020-09-11 15:34:29 +02:00
Alexis de Lattre
1074fcba21 Show property_cost_method on product form view 2020-09-11 14:33:05 +02:00
hparfr
d2b63236e3 Fix stripe installation issue due to wrong account_type on transfert account
template
2020-04-29 23:00:08 +02:00
94 changed files with 3006 additions and 789 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

@@ -1,61 +0,0 @@
======================
Base Partner Reference
======================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-Akretion%2FOdoo--Usability-lightgray.png?logo=github
:target: https://github.com/Akretion/Odoo-Usability/tree/12.0/base_partner_ref
:alt: Akretion/Odoo-Usability
|badge1| |badge2| |badge3|
Improve usage of partner's Internal Reference, this module adds the following functions:
- Adds Internal Reference in partner tree view
- Adds Internal Reference in name_get()
- Adds unicity constraint on Internal Reference
**Table of contents**
.. contents::
:local:
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/Akretion/Odoo-Usability/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/Akretion/Odoo-Usability/issues/new?body=module:%20base_partner_ref%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Akretion
Contributors
~~~~~~~~~~~~
* Alexis de Lattre <alexis.delattre@akretion.com>
* Renato Lima <renato.lima@akretion.com.br>
Maintainers
~~~~~~~~~~~
This module is part of the `Akretion/Odoo-Usability <https://github.com/Akretion/Odoo-Usability/tree/12.0/base_partner_ref>`_ project on GitHub.
You are welcome to contribute.

View File

@@ -1,4 +1 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models
from . import tests
from . import partner

View File

@@ -21,8 +21,6 @@ Base Partner Reference
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['base'],
'data': [
'views/partner.xml',
],
'data': ['partner_view.xml'],
'installable': True,
}

View File

@@ -1,3 +0,0 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import partner

View File

@@ -2,29 +2,25 @@
# @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
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
ref = fields.Char(
copy=False, # To avoid blocking duplicate
)
ref = fields.Char(copy=False) # To avoid blocking duplicate
invalidate_display_name = fields.Boolean()
_sql_constraints = [(
'ref_unique',
'unique(ref)',
_('A partner already exists with this internal reference !')
)]
'A partner already exists with this internal reference!'
)]
# add 'ref' in depends
@api.depends('is_company', 'name', 'parent_id.name', 'type',
'company_name', 'ref', 'invalidate_display_name')
@api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name', 'ref', 'invalidate_display_name')
def _compute_display_name(self):
super()._compute_display_name()
super(ResPartner, self)._compute_display_name()
def _get_name(self):
partner = self
@@ -36,15 +32,12 @@ class ResPartner(models.Model):
# END modif of native method
if partner.company_name or partner.parent_id:
if not name and partner.type in ['invoice', 'delivery', 'other']:
name = dict(self.fields_get(
['type'])['type']['selection'])[partner.type]
name = dict(self.fields_get(['type'])['type']['selection'])[partner.type]
if not partner.is_company:
# START modif of native name_get() method
company_name = (
partner.commercial_company_name or partner.parent_id.name)
company_name = partner.commercial_company_name or partner.parent_id.name
if partner.parent_id.ref:
company_name = u"[%s] %s" % (
partner.parent_id.ref, company_name)
company_name = u"[%s] %s" % (partner.parent_id.ref, company_name)
name = "%s, %s" % (company_name, name)
# END modif of native name_get() method
if self._context.get('show_address_only'):

View File

@@ -1,2 +0,0 @@
* Alexis de Lattre <alexis.delattre@akretion.com>
* Renato Lima <renato.lima@akretion.com.br>

View File

@@ -1,4 +0,0 @@
Improve usage of partner's Internal Reference, this module adds the following functions:
- Adds Internal Reference in partner tree view
- Adds Internal Reference in name_get()
- Adds unicity constraint on Internal Reference

View File

@@ -1,423 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<title>Base Partner Reference</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="base-partner-reference">
<h1 class="title">Base Partner Reference</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/Akretion/Odoo-Usability/tree/12.0/base_partner_ref"><img alt="Akretion/Odoo-Usability" src="https://img.shields.io/badge/github-Akretion%2FOdoo--Usability-lightgray.png?logo=github" /></a></p>
<dl class="docutils">
<dt>Improve usage of partners Internal Reference, this module adds the following functions:</dt>
<dd><ul class="first last simple">
<li>Adds Internal Reference in partner tree view</li>
<li>Adds Internal Reference in name_get()</li>
<li>Adds unicity constraint on Internal Reference</li>
</ul>
</dd>
</dl>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="id1">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id2">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id3">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id4">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id5">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id1">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/Akretion/Odoo-Usability/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/Akretion/Odoo-Usability/issues/new?body=module:%20base_partner_ref%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id2">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id3">Authors</a></h2>
<ul class="simple">
<li>Akretion</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id4">Contributors</a></h2>
<ul class="simple">
<li>Alexis de Lattre &lt;<a class="reference external" href="mailto:alexis.delattre&#64;akretion.com">alexis.delattre&#64;akretion.com</a>&gt;</li>
<li>Renato Lima &lt;<a class="reference external" href="mailto:renato.lima&#64;akretion.com.br">renato.lima&#64;akretion.com.br</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id5">Maintainers</a></h2>
<p>This module is part of the <a class="reference external" href="https://github.com/Akretion/Odoo-Usability/tree/12.0/base_partner_ref">Akretion/Odoo-Usability</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,3 +0,0 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_partner

View File

@@ -1,33 +0,0 @@
# Copyright 2020 Akretion
# @author: Renato Lima <renato.lima@akretion.com.br>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from psycopg2 import IntegrityError
from odoo.tools import mute_logger
from odoo.tests.common import TransactionCase
class TestPartner(TransactionCase):
def setUp(self):
super().setUp()
self.partner_values = {
'name': 'Akretion Test Partner',
'ref': '220',
}
self.partner = self._create_partner(self.partner_values)
def _create_partner(self, values):
return self.env['res.partner'].create(values)
def test_partners_same_ref(self):
"""Check create two partners with same ref"""
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
self._create_partner(self.partner_values)
def test_partner_get_name(self):
"""Check partner name get"""
self.assertEqual(
self.partner._get_name(), '[220] Akretion Test Partner'
)

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

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

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):
@@ -252,9 +266,9 @@ class MrpProduction(models.Model):
for order in self:
if order.product_id.cost_method == 'average':
unit_cost = order.compute_order_unit_cost()
order.unit_cost = unit_cost
order.write({'unit_cost': unit_cost})
logger.info('MO %s: unit_cost=%s', order.name, unit_cost)
for finished_move in order.move_finished_ids.filtered(
lambda x: x.product_id == order.product_id):
finished_move.price_unit = unit_cost
order.move_finished_ids.filtered(
lambda x: x.product_id == order.product_id).write({
'price_unit': unit_cost})
return super(MrpProduction, self).post_inventory()

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

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 12.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-07-16 13:56+0000\n"
"PO-Revision-Date: 2019-07-16 16:01+0200\n"
"POT-Creation-Date: 2020-10-06 13:37+0000\n"
"PO-Revision-Date: 2020-10-06 15:38+0200\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -15,41 +15,52 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: \n"
"Language: fr\n"
"X-Generator: Poedit 2.0.6\n"
"X-Generator: Poedit 2.3\n"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.mrp_production_form_view
msgid "Are you sure you want to cancel this manufacturing order?"
msgstr "Etes vous sur de vouloir annuler cet ordre de production"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.product_template_form_view_bom_button
msgid "Bill of Materials"
msgstr "Nomenclature"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Lot"
msgstr ""
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_product_product
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Product"
msgstr "Article"
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_product_template
msgid "Product Template"
msgstr "Modèle d'article"
#. module: mrp_usability
#: model:ir.model,name:mrp_usability.model_mrp_production
msgid "Production Order"
msgstr "Ordre de production"
msgstr ""
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid "Quantity"
msgstr "Quantité"
msgid "Sold Out Quantity"
msgstr "Quantité en Rupture"
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.report_mrporder
msgid ""
"These products were unavailable (or partially) while edition of this Manufacturing Order.\n"
" Here is complete quantities for these."
" Here is missing quantities."
msgstr ""
"Les produits ci-dessous étaient indisponibles (complètement ou partiellement) lors de l'édition de l'OF.<br/>\n"
"Voici les quantités totales de ceux-ci."
"Voici les quantités manquantes."
#. module: mrp_usability
#: model_terms:ir.ui.view,arch_db:mrp_usability.view_mrp_bom_filter

View File

@@ -1,11 +1,37 @@
# © 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 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
qty_in_lots = sum([x.product_uom_qty for x in lines])
diff = round(move.product_qty - qty_in_lots, 3)
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

@@ -16,27 +16,26 @@
t-value="any(o.move_raw_ids.filtered(lambda x: x.product_uom_qty &gt; x.reserved_availability))"/>
<h4 if="has_product_unavailable">
These products were unavailable (or partially) while edition of this Manufacturing Order.
Here is complete quantities for these.
Here is missing quantities.
</h4>
<table class="table table-sm" t-if="o.move_raw_ids and has_product_unavailable">
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Sold Out Quantity</th>
</tr>
</thead>
<tbody>
<t t-set="lines"
<t t-set="moves"
t-value="o.move_raw_ids.filtered(lambda x: x.product_uom_qty &gt; x.reserved_availability)"/>
<t t-foreach="lines" t-as="ml">
<t t-foreach="moves" t-as="m">
<tr>
<td>
<span t-field="ml.product_id"/>
<span t-field="m.product_id"/>
</td>
<td>
<span t-esc="ml.product_uom_qty" t-if="ml.state !='done'"/>
<span t-esc="ml.qty_done" t-if="ml.state =='done'"/>
<span t-field="ml.product_uom" groups="uom.group_uom"/>
<span t-esc="o.get_stock_move_sold_out_report(m)" t-if="m.state !='done'"/>
<span t-field="m.product_uom" groups="uom.group_uom"/>
</td>
</tr>
</t>

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

@@ -16,11 +16,6 @@
<field name="standard_price" class="oe_inline" position="after">
<button name="show_product_price_history" class="oe_inline oe_link" type="object" string="Show History" context="{'active_id': active_id}"/>
</field>
<!-- Don't make it too big, othesize computers with small resolutions
will see the product name + image under the block of buttons -->
<div class="oe_title" position="attributes">
<attribute name="style">width: 650px;</attribute>
</div>
</field>
</record>

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

View File

@@ -0,0 +1,31 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no configurator button",
"summary": "Hide 'configure a product' from sale line",
"version": "12.0.1.0.0",
"category": "Usabability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale",
],
"data": [
"views/sale_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_order_view_form" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//control/create[@groups='product.group_product_variant']" position="replace"/>
</field>
</record>
</odoo>

View File

View File

@@ -0,0 +1,30 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no optional product",
"summary": "Hide optional product",
"version": "12.0.1.0.0",
"category": "Usability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale_management",
],
"data": [
"views/product_template_view.xml",
"views/sale_order_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_template_view_form" model="ir.ui.view">
<field name="model">product.template</field>
<field name="inherit_id" ref="sale.product_template_form_view"/>
<field name="arch" type="xml">
<group name="options" position="attributes">
<attribute name="invisible"/>
</group>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="sale_order_form_quote" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_management.sale_order_form_quote"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='sale_order_option_ids']/.." position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
</odoo>

View File

View File

@@ -0,0 +1,31 @@
# Copyright 2020 Akretion (https://www.akretion.com).
# @author Sébastien BEAU <sebastien.beau@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale no preview button",
"summary": "Hide 'preview' from sale",
"version": "12.0.1.0.0",
"category": "Usabability",
"website": "www.akretion.com",
"author": " Akretion",
"license": "AGPL-3",
"application": False,
"installable": True,
"external_dependencies": {
"python": [],
"bin": [],
},
"depends": [
"sale",
],
"data": [
"views/sale_view.xml",
],
"demo": [
],
"qweb": [
]
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<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="arch" type="xml">
<button name="preview_sale_order" position="attributes">
<attribute name="invisible">1</attribute>
</button>
</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

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2019 Akretion (http://www.akretion.com)
# Copyright 2019-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).
@@ -18,6 +18,8 @@ The usability enhancements include:
* activate the refund option by default in return wizard on pickings
* show field *property_cost_method* on product form view
* add ability to select a stock location on the inventory valuation report
@@ -26,6 +28,9 @@ This module has been written by Alexis de Lattre from Akretion <alexis.delattre@
'author': 'Akretion',
'website': 'http://www.akretion.com',
'depends': ['stock_account'],
'data': ['wizard/stock_quantity_history_view.xml'],
'data': [
'product_view.xml',
'wizard/stock_quantity_history_view.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,23 @@
<?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>
<record id="view_template_property_form" model="ir.ui.view">
<field name="name">stock_account.product.template.form</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="stock_account.view_template_property_form"/>
<field name="arch" type="xml">
<field name="property_cost_method" position="attributes">
<attribute name="invisible">0</attribute>
</field>
</field>
</record>
</odoo>

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__)
@@ -19,6 +20,7 @@ class StockPicking(models.Model):
move_type = fields.Selection(track_visibility='onchange')
# Can be used in view to hide some fields depending of pick type
picking_type_code = fields.Selection(related='picking_type_id.code')
is_locked = fields.Boolean(track_visibility='onchange')
@api.multi
def do_unreserve(self):
@@ -97,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,
@@ -119,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,
@@ -144,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">
@@ -139,43 +144,35 @@
</field>
</record>
<!-- Display route in stock moves -->
<!--
<!-- Display advanced fields in stock moves form view -->
<record id="view_move_form" model="ir.ui.view">
<field name="name">stock.usability.stock.move.form</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock.view_move_form" />
<field name="arch" type="xml">
<!--
<field name="state" position="before">
<button type="object" name="button_do_unreserve" string="Unreserve"
groups="stock.group_stock_user"
attrs="{'invisible': [('reserved_quant_ids', '=', [])]}"/>
</field>
<field name="picking_id" position="after">
</field> -->
<field name="origin" position="after">
<field name="picking_id" readonly="1" string="Picking"/>
<field name="inventory_id" readonly="1"/>
</field>
<group name="moved_quants_grp" position="after">
<notebook colspan="2">
<page string="Notes" name="notes">
<field name="note" nolabel="1"/>
</page>
<page string="Advanced Parameters" name="advanced-params" groups="stock.group_stock_manager">
<group name="advanced">
<field name="procurement_id"/>
<field name="route_ids" widget="many2many_tags"/>
<field name="rule_id" readonly="1"/>
<field name="push_rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit"
attrs="{'readonly': [('state', '=', 'done')]}"/>
<field name="reserved_quant_ids" readonly="1"/>
</group>
</page>
</notebook>
<group name="origin_grp" position="after">
<group name="advanced" string="Advanced" groups="stock.group_stock_manager">
<field name="warehouse_id" readonly="1"/>
<field name="route_ids" widget="many2many_tags" readonly="1"/>
<field name="rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="restrict_partner_id" readonly="1"/>
</group>
</group>
</field>
</record>
-->
<record id="view_move_picking_form" model="ir.ui.view">
<field name="name">stock.usability.stock.move.picking.form</field>
@@ -187,32 +184,20 @@
groups="stock.group_stock_user"
states="partially_available,assigned"/>
</field>
<!--
<field name="group_id" position="replace"/>
<group name="moved_quants_grp" position="after">
<notebook colspan="2">
<page string="Notes" name="notes">
<field name="note" nolabel="1"/>
</page>
<page string="Advanced Parameters" name="advanced-params" groups="stock.group_stock_manager">
<group name="advanced">
<field name="partner_id"/>
<field name="procurement_id"/>
<field name="group_id"/>
<field name="route_ids" widget="many2many_tags"/>
<field name="rule_id" readonly="1"/>
<field name="push_rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="reserved_quant_ids" readonly="1"/>
</group>
</page>
</notebook>
<group name="quants_grp" position="after">
<group string="Advanced" name="advanced" groups="stock.group_stock_manager">
<field name="origin" readonly="1"/>
<field name="warehouse_id" readonly="1"/>
<field name="group_id" readonly="1"/>
<field name="route_ids" widget="many2many_tags" readonly="1"/>
<field name="rule_id" readonly="1"/>
<field name="propagate" readonly="1"/>
<field name="price_unit" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="restrict_partner_id" readonly="1"/>
</group>
</group>
-->
<field name="move_dest_ids" position="before">
<field name="rule_id" readonly="1"/>
</field>
</field>
</record>
@@ -282,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">
@@ -370,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>
@@ -381,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>
@@ -389,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>
@@ -400,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>
@@ -434,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

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

View File

@@ -0,0 +1,47 @@
# 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).
{
'name': 'Stock Valuation XLSX',
'version': '12.0.1.0.1',
'category': 'Tools',
'license': 'AGPL-3',
'summary': 'Generate XLSX reports for past or present stock levels',
'description': """
Stock Valuation XLSX
====================
This module generate nice XLSX stock valuation reports either:
* from a physical inventory,
* from present stock levels (i.e. from quants),
* from past stock levels.
It has several options:
* filter per product category,
* split by lots,
* split by stock location,
* display subtotals per category.
You can access this XLSX stock valuation report either:
* from the menu *Inventory > Reports > Stock Valuation XLSX* (it replaces the native menu *Inventory at Date*)
* from the form view of *validated* inventories (menu *Inventory > Inventory Control > Inventory Adjustments*) via the button *XLSX Valuation Report*.
This module has been written by Alexis de Lattre from Akretion <alexis.delattre@akretion.com>.
""",
'author': "Akretion",
'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

@@ -0,0 +1,25 @@
<?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>
<record id="view_inventory_form" model="ir.ui.view">
<field name="name">xlsx.stock.inventory.form</field>
<field name="model">stock.inventory</field>
<field name="inherit_id" ref="stock.view_inventory_form"/>
<field name="arch" type="xml">
<button name="action_validate" position="after">
<button name="%(stock_valuation_xlsx_action)d" type="action"
states="done" string="XLSX Valuation Report"
context="{'default_source': 'inventory', 'default_inventory_id': active_id, 'default_location_id': location_id}"/>
</button>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,629 @@
# Copyright 2020 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 dateutil.relativedelta import relativedelta
from odoo.tools import float_is_zero, float_round
from io import BytesIO
from datetime import datetime
import xlsxwriter
import logging
import base64
logger = logging.getLogger(__name__)
class StockValuationXlsx(models.TransientModel):
_name = 'stock.valuation.xlsx'
_description = 'Generate XLSX report for stock valuation'
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
# a lot of confusion for users
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 "
u"be taken in the valuation.")
categ_ids = fields.Many2many(
'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'),
], string='Source data', default='stock', required=True,
states={'done': [('readonly', True)]})
inventory_id = fields.Many2one(
'stock.inventory', string='Inventory', domain=[('state', '=', 'done')],
states={'done': [('readonly', True)]})
stock_date_type = fields.Selection([
('present', 'Present'),
('past', 'Past'),
], string='Present or Past', default='present',
states={'done': [('readonly', True)]})
past_date = fields.Datetime(
string='Past Date', states={'done': [('readonly', True)]},
default=fields.Datetime.now)
categ_subtotal = fields.Boolean(
string='Subtotals per Categories', default=True,
states={'done': [('readonly', True)]},
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')
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()
if (
self.source == 'stock' and
self.stock_date_type == 'past' and
self.past_date > fields.Datetime.now()):
raise UserError(_("The 'Past Date' must be in the past !"))
if self.source == 'inventory':
if not self.inventory_id:
raise UserError(_("You must select an inventory."))
elif self.inventory_id.state != 'done':
raise UserError(_(
"The selected inventory (%s) is not in done state.")
% self.inventory_id.display_name)
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()
products = self.env['product.product'].search(domain)
return products.ids
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'].with_context(force_company=company_id)
ppho = self.env['product.price.history']
fields_list = self._prepare_product_fields()
if not standard_price_past_date:
fields_list.append('standard_price')
products = ppo.search_read([('id', 'in', in_stock_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_past_date:
history = ppho.search_read([
('company_id', '=', company_id),
('product_id', '=', p['id']),
('datetime', '<=', standard_price_past_date)],
['cost'], order='datetime desc, id desc', limit=1)
standard_price = history and history[0]['cost'] or 0.0
else:
standard_price = p['standard_price']
product_id2data[p['id']] = {'standard_price': 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
@api.model
def product_categ_id2name(self, categories):
pco = self.env['product.category']
categ_id2name = {}
categ_domain = []
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 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']
return loc_id2name
def compute_data_from_inventory(self, product_ids, prec_qty):
self.ensure_one()
logger.debug('Start compute_data_from_inventory')
# Can he modify UoM ?
inv_lines = self.env['stock.inventory.line'].search_read([
('inventory_id', '=', self.inventory_id.id),
('location_id', 'child_of', self.location_id.id),
('product_id', 'in', product_ids),
('product_qty', '>', 0),
], ['product_id', 'location_id', 'prod_lot_id', 'product_qty'])
res = []
in_stock_products = {}
for l in inv_lines:
if not float_is_zero(l['product_qty'], precision_digits=prec_qty):
res.append({
'product_id': l['product_id'][0],
'lot_id': l['prod_lot_id'] and l['prod_lot_id'][0] or False,
'qty': l['product_qty'],
'location_id': l['location_id'][0],
})
in_stock_products[l['product_id'][0]] = True
logger.debug('End compute_data_from_inventory')
return res, in_stock_products
def compute_data_from_present_stock(self, company_id, product_ids, prec_qty):
self.ensure_one()
logger.debug('Start compute_data_from_present_stock')
quants = self.env['stock.quant'].search_read([
('product_id', 'in', product_ids),
('location_id', 'child_of', self.location_id.id),
('company_id', '=', company_id),
], ['product_id', 'lot_id', 'location_id', 'quantity'])
res = []
in_stock_products = {}
for quant in quants:
if not float_is_zero(quant['quantity'], precision_digits=prec_qty):
res.append({
'product_id': quant['product_id'][0],
'lot_id': quant['lot_id'] and quant['lot_id'][0] or False,
'location_id': quant['location_id'][0],
'qty': quant['quantity'],
})
in_stock_products[quant['product_id'][0]] = True
logger.debug('End compute_data_from_present_stock')
return res, in_stock_products
def compute_data_from_past_stock(self, product_ids, prec_qty, past_date):
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=self.location_id.id).browse(product_ids)
res = []
in_stock_products = {}
for product in products:
qty = product.qty_available
if not float_is_zero(qty, precision_digits=prec_qty):
res.append({
'product_id': product.id,
'qty': qty,
'lot_id': False,
'location_id': False,
})
in_stock_products[product.id] = True
logger.debug('End compute_data_from_past_stock')
return res, in_stock_products
def group_result(self, data, split_by_lot, split_by_location):
logger.debug(
'Start group_result split_by_lot=%s, split_by_location=%s',
split_by_lot, split_by_location)
wdict = {}
for l in data:
key_list = [l['product_id']]
if split_by_lot:
key_list.append(l['lot_id'])
if split_by_location:
key_list.append(l['location_id'])
key = tuple(key_list)
wdict.setdefault(key, dict(product_id=l['product_id'], lot_id=l['lot_id'], location_id=l['location_id'], qty=0.0))
wdict[key]['qty'] += l['qty']
logger.debug('End group_result')
return wdict.values()
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, apply_depreciation):
logger.debug('Start stringify_and_sort_result')
res = []
for l in data:
product_id = l['product_id']
qty = float_round(l['qty'], precision_digits=prec_qty)
standard_price = float_round(
product_id2data[product_id]['standard_price'],
precision_digits=prec_price)
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']],
))
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 valuation report')
splo = self.env['stock.production.lot'].with_context(active_test=False)
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)
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."))
split_by_lot = self.split_by_lot
split_by_location = self.split_by_location
if self.source == 'stock':
if self.stock_date_type == 'present':
past_date = False
data, in_stock_products = self.compute_data_from_present_stock(
company_id, product_ids, prec_qty)
elif self.stock_date_type == 'past':
split_by_lot = False
split_by_location = False
past_date = self.past_date
data, in_stock_products = self.compute_data_from_past_stock(
product_ids, prec_qty, past_date)
elif self.source == 'inventory':
past_date = self.inventory_id.date
data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty)
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 = 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, apply_depreciation)
logger.debug('Start create XLSX workbook')
file_data = BytesIO()
workbook = xlsxwriter.Workbook(file_data)
sheet = workbook.add_worksheet('Stock')
styles = self._prepare_styles(workbook, company, prec_price)
cols = self._prepare_cols()
categ_subtotal = self.categ_subtotal
# remove cols that we won't use
if not split_by_lot:
cols.pop('lot_name', None)
cols.pop('expiry_date', None)
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']):
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)
if past_date:
stock_time_utc_dt = past_date
stock_time_dt = fields.Datetime.context_timestamp(self, stock_time_utc_dt)
stock_time_str = fields.Datetime.to_string(stock_time_dt)
else:
stock_time_str = now_str
if standard_price_past_date:
standard_price_date_str = stock_time_str
else:
standard_price_date_str = now_str
i = 0
sheet.write(i, 0, 'Odoo - Stock Valuation', styles['doc_title'])
sheet.set_row(0, 26)
i += 1
sheet.write(i, 0, 'Inventory Date: %s' % stock_time_str, styles['doc_subtitle'])
i += 1
sheet.write(i, 0, 'Cost Price Date: %s' % standard_price_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, cols['subtotal']['pos'] - 1, _("TOTAL:"), styles['total_title'])
total_row = i
# LINES
if categ_subtotal:
categ_ids = categ_id2name.keys()
else:
categ_ids = [0]
total = 0.0
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:
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
total += l['subtotal']
ctotal += l['subtotal']
categ_has_line = True
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':
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:
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_subtotal, crow + 2, letter_subtotal, 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
total_formula = '=SUM(%s%d:%s%d)' % (letter_subtotal, total_row + 2, letter_subtotal, i + 1)
sheet.write_formula(total_row, cols['subtotal']['pos'], total_formula, styles['total_currency'], float_round(total, precision_rounding=prec_cur_rounding))
workbook.close()
logger.debug('End create XLSX workbook')
file_data.seek(0)
filename = 'Odoo_stock_%s.xlsx' % stock_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 = {
# 'name': _('Stock Valuation XLSX'),
# 'type': 'ir.actions.act_url',
# 'url': "web/content/?model=%s&id=%d&filename_field=export_filename&"
# "field=export_file&download=true&filename=%s" % (
# self._name, self.id, self.export_filename),
# 'target': 'self',
# }
action = self.env['ir.actions.act_window'].for_xml_id(
'stock_valuation_xlsx', 'stock_valuation_xlsx_action')
action['res_id'] = self.id
return action
def _prepare_styles(self, workbook, company, prec_price):
total_bg_color = '#faa03a'
categ_bg_color = '#e1daf5'
col_title_bg_color = '#fff9b4'
regular_font_size = 10
currency_num_format = u'# ### ##0.00 %s' % company.currency_id.symbol
price_currency_num_format = u'# ### ##0.%s %s' % ('0' * prec_price, company.currency_id.symbol)
styles = {
'doc_title': workbook.add_format({
'bold': True, 'font_size': regular_font_size + 10,
'font_color': '#003b6f'}),
'doc_subtitle': workbook.add_format({
'bold': True, 'font_size': regular_font_size}),
'col_title': workbook.add_format({
'bold': True, 'bg_color': col_title_bg_color,
'text_wrap': True, 'font_size': regular_font_size,
'align': 'center',
}),
'total_title': workbook.add_format({
'bold': True, 'text_wrap': True, 'font_size': regular_font_size + 2,
'align': 'right', 'bg_color': total_bg_color}),
'total_currency': workbook.add_format({
'num_format': currency_num_format, 'bg_color': total_bg_color}),
'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({
'bold': True, 'bg_color': categ_bg_color,
'font_size': regular_font_size}),
'categ_currency': workbook.add_format({
'num_format': currency_num_format, 'bg_color': categ_bg_color}),
'date_title': workbook.add_format({
'bold': True, 'font_size': regular_font_size, 'align': 'right'}),
'date_title_val': workbook.add_format({
'bold': True, 'font_size': regular_font_size}),
}
return styles
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')},
'loc_name': {'width': 25, 'style': 'regular_small', 'sequence': 30, 'title': _('Location Name')},
'lot_name': {'width': 18, 'style': 'regular', 'sequence': 40, 'title': _('Lot')},
'expiry_date': {'width': 11, 'style': 'regular_date', 'sequence': 50, 'title': _('Expiry Date'), 'type': 'date'},
'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_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

@@ -0,0 +1,63 @@
<?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>
<record id="stock_valuation_xlsx_form" model="ir.ui.view">
<field name="name">stock.valuation.xlsx.form</field>
<field name="model">stock.valuation.xlsx</field>
<field name="arch" type="xml">
<form string="Stock valuation 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="source" widget="radio"/>
<field name="inventory_id" attrs="{'invisible': [('source', '!=', 'inventory')], 'required': [('source', '=', 'inventory')]}"/>
<field name="stock_date_type" attrs="{'invisible': [('source', '!=', 'stock')], 'required': [('source', '=', 'stock')]}" widget="radio"/>
<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"/>
<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_valuation_xlsx_action" model="ir.actions.act_window">
<field name="name">Stock Valuation XLSX</field>
<field name="res_model">stock.valuation.xlsx</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Replace native menu, to avoid user confusion -->
<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>