diff --git a/account_usability/account.py b/account_usability/account.py index ebd6186f..65b4c8c6 100644 --- a/account_usability/account.py +++ b/account_usability/account.py @@ -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 @@ -407,6 +407,58 @@ def _compute_default_credit_debit(self): 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' @@ -672,6 +724,16 @@ def name_get(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 AccountReconciliation(models.AbstractModel): _inherit = 'account.reconciliation.widget' @@ -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 diff --git a/account_usability/readme/DESCRIPTION.rst b/account_usability/readme/DESCRIPTION.rst index 36bac9f4..4a992970 100644 --- a/account_usability/readme/DESCRIPTION.rst +++ b/account_usability/readme/DESCRIPTION.rst @@ -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: diff --git a/base_dynamic_list/__init__.py b/base_dynamic_list/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/base_dynamic_list/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_dynamic_list/__manifest__.py b/base_dynamic_list/__manifest__.py new file mode 100644 index 00000000..4c664e95 --- /dev/null +++ b/base_dynamic_list/__manifest__.py @@ -0,0 +1,62 @@ +# Copyright 2020-2022 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# 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*: + + + Risk Type + dynamic.list + tree,form + [('domain', '=', 'risk.type')] + {'default_domain': 'risk.type'} + + + + +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, +} diff --git a/base_dynamic_list/models/__init__.py b/base_dynamic_list/models/__init__.py new file mode 100644 index 00000000..ab91119e --- /dev/null +++ b/base_dynamic_list/models/__init__.py @@ -0,0 +1 @@ +from . import dynamic_list diff --git a/base_dynamic_list/models/dynamic_list.py b/base_dynamic_list/models/dynamic_list.py new file mode 100644 index 00000000..4146cfb8 --- /dev/null +++ b/base_dynamic_list/models/dynamic_list.py @@ -0,0 +1,115 @@ +# Copyright 2020-2022 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# 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) diff --git a/base_dynamic_list/security/ir.model.access.csv b/base_dynamic_list/security/ir.model.access.csv new file mode 100644 index 00000000..b7a626af --- /dev/null +++ b/base_dynamic_list/security/ir.model.access.csv @@ -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 diff --git a/base_dynamic_list/views/dynamic_list.xml b/base_dynamic_list/views/dynamic_list.xml new file mode 100644 index 00000000..28b2ca19 --- /dev/null +++ b/base_dynamic_list/views/dynamic_list.xml @@ -0,0 +1,240 @@ + + + + + + + + + + dynamic.list + +
+ +
+ +
+ + + + +
+
+
+
+ + + dynamic.list + + + + + + + + + + + dynamic.list + + + + + + + + + + + + + + Simple List + dynamic.list + tree,form + {'dynamic_list_main_view': True, 'search_default_domain_groupby': True} + + + + + + dynamic.list.translate + +
+ +
+ +
+ + + + +
+
+
+
+ + + dynamic.list.translate + + + + + + + + + + + dynamic.list.translate + + + + + + + + + + + + + + Translatable Simple List + dynamic.list.translate + tree,form + {'dynamic_list_translate_main_view': True, 'search_default_domain_groupby': True} + + + + + + dynamic.list.code + +
+ +
+ +
+ + + + + +
+
+
+
+ + + dynamic.list.code + + + + + + + + + + + + dynamic.list.code + + + + + + + + + + + + + + + Code List + dynamic.list.code + tree,form + {'dynamic_list_code_main_view': True, 'search_default_domain_groupby': True} + + + + + + dynamic.list.code.translate + +
+ +
+ +
+ + + + + +
+
+
+
+ + + dynamic.list.code.translate + + + + + + + + + + + + dynamic.list.code.translate + + + + + + + + + + + + + + + Translatable Code List + dynamic.list.code.translate + tree,form + {'dynamic_list_code_translate_main_view': True, 'search_default_domain_groupby': True} + + + + + +
diff --git a/base_usability/models/users.py b/base_usability/models/users.py index ffebe32d..22f63e17 100644 --- a/base_usability/models/users.py +++ b/base_usability/models/users.py @@ -23,12 +23,9 @@ def default_get(self, fields_list): @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 @@ def _script_partners_linked_to_users_no_company(self): user.login, user.id) logger.info( 'END setting company_id=False on partners related to users') - return True diff --git a/developer_menu/menu_view.xml b/developer_menu/menu_view.xml index 7f47679c..6cfddb2d 100644 --- a/developer_menu/menu_view.xml +++ b/developer_menu/menu_view.xml @@ -2,7 +2,7 @@ - + diff --git a/mail_usability/README.rst b/mail_usability/README.rst new file mode 100644 index 00000000..ebac02d7 --- /dev/null +++ b/mail_usability/README.rst @@ -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 diff --git a/mail_usability/__init__.py b/mail_usability/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/mail_usability/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/mail_usability/__manifest__.py b/mail_usability/__manifest__.py new file mode 100644 index 00000000..ca3c576a --- /dev/null +++ b/mail_usability/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2020 Akretion France (http://www.akretion.com) +# @author Benoît Guillot +# @author Alexis de Lattre +# 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, +} diff --git a/mail_usability/data/mail_data.xml b/mail_usability/data/mail_data.xml new file mode 100644 index 00000000..0b510e43 --- /dev/null +++ b/mail_usability/data/mail_data.xml @@ -0,0 +1,18 @@ + + + + + + Notification Email + ${object.subject} + + + ${object.body | safe} + + + + + + diff --git a/mail_usability/models/__init__.py b/mail_usability/models/__init__.py new file mode 100644 index 00000000..030741b3 --- /dev/null +++ b/mail_usability/models/__init__.py @@ -0,0 +1,5 @@ +from . import mail +from . import tools +from . import mail_template +from . import mail_message +from . import res_partner diff --git a/mail_usability/models/mail.py b/mail_usability/models/mail.py new file mode 100644 index 00000000..d8a92c78 --- /dev/null +++ b/mail_usability/models/mail.py @@ -0,0 +1,36 @@ +# Copyright 2016-2017 Akretion France (http://www.akretion.com) +# @author: Alexis de Lattre +# 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) diff --git a/mail_usability/models/mail_message.py b/mail_usability/models/mail_message.py new file mode 100644 index 00000000..8c97754f --- /dev/null +++ b/mail_usability/models/mail_message.py @@ -0,0 +1,19 @@ +# Copyright 2019 Akretion France (http://www.akretion.com) +# @author Sébastien BEAU +# 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 diff --git a/mail_usability/models/mail_template.py b/mail_usability/models/mail_template.py new file mode 100644 index 00000000..1cfdb0ce --- /dev/null +++ b/mail_usability/models/mail_template.py @@ -0,0 +1,11 @@ +# Copyright 2018 Akretion France (http://www.akretion.com) +# @author Sébastien BEAU +# 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) diff --git a/mail_usability/models/res_partner.py b/mail_usability/models/res_partner.py new file mode 100644 index 00000000..994df153 --- /dev/null +++ b/mail_usability/models/res_partner.py @@ -0,0 +1,27 @@ +# Copyright 2016-2019 Akretion France (http://www.akretion.com) +# @author Sébastien BEAU +# @author: Alexis de Lattre +# 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) diff --git a/mail_usability/models/tools.py b/mail_usability/models/tools.py new file mode 100644 index 00000000..4c52393c --- /dev/null +++ b/mail_usability/models/tools.py @@ -0,0 +1,43 @@ +# Copyright 2018 Akretion France (http://www.akretion.com) +# @author Sébastien BEAU +# 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 diff --git a/mail_usability/static/description/icon.png b/mail_usability/static/description/icon.png new file mode 100644 index 00000000..c91da798 Binary files /dev/null and b/mail_usability/static/description/icon.png differ diff --git a/mail_usability/views/mail_view.xml b/mail_usability/views/mail_view.xml new file mode 100644 index 00000000..15161532 --- /dev/null +++ b/mail_usability/views/mail_view.xml @@ -0,0 +1,16 @@ + + + + + mail.mail + + + + + + + + + + + diff --git a/mail_usability/wizard/__init__.py b/mail_usability/wizard/__init__.py new file mode 100644 index 00000000..134743a9 --- /dev/null +++ b/mail_usability/wizard/__init__.py @@ -0,0 +1 @@ +from . import email_template_preview diff --git a/mail_usability/wizard/email_template_preview.py b/mail_usability/wizard/email_template_preview.py new file mode 100644 index 00000000..ae26a042 --- /dev/null +++ b/mail_usability/wizard/email_template_preview.py @@ -0,0 +1,43 @@ +# Copyright 2019 Akretion France (http://www.akretion.com) +# @author Sébastien BEAU +# 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) diff --git a/mail_usability/wizard/email_template_preview_view.xml b/mail_usability/wizard/email_template_preview_view.xml new file mode 100644 index 00000000..1d64c9e6 --- /dev/null +++ b/mail_usability/wizard/email_template_preview_view.xml @@ -0,0 +1,24 @@ + + + + + email_template.preview + + + + True + + + + +
+
+
+
+ +
diff --git a/mrp_average_cost/models/mrp.py b/mrp_average_cost/models/mrp.py index 8648c0f2..e81d633c 100644 --- a/mrp_average_cost/models/mrp.py +++ b/mrp_average_cost/models/mrp.py @@ -75,7 +75,7 @@ def _compute_total_cost(self): 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'), diff --git a/mrp_usability/models/mrp.py b/mrp_usability/models/mrp.py index 935e2d88..6de4202c 100644 --- a/mrp_usability/models/mrp.py +++ b/mrp_usability/models/mrp.py @@ -1,15 +1,18 @@ -# © 2015-2016 Akretion (http://www.akretion.com) +# Copyright 2015-2021 Akretion (http://www.akretion.com) # @author Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, models +from odoo import api, fields, models class MrpProduction(models.Model): _inherit = 'mrp.production' _order = 'id desc' + date_planned_start = fields.Datetime(track_visibility='onchange') + date_planned_finished = fields.Datetime(track_visibility='onchange') + @api.model def get_stock_move_sold_out_report(self, move): lines = move.active_move_line_ids @@ -18,3 +21,17 @@ def get_stock_move_sold_out_report(self, move): 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') diff --git a/mrp_usability/views/mrp_views.xml b/mrp_usability/views/mrp_views.xml index ba5c5c1e..36551ad3 100644 --- a/mrp_usability/views/mrp_views.xml +++ b/mrp_usability/views/mrp_views.xml @@ -26,6 +26,26 @@ + + + + + + + + + + stock.move + + + + + + + 0 + 0 + stock.group_stock_multi_locations + @@ -40,6 +60,17 @@ + + stock.move.line + + + + + + + + + mrp.bom diff --git a/purchase_usability/purchase.py b/purchase_usability/purchase.py index ac42171d..51bfe422 100644 --- a/purchase_usability/purchase.py +++ b/purchase_usability/purchase.py @@ -3,8 +3,9 @@ # @author Alexis de Lattre # 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 @@ def name_get(self): 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 diff --git a/purchase_usability/stock_view.xml b/purchase_usability/stock_view.xml deleted file mode 100644 index a6d6c201..00000000 --- a/purchase_usability/stock_view.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - purchase_usability.stock.picking.form - stock.picking - - - - - - - - - - - diff --git a/sale_down_payment/models/account_move_line.py b/sale_down_payment/models/account_move_line.py index 601f3efe..347a12d1 100644 --- a/sale_down_payment/models/account_move_line.py +++ b/sale_down_payment/models/account_move_line.py @@ -30,3 +30,8 @@ def sale_id_check(self): 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() + diff --git a/sale_down_payment/models/account_payment.py b/sale_down_payment/models/account_payment.py index a10af15a..0eaaf49f 100644 --- a/sale_down_payment/models/account_payment.py +++ b/sale_down_payment/models/account_payment.py @@ -25,6 +25,14 @@ def _get_counterpart_move_line_vals(self, invoice=False): 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 @@ def default_get(self, fields_list): 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 diff --git a/sale_down_payment/wizard/account_bank_statement_sale.py b/sale_down_payment/wizard/account_bank_statement_sale.py index 8d86a85c..5288dd7e 100644 --- a/sale_down_payment/wizard/account_bank_statement_sale.py +++ b/sale_down_payment/wizard/account_bank_statement_sale.py @@ -64,7 +64,9 @@ def validate(self): 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): diff --git a/sale_mrp_usability/__init__.py b/sale_mrp_usability/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/sale_mrp_usability/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_mrp_usability/__manifest__.py b/sale_mrp_usability/__manifest__.py new file mode 100644 index 00000000..ad587760 --- /dev/null +++ b/sale_mrp_usability/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# 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, +} diff --git a/sale_mrp_usability/models/__init__.py b/sale_mrp_usability/models/__init__.py new file mode 100644 index 00000000..c0df513c --- /dev/null +++ b/sale_mrp_usability/models/__init__.py @@ -0,0 +1,2 @@ +from . import sale +from . import mrp_production diff --git a/sale_mrp_usability/models/mrp_production.py b/sale_mrp_usability/models/mrp_production.py new file mode 100644 index 00000000..68a919c4 --- /dev/null +++ b/sale_mrp_usability/models/mrp_production.py @@ -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 diff --git a/sale_mrp_usability/models/sale.py b/sale_mrp_usability/models/sale.py new file mode 100644 index 00000000..92601e17 --- /dev/null +++ b/sale_mrp_usability/models/sale.py @@ -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 diff --git a/sale_mrp_usability/views/mrp_production.xml b/sale_mrp_usability/views/mrp_production.xml new file mode 100644 index 00000000..fe861b29 --- /dev/null +++ b/sale_mrp_usability/views/mrp_production.xml @@ -0,0 +1,22 @@ + + + + + + mrp.production + + + + + + + + + diff --git a/sale_mrp_usability/views/sale_order.xml b/sale_mrp_usability/views/sale_order.xml new file mode 100644 index 00000000..03824eed --- /dev/null +++ b/sale_mrp_usability/views/sale_order.xml @@ -0,0 +1,23 @@ + + + + + + sale.order + + + + + + + + + diff --git a/sale_stock_usability/i18n/fr.po b/sale_stock_usability/i18n/fr.po deleted file mode 100644 index 34e2f73b..00000000 --- a/sale_stock_usability/i18n/fr.po +++ /dev/null @@ -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" diff --git a/sale_usability/sale.py b/sale_usability/sale.py index f94f8f2c..668d088e 100644 --- a/sale_usability/sale.py +++ b/sale_usability/sale.py @@ -2,8 +2,9 @@ # @author Alexis de Lattre # 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 @@ def py3o_lines_layout(self): # {'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 diff --git a/sale_usability/sale_view.xml b/sale_usability/sale_view.xml index 2b4bcb45..a310effe 100644 --- a/sale_usability/sale_view.xml +++ b/sale_usability/sale_view.xml @@ -26,6 +26,9 @@ + diff --git a/stock_usability/procurement.py b/stock_usability/procurement.py index f0974377..f0adf90a 100644 --- a/stock_usability/procurement.py +++ b/stock_usability/procurement.py @@ -12,6 +12,9 @@ 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): diff --git a/stock_usability/stock.py b/stock_usability/stock.py index c71c38f6..fdf858ca 100644 --- a/stock_usability/stock.py +++ b/stock_usability/stock.py @@ -3,6 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import models, fields, api, _ +from odoo.exceptions import UserError import logging logger = logging.getLogger(__name__) @@ -98,7 +99,7 @@ def button_do_unreserve(self): picking = move.picking_id if picking: product = move.product_id - picking.message_post(_( + picking.message_post(body=_( "Product %s qty %s %s unreserved") % (product.id, product.display_name, @@ -120,7 +121,7 @@ def button_do_unreserve(self): picking = moveline.move_id.picking_id if picking: product = moveline.product_id - picking.message_post(_( + picking.message_post(body=_( "Product %s qty %s %s unreserved") % (product.id, product.display_name, @@ -145,3 +146,16 @@ def action_stock_move_lines_reserved(self): action = self.action_view_stock_moves() action['context'] = {'search_default_todo': True} 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)]}) diff --git a/stock_usability/stock_view.xml b/stock_usability/stock_view.xml index 14430561..8e52d9cd 100644 --- a/stock_usability/stock_view.xml +++ b/stock_usability/stock_view.xml @@ -23,6 +23,11 @@ + + + + @@ -350,7 +355,9 @@ should be able to access it. So I add a menu entry under Inventory Control. --> - + @@ -361,6 +368,7 @@ should be able to access it. So I add a menu entry under Inventory Control. --> + state == 'draft' @@ -369,6 +377,16 @@ should be able to access it. So I add a menu entry under Inventory Control. --> + + stock.inventory.line + + + + {'readonly': ['|', ('product_tracking', '=', 'none'), ('state', '=', 'done')]} + + + + stock.usability.quant.tree stock.quant diff --git a/stock_valuation_xlsx/__init__.py b/stock_valuation_xlsx/__init__.py index 40272379..9b429614 100644 --- a/stock_valuation_xlsx/__init__.py +++ b/stock_valuation_xlsx/__init__.py @@ -1 +1,2 @@ +from . import models from . import wizard diff --git a/stock_valuation_xlsx/__manifest__.py b/stock_valuation_xlsx/__manifest__.py index 65ba4352..721c46bc 100644 --- a/stock_valuation_xlsx/__manifest__.py +++ b/stock_valuation_xlsx/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Stock Valuation XLSX', - 'version': '12.0.1.0.0', + 'version': '12.0.1.0.1', 'category': 'Tools', 'license': 'AGPL-3', 'summary': 'Generate XLSX reports for past or present stock levels', @@ -37,8 +37,11 @@ '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, } diff --git a/stock_valuation_xlsx/models/__init__.py b/stock_valuation_xlsx/models/__init__.py new file mode 100644 index 00000000..eb4d7693 --- /dev/null +++ b/stock_valuation_xlsx/models/__init__.py @@ -0,0 +1 @@ +from . import stock_expiry_depreciation_rule diff --git a/stock_valuation_xlsx/models/stock_expiry_depreciation_rule.py b/stock_valuation_xlsx/models/stock_expiry_depreciation_rule.py new file mode 100644 index 00000000..e4389142 --- /dev/null +++ b/stock_valuation_xlsx/models/stock_expiry_depreciation_rule.py @@ -0,0 +1,35 @@ +# Copyright 2021 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# 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.' + )] diff --git a/stock_valuation_xlsx/security/ir.model.access.csv b/stock_valuation_xlsx/security/ir.model.access.csv new file mode 100644 index 00000000..fe37b4d9 --- /dev/null +++ b/stock_valuation_xlsx/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_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 diff --git a/stock_valuation_xlsx/views/stock_expiry_depreciation_rule.xml b/stock_valuation_xlsx/views/stock_expiry_depreciation_rule.xml new file mode 100644 index 00000000..6d0c784c --- /dev/null +++ b/stock_valuation_xlsx/views/stock_expiry_depreciation_rule.xml @@ -0,0 +1,35 @@ + + + + + + + + stock.expiry.depreciation.rule + + + + + + + + + + + + Stock Depreciation Rules + stock.expiry.depreciation.rule + tree + + + + + + diff --git a/stock_valuation_xlsx/wizard/__init__.py b/stock_valuation_xlsx/wizard/__init__.py index 768a578f..36fc9606 100644 --- a/stock_valuation_xlsx/wizard/__init__.py +++ b/stock_valuation_xlsx/wizard/__init__.py @@ -1 +1,2 @@ from . import stock_valuation_xlsx +from . import stock_variation_xlsx diff --git a/stock_valuation_xlsx/wizard/stock_valuation_xlsx.py b/stock_valuation_xlsx/wizard/stock_valuation_xlsx.py index 99c933b6..d0ed12c7 100644 --- a/stock_valuation_xlsx/wizard/stock_valuation_xlsx.py +++ b/stock_valuation_xlsx/wizard/stock_valuation_xlsx.py @@ -4,6 +4,7 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError +from dateutil.relativedelta import relativedelta from odoo.tools import float_is_zero, float_round from io import BytesIO from datetime import datetime @@ -18,7 +19,7 @@ class StockValuationXlsx(models.TransientModel): _name = 'stock.valuation.xlsx' _description = 'Generate XLSX report for stock valuation' - export_file = fields.Binary(string='XLSX Report', readonly=True) + export_file = fields.Binary(string='XLSX Report', readonly=True, attachment=True) export_filename = fields.Char(readonly=True) # I don't use ir.actions.url on v12, because it renders # the wizard unusable after the first report generation, which creates @@ -38,8 +39,10 @@ class StockValuationXlsx(models.TransientModel): help="The childen locations of the selected locations will " u"be taken in the valuation.") categ_ids = fields.Many2many( - 'product.category', string='Product Categories', - states={'done': [('readonly', True)]}) + 'product.category', string='Product Category Filter', + help="Leave this field empty to have a stock valuation for all your products.", + states={'done': [('readonly', True)]}, + ) source = fields.Selection([ ('inventory', 'Physical Inventory'), ('stock', 'Stock Levels'), @@ -59,17 +62,33 @@ class StockValuationXlsx(models.TransientModel): categ_subtotal = fields.Boolean( string='Subtotals per Categories', default=True, states={'done': [('readonly', True)]}, - help="Show a subtotal per product category") + help="Show a subtotal per product category.") standard_price_date = fields.Selection([ ('past', 'Past Date or Inventory Date'), ('present', 'Current'), ], default='past', string='Cost Price Date', states={'done': [('readonly', True)]}) + # I can't put a compute field for has_expiry_date + # because I want to have the value when the wizard is started, + # and not wait until run + has_expiry_date = fields.Boolean( + default=lambda self: self._default_has_expiry_date(), readonly=True) + apply_depreciation = fields.Boolean( + string='Apply Depreciation Rules', default=True, + states={'done': [('readonly', True)]}) split_by_lot = fields.Boolean( string='Display Lots', states={'done': [('readonly', True)]}) split_by_location = fields.Boolean( string='Display Stock Locations', states={'done': [('readonly', True)]}) + @api.model + def _default_has_expiry_date(self): + splo = self.env['stock.production.lot'] + has_expiry_date = False + if hasattr(splo, 'expiry_date'): + has_expiry_date = True + return has_expiry_date + @api.model def _default_location(self): wh = self.env.ref('stock.warehouse0') @@ -123,11 +142,22 @@ def get_product_ids(self): def _prepare_product_fields(self): return ['uom_id', 'name', 'default_code', 'categ_id'] + def _prepare_expiry_depreciation_rules(self, company_id, past_date): + rules = self.env['stock.expiry.depreciation.rule'].search_read([('company_id', '=', company_id)], ['start_limit_days', 'ratio'], order='start_limit_days desc') + if past_date: + date_dt = fields.Date.to_date(past_date) # convert datetime to date + else: + date_dt = fields.Date.context_today(self) + for rule in rules: + rule['start_date'] = date_dt - relativedelta(days=rule['start_limit_days']) + logger.debug('depreciation_rules=%s', rules) + return rules + def compute_product_data( self, company_id, in_stock_product_ids, standard_price_past_date=False): self.ensure_one() logger.debug('Start compute_product_data') - ppo = self.env['product.product'] + ppo = self.env['product.product'].with_context(force_company=company_id) ppho = self.env['product.price.history'] fields_list = self._prepare_product_fields() if not standard_price_past_date: @@ -156,38 +186,56 @@ def compute_product_data( logger.debug('End compute_product_data') return product_id2data - def id2name(self, product_ids): - logger.debug('Start id2name') + @api.model + def product_categ_id2name(self, categories): pco = self.env['product.category'] - splo = self.env['stock.production.lot'] - slo = self.env['stock.location'].with_context(active_test=False) - puo = self.env['uom.uom'].with_context(active_test=False) categ_id2name = {} categ_domain = [] - if self.categ_ids: - categ_domain = [('id', 'child_of', self.categ_ids.ids)] + if categories: + categ_domain = [('id', 'child_of', categories.ids)] for categ in pco.search_read(categ_domain, ['display_name']): categ_id2name[categ['id']] = categ['display_name'] + return categ_id2name + + @api.model + def uom_id2name(self): + puo = self.env['uom.uom'].with_context(active_test=False) uom_id2name = {} uoms = puo.search_read([], ['name']) for uom in uoms: uom_id2name[uom['id']] = uom['name'] + return uom_id2name + + @api.model + def prodlot_id2data(self, product_ids, has_expiry_date, depreciation_rules): + splo = self.env['stock.production.lot'] lot_id2data = {} lot_fields = ['name'] - if hasattr(splo, 'expiry_date'): + if has_expiry_date: lot_fields.append('expiry_date') lots = splo.search_read( [('product_id', 'in', product_ids)], lot_fields) for lot in lots: lot_id2data[lot['id']] = lot + lot_id2data[lot['id']]['depreciation_ratio'] = 0 + if depreciation_rules and lot.get('expiry_date'): + expiry_date = lot['expiry_date'] + for rule in depreciation_rules: + if expiry_date <= rule['start_date']: + lot_id2data[lot['id']]['depreciation_ratio'] = rule['ratio'] / 100.0 + break + return lot_id2data + + @api.model + def stock_location_id2name(self, location): + slo = self.env['stock.location'].with_context(active_test=False) loc_id2name = {} locs = slo.search_read( [('id', 'child_of', self.location_id.id)], ['display_name']) for loc in locs: loc_id2name[loc['id']] = loc['display_name'] - logger.debug('End id2name') - return categ_id2name, uom_id2name, lot_id2data, loc_id2name + return loc_id2name def compute_data_from_inventory(self, product_ids, prec_qty): self.ensure_one() @@ -275,7 +323,7 @@ def group_result(self, data, split_by_lot, split_by_location): def stringify_and_sort_result( self, product_ids, product_id2data, data, prec_qty, prec_price, prec_cur_rounding, categ_id2name, - uom_id2name, lot_id2data, loc_id2name): + uom_id2name, lot_id2data, loc_id2name, apply_depreciation): logger.debug('Start stringify_and_sort_result') res = [] for l in data: @@ -284,17 +332,27 @@ def stringify_and_sort_result( standard_price = float_round( product_id2data[product_id]['standard_price'], precision_digits=prec_price) - subtotal = float_round( + subtotal_before_depreciation = float_round( standard_price * qty, precision_rounding=prec_cur_rounding) + depreciation_ratio = 0 + if apply_depreciation and l['lot_id']: + depreciation_ratio = lot_id2data[l['lot_id']].get('depreciation_ratio', 0) + subtotal = float_round( + subtotal_before_depreciation * (1 - depreciation_ratio), + precision_rounding=prec_cur_rounding) + else: + subtotal = subtotal_before_depreciation res.append(dict( product_id2data[product_id], product_name=product_id2data[product_id]['name'], loc_name=l['location_id'] and loc_id2name[l['location_id']] or '', lot_name=l['lot_id'] and lot_id2data[l['lot_id']]['name'] or '', expiry_date=l['lot_id'] and lot_id2data[l['lot_id']].get('expiry_date'), + depreciation_ratio=depreciation_ratio, qty=qty, uom_name=uom_id2name[product_id2data[product_id]['uom_id']], standard_price=standard_price, + subtotal_before_depreciation=subtotal_before_depreciation, subtotal=subtotal, categ_name=categ_id2name[product_id2data[product_id]['categ_id']], )) @@ -313,6 +371,12 @@ def generate(self): prec_cur_rounding = company.currency_id.rounding self._check_config(company_id) + apply_depreciation = self.apply_depreciation + if ( + (self.source == 'stock' and self.stock_date_type == 'past') or + not self.split_by_lot or + not self.has_expiry_date): + apply_depreciation = False product_ids = self.get_product_ids() if not product_ids: raise UserError(_("There are no products to analyse.")) @@ -332,18 +396,32 @@ def generate(self): elif self.source == 'inventory': past_date = self.inventory_id.date data, in_stock_products = self.compute_data_from_inventory(product_ids, prec_qty) - standard_price_past_date = past_date - if not (self.source == 'stock' and self.stock_date_type == 'present') and self.standard_price_date == 'present': + if self.source == 'stock' and self.stock_date_type == 'present': standard_price_past_date = False + else: # field standard_price_date is shown on screen + if self.standard_price_date == 'present': + standard_price_past_date = False + else: + standard_price_past_date = past_date + depreciation_rules = [] + if apply_depreciation: + depreciation_rules = self._prepare_expiry_depreciation_rules(company_id, past_date) + if not depreciation_rules: + raise UserError(_( + "The are not stock depreciation rule for company '%s'.") + % company.display_name) in_stock_product_ids = list(in_stock_products.keys()) product_id2data = self.compute_product_data( company_id, in_stock_product_ids, standard_price_past_date=standard_price_past_date) data_res = self.group_result(data, split_by_lot, split_by_location) - categ_id2name, uom_id2name, lot_id2data, loc_id2name = self.id2name(product_ids) + categ_id2name = self.product_categ_id2name(self.categ_ids) + uom_id2name = self.uom_id2name() + lot_id2data = self.prodlot_id2data(in_stock_product_ids, self.has_expiry_date, depreciation_rules) + loc_id2name = self.stock_location_id2name(self.location_id) res = self.stringify_and_sort_result( product_ids, product_id2data, data_res, prec_qty, prec_price, prec_cur_rounding, - categ_id2name, uom_id2name, lot_id2data, loc_id2name) + categ_id2name, uom_id2name, lot_id2data, loc_id2name, apply_depreciation) logger.debug('Start create XLSX workbook') file_data = BytesIO() @@ -356,12 +434,15 @@ def generate(self): if not split_by_lot: cols.pop('lot_name', None) cols.pop('expiry_date', None) - if not hasattr(splo, 'expiry_date'): + if not self.has_expiry_date: cols.pop('expiry_date', None) if not split_by_location: cols.pop('loc_name', None) if not categ_subtotal: cols.pop('categ_subtotal', None) + if not apply_depreciation: + cols.pop('depreciation_ratio', None) + cols.pop('subtotal_before_depreciation', None) j = 0 for col, col_vals in sorted(cols.items(), key=lambda x: x[1]['sequence']): @@ -417,6 +498,9 @@ def generate(self): letter_qty = cols['qty']['pos_letter'] letter_price = cols['standard_price']['pos_letter'] letter_subtotal = cols['subtotal']['pos_letter'] + if apply_depreciation: + letter_subtotal_before_depreciation = cols['subtotal_before_depreciation']['pos_letter'] + letter_depreciation_ratio = cols['depreciation_ratio']['pos_letter'] crow = 0 lines = res for categ_id in categ_ids: @@ -432,12 +516,20 @@ def generate(self): total += l['subtotal'] ctotal += l['subtotal'] categ_has_line = True - subtotal_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1) + qty_by_price_formula = '=%s%d*%s%d' % (letter_qty, i + 1, letter_price, i + 1) + if apply_depreciation: + sheet.write_formula(i, cols['subtotal_before_depreciation']['pos'], qty_by_price_formula, styles['regular_currency'], l['subtotal_before_depreciation']) + subtotal_formula = '=%s%d*(1 - %s%d)' % (letter_subtotal_before_depreciation, i + 1, letter_depreciation_ratio, i + 1) + else: + subtotal_formula = qty_by_price_formula sheet.write_formula(i, cols['subtotal']['pos'], subtotal_formula, styles['regular_currency'], l['subtotal']) for col_name, col in cols.items(): if not col.get('formula'): - if col.get('type') == 'date' and l[col_name]: - l[col_name] = fields.Date.from_string(l[col_name]) + if col.get('type') == 'date': + if l[col_name]: + l[col_name] = fields.Date.from_string(l[col_name]) + else: + l[col_name] = '' # to avoid display of 31/12/1899 sheet.write(i, col['pos'], l[col_name], styles[col['style']]) if categ_subtotal: if categ_has_line: @@ -503,6 +595,7 @@ def _prepare_styles(self, workbook, company, prec_price): 'regular_date': workbook.add_format({'num_format': 'dd/mm/yyyy'}), 'regular_currency': workbook.add_format({'num_format': currency_num_format}), 'regular_price_currency': workbook.add_format({'num_format': price_currency_num_format}), + 'regular_int_percent': workbook.add_format({'num_format': u'0.%'}), 'regular': workbook.add_format({}), 'regular_small': workbook.add_format({'font_size': regular_font_size - 2}), 'categ_title': workbook.add_format({ @@ -527,8 +620,10 @@ def _prepare_cols(self): 'qty': {'width': 8, 'style': 'regular', 'sequence': 60, 'title': _('Qty')}, 'uom_name': {'width': 5, 'style': 'regular_small', 'sequence': 70, 'title': _('UoM')}, 'standard_price': {'width': 14, 'style': 'regular_price_currency', 'sequence': 80, 'title': _('Cost Price')}, - 'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 90, 'title': _('Sub-total'), 'formula': True}, - 'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 100, 'title': _('Categ Sub-total'), 'formula': True}, - 'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 110, 'title': _('Product Category')}, + 'subtotal_before_depreciation': {'width': 16, 'style': 'regular_currency', 'sequence': 90, 'title': _('Sub-total'), 'formula': True}, + 'depreciation_ratio': {'width': 10, 'style': 'regular_int_percent', 'sequence': 100, 'title': _('Depreciation')}, + 'subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 110, 'title': _('Sub-total'), 'formula': True}, + 'categ_subtotal': {'width': 16, 'style': 'regular_currency', 'sequence': 120, 'title': _('Categ Sub-total'), 'formula': True}, + 'categ_name': {'width': 40, 'style': 'regular_small', 'sequence': 130, 'title': _('Product Category')}, } return cols diff --git a/stock_valuation_xlsx/wizard/stock_valuation_xlsx_view.xml b/stock_valuation_xlsx/wizard/stock_valuation_xlsx_view.xml index 16a35fb1..bf90648f 100644 --- a/stock_valuation_xlsx/wizard/stock_valuation_xlsx_view.xml +++ b/stock_valuation_xlsx/wizard/stock_valuation_xlsx_view.xml @@ -27,8 +27,10 @@ + + @@ -55,6 +57,7 @@ Stock Valuation XLSX + 0
diff --git a/stock_valuation_xlsx/wizard/stock_variation_xlsx.py b/stock_valuation_xlsx/wizard/stock_variation_xlsx.py new file mode 100644 index 00000000..ae40cccb --- /dev/null +++ b/stock_valuation_xlsx/wizard/stock_variation_xlsx.py @@ -0,0 +1,459 @@ +# Copyright 2020-2021 Akretion France (http://www.akretion.com/) +# @author Alexis de Lattre +# 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 diff --git a/stock_valuation_xlsx/wizard/stock_variation_xlsx_view.xml b/stock_valuation_xlsx/wizard/stock_variation_xlsx_view.xml new file mode 100644 index 00000000..9e3ddc77 --- /dev/null +++ b/stock_valuation_xlsx/wizard/stock_variation_xlsx_view.xml @@ -0,0 +1,61 @@ + + + + + + + + stock.variation.xlsx.form + stock.variation.xlsx + +
+
+

The generated XLSX report has the valuation of stockable products located on the selected stock locations (and their childrens).

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Stock Variation XLSX + stock.variation.xlsx + form + new + + + + + +