diff options
| author | Florian Gilbert <flg@odoo.com> | 2019-08-22 10:03:26 +0000 |
|---|---|---|
| committer | Florian Gilbert <flg@odoo.com> | 2019-09-25 11:26:02 +0000 |
| commit | ab483a3a6610a09391ec00e9c1c49489ca4a4f12 (patch) | |
| tree | 0882cebae27345110d8049970e37eaa3fea11991 /addons/account | |
| parent | 02dfe1e8e789ee783f58f667efdf47c017585298 (diff) | |
[IMP] account: Add restrict mode and normal mode for account move.
This commit deletes account_cancel module and squash it into account.
The 'update_posted' field is changed by 'restrict_mode_hash_table' field.
If 'restrict_mode_hash_table' is true, you have a hash chain on your account journal.
These hash chains prove the inalterability of your accounting.
l10n_fr_certification module is deleted and all hashing method are moved in account module.
Now, you can download a PDF report about your inalterability in the company settings.
If you have l10n_fr or l10n_post_cert installed you have more information on this report
like the inalterability of pos orders, etc.
Task ID: 2039160
Diffstat (limited to 'addons/account')
| -rw-r--r-- | addons/account/__manifest__.py | 3 | ||||
| -rw-r--r-- | addons/account/models/account.py | 37 | ||||
| -rw-r--r-- | addons/account/models/account_bank_statement.py | 7 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 125 | ||||
| -rw-r--r-- | addons/account/models/account_payment.py | 16 | ||||
| -rw-r--r-- | addons/account/models/company.py | 81 | ||||
| -rw-r--r-- | addons/account/report/__init__.py | 1 | ||||
| -rw-r--r-- | addons/account/report/account_hash_integrity_templates.py | 22 | ||||
| -rw-r--r-- | addons/account/report/account_hash_integrity_templates.xml | 106 | ||||
| -rw-r--r-- | addons/account/tests/test_account_move_partner_count.py | 1 | ||||
| -rw-r--r-- | addons/account/tests/test_account_move_tax_lock_date.py | 32 | ||||
| -rw-r--r-- | addons/account/tests/test_payment.py | 22 | ||||
| -rw-r--r-- | addons/account/tests/test_reconciliation.py | 2 | ||||
| -rw-r--r-- | addons/account/views/account_move_views.xml | 35 | ||||
| -rw-r--r-- | addons/account/views/account_payment_view.xml | 5 | ||||
| -rw-r--r-- | addons/account/views/account_report.xml | 10 | ||||
| -rw-r--r-- | addons/account/views/account_view.xml | 4 |
17 files changed, 420 insertions, 89 deletions
diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 1670fd3bc62..f7544acca7b 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -61,7 +61,8 @@ You could use this simplified accounting in case you work with an (external) acc 'views/digest_views.xml', 'wizard/account_invoice_send_views.xml', 'views/account_tax_report_views.xml', - 'views/report_statement.xml' + 'views/report_statement.xml', + 'report/account_hash_integrity_templates.xml' ], 'demo': [ 'demo/account_demo.xml', diff --git a/addons/account/models/account.py b/addons/account/models/account.py index fb8c6d9cb2c..b3d45dc26f6 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -638,8 +638,8 @@ class AccountJournal(models.Model): ondelete='restrict') default_debit_account_id = fields.Many2one('account.account', string='Default Debit Account', domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", help="It acts as a default account for debit amount", ondelete='restrict') - update_posted = fields.Boolean(string='Allow Cancelling Entries', - help="Check this box if you want to allow the cancellation the entries related to this journal or of the invoice related to this journal") + restrict_mode_hash_table = fields.Boolean(string="Lock Posted Entries with Hash", + help="If ticked, the accounting entry or invoice receives a hash as soon as it is posted and cannot be modified anymore.") sequence_id = fields.Many2one('ir.sequence', string='Entry Sequence', help="This field contains the information related to the numbering of the journal entries of this journal.", required=True, copy=False) refund_sequence_id = fields.Many2one('ir.sequence', string='Credit Note Entry Sequence', @@ -694,6 +694,8 @@ class AccountJournal(models.Model): journal_group_ids = fields.Many2many('account.journal.group', domain="[('company_id', '=', company_id)]", string="Journal Groups") + secure_sequence_id = fields.Many2one('ir.sequence', help='Sequence to use to ensure the securisation of data', readonly=True, copy=False) + _sql_constraints = [ ('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'), ] @@ -860,6 +862,11 @@ class AccountJournal(models.Model): raise UserError(_("The partners of the journal's company and the related bank account mismatch.")) if 'alias_name' in vals: journal._update_mail_alias(vals) + if 'restrict_mode_hash_table' in vals and not vals.get('restrict_mode_hash_table'): + journal_entry = self.env['account.move'].search([('journal_id', '=', self.id), ('state', '=', 'posted'), ('secure_sequence_number', '!=', 0)], limit=1) + if len(journal_entry) > 0: + field_string = self._fields['restrict_mode_hash_table'].get_description(self.env)['string'] + raise UserError(_("You cannot modify the field %s of a journal that already has accounting entries.") % field_string) result = super(AccountJournal, self).write(vals) # Create the bank_account_id if necessary @@ -882,6 +889,10 @@ class AccountJournal(models.Model): pending_payments = draft_moves.mapped('line_ids.payment_id') pending_payments.mapped('move_line_ids.move_id').post() pending_payments.mapped('reconciled_invoice_ids').filtered(lambda x: x.state == 'in_payment').write({'state': 'paid'}) + for record in self: + if record.restrict_mode_hash_table and not record.secure_sequence_id: + record._create_secure_sequence(['secure_sequence_id']) + return result @api.model @@ -1073,6 +1084,28 @@ class AccountJournal(models.Model): action_vals['view_mode'] = 'tree,form' return action_vals + def _create_secure_sequence(self, sequence_fields): + """This function creates a no_gap sequence on each journal 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 on a specific journal. + """ + for journal in self: + vals_write = {} + for seq_field in sequence_fields: + if not journal[seq_field]: + vals = { + 'name': _('Securisation of %s - %s') % (seq_field, journal.name), + 'code': 'SECUR%s-%s' % (journal.id, seq_field), + 'implementation': 'no_gap', + 'prefix': '', + 'suffix': '', + 'padding': 0, + 'company_id': journal.company_id.id} + seq = self.env['ir.sequence'].create(vals) + vals_write[seq_field] = seq.id + if vals_write: + journal.write(vals_write) + class ResPartnerBank(models.Model): _inherit = "res.partner.bank" diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 8ddf1b6000a..7799968263d 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -332,6 +332,9 @@ class AccountBankStatement(models.Model): statement.name = st_number statement.state = 'open' + def button_reopen(self): + self.state = 'open' + def action_bank_reconcile_bank_statements(self): self.ensure_one() bank_stmt_lines = self.mapped('line_ids') @@ -445,9 +448,9 @@ class AccountBankStatementLine(models.Model): if aml_to_cancel: aml_to_cancel.remove_move_reconcile() moves_to_cancel = aml_to_cancel.mapped('move_id') - moves_to_cancel.button_cancel() moves_to_cancel.button_draft() - moves_to_cancel.unlink() + moves_to_cancel.button_cancel() + moves_to_cancel.with_context(force_delete=True).unlink() if payment_to_cancel: payment_to_cancel.unlink() diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 131ca0f3a53..2bfe746fbce 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -10,6 +10,8 @@ from datetime import date from itertools import groupby, chain from stdnum.iso7064 import mod_97_10 from itertools import zip_longest +from hashlib import sha256 +from json import dumps import json import re @@ -18,6 +20,9 @@ import psycopg2 _logger = logging.getLogger(__name__) +#forbidden fields +INTEGRITY_HASH_MOVE_FIELDS = ['date', 'journal_id', 'company_id'] +INTEGRITY_HASH_LINE_FIELDS = ['debit', 'credit', 'account_id', 'partner_id'] class AccountMove(models.Model): _name = "account.move" @@ -168,8 +173,7 @@ class AccountMove(models.Model): domain="[('company_id', '=', company_id)]", help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices. " "The default value comes from the customer.") - invoice_user_id = fields.Many2one('res.users', readonly=True, copy=False, tracking=True, - states={'draft': [('readonly', False)]}, + invoice_user_id = fields.Many2one('res.users', copy=False, tracking=True, string='Salesperson', default=lambda self: self.env.user) user_id = fields.Many2one(string='User', related='invoice_user_id', @@ -184,8 +188,7 @@ class AccountMove(models.Model): states={'draft': [('readonly', False)]}) invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False, states={'draft': [('readonly', False)]}) - invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False, readonly=True, - states={'draft': [('readonly', False)]}, + invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False, help="The payment reference to set on journal items.") invoice_sent = fields.Boolean(readonly=True, default=False, copy=False, help="It indicates that the invoice has been sent.") @@ -201,7 +204,7 @@ class AccountMove(models.Model): states={'draft': [('readonly', False)]}) invoice_partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account', help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.', - readonly=True, states={'draft': [('readonly', False)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm', default=_get_default_invoice_incoterm, help='International Commercial Terms are a series of predefined commercial terms used in international transactions.') @@ -244,6 +247,11 @@ class AccountMove(models.Model): help="Technical field used to display an alert on invoices if there is at least a matching amount in any supsense account.") # Technical field to hide Reconciled Entries stat button has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries") + # ==== Hash Fields ==== + restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table') + secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False) + inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False) + string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True) # ------------------------------------------------------------------------- # ONCHANGE METHODS @@ -1502,12 +1510,29 @@ class AccountMove(models.Model): def write(self, vals): not_paid_invoices = self.filtered(lambda move: move.is_invoice(include_receipts=True) and move.invoice_payment_state not in ('paid', 'in_payment')) + for move in self: + if (move.restrict_mode_hash_table and move.state == "posted" and set(vals).intersection(INTEGRITY_HASH_MOVE_FIELDS)): + raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_MOVE_FIELDS)) + if (move.restrict_mode_hash_table and move.inalterable_hash and 'inalterable_hash' in vals) or (move.secure_sequence_number and 'secure_sequence_number' in vals): + raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.')) + if (move.name != '/' and 'journal_id' in vals and move.journal_id.id != vals['journal_id']): + raise UserError(_('You cannot edit the journal of an account move if it has been posted once.')) + if 'date' in vals and move.date != vals['date']: + move._check_move_consistency(check_balanced=False) + if self._move_autocomplete_invoice_lines_write(vals): res = True else: vals.pop('invoice_line_ids', None) res = super(AccountMove, self.with_context(check_move_validity=False)).write(vals) + if ('state' in vals and vals.get('state') == 'posted') and self.restrict_mode_hash_table: + for move in self.filtered(lambda m: not(m.secure_sequence_number or m.inalterable_hash)): + new_number = move.journal_id.secure_sequence_id.next_by_id() + vals_hashing = {'secure_sequence_number': new_number, + 'inalterable_hash': move._get_new_hash(new_number)} + res |= super(AccountMove, move).write(vals_hashing) + # Ensure the move is still well balanced. if 'line_ids' in vals and self._context.get('check_move_validity', True): # 'check_move_validity' is needed as the write will be done line per line. @@ -1520,8 +1545,8 @@ class AccountMove(models.Model): def unlink(self): for move in self: - # check the lock date + check if some entries are reconciled - move.line_ids._update_check() + if move.name != '/' and not self._context.get('force_delete'): + raise UserError(_("You cannot delete an entry which has been posted once.")) move.line_ids.unlink() return super(AccountMove, self).unlink() @@ -2000,11 +2025,6 @@ class AccountMove(models.Model): return lines.reconcile() def button_draft(self): - if any(move.state != 'cancel' for move in self): - raise UserError(_('Only cancelled journal entries can be reset to draft.')) - self.write({'state': 'draft'}) - - def button_cancel(self): self._check_tax_lock_date() AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -2014,21 +2034,20 @@ class AccountMove(models.Model): for move in self: if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'): - raise UserError(_('You cannot cancel an exchange difference journal entry.')) + raise UserError(_('You cannot reset to draft an exchange difference journal entry.')) if move.tax_cash_basis_rec_id: - raise UserError(_('You cannot cancel a tax cash basis journal entry.')) - if not move.journal_id.update_posted and move.id not in excluded_move_ids: - raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) + raise UserError(_('You cannot reset to draft a tax cash basis journal entry.')) + if move.restrict_mode_hash_table and move.state == 'posted' and move.id not in excluded_move_ids: + raise UserError(_('You cannot modify a posted entry of this journal because it is in strict mode.')) # We remove all the analytics entries for this journal move.mapped('line_ids.analytic_line_ids').unlink() - if self.ids: - self.check_access_rights('write') - self.check_access_rule('write') - self._check_fiscalyear_lock_date() - self._cr.execute('UPDATE account_move SET state=%s WHERE id IN %s', ('cancel', tuple(self.ids))) - self.invalidate_cache() + self._check_fiscalyear_lock_date() self.mapped('line_ids').remove_move_reconcile() + self.write({'state': 'draft'}) + + def button_cancel(self): + self.write({'state': 'cancel'}) return True def action_invoice_sent(self): @@ -2060,6 +2079,51 @@ class AccountMove(models.Model): 'context': ctx, } + def _get_new_hash(self, secure_seq_number): + """ Returns the hash to write on journal entries when they get posted""" + self.ensure_one() + #get the only one exact previous move in the securisation sequence + prev_move = self.search([('state', '=', 'posted'), + ('company_id', '=', self.company_id.id), + ('journal_id', '=', self.journal_id.id), + ('secure_sequence_number', '!=', 0), + ('secure_sequence_number', '=', int(secure_seq_number) - 1)]) + if prev_move and len(prev_move) != 1: + raise UserError( + _('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.inalterable_hash if prev_move else u'') + + 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.string_to_hash).encode('utf-8')) + 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 + return str(field_value) + + for move in self: + values = {} + for field in INTEGRITY_HASH_MOVE_FIELDS: + values[field] = _getattrstring(move, field) + + for line in move.line_ids: + for field in INTEGRITY_HASH_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) + move.string_to_hash = dumps(values, sort_keys=True, + ensure_ascii=True, indent=None, + separators=(',',':')) + def action_invoice_print(self): """ Print the invoice and mark it as sent, so that we can see more easily the next step of the workflow @@ -2282,11 +2346,11 @@ class AccountMoveLine(models.Model): amount_residual_currency = fields.Monetary(string='Residual Amount in Currency', store=True, compute='_amount_residual', help="The residual amount on a journal item expressed in its currency (possibly not the company currency).") - full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching #", copy=False, index=True) + full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching #", copy=False, index=True, readonly=True) matched_debit_ids = fields.One2many('account.partial.reconcile', 'credit_move_id', string='Matched Debits', - help='Debit journal items that are matched with this journal item.') + help='Debit journal items that are matched with this journal item.', readonly=True) matched_credit_ids = fields.One2many('account.partial.reconcile', 'debit_move_id', string='Matched Credits', - help='Credit journal items that are matched with this journal item.') + help='Credit journal items that are matched with this journal item.', readonly=True) # ==== Analytic fields ==== analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines') @@ -2847,8 +2911,6 @@ class AccountMoveLine(models.Model): move_ids = set() for line in self: err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id)) - if line.move_id.state == 'posted': - raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg) if line.reconciled and not (line.debit == 0 and line.credit == 0): raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg) if line.move_id.id not in move_ids: @@ -2970,8 +3032,13 @@ class AccountMoveLine(models.Model): # this is needed to compute the correct amount_residual_currency and potentially create an exchange difference entry self._update_check() # when making a reconciliation on an existing liquidity journal item, mark the payment as reconciled - for record in self: - if 'statement_line_id' in vals and record.payment_id: + for line in self: + if line.parent_state == 'posted': + if line.move_id.restrict_mode_hash_table and set(vals).intersection(INTEGRITY_HASH_LINE_FIELDS): + raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_LINE_FIELDS)) + if any(key in vals for key in ('tax_ids', 'tax_line_ids')): + raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.')) + if 'statement_line_id' in vals and line.payment_id: # In case of an internal transfer, there are 2 liquidity move lines to match with a bank statement if all(line.statement_id for line in record.payment_id.move_line_ids.filtered( lambda r: r.id != record.id and r.account_id.internal_type == 'liquidity')): diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index 6b4dd3a1078..f15e1539ed1 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -441,16 +441,7 @@ class account_payment(models.Model): payment.write({'state': 'posted'}) def cancel(self): - for rec in self: - for move in rec.move_line_ids.mapped('move_id'): - if rec.reconciled_invoice_ids: - move.line_ids.remove_move_reconcile() - if move.state != 'draft': - move.button_cancel() - move.unlink() - rec.write({ - 'state': 'cancelled', - }) + self.write({'state': 'cancelled'}) def unlink(self): if any(bool(rec.move_line_ids) for rec in self): @@ -703,7 +694,10 @@ class account_payment(models.Model): return True def action_draft(self): - return self.write({'state': 'draft'}) + moves = self.mapped('move_line_ids.move_id') + moves.filtered(lambda move: move.state == 'posted').button_draft() + moves.with_context(force_delete=True).unlink() + self.write({'state': 'draft'}) def _get_invoice_payment_amount(self, inv): """ diff --git a/addons/account/models/company.py b/addons/account/models/company.py index e40265d4f64..7116dafff8c 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta from odoo import fields, models, api, _ from odoo.exceptions import ValidationError, UserError, RedirectWarning -from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT +from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT, format_date from odoo.tools.float_utils import float_round, float_is_zero from odoo.tools import date_utils from odoo.tests.common import Form @@ -553,3 +553,82 @@ class ResCompany(models.Model): "Please go to Account Configuration and select or install a fiscal localization.") raise RedirectWarning(msg, action.id, _("Go to the configuration panel")) return account + + @api.model + def _action_check_hash_integrity(self): + return self.env.ref('account.action_report_account_hash_integrity').report_action(self.id) + + def _check_hash_integrity(self): + """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): + return(move.name, move.inalterable_hash, fields.Date.to_string(move.date)) + + journals = self.env['account.journal'].search([('company_id', '=', self.id)]) + results_by_journal = { + 'results': [], + 'printing_date': format_date(self.env, fields.Date.to_string(fields.Date.today())) + } + + for journal in journals: + rslt = { + 'journal_name': journal.name, + 'journal_code': journal.code, + 'restricted_by_hash_table': journal.restrict_mode_hash_table and 'V' or 'X', + 'msg_cover': '', + 'first_hash': 'None', + 'first_move_name': 'None', + 'first_move_date': 'None', + 'last_hash': 'None', + 'last_move_name': 'None', + 'last_move_date': 'None', + } + if not journal.restrict_mode_hash_table: + rslt.update({'msg_cover': _('This journal is not in strict mode.')}) + results_by_journal['results'].append(rslt) + continue + + all_moves_count = self.env['account.move'].search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)]) + moves = self.env['account.move'].search([('state', '=', 'posted'), ('journal_id', '=', journal.id), + ('secure_sequence_number', '!=', 0)], order="secure_sequence_number ASC") + if not moves: + rslt.update({ + 'msg_cover': _('There isn\'t any journal entry flagged for data inalterability yet for this journal.'), + }) + results_by_journal['results'].append(rslt) + continue + + previous_hash = u'' + start_move_info = [] + hash_corrupted = False + for move in moves: + if move.inalterable_hash != move._compute_hash(previous_hash=previous_hash): + rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.') % move.id}) + results_by_journal['results'].append(rslt) + hash_corrupted = True + break + if not previous_hash: + #save the date and sequence number of the first move hashed + start_move_info = build_move_info(move) + previous_hash = move.inalterable_hash + end_move_info = build_move_info(move) + + if hash_corrupted: + continue + + rslt.update({ + 'first_move_name': start_move_info[0], + 'first_hash': start_move_info[1], + 'first_move_date': format_date(self.env, start_move_info[2]), + 'last_move_name': end_move_info[0], + 'last_hash': end_move_info[1], + 'last_move_date': format_date(self.env, end_move_info[2]), + }) + if len(moves) == all_moves_count: + rslt.update({'msg_cover': _('All entries are hashed.')}) + else: + rslt.update({'msg_cover': _('Entries are hashed from %s (%s)') % (start_move_info[0], format_date(self.env, start_move_info[2]))}) + results_by_journal['results'].append(rslt) + + return results_by_journal diff --git a/addons/account/report/__init__.py b/addons/account/report/__init__.py index 0082db0381a..085f3cea256 100644 --- a/addons/account/report/__init__.py +++ b/addons/account/report/__init__.py @@ -4,3 +4,4 @@ from . import account_invoice_report from . import account_journal from . import account_aged_partner_balance +from . import account_hash_integrity_templates diff --git a/addons/account/report/account_hash_integrity_templates.py b/addons/account/report/account_hash_integrity_templates.py new file mode 100644 index 00000000000..fe246841522 --- /dev/null +++ b/addons/account/report/account_hash_integrity_templates.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ReportAccountHashIntegrity(models.AbstractModel): + _name = 'report.account.report_hash_integrity' + _description = 'Get hash integrity result as PDF.' + + @api.model + def _get_report_values(self, docids, data=None): + if data: + data.update(self.env.company._check_hash_integrity()) + else: + data = self.env.company._check_hash_integrity() + return { + 'doc_ids' : docids, + 'doc_model' : self.env['res.company'], + 'data' : data, + 'docs' : self.env['res.company'].browse(self.env.company.id), + } diff --git a/addons/account/report/account_hash_integrity_templates.xml b/addons/account/report/account_hash_integrity_templates.xml new file mode 100644 index 00000000000..14716749395 --- /dev/null +++ b/addons/account/report/account_hash_integrity_templates.xml @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="utf-8" ?> +<odoo> + <data> + <template id="report_hash_integrity"> + <t t-call="web.html_container"> + <t t-foreach="docs" t-as="company"> + <t t-call="web.external_layout"> + <div class="page"> + <div class="row" id="hash_header"> + <div class="col-12"> + <br/> + <h2>Hash Integrity Result - <span t-esc="data['printing_date']"/></h2> + <br/> + </div> + </div> + <div class="row"> + <div class="col-12" id="hash_config_review"> + <br/> + <h3>Configuration review</h3> + <br/> + </div> + </div> + <div class="row"> + <div class="col-12"> + <table class="table table-bordered" style="table-layout: fixed" id="hash_config_table"> + <thead style="display: table-row-group"> + <tr> + <th class="text-center" style="width: 30%" scope="col">Journal</th> + <th class="text-center" style="width: 20%" scope="col">Inalterability check</th> + <th class="text-center" style="width: 50%" scope="col">Coverage</th> + </tr> + </thead> + <tbody> + <t t-foreach="data['results']" t-as="result"> + <tr> + <td> + [<span t-esc="result['journal_code']"/>] <span t-esc="result['journal_name']"/> + </td> + <td class="text-center"><span t-esc="result['restricted_by_hash_table']"/></td> + <td><span t-esc="result['msg_cover']"/></td> + </tr> + </t> + </tbody> + </table> + </div> + </div> + <t t-if="any(result['first_hash'] != 'None' for result in data['results'])"> + <div class="row" style="page-break-before:always;"> + <div class="col-12" id="hash_data_consistency"> + <br/> + <h3>Data consistency check</h3> + <br/> + </div> + </div> + <div class="row"> + <div class="col-12" id="hash_data_consistency_table"> + <table class="table table-bordered" style="table-layout: fixed"> + <thead style="display: table-row-group"> + <tr> + <th class="text-center" style="width: 20%" scope="col">Journal</th> + <th class="text-center" style="width: 20%" scope="col">First Hash</th> + <th class="text-center" style="width: 20%" scope="col">First Entry</th> + <th class="text-center" style="width: 20%" scope="col">Last Hash</th> + <th class="text-center" style="width: 20%" scope="col">Last Entry</th> + </tr> + </thead> + <tbody> + <t t-foreach="data['results']" t-as="result"> + <t t-if="result['first_hash'] != 'None'"> + <tr> + <td><span t-esc="result['journal_code']"/></td> + <td><span t-esc="result['first_hash']"/></td> + <td> + <span t-esc="result['first_move_name']"/> <br/> + <span t-esc="result['first_move_date']"/> + </td> + <td><span t-esc="result['last_hash']"/></td> + <td> + <span t-esc="result['last_move_name']"/> <br/> + <span t-esc="result['last_move_date']"/> + </td> + </tr> + </t> + </t> + </tbody> + </table> + </div> + </div> + <div class="row" id="hash_last_div"> + <div class="col-12" id="hash_chain_compliant"> + <br/> + <h6> + The hash chain is compliant: it is not possible to alter the + data without breaking the hash chain for subsequent parts. + </h6> + <br/> + </div> + </div> + </t> + </div> + </t> + </t> + </t> + </template> + </data> +</odoo> diff --git a/addons/account/tests/test_account_move_partner_count.py b/addons/account/tests/test_account_move_partner_count.py index 68527cf7572..203c4dc0493 100644 --- a/addons/account/tests/test_account_move_partner_count.py +++ b/addons/account/tests/test_account_move_partner_count.py @@ -45,6 +45,7 @@ class TestAccountMovePartnerCount(TransactionCase): with self.registry.cursor() as cr: env = self.env(cr=cr) move = self.move.with_env(env) + move.name = '/' move.state = 'draft' move.unlink() self.journal.with_env(env).unlink() diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py index 280536e6b46..efd383cb815 100644 --- a/addons/account/tests/test_account_move_tax_lock_date.py +++ b/addons/account/tests/test_account_move_tax_lock_date.py @@ -19,12 +19,11 @@ class TestTaxBlockDate(AccountingTestCase): self.company = self.env.company company_id = self.company.id - last_day_month = date.today() - self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1]) - first_day_month = date.today() - self.first_day_month = first_day_month.replace(day=1) - middle_day_month = date.today() - middle_day_month = middle_day_month.replace(day=15) + last_day_previous_month = date.today() + last_day_previous_month = last_day_previous_month.replace(month=last_day_previous_month.month-1) + self.last_day_previous_month = last_day_previous_month.replace(day=monthrange(last_day_previous_month.year, last_day_previous_month.month)[1]) + self.first_day_previous_month = self.last_day_previous_month.replace(day=1) + middle_day_previous_month = self.last_day_previous_month.replace(day=15) self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0] self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] @@ -34,7 +33,7 @@ class TestTaxBlockDate(AccountingTestCase): self.move = { 'name': '/', 'journal_id': self.sale_journal_id.id, - 'date': middle_day_month, + 'date': middle_day_previous_month, 'line_ids': [(0, 0, { 'name': 'foo', 'debit': 10, @@ -50,7 +49,7 @@ class TestTaxBlockDate(AccountingTestCase): self.move_no_tax = { 'name': '/', 'journal_id': self.sale_journal_id.id, - 'date': middle_day_month, + 'date': middle_day_previous_month, 'line_ids': [(0, 0, { 'name': 'foo', 'debit': 10, @@ -67,32 +66,31 @@ class TestTaxBlockDate(AccountingTestCase): before the tax lock date. """ # Posting after the lock date is always allowed - self.company.tax_lock_date = self.first_day_month + self.company.tax_lock_date = self.first_day_previous_month # copy() because mail.thread modifies the dictionary given to create move_after_lock = self.env['account.move'].create(self.move.copy()) move_after_lock.post() # Posting before the tax lock date is allowed only if the move doesn't contain any tax - self.user_id.company_id.tax_lock_date = self.last_day_month + self.user_id.company_id.tax_lock_date = self.last_day_previous_month move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax) move_before_lock_no_tax.post() # Posting before the tax lock date is not allowed for moves impacting the tax report - self.user_id.company_id.tax_lock_date = self.last_day_month + self.user_id.company_id.tax_lock_date = self.last_day_previous_month with self.assertRaises(UserError): self.env['account.move'].create(self.move.copy()) - def test_tax_lock_date_cancel(self): - """ Checks that it is not possible to cancel an entry posted before the + def test_tax_lock_date_reset_to_draft(self): + """ Checks that it is not possible to reset to draft an entry posted before the tax lock date if it impacts the tax report. """ - self.sale_journal_id.update_posted = True move = self.env['account.move'].create(self.move) move.post() move_no_tax = self.env['account.move'].create(self.move_no_tax) move_no_tax.post() - self.user_id.company_id.tax_lock_date = self.last_day_month + self.user_id.company_id.tax_lock_date = self.last_day_previous_month - move_no_tax.button_cancel() + move_no_tax.button_draft() with self.assertRaises(UserError): - move.button_cancel() + move.button_draft() diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index 557a482d34f..953d6c1754c 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -428,9 +428,7 @@ class TestPayment(AccountingTestCase): # The invoice should now be paid self.assertEqual(invoice.invoice_payment_state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") - def test_payment_cancel_keep_name(self): - self.bank_journal_euro.update_posted = True - + def test_payment_draft_keep_name(self): payment = self.payment_model.create({ 'payment_type': 'inbound', 'payment_method_id': self.payment_method_manual_in.id, @@ -447,18 +445,14 @@ class TestPayment(AccountingTestCase): name = payment.move_line_ids.mapped('move_id').name self.assertTrue(name) - payment.cancel() - self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.action_draft() + self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.post() self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 1) self.assertEqual(name, payment.move_line_ids.mapped('move_id').name) - def test_payment_transfer_cancel_keep_names(self): - self.bank_journal_euro.update_posted = True - self.cash_journal_euro.update_posted = True - + def test_payment_transfer_draft_keep_names(self): payment = self.payment_model.create({ 'payment_type': 'transfer', 'payment_method_id': self.payment_method_manual_out.id, @@ -487,9 +481,8 @@ class TestPayment(AccountingTestCase): self.assertEqual(reconciled_lines.mapped('move_id'), all_moves) reconciled_lines.remove_move_reconcile() - payment.cancel() - self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.action_draft() + self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.post() self.assertEqual(len(payment.move_line_ids.mapped('move_id')), 2) @@ -502,9 +495,7 @@ class TestPayment(AccountingTestCase): self.assertEqual(name, move.name) self.assertEqual(transfer_name, transfer_move.name) - def test_payment_cancel_to_transfer(self): - self.bank_journal_euro.update_posted = True - + def test_payment_draft_to_transfer(self): payment = self.payment_model.create({ 'payment_type': 'inbound', 'payment_method_id': self.payment_method_manual_in.id, @@ -521,9 +512,8 @@ class TestPayment(AccountingTestCase): name = payment.move_line_ids.mapped('move_id').name self.assertTrue(name) - payment.cancel() - self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.action_draft() + self.assertFalse(payment.move_line_ids.mapped('move_id')) payment.write({ 'payment_type': 'transfer', diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 94e5e69bee4..91c52a22c85 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -1506,7 +1506,7 @@ class TestReconciliationExec(TestReconciliation): partner = self.env['res.partner'].create({'name': 'UncertainPartner'}) currency = self.env.company.currency_id invoice = self.create_invoice_partner(currency_id=currency.id, partner_id=partner.id) - journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'update_posted':True}) + journal = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'THE', 'restrict_mode_hash_table':False}) statement = self.make_payment(invoice, journal, 50) st_line = statement.line_ids diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 12aace1ff9b..2def26b8cb3 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -27,7 +27,7 @@ <field name="account_id" options="{'no_create': True}" domain="[('company_id', '=', company_id)]" attrs="{'readonly':[('parent_state','=','posted')]}"/> <field name="debit" attrs="{'readonly':[('parent_state','=','posted')]}"/> <field name="credit" attrs="{'readonly':[('parent_state','=','posted')]}"/> - <field name="quantity"/> + <field name="quantity" attrs="{'readonly':[('parent_state','=','posted')]}"/> </group> <group string="Accounting Documents"> <field name="move_id" attrs="{'readonly':[('parent_state','=','posted')]}"/> @@ -68,8 +68,10 @@ <field name="blocked"/> </group> <group string="Analytic" groups="analytic.group_analytic_accounting,analytic.group_analytic_tags"> - <field name="analytic_account_id" groups="analytic.group_analytic_accounting"/> - <field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags"/> + <field name="analytic_account_id" groups="analytic.group_analytic_accounting" + attrs="{'readonly':[('parent_state','=','posted')]}"/> + <field name="analytic_tag_ids" groups="analytic.group_analytic_tags" + widget="many2many_tags"/> </group> </group> </page> @@ -138,7 +140,7 @@ <field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/> <field name="journal_id" options='{"no_open":True}'/> <field name="move_id" optional="show"/> - <field name="account_id" options="{'no_open': True, 'no_create': True}" domain="[('company_id', '=', company_id)]" groups="account.group_account_user" attrs="{'readonly':[('parent_state','=','posted')]}"/> + <field name="account_id" options="{'no_open': True, 'no_create': True}" domain="[('company_id', '=', company_id)]" groups="account.group_account_user"/> <field name="partner_id" optional="show"/> <field name="statement_id" invisible="1"/> <field name="ref" optional="show"/> @@ -507,6 +509,10 @@ <button name="action_duplicate" string="Duplicate" type="object" groups="account.group_account_invoice" attrs="{'invisible': [('type', '!=', 'entry')]}"/> <!-- Cancel --> + <button name="button_cancel" string="Cancel Entry" type="object" groups="account.group_account_invoice" + attrs="{'invisible' : ['|', ('id', '=', False), ('state', '!=', 'draft')]}"/> + <button name="button_draft" string="Reset to Draft" type="object" groups="account.group_account_invoice" + attrs="{'invisible' : ['|', ('restrict_mode_hash_table', '=', True), ('state', 'not in', ('posted', 'cancel'))]}"/> <field name="state" widget="statusbar" statusbar_visible="draft,posted"/> </header> <!-- Invoice outstanding credits --> @@ -557,6 +563,7 @@ <field name="invoice_sequence_number_next" invisible="1"/> <field name="invoice_has_matching_suspense_amount" invisible="1"/> <field name="has_reconciled_entries" invisible="1"/> + <field name="restrict_mode_hash_table" invisible="1"/> <div> <!-- Invoice draft header --> @@ -642,7 +649,8 @@ invisible="context.get('default_journal_id') and context.get('type', 'entry') != 'entry'" groups="account.group_account_user" options="{'no_create': True}" - domain="[('type', '=?', invoice_filter_type_domain)]"/> + domain="[('type', '=?', invoice_filter_type_domain)]" + attrs="{'readonly': [('name', '!=', '/')]}"/> <field name="company_id" groups="base.group_multi_company"/> @@ -866,6 +874,7 @@ attrs="{'invisible': [('type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}"> <field name="invoice_incoterm_id"/> <field name="fiscal_position_id"/> + <field name="inalterable_hash" groups="base.group_no_one,account.group_account_user"/> <field name="invoice_cash_rounding_id" groups="account.group_cash_rounding"/> <field name="invoice_source_email" widget="email" @@ -874,9 +883,10 @@ <group string="Payments" name="payments_info_group" attrs="{'invisible': [('type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}"> - <field name="invoice_payment_ref"/> + <field name="invoice_payment_ref" attrs="{'readonly': [('state', '=', 'paid'), ('type', 'in', ('in_invoice', 'in_refund'))]}"/> <field name="invoice_partner_bank_id" - domain="[('partner_id', '=', bank_partner_id)]"/> + domain="[('partner_id', '=', bank_partner_id)]" + attrs="{'readonly': [('state', '=', 'paid')]}"/> </group> </group> </page> @@ -893,6 +903,7 @@ </group> <group> <field name="company_id" groups="base.group_multi_company" required="1"/> + <field name="inalterable_hash" groups="base.group_no_one,account.group_account_user"/> </group> </group> </page> @@ -1279,6 +1290,16 @@ if records: </field> </record> + <record model="ir.actions.server" id="action_check_hash_integrity"> + <field name="name">Data Inalterability Check</field> + <field name="model_id" ref="account.model_res_company"/> + <field name="type">ir.actions.server</field> + <field name="state">code</field> + <field name="code"> + action = env.company._action_check_hash_integrity() + </field> + </record> + <!-- MENUS --> <menuitem diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index 3f879df6da3..7242a091a2f 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -126,8 +126,9 @@ <form string="Register Payment"> <header> <button name="post" class="oe_highlight" states="draft" string="Confirm" type="object"/> - <button name="action_draft" class="oe_highlight" states="cancelled" string="Set To Draft" type="object"/> - <field name="state" widget="statusbar" statusbar_visible="draft,posted,reconciled"/> + <button name="action_draft" class="btn btn-secondary" states="posted" string="Reset To Draft" type="object"/> + <button name="cancel" attrs="{'invisible': [('state', 'in', ('cancelled', 'posted'))]}" string="Cancel" type="object"/> + <field name="state" widget="statusbar" statusbar_visible="draft,posted,reconciled,cancelled"/> </header> <sheet> <div class="oe_button_box" name="button_box"> diff --git a/addons/account/views/account_report.xml b/addons/account/views/account_report.xml index 86f2281424d..11d6e792f47 100644 --- a/addons/account/views/account_report.xml +++ b/addons/account/views/account_report.xml @@ -75,5 +75,15 @@ report_type="qweb-pdf" /> + <report + id="action_report_account_hash_integrity" + model="res.company" + string="Hash integrity result PDF" + report_type="qweb-pdf" + name="account.report_hash_integrity" + file="account.report_hash_integrity" + menu="False" + /> + </data> </odoo> diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 39865207430..86e8dfff114 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -319,6 +319,7 @@ <div class="text-muted" colspan="2">Keep empty for no control</div> <field name="type_control_ids" widget="many2many_tags"/> <field name="account_control_ids" widget="many2many_tags"/> + <field name="restrict_mode_hash_table" groups="account.group_account_user"/> </group> <group string="Payment Method Types" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"> <field name="inbound_payment_method_ids" widget="many2many_checkboxes"/> @@ -640,6 +641,7 @@ action = model.setting_init_bank_account_action() <field name="all_lines_reconciled" invisible="1" /> <button name="action_bank_reconcile_bank_statements" string="Reconcile" type="object" class="oe_highlight" attrs="{'invisible':['|','|',('all_lines_reconciled','=',True),('line_ids','=',[]),('state', '!=', 'open')]}"/> <button name="check_confirm_bank" string="Validate" type="object" class="oe_highlight" attrs="{'invisible':['|','|',('all_lines_reconciled','=',False),('line_ids','=',[]),('state', '!=', 'open')]}"/> + <button name="button_reopen" states="confirm" string="Reset to New" type="object"/> <button name="%(action_cash_box_out)d" string="Take Money In/Out" type="action" class="oe_highlight" attrs="{'invisible': [('journal_type','!=','cash')]}"/> <field name="state" widget="statusbar" statusbar_visible="open,confirm"/> </header> @@ -698,6 +700,8 @@ action = model.setting_init_bank_account_action() <field name="account_number" optional="hidden"/> <field name="amount_currency" optional="hidden"/> <field name="currency_id" optional="hidden"/> + <field name="state" invisible="1"/> + <button name="button_cancel_reconciliation" attrs="{'invisible': ['|',('journal_entry_ids', '=', []), ('state', '=', 'confirm')]}" string="Revert reconciliation" type="object" icon="fa-undo"/> </tree> </field> </page> |
