diff --git a/addons/l10n_fr_certification/__init__.py b/addons/l10n_fr_certification/__init__.py index 24644871f1e..60027beb24f 100644 --- a/addons/l10n_fr_certification/__init__.py +++ b/addons/l10n_fr_certification/__init__.py @@ -3,31 +3,19 @@ import models from openerp import api, SUPERUSER_ID +from models.res_company import UNALTERABLE_COUNTRIES + def _setup_inalterability(cr, registry): env = api.Environment(cr, SUPERUSER_ID, {}) # enable ping for this module env['publisher_warranty.contract'].update_notification(cron_mode=True) - # make sure account_cancel is not usable at the same time as l10n_fr - # FORWARD PORT NOTICE - # In master as of March 2017, RCO-ODOO coded an exclusive field on modules to flag incompatibility - - wanted_states = ['installed', 'to upgrade', 'to install'] - account_cancel_module = env['ir.module.module'].search([('name', '=', 'account_cancel')], limit=1) - - if account_cancel_module and account_cancel_module.state in wanted_states: - views_xml_id = env['ir.model.data'].search([('module', '=', 'account_cancel'), ('model', '=', 'ir.ui.view')]) - ir_views = env['ir.ui.view'].browse([v.res_id for v in views_xml_id]) - for cancel_view in ir_views: - cancel_view.write({'active': False}) - - fr_companies = env['res.company'].search([('partner_id.country_id.code', '=', 'FR')]) + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) if fr_companies: # create the securisation sequence per company - fr_companies._create_secure_sequence() + fr_companies._create_secure_sequence(['l10n_fr_secure_sequence_id']) #reset the update_posted field on journals journals = env['account.journal'].search([('company_id', 'in', fr_companies.ids)]) - for journal in journals: - journal.write({'update_posted': False}) + journals.write({'update_posted': False}) diff --git a/addons/l10n_fr_certification/__manifest__.py b/addons/l10n_fr_certification/__manifest__.py index 7e2ea327d54..426eabf66ef 100644 --- a/addons/l10n_fr_certification/__manifest__.py +++ b/addons/l10n_fr_certification/__manifest__.py @@ -2,18 +2,30 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. { - 'name': 'France - Accounting - Certified CGI 286 I-3 bis', + 'name': 'France - VAT Anti-Fraud Certification (CGI 286 I-3 bis)', 'version': '1.0', 'category': 'Localization', 'description': """ +This add-on brings the technical requirements of the French regulation CGI art. 286, I. 3° bis that stipulates certain criteria concerning the inalterability, security, storage and archiving of data related to sales to private individuals (B2C). +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +The module adds following features: + + Inalterability: deactivation of all the ways to cancel or modify key data, invoices and journal entries + + Security: chaining algorithm to verify the inalterability + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + + Access to download the mandatory Certificate of Conformity delivered by Odoo SA (only for Odoo Enterprise users) """, 'depends': ['l10n_fr'], 'installable': True, 'auto_install': False, - 'application': True, + 'application': False, 'data': [ - 'views/no_cancel.xml', 'data/account_move.xml', + 'views/res_config.xml', ], 'post_init_hook': '_setup_inalterability', } diff --git a/addons/l10n_fr_certification/data/account_move.xml b/addons/l10n_fr_certification/data/account_move.xml index 95adc8d8c2e..2794e6364f9 100644 --- a/addons/l10n_fr_certification/data/account_move.xml +++ b/addons/l10n_fr_certification/data/account_move.xml @@ -1,6 +1,6 @@ - Intégrité des données comptables + Data Inalterability Check ir.actions.server @@ -8,6 +8,18 @@ - + + Get the certification now! + ir.actions.server + + +db_uuid = env['ir.config_parameter'].sudo().get_param('database.uuid') +action = {"type": "ir.actions.act_url", + "url": "https://www.odoo.com/my/contract/french-certification/db/" + db_uuid, + "target": "new",} + + + + diff --git a/addons/l10n_fr_certification/i18n/l10n_fr_certification.pot b/addons/l10n_fr_certification/i18n/l10n_fr_certification.pot new file mode 100644 index 00000000000..346bc685e8b --- /dev/null +++ b/addons/l10n_fr_certification/i18n/l10n_fr_certification.pot @@ -0,0 +1,155 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_fr_certification +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-12-06 16:03+0000\n" +"PO-Revision-Date: 2017-12-06 16:03+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:108 +#, python-format +msgid "(ref.: %s)" +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:79 +#, python-format +msgid "According to the French law, you cannot modify a journal entry in order for its posted data to be updated or deleted. Unauthorized field: %s." +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:165 +#: code:addons/l10n_fr_certification/models/account.py:185 +#: code:addons/l10n_fr_certification/models/account.py:194 +#, python-format +msgid "According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s." +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:154 +#, python-format +msgid "According to the French law, you cannot modify a journal item in order for its posted data to be updated or deleted. Unauthorized field: %s." +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model,name:l10n_fr_certification.model_account_move +msgid "Account Entry" +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:34 +#, python-format +msgid "An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry." +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model,name:l10n_fr_certification.model_res_company +msgid "Companies" +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:123 +#, python-format +msgid "Corrupted data on journal entry with id %s." +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.ui.view,arch_db:l10n_fr_certification.view_general_fr_inalterability_inherit_config +msgid "Data Inalterability" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.actions.server,name:l10n_fr_certification.action_check_hash_integrity +#: model:ir.ui.menu,name:l10n_fr_certification.menu_check_move_integrity +#: model:ir.ui.menu,name:l10n_fr_certification.menu_check_move_integrity_reporting +#: model:ir.ui.view,arch_db:l10n_fr_certification.view_general_fr_inalterability_inherit_config +msgid "Data Inalterability Check" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.ui.menu,name:l10n_fr_certification.inalterability_fr_statements_menu +msgid "French Statements" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.actions.server,name:l10n_fr_certification.odoo_french_certification +#: model:ir.ui.view,arch_db:l10n_fr_certification.view_general_fr_inalterability_inherit_config +msgid "Get the certification now!" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model.fields,field_description:l10n_fr_certification.field_account_move_l10n_fr_hash +msgid "Inalterability Hash" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model.fields,field_description:l10n_fr_certification.field_account_move_l10n_fr_secure_sequence_number +msgid "Inalteralbility No Gap Sequence #" +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:175 +#, python-format +msgid "It is not permitted to disable the data inalterability in this journal (%s) since journal entries have already been protected." +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model,name:l10n_fr_certification.model_account_journal +msgid "Journal" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model,name:l10n_fr_certification.model_account_move_line +msgid "Journal Item" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model.fields,field_description:l10n_fr_certification.field_account_move_l10n_fr_string_to_hash +msgid "L10n fr string to hash" +msgstr "" + +#. module: l10n_fr_certification +#: model:ir.model.fields,field_description:l10n_fr_certification.field_res_company_l10n_fr_secure_sequence_id +msgid "Sequence to use to ensure the securisation of data" +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:136 +#, python-format +msgid "Successful test !\n" +"\n" +" The journal entries are guaranteed to be in their original and inalterable state\n" +" From: %(start_move_name)s %(start_move_ref)s\n" +" To: %(end_move_name)s %(end_move_ref)s\n" +"\n" +" For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users)." +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:118 +#, python-format +msgid "There isn't any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis." +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:99 +#, python-format +msgid "You cannot modify a posted journal entry. This ensures its inalterability." +msgstr "" + +#. module: l10n_fr_certification +#: code:addons/l10n_fr_certification/models/account.py:82 +#, python-format +msgid "You cannot overwrite the values ensuring the inalterability of the accounting." +msgstr "" + diff --git a/addons/l10n_fr_certification/models/account.py b/addons/l10n_fr_certification/models/account.py index 94db3c46273..d7aeda0bb48 100644 --- a/addons/l10n_fr_certification/models/account.py +++ b/addons/l10n_fr_certification/models/account.py @@ -6,7 +6,6 @@ from openerp import models, api, fields from openerp.tools.translate import _ from openerp.exceptions import UserError -ERR_MSG = _("According to the french law, you cannot modify a %s in order for its posted data to be updated or deleted. Field: %s") #forbidden fields MOVE_FIELDS = ['date', 'journal_id', 'company_id'] @@ -16,8 +15,10 @@ LINE_FIELDS = ['debit', 'credit', 'account_id', 'partner_id'] class AccountMove(models.Model): _inherit = "account.move" - l10n_fr_secure_sequence_number = fields.Integer(readonly=True, copy=False) - l10n_fr_hash = fields.Char(readonly=True, copy=False) + # TO DO in master : refactor hashing algo to go into a mixin + + l10n_fr_secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) + l10n_fr_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False) l10n_fr_string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True, store=False) def _get_new_hash(self, secure_seq_number): @@ -26,10 +27,11 @@ class AccountMove(models.Model): #get the only one exact previous move in the securisation sequence prev_move = self.search([('state', '=', 'posted'), ('company_id', '=', self.company_id.id), + ('l10n_fr_secure_sequence_number', '!=', 0), ('l10n_fr_secure_sequence_number', '=', int(secure_seq_number) - 1)]) if prev_move and len(prev_move) != 1: raise UserError( - _('Error occured when computing the hash. Impossible to get the unique previous posted move')) + _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.')) #build and return the hash return self._compute_hash(prev_move.l10n_fr_hash if prev_move else '') @@ -67,33 +69,34 @@ class AccountMove(models.Model): def write(self, vals): has_been_posted = False for move in self: - if move.company_id.country_id.code == 'FR': + if move.company_id._is_accounting_unalterable(): # write the hash and the secure_sequence_number when posting an account.move if vals.get('state') == 'posted': has_been_posted = True # restrict the operation in case we are trying to write a forbidden field if (move.state == "posted" and set(vals).intersection(MOVE_FIELDS)): - raise UserError(ERR_MSG % (self._name, ', '.join(MOVE_FIELDS))) + raise UserError(_("According to the French law, you cannot modify a journal entry in order for its posted data to be updated or deleted. Unauthorized field: %s.") % ', '.join(MOVE_FIELDS)) # restrict the operation in case we are trying to overwrite existing hash if (move.l10n_fr_hash and 'l10n_fr_hash' in vals) or (move.l10n_fr_secure_sequence_number and 'l10n_fr_secure_sequence_number' in vals): raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.')) res = super(AccountMove, self).write(vals) # write the hash and the secure_sequence_number when posting an account.move if has_been_posted: - for move in self.filtered(lambda m: m.company_id.country_id.code == 'FR' and - not (m.l10n_fr_secure_sequence_number or m.l10n_fr_hash)): + for move in self.filtered(lambda m: m.company_id._is_accounting_unalterable() and + not (m.l10n_fr_secure_sequence_number or m.l10n_fr_hash)): new_number = move.company_id.l10n_fr_secure_sequence_id.next_by_id() vals_hashing = {'l10n_fr_secure_sequence_number': new_number, 'l10n_fr_hash': move._get_new_hash(new_number)} res |= super(AccountMove, move).write(vals_hashing) return res + @api.multi def button_cancel(self): #by-pass the normal behavior/message that tells people can cancel a posted journal entry #if the journal allows it. - if self.company_id.country_id.code == 'FR': - raise UserError(_('You cannot modify a posted entry of a journal.')) + if self.company_id._is_accounting_unalterable(): + raise UserError(_('You cannot modify a posted journal entry. This ensures its inalterability.')) super(AccountMove, self).button_cancel() @api.model @@ -101,29 +104,43 @@ class AccountMove(models.Model): """Checks that all posted moves have still the same data as when they were posted and raises an error with the result. """ + def build_move_info(move): + entry_reference = _('(ref.: %s)') + move_reference_string = move.ref and entry_reference % move.ref or '' + return [move.name, move_reference_string] + moves = self.search([('state', '=', 'posted'), ('company_id', '=', company_id), ('l10n_fr_secure_sequence_number', '!=', 0)], order="l10n_fr_secure_sequence_number ASC") if not moves: - raise UserError(_('Warning: impossible to find any move with a hash.')) + raise UserError(_('There isn\'t any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis.') % self.env.user.company_id.name) previous_hash = '' start_move_info = [] for move in moves: if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash): - raise UserError(_('Corrupted Data on move %s.') % move.id) + raise UserError(_('Corrupted data on journal entry with id %s.') % move.id) if not previous_hash: #save the date and sequence number of the first move hashed - start_move_info = [move.date, move.l10n_fr_secure_sequence_number] + start_move_info = build_move_info(move) previous_hash = move.l10n_fr_hash - end_move_info = [move.date, move.l10n_fr_secure_sequence_number] - raise UserError(_('''Successfully checked the integrity of account moves. + end_move_info = build_move_info(move) - The account moves are guaranteed to be in their original and inalterable state - - since: %s (Sequence Number: %s) - - to: %s (Sequence Number: %s)''' - ) % (start_move_info[0], start_move_info[1], end_move_info[0], end_move_info[1])) + report_dict = {'start_move_name': start_move_info[0], + 'start_move_ref': start_move_info[1], + 'end_move_name': end_move_info[0], + 'end_move_ref': end_move_info[1]} + + # Raise on success + raise UserError(_('''Successful test ! + + The journal entries are guaranteed to be in their original and inalterable state + From: %(start_move_name)s %(start_move_ref)s + To: %(end_move_name)s %(end_move_ref)s + + For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users).''' + ) % report_dict) class AccountMoveLine(models.Model): @@ -133,25 +150,46 @@ class AccountMoveLine(models.Model): def write(self, vals): # restrict the operation in case we are trying to write a forbidden field if set(vals).intersection(LINE_FIELDS): - if any(l.company_id.country_id.code == 'FR' and l.move_id.state == 'posted' for l in self): - raise UserError(ERR_MSG % (self._name, ', '.join(LINE_FIELDS))) + if any(l.company_id._is_accounting_unalterable() and l.move_id.state == 'posted' for l in self): + raise UserError(_("According to the French law, you cannot modify a journal item in order for its posted data to be updated or deleted. Unauthorized field: %s.") % ', '.join(LINE_FIELDS)) return super(AccountMoveLine, self).write(vals) class AccountJournal(models.Model): _inherit = "account.journal" + @api.onchange('update_posted') + def _onchange_update_posted(self): + if self.update_posted and self.company_id._is_accounting_unalterable(): + field_string = self._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) + + @api.multi + def _is_journal_alterable(self): + self.ensure_one() + critical_domain = [('journal_id', '=', self.id), + '|', ('l10n_fr_hash', '!=', False), + '&', ('l10n_fr_secure_sequence_number', '!=', False), + ('l10n_fr_secure_sequence_number', '!=', 0)] + if self.env['account.move'].search(critical_domain): + raise UserError(_('It is not permitted to disable the data inalterability in this journal (%s) since journal entries have already been protected.') % (self.name, )) + return True + @api.multi def write(self, vals): # restrict the operation in case we are trying to write a forbidden field - for rec in self: - if rec.company_id.country_id.code == 'FR' and vals.get('update_posted'): - raise UserError(ERR_MSG % (self._name, 'update_posted')) + for journal in self: + if journal.company_id._is_accounting_unalterable(): + if vals.get('update_posted'): + field_string = journal._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) return super(AccountJournal, self).write(vals) @api.model def create(self, vals): # restrict the operation in case we are trying to set a forbidden field - if self.company_id.country_id.code == 'FR' and vals.get('update_posted'): - raise UserError(ERR_MSG % (self._name, 'update_posted')) + if self.company_id._is_accounting_unalterable(): + if vals.get('update_posted'): + field_string = self._fields['update_posted'].get_description(self.env)['string'] + raise UserError(_("According to the French law, you cannot modify a journal in order for its posted data to be updated or deleted. Unauthorized field: %s.") % field_string) return super(AccountJournal, self).create(vals) diff --git a/addons/l10n_fr_certification/models/res_company.py b/addons/l10n_fr_certification/models/res_company.py index 66a2a633164..91e00081914 100644 --- a/addons/l10n_fr_certification/models/res_company.py +++ b/addons/l10n_fr_certification/models/res_company.py @@ -3,40 +3,61 @@ from openerp import fields, models, api +UNALTERABLE_COUNTRIES = ['FR', 'MF', 'MQ', 'NC', 'PF', 'RE', 'GF', 'GP', 'TF'] + + class ResCompany(models.Model): _inherit = 'res.company' + # To do in master : refactor to set sequences more generic + l10n_fr_secure_sequence_id = fields.Many2one('ir.sequence', 'Sequence to use to ensure the securisation of data', readonly=True) @api.model def create(self, vals): company = super(ResCompany, self).create(vals) #when creating a new french company, create the securisation sequence as well - if company.country_id == self.env.ref('base.fr'): - company._create_secure_sequence() + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_secure_sequence_id'] + company._create_secure_sequence(sequence_fields) return company @api.multi def write(self, vals): res = super(ResCompany, self).write(vals) #if country changed to fr, create the securisation sequence - if vals.get('country_id') and vals.get('country_id') == self.env.ref('base.fr').id: - self.filtered(lambda c: not c.l10n_fr_secure_sequence_id)._create_secure_sequence() + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_secure_sequence_id'] + company._create_secure_sequence(sequence_fields) return res - def _create_secure_sequence(self): + def _create_secure_sequence(self, sequence_fields): """This function creates a no_gap sequence on each companies in self that will ensure a unique number is given to all posted account.move in such a way that we can always find the previous move of a journal entry. """ for company in self: - vals = { - 'name': 'French Securisation of account_move_line - ' + company.name, - 'code': 'FRSECUR', - 'implementation': 'no_gap', - 'prefix': '', - 'suffix': '', - 'padding': 0, - 'company_id': company.id} - seq = self.env['ir.sequence'].create(vals) - company.write({'l10n_fr_secure_sequence_id': seq.id}) + vals_write = {} + for seq_field in sequence_fields: + if not company[seq_field]: + vals = { + 'name': 'French Securisation of ' + seq_field + ' - ' + company.name, + 'code': 'FRSECUR', + 'implementation': 'no_gap', + 'prefix': '', + 'suffix': '', + 'padding': 0, + 'company_id': company.id} + seq = self.env['ir.sequence'].create(vals) + vals_write[seq_field] = seq.id + if vals_write: + company.write(vals_write) + + def _is_vat_french(self): + return self.vat and self.vat.startswith('FR') and len(self.vat) == 13 + + def _is_accounting_unalterable(self): + if not self.vat and not self.country_id: + return False + return self.country_id and self.country_id.code in UNALTERABLE_COUNTRIES or self._is_vat_french() diff --git a/addons/l10n_fr_certification/static/description/icon.png b/addons/l10n_fr_certification/static/description/icon.png new file mode 100644 index 00000000000..bbcaa4fcce7 Binary files /dev/null and b/addons/l10n_fr_certification/static/description/icon.png differ diff --git a/addons/l10n_fr_certification/views/no_cancel.xml b/addons/l10n_fr_certification/views/no_cancel.xml deleted file mode 100644 index 43bc66d6ee3..00000000000 --- a/addons/l10n_fr_certification/views/no_cancel.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - bank.statement.draft.form.inherit - account.bank.statement - - - - 1 - - - - - - invoice.form.cancel.inherit - account.invoice - - - - 1 - - - - - - account.move.form.nocancel.inherit - account.move - - - - 1 - - - - - - diff --git a/addons/l10n_fr_certification/views/res_config.xml b/addons/l10n_fr_certification/views/res_config.xml new file mode 100644 index 00000000000..36f66f00df2 --- /dev/null +++ b/addons/l10n_fr_certification/views/res_config.xml @@ -0,0 +1,18 @@ + + + Inalterability settings + base.config.settings + + + + + + + + + \ No newline at end of file diff --git a/addons/l10n_fr_pos_cert/__init__.py b/addons/l10n_fr_pos_cert/__init__.py new file mode 100644 index 00000000000..f95ac6e7067 --- /dev/null +++ b/addons/l10n_fr_pos_cert/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import models +from openerp import api, SUPERUSER_ID +from ..l10n_fr_certification.models.res_company import UNALTERABLE_COUNTRIES + + +def _setup_inalterability(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # enable ping for this module + env['publisher_warranty.contract'].update_notification(cron_mode=True) + + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) + if fr_companies: + # create the securisation sequence per company + fr_companies._create_secure_sequence(['l10n_fr_pos_cert_sequence_id']) diff --git a/addons/l10n_fr_pos_cert/__openerp__.py b/addons/l10n_fr_pos_cert/__openerp__.py new file mode 100644 index 00000000000..40907b49621 --- /dev/null +++ b/addons/l10n_fr_pos_cert/__openerp__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - VAT Anti-Fraud Certification for Point of Sale (CGI 286 I-3 bis)', + 'version': '1.0', + 'category': 'Localization', + 'description': """ +This add-on brings the technical requirements of the French regulation CGI art. 286, I. 3° bis that stipulates certain criteria concerning the inalterability, security, storage and archiving of data related to sales to private individuals (B2C). +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +Install it if you use the Point of Sale app to sell to individuals. + +The module adds following features: + + Inalterability: deactivation of all the ways to cancel or modify key data of POS orders, invoices and journal entries + + Security: chaining algorithm to verify the inalterability + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + + Access to download the mandatory Certificate of Conformity delivered by Odoo SA (only for Odoo Enterprise users) +""", + 'depends': ['l10n_fr_sale_closing', 'point_of_sale'], + 'installable': True, + 'auto_install': False, + 'application': False, + 'data': [ + 'data/pos_inalterability.xml', + 'views/account_views.xml', + ], + 'post_init_hook': '_setup_inalterability', +} diff --git a/addons/l10n_fr_pos_cert/data/pos_inalterability.xml b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml new file mode 100644 index 00000000000..ace4c3de24d --- /dev/null +++ b/addons/l10n_fr_pos_cert/data/pos_inalterability.xml @@ -0,0 +1,17 @@ + + + + + + + + Pos Orders Inalterability Check + + ir.actions.server + +action = env['pos.order']._check_hash_integrity(env.user.company_id.id) + + + + + \ No newline at end of file diff --git a/addons/l10n_fr_pos_cert/i18n/l10n_fr_pos_cert.pot b/addons/l10n_fr_pos_cert/i18n/l10n_fr_pos_cert.pot new file mode 100644 index 00000000000..4e1507eb9f0 --- /dev/null +++ b/addons/l10n_fr_pos_cert/i18n/l10n_fr_pos_cert.pot @@ -0,0 +1,166 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_fr_pos_cert +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-12-06 16:08+0000\n" +"PO-Revision-Date: 2017-12-06 16:08+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:145 +#, python-format +msgid "(Receipt ref.: %s)" +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:57 +#, python-format +msgid "According to the French law, you cannot modify a %s. Forbidden fields: %s." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:193 +#, python-format +msgid "According to the French law, you cannot modify a point of sale order line. Forbidden fields: %s." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:124 +#, python-format +msgid "According to the French law, you cannot modify a point of sale order. Forbidden fields: %s." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:77 +#, python-format +msgid "An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order." +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_account_bank_statement +msgid "Bank Statement" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_res_company +msgid "Companies" +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:160 +#, python-format +msgid "Corrupted data on point of sale order with id %s." +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.ui.menu,name:l10n_fr_pos_cert.pos_fr_statements_menu +msgid "French Statements" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model.fields,field_description:l10n_fr_pos_cert.field_pos_order_l10n_fr_hash +msgid "Inalteralbility Hash" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model.fields,field_description:l10n_fr_pos_cert.field_pos_order_l10n_fr_secure_sequence_number +msgid "Inalteralbility No Gap Sequence #" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model.fields,field_description:l10n_fr_pos_cert.field_res_company_l10n_fr_pos_cert_sequence_id +msgid "L10n fr pos cert sequence id" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model.fields,field_description:l10n_fr_pos_cert.field_pos_order_l10n_fr_string_to_hash +msgid "L10n fr string to hash" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_pos_order_line +msgid "Lines of Point of Sale" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_pos_order +msgid "Point of Sale" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.actions.server,name:l10n_fr_pos_cert.action_check_hash_integrity_pos_orders +#: model:ir.ui.menu,name:l10n_fr_pos_cert.menu_check_pos_order_integrity +msgid "Pos Orders Inalterability Check" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.ui.menu,name:l10n_fr_pos_cert.menu_account_closing +msgid "Sales Closings" +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:175 +#, python-format +msgid "Successful test !\n" +"\n" +" The point of sale orders are guaranteed to be in their original and inalterable state\n" +" From: %(start_order_name)s %(start_order_ref)s recorded on %(start_order_date)s\n" +" To: %(end_order_name)s %(end_order_ref)s recorded on %(end_order_date)s\n" +"\n" +" For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users)." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:155 +#, python-format +msgid "There isn't any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS" +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:45 +#, python-format +msgid "This session has been opened another day. To comply with the French law, you should close sessions on a daily basis. Please close session %s and open a new one." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/account_bank_statement.py:11 +#, python-format +msgid "You cannot modify anything on a bank statement (name: %s) that was created by point of sale operations." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/account_bank_statement.py:20 +#, python-format +msgid "You cannot modify anything on a bank statement line (name: %s) that was created by point of sale operations." +msgstr "" + +#. module: l10n_fr_pos_cert +#: code:addons/l10n_fr_pos_cert/models/pos.py:127 +#, python-format +msgid "You cannot overwrite the values ensuring the inalterability of the point of sale." +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_pos_config +msgid "pos.config" +msgstr "" + +#. module: l10n_fr_pos_cert +#: model:ir.model,name:l10n_fr_pos_cert.model_pos_session +msgid "pos.session" +msgstr "" + diff --git a/addons/l10n_fr_pos_cert/models/__init__.py b/addons/l10n_fr_pos_cert/models/__init__.py new file mode 100644 index 00000000000..e56d2fcd33c --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/__init__.py @@ -0,0 +1,3 @@ +import account_bank_statement +import res_company +import pos \ No newline at end of file diff --git a/addons/l10n_fr_pos_cert/models/account_bank_statement.py b/addons/l10n_fr_pos_cert/models/account_bank_statement.py new file mode 100644 index 00000000000..28afbf0d549 --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/account_bank_statement.py @@ -0,0 +1,21 @@ +from openerp import models, api +from openerp.tools.translate import _ +from openerp.exceptions import UserError + + +class AccountBankStatement(models.Model): + _inherit = 'account.bank.statement' + + def unlink(self): + for statement in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): + raise UserError(_('You cannot modify anything on a bank statement (name: %s) that was created by point of sale operations.') % (statement.name,)) + return super(AccountBankStatement, self).unlink() + + +class AccountBankStatementLine(models.Model): + _inherit = 'account.bank.statement.line' + + def unlink(self): + for line in self.filtered(lambda s: s.company_id._is_accounting_unalterable() and s.journal_id.journal_user): + raise UserError(_('You cannot modify anything on a bank statement line (name: %s) that was created by point of sale operations.') % (line.name,)) + return super(AccountBankStatementLine, self).unlink() diff --git a/addons/l10n_fr_pos_cert/models/pos.py b/addons/l10n_fr_pos_cert/models/pos.py new file mode 100644 index 00000000000..d6d88604e8c --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/pos.py @@ -0,0 +1,194 @@ +from datetime import datetime +from hashlib import sha256 +from json import dumps +import pytz + +from openerp import models, api, fields +from openerp.fields import Datetime +from openerp.tools.translate import _ +from openerp.exceptions import UserError + + +def ctx_tz(record, field): + res_lang = None + ctx = record._context + tz_name = pytz.timezone(ctx.get('tz') or record.env.user.tz) + timestamp = Datetime.from_string(record[field]) + if ctx.get('lang'): + res_lang = record.env['res.lang'].search([('code', '=', ctx['lang'])], limit=1) + if res_lang: + timestamp = pytz.utc.localize(timestamp, is_dst=False) + return datetime.strftime(timestamp.astimezone(tz_name), res_lang.date_format + ' ' + res_lang.time_format) + return Datetime.context_timestamp(record, timestamp) + + +class pos_config(models.Model): + _inherit = 'pos.config' + + @api.multi + def open_ui(self): + for config in self.filtered(lambda c: c.company_id._is_accounting_unalterable()): + if config.current_session_id: + config.current_session_id._check_session_timing() + return super(pos_config, self).open_ui() + + +class pos_session(models.Model): + _inherit = 'pos.session' + + @api.multi + def _check_session_timing(self): + self.ensure_one() + date_today = datetime.utcnow() + session_start = Datetime.from_string(self.start_at) + if not date_today - datetime.timedelta(hours=24) <= session_start: + raise UserError(_("This session has been opened another day. To comply with the French law, you should close sessions on a daily basis. Please close session %s and open a new one.") % self.name) + return True + + @api.multi + def open_frontend_cb(self): + for session in self.filtered(lambda s: s.config_id.company_id._is_accounting_unalterable()): + session._check_session_timing() + return super(pos_session, self).open_frontend_cb() + + +ORDER_FIELDS = ['date_order', 'user_id', 'lines', 'statement_ids', 'pricelist_id', 'partner_id', 'session_id', 'pos_reference', 'sale_journal', 'fiscal_position_id'] +LINE_FIELDS = ['notice', 'product_id', 'qty', 'price_unit', 'discount', 'tax_ids', 'tax_ids_after_fiscal_position'] +ERR_MSG = _('According to the French law, you cannot modify a %s. Forbidden fields: %s.') + + +class pos_order(models.Model): + _inherit = 'pos.order' + + l10n_fr_hash = fields.Char(string="Inalteralbility Hash", readonly=True, copy=False) + l10n_fr_secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) + l10n_fr_string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True, store=False) + + def _get_new_hash(self, secure_seq_number): + """ Returns the hash to write on pos orders when they get posted""" + self.ensure_one() + #get the only one exact previous order in the securisation sequence + prev_order = self.search([('state', 'in', ['paid', 'done', 'invoiced']), + ('company_id', '=', self.company_id.id), + ('l10n_fr_secure_sequence_number', '!=', 0), + ('l10n_fr_secure_sequence_number', '=', int(secure_seq_number) - 1)]) + if prev_order and len(prev_order) != 1: + raise UserError( + _('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.')) + + #build and return the hash + return self._compute_hash(prev_order.l10n_fr_hash if prev_order else '') + + def _compute_hash(self, previous_hash): + """ Computes the hash of the browse_record given as self, based on the hash + of the previous record in the company's securisation sequence given as parameter""" + self.ensure_one() + hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) + return hash_string.hexdigest() + + def _compute_string_to_hash(self): + def _getattrstring(obj, field_str): + field_value = obj[field_str] + if obj._fields[field_str].type == 'many2one': + field_value = field_value.id + if obj._fields[field_str].type in ['many2many', 'one2many']: + field_value = field_value.ids + return str(field_value) + + for order in self: + values = {} + for field in ORDER_FIELDS: + values[field] = _getattrstring(order, field) + + for line in order.lines: + for field in LINE_FIELDS: + k = 'line_%d_%s' % (line.id, field) + values[k] = _getattrstring(line, field) + #make the json serialization canonical + # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) + order.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", + ensure_ascii=True, indent=None, + separators=(',',':')) + + @api.multi + def write(self, vals): + has_been_posted = False + for order in self: + if order.company_id._is_accounting_unalterable(): + # write the hash and the secure_sequence_number when posting or invoicing an pos.order + if vals.get('state') in ['paid', 'done', 'invoiced']: + has_been_posted = True + + # restrict the operation in case we are trying to write a forbidden field + if (order.state in ['paid', 'done', 'invoiced'] and set(vals).intersection(ORDER_FIELDS)): + raise UserError(_('According to the French law, you cannot modify a point of sale order. Forbidden fields: %s.') % ', '.join(ORDER_FIELDS)) + # restrict the operation in case we are trying to overwrite existing hash + if (order.l10n_fr_hash and 'l10n_fr_hash' in vals) or (order.l10n_fr_secure_sequence_number and 'l10n_fr_secure_sequence_number' in vals): + raise UserError(_('You cannot overwrite the values ensuring the inalterability of the point of sale.')) + res = super(pos_order, self).write(vals) + # write the hash and the secure_sequence_number when posting or invoicing a pos order + if has_been_posted: + for order in self.filtered(lambda o: o.company_id._is_accounting_unalterable() and + not (o.l10n_fr_secure_sequence_number or o.l10n_fr_hash)): + new_number = order.company_id.l10n_fr_pos_cert_sequence_id.next_by_id() + vals_hashing = {'l10n_fr_secure_sequence_number': new_number, + 'l10n_fr_hash': order._get_new_hash(new_number)} + res |= super(pos_order, order).write(vals_hashing) + return res + + @api.model + def _check_hash_integrity(self, company_id): + """Checks that all posted or invoiced pos orders have still the same data as when they were posted + and raises an error with the result. + """ + def build_order_info(order): + entry_reference = _('(Receipt ref.: %s)') + order_reference_string = order.pos_reference and entry_reference % order.pos_reference or '' + return [ctx_tz(order, 'date_order'), order.l10n_fr_secure_sequence_number, order.name, order_reference_string, ctx_tz(order, 'write_date')] + + orders = self.search([('state', 'in', ['paid', 'done', 'invoiced']), + ('company_id', '=', company_id), + ('l10n_fr_secure_sequence_number', '!=', 0)], + order="l10n_fr_secure_sequence_number ASC") + + if not orders: + raise UserError(_('There isn\'t any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS') % self.env.user.company_id.name) + previous_hash = '' + start_order_info = [] + for order in orders: + if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash): + raise UserError(_('Corrupted data on point of sale order with id %s.') % order.id) + previous_hash = order.l10n_fr_hash + + orders_sorted_date = orders.sorted(lambda o: o.date_order) + start_order_info = build_order_info(orders_sorted_date[0]) + end_order_info = build_order_info(orders_sorted_date[-1]) + + report_dict = {'start_order_name': start_order_info[2], + 'start_order_ref': start_order_info[3], + 'start_order_date': start_order_info[0], + 'end_order_name': end_order_info[2], + 'end_order_ref': end_order_info[3], + 'end_order_date': end_order_info[0]} + + # Raise on success + raise UserError(_('''Successful test ! + + The point of sale orders are guaranteed to be in their original and inalterable state + From: %(start_order_name)s %(start_order_ref)s recorded on %(start_order_date)s + To: %(end_order_name)s %(end_order_ref)s recorded on %(end_order_date)s + + For this report to be legally meaningful, please download your certification from your customer account on Odoo.com (Only for Odoo Enterprise users).''' + ) % report_dict) + + +class PosOrderLine(models.Model): + _inherit = "pos.order.line" + + @api.multi + def write(self, vals): + # restrict the operation in case we are trying to write a forbidden field + if set(vals).intersection(LINE_FIELDS): + if any(l.company_id._is_accounting_unalterable() and l.order_id.state in ['done', 'invoiced'] for l in self): + raise UserError(_('According to the French law, you cannot modify a point of sale order line. Forbidden fields: %s.') % ', '.join(LINE_FIELDS)) + return super(PosOrderLine, self).write(vals) diff --git a/addons/l10n_fr_pos_cert/models/res_company.py b/addons/l10n_fr_pos_cert/models/res_company.py new file mode 100644 index 00000000000..28e13c0d1d2 --- /dev/null +++ b/addons/l10n_fr_pos_cert/models/res_company.py @@ -0,0 +1,26 @@ +from openerp import models, api, fields + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_fr_pos_cert_sequence_id = fields.Many2one('ir.sequence') + + @api.model + def create(self, vals): + company = super(ResCompany, self).create(vals) + #when creating a new french company, create the securisation sequence as well + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_pos_cert_sequence_id'] + company._create_secure_sequence(sequence_fields) + return company + + @api.multi + def write(self, vals): + res = super(ResCompany, self).write(vals) + #if country changed to fr, create the securisation sequence + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_pos_cert_sequence_id'] + company._create_secure_sequence(sequence_fields) + return res diff --git a/addons/l10n_fr_pos_cert/static/description/icon.png b/addons/l10n_fr_pos_cert/static/description/icon.png new file mode 100644 index 00000000000..bbcaa4fcce7 Binary files /dev/null and b/addons/l10n_fr_pos_cert/static/description/icon.png differ diff --git a/addons/l10n_fr_pos_cert/views/account_views.xml b/addons/l10n_fr_pos_cert/views/account_views.xml new file mode 100644 index 00000000000..bfe6100061f --- /dev/null +++ b/addons/l10n_fr_pos_cert/views/account_views.xml @@ -0,0 +1,26 @@ + + + account.bank.statement.form + account.bank.statement + + extension + + + + + + {'readonly': [('pos_session_id', '!=', False)]} + + + {'readonly': [('pos_session_id', '!=', False)]} + + + {'readonly': [('pos_session_id', '!=', False)]} + + + {'readonly': [('pos_session_id', '!=', False)]} + + + + + \ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/__init__.py b/addons/l10n_fr_sale_closing/__init__.py new file mode 100644 index 00000000000..ca9ea5e3dd8 --- /dev/null +++ b/addons/l10n_fr_sale_closing/__init__.py @@ -0,0 +1,14 @@ +import models +from openerp import api, SUPERUSER_ID +from ..l10n_fr_certification.models.res_company import UNALTERABLE_COUNTRIES + + +def _setup_inalterability(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + # enable ping for this module + env['publisher_warranty.contract'].update_notification(cron_mode=True) + + fr_companies = env['res.company'].search([('partner_id.country_id.code', 'in', UNALTERABLE_COUNTRIES)]) + if fr_companies: + # create the securisation sequence per company + fr_companies._create_secure_sequence(['l10n_fr_closing_sequence_id']) diff --git a/addons/l10n_fr_sale_closing/__openerp__.py b/addons/l10n_fr_sale_closing/__openerp__.py new file mode 100644 index 00000000000..83fcad8f626 --- /dev/null +++ b/addons/l10n_fr_sale_closing/__openerp__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'France - VAT Anti-Fraud Certification (CGI 286 I-3 bis) - Sale Closings', + 'version': '1.0', + 'category': 'Localization', + 'description': """ +This module adds support for automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +The module adds following features: + + Storage: automatic sales closings with computation of both period and cumulative totals (daily, monthly, annually) + +""", + 'depends': ['l10n_fr_certification'], + 'installable': True, + 'auto_install': True, + 'application': False, + 'data': [ + 'views/account_sale_closure.xml', + 'data/account_sale_closure_cron.xml', + 'security/ir.model.access.csv', + 'security/account_closing_intercompany.xml', + ], + 'post_init_hook': '_setup_inalterability', +} diff --git a/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml b/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml new file mode 100644 index 00000000000..edc1b1566ec --- /dev/null +++ b/addons/l10n_fr_sale_closing/data/account_sale_closure_cron.xml @@ -0,0 +1,35 @@ + + + Generate Daily Sales Closing + 1 + days + -1 + + + + + + + + Generate Monthly Sales Closing + 1 + months + -1 + + + + + + + + Generate Annual Sales Closing + 12 + months + -1 + + + + + + + diff --git a/addons/l10n_fr_sale_closing/i18n/l10n_fr_sale_closing.pot b/addons/l10n_fr_sale_closing/i18n/l10n_fr_sale_closing.pot new file mode 100644 index 00000000000..38ec555a380 --- /dev/null +++ b/addons/l10n_fr_sale_closing/i18n/l10n_fr_sale_closing.pot @@ -0,0 +1,219 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_fr_sale_closing +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2017-12-06 16:03+0000\n" +"PO-Revision-Date: 2017-12-06 16:03+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_fr_sale_closing +#: model:ir.ui.view,arch_db:l10n_fr_sale_closing.form_view_account_sale_closing +msgid "Account Closing" +msgstr "" + +#. module: l10n_fr_sale_closing +#: selection:account.sale.closing,frequency:0 +msgid "Annual" +msgstr "" + +#. module: l10n_fr_sale_closing +#: code:addons/l10n_fr_sale_closing/models/account_closing.py:134 +#, python-format +msgid "Annual Closing" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_date_closing_stop +msgid "Closing Date" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_frequency +msgid "Closing Type" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model,name:l10n_fr_sale_closing.model_res_company +msgid "Companies" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_company_id +msgid "Company" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_create_uid +msgid "Created by" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_create_date +msgid "Created on" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_total_beginning +msgid "Cumulative Grand Total" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_currency_id +msgid "Currency" +msgstr "" + +#. module: l10n_fr_sale_closing +#: selection:account.sale.closing,frequency:0 +msgid "Daily" +msgstr "" + +#. module: l10n_fr_sale_closing +#: code:addons/l10n_fr_sale_closing/models/account_closing.py:125 +#, python-format +msgid "Daily Closing" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_date_closing_start +msgid "Date from which the total interval is computed" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_date_closing_stop +msgid "Date to which the values are computed" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_display_name +msgid "Display Name" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_name +msgid "Frequency and unique sequence number" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_id +msgid "ID" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_last_move_id +msgid "Last Journal entry included in the grand total" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing___last_update +msgid "Last Modified on" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_write_date +msgid "Last Updated on" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_last_move_id +msgid "Last journal entry" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_last_move_hash +msgid "Last journal entry's inalteralbility hash" +msgstr "" + +#. module: l10n_fr_sale_closing +#: selection:account.sale.closing,frequency:0 +msgid "Monthly" +msgstr "" + +#. module: l10n_fr_sale_closing +#: code:addons/l10n_fr_sale_closing/models/account_closing.py:130 +#, python-format +msgid "Monthly Closing" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_name +msgid "Name" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_total_interval +msgid "Period Total" +msgstr "" + +#. module: l10n_fr_sale_closing +#: code:addons/l10n_fr_sale_closing/models/account_closing.py:142 +#: code:addons/l10n_fr_sale_closing/models/account_closing.py:146 +#, python-format +msgid "Sale Closings are not meant to be written or deleted under any circumstances." +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.actions.act_window,name:l10n_fr_sale_closing.action_list_view_account_sale_closing +#: model:ir.ui.menu,name:l10n_fr_sale_closing.menu_account_closing +#: model:ir.ui.menu,name:l10n_fr_sale_closing.menu_account_closing_reporting +msgid "Sales Closings" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.actions.act_window,help:l10n_fr_sale_closing.action_list_view_account_sale_closing +msgid "Sales closings run automatically on a daily, monthly and annual basis. It computes both period and cumulative totals from all the sales entries posted in the system after the previous closing." +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_sequence_number +msgid "Sequence #" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_res_company_l10n_fr_closing_sequence_id +msgid "Sequence to use to build sale closings" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,field_description:l10n_fr_sale_closing.field_account_sale_closing_date_closing_start +msgid "Starting Date" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.actions.act_window,help:l10n_fr_sale_closing.action_list_view_account_sale_closing +msgid "The closings are created by Odoo" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_currency_id +msgid "The company's currency" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_total_interval +msgid "Total in receivable accounts during the interval, excluding overlapping periods" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model.fields,help:l10n_fr_sale_closing.field_account_sale_closing_total_beginning +msgid "Total in receivable accounts since the beginnig of times" +msgstr "" + +#. module: l10n_fr_sale_closing +#: model:ir.model,name:l10n_fr_sale_closing.model_account_sale_closing +msgid "account.sale.closing" +msgstr "" + diff --git a/addons/l10n_fr_sale_closing/models/__init__.py b/addons/l10n_fr_sale_closing/models/__init__.py new file mode 100644 index 00000000000..64942388936 --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/__init__.py @@ -0,0 +1,2 @@ +import res_company +import account_closing \ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/models/account_closing.py b/addons/l10n_fr_sale_closing/models/account_closing.py new file mode 100644 index 00000000000..c6bc3b64d91 --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/account_closing.py @@ -0,0 +1,158 @@ +from datetime import datetime, timedelta + +from openerp import models, api, fields +from openerp.fields import Datetime as FieldDateTime +from openerp.tools.translate import _ +from openerp.exceptions import UserError + + +class AccountClosing(models.Model): + """ + This object holds an interval total and a grand total of the accounts of type receivable for a company, + as well as the last account_move that has been counted in a previous object + It takes its earliest brother to infer from when the computation needs to be done + in order to compute its own data. + """ + _name = 'account.sale.closing' + _order = 'date_closing_stop desc, sequence_number desc' + + name = fields.Char(help="Frequency and unique sequence number", required=True) + company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True) + date_closing_stop = fields.Datetime(string="Closing Date", help='Date to which the values are computed', readonly=True, required=True) + date_closing_start = fields.Datetime(string="Starting Date", help='Date from which the total interval is computed', readonly=True, required=True) + frequency = fields.Selection(string='Closing Type', selection=[('daily', 'Daily'), ('monthly', 'Monthly'), ('annually', 'Annual')], readonly=True, required=True) + total_interval = fields.Monetary(string="Period Total", help='Total in receivable accounts during the interval, excluding overlapping periods', readonly=True, required=True) + cumulative_total = fields.Monetary(string="Cumulative Grand Total", help='Total in receivable accounts since the beginnig of times', readonly=True, required=True) + sequence_number = fields.Integer('Sequence #', readonly=True, required=True) + last_move_id = fields.Many2one('account.move', string='Last journal entry', help='Last Journal entry included in the grand total', readonly=True) + last_move_hash = fields.Char(string='Last journal entry\'s inalteralbility hash', readonly=True) + currency_id = fields.Many2one('res.currency', string='Currency', help="The company's currency", readonly=True, related='company_id.currency_id', store=True) + + def _query_for_aml(self, company, first_move_sequence_number, date_start): + params = {'company_id': company.id} + query = '''WITH aggregate AS (SELECT m.id AS move_id, + aml.balance AS balance, + aml.id as line_id + FROM account_move_line aml + JOIN account_journal j ON aml.journal_id = j.id + JOIN account_account acc ON acc.id = aml.account_id + JOIN account_account_type t ON (t.id = acc.user_type_id AND t.type = 'receivable') + JOIN account_move m ON m.id = aml.move_id + WHERE j.type = 'sale' + AND aml.company_id = %(company_id)s + AND m.state = 'posted' ''' + + if first_move_sequence_number is not False and first_move_sequence_number is not None: + params['first_move_sequence_number'] = first_move_sequence_number + query += '''AND m.l10n_fr_secure_sequence_number > %(first_move_sequence_number)s''' + elif date_start: + #the first time we compute the closing, we consider only from the installation of the module + params['date_start'] = date_start + query += '''AND m.date >= %(date_start)s''' + + query += " ORDER BY m.l10n_fr_secure_sequence_number DESC) " + query += '''SELECT array_agg(move_id) AS move_ids, + array_agg(line_id) AS line_ids, + sum(balance) AS balance + FROM aggregate''' + + self.env.cr.execute(query, params) + return self.env.cr.dictfetchall()[0] + + def _compute_amounts(self, frequency, company): + """ + Method used to compute all the business data of the new object. + It will search for previous closings of the same frequency to infer the move from which + account move lines should be fetched. + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @param {recordset} company: the company for which the closing is done + @return {dict} containing {field: value} for each business field of the object + """ + interval_dates = self._interval_dates(frequency, company) + previous_closing = self.search([ + ('frequency', '=', frequency), + ('company_id', '=', company.id)], limit=1, order='sequence_number desc') + + first_move = self.env['account.move'] + date_start = interval_dates['interval_from'] + cumulative_total = 0 + if previous_closing: + first_move = previous_closing.last_move_id + date_start = previous_closing.create_date + cumulative_total += previous_closing.cumulative_total + + aml_aggregate = self._query_for_aml(company, first_move.l10n_fr_secure_sequence_number, date_start) + + total_interval = aml_aggregate['balance'] or 0 + cumulative_total += total_interval + + # We keep the reference to avoid gaps (like daily object during the weekend) + last_move = first_move + if aml_aggregate['move_ids']: + last_move = last_move.browse(aml_aggregate['move_ids'][0]) + + return {'total_interval': total_interval, + 'cumulative_total': cumulative_total, + 'last_move_id': last_move.id, + 'last_move_hash': last_move.l10n_fr_hash, + 'date_closing_stop': interval_dates['date_stop'], + 'date_closing_start': date_start, + 'name': interval_dates['name_interval'] + ' - ' + interval_dates['date_stop'][:10]} + + def _interval_dates(self, frequency, company): + """ + Method used to compute the theoretical date from which account move lines should be fetched + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @param {recordset} company: the company for which the closing is done + @return {dict} the theoretical date from which account move lines are fetched. + date_stop date to which the move lines are fetched, always now() + the dates are in their Odoo Database string representation + """ + date_stop = datetime.utcnow() + interval_from = None + name_interval = '' + if frequency == 'daily': + interval_from = date_stop - timedelta(days=1) + name_interval = _('Daily Closing') + elif frequency == 'monthly': + month_target = date_stop.month > 1 and date_stop.month - 1 or 12 + year_target = month_target < 12 and date_stop.year or date_stop.year - 1 + interval_from = date_stop.replace(year=year_target, month=month_target) + name_interval = _('Monthly Closing') + elif frequency == 'annually': + year_target = date_stop.year - 1 + interval_from = date_stop.replace(year=year_target) + name_interval = _('Annual Closing') + + return {'interval_from': FieldDateTime.to_string(interval_from), + 'date_stop': FieldDateTime.to_string(date_stop), + 'name_interval': name_interval} + + @api.multi + def write(self, vals): + raise UserError(_('Sale Closings are not meant to be written or deleted under any circumstances.')) + + @api.multi + def unlink(self): + raise UserError(_('Sale Closings are not meant to be written or deleted under any circumstances.')) + + @api.model + def _automated_closing(self, frequency='daily'): + """To be executed by the CRON to create an object of the given frequency for each company that needs it + @param {string} frequency: a valid value of the selection field on the object (daily, monthly, annually) + frequencies are literal (daily means 24 hours and so on) + @return {recordset} all the objects created for the given frequency + """ + res_company = self.env['res.company'].search([]) + account_closings = self.env['account.sale.closing'] + for company in res_company.filtered(lambda c: c._is_accounting_unalterable()): + new_sequence_number = company.l10n_fr_closing_sequence_id.next_by_id() + values = self._compute_amounts(frequency, company) + values['frequency'] = frequency + values['company_id'] = company.id + values['sequence_number'] = new_sequence_number + account_closings |= account_closings.create(values) + + return account_closings diff --git a/addons/l10n_fr_sale_closing/models/res_company.py b/addons/l10n_fr_sale_closing/models/res_company.py new file mode 100644 index 00000000000..7a5a14b46c9 --- /dev/null +++ b/addons/l10n_fr_sale_closing/models/res_company.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from openerp import fields, models, api + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_fr_closing_sequence_id = fields.Many2one('ir.sequence', 'Sequence to use to build sale closings', readonly=True) + + @api.model + def create(self, vals): + company = super(ResCompany, self).create(vals) + #when creating a new french company, create the securisation sequence as well + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_closing_sequence_id'] + company._create_secure_sequence(sequence_fields) + return company + + @api.multi + def write(self, vals): + res = super(ResCompany, self).write(vals) + #if country changed to fr, create the securisation sequence + for company in self: + if company._is_accounting_unalterable(): + sequence_fields = ['l10n_fr_closing_sequence_id'] + company._create_secure_sequence(sequence_fields) + return res diff --git a/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml b/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml new file mode 100644 index 00000000000..e5a36f61479 --- /dev/null +++ b/addons/l10n_fr_sale_closing/security/account_closing_intercompany.xml @@ -0,0 +1,8 @@ + + + Sale Closing multi-company + + [('company_id','child_of',[user.company_id.id])] + + + \ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/security/ir.model.access.csv b/addons/l10n_fr_sale_closing/security/ir.model.access.csv new file mode 100644 index 00000000000..32a4e37f3d0 --- /dev/null +++ b/addons/l10n_fr_sale_closing/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_l10n_fr_sale_closing_account_sale_closing_user,l10n_fr_sale_closing.account.sale.closing.user,l10n_fr_sale_closing.model_account_sale_closing,base.group_user,1,0,0,0 \ No newline at end of file diff --git a/addons/l10n_fr_sale_closing/static/description/icon.png b/addons/l10n_fr_sale_closing/static/description/icon.png new file mode 100644 index 00000000000..bbcaa4fcce7 Binary files /dev/null and b/addons/l10n_fr_sale_closing/static/description/icon.png differ diff --git a/addons/l10n_fr_sale_closing/views/account_sale_closure.xml b/addons/l10n_fr_sale_closing/views/account_sale_closure.xml new file mode 100644 index 00000000000..97ddeb05056 --- /dev/null +++ b/addons/l10n_fr_sale_closing/views/account_sale_closure.xml @@ -0,0 +1,68 @@ + + + Sales Closings + account.sale.closing + + + + + + + + + + + + + + + + Sales Closings + account.sale.closing + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Sales Closings + account.sale.closing + tree + list,form + +

+ The closings are created by Odoo +

+ Sales closings run automatically on a daily, monthly and annual basis. It computes both period and cumulative totals from all the sales entries posted in the system after the previous closing. +

+
+
+ + +
diff --git a/addons/web/static/src/less/form_view_extra.less b/addons/web/static/src/less/form_view_extra.less index 300724e2819..3377634611a 100644 --- a/addons/web/static/src/less/form_view_extra.less +++ b/addons/web/static/src/less/form_view_extra.less @@ -187,11 +187,17 @@ } // All required form fields + .o-input-required-style() { + background-color: #D2D2FF; + } .o_form_required { - &.o_form_input, .o_form_input, &.o_form_textarea, .o_form_textarea { - background-color: #D2D2FF; + &.o_form_input, .o_form_input, textarea.o_form_textarea { + .o-input-required-style(); } } + textarea.o_form_textarea.o_form_required { + .o-input-required-style(); + } .o_form_field.o_form_field_many2one .o_external_button { padding: 0; diff --git a/addons/website_event_sale/views/website_event_sale_templates.xml b/addons/website_event_sale/views/website_event_sale_templates.xml index d0005892fd6..a1a648ce738 100644 --- a/addons/website_event_sale/views/website_event_sale_templates.xml +++ b/addons/website_event_sale/views/website_event_sale_templates.xml @@ -88,7 +88,7 @@ class="btn btn-primary btn-lg pull-right a-submit" t-attf-id="#{event.id}">Order Now - event.event_ticket_ids + event.event_ticket_ids and not all([ticket.is_expired for ticket in event.event_ticket_ids])