summaryrefslogtreecommitdiff
path: root/addons/l10n_fr_pos_cert/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/l10n_fr_pos_cert/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/l10n_fr_pos_cert/models')
-rw-r--r--addons/l10n_fr_pos_cert/models/__init__.py7
-rw-r--r--addons/l10n_fr_pos_cert/models/account_bank_statement.py23
-rw-r--r--addons/l10n_fr_pos_cert/models/account_closing.py167
-rw-r--r--addons/l10n_fr_pos_cert/models/account_fiscal_position.py19
-rw-r--r--addons/l10n_fr_pos_cert/models/pos.py145
-rw-r--r--addons/l10n_fr_pos_cert/models/res_company.py96
6 files changed, 457 insertions, 0 deletions
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 00000000..771f4698
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import account_bank_statement
+from . import account_fiscal_position
+from . import res_company
+from . import pos
+from . import account_closing
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 00000000..f8ace6d2
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/account_bank_statement.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import models, api
+from odoo.tools.translate import _
+from odoo.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.pos_payment_method_ids):
+ 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.pos_payment_method_ids):
+ 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/account_closing.py b/addons/l10n_fr_pos_cert/models/account_closing.py
new file mode 100644
index 00000000..ecc77bac
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/account_closing.py
@@ -0,0 +1,167 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime, timedelta
+
+from odoo import models, api, fields
+from odoo.fields import Datetime as FieldDateTime
+from odoo.tools.translate import _
+from odoo.exceptions import UserError
+from odoo.osv.expression import AND
+
+
+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'
+ _description = "Sale Closing"
+
+ 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_order_id = fields.Many2one('pos.order', string='Last Pos Order', help='Last Pos order included in the grand total', readonly=True)
+ last_order_hash = fields.Char(string='Last Order 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.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.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_order = self.env['pos.order']
+ date_start = interval_dates['interval_from']
+ cumulative_total = 0
+ if previous_closing:
+ first_order = previous_closing.last_order_id
+ date_start = previous_closing.create_date
+ cumulative_total += previous_closing.cumulative_total
+
+ domain = [('company_id', '=', company.id), ('state', 'in', ('paid', 'done', 'invoiced'))]
+ if first_order.l10n_fr_secure_sequence_number is not False and first_order.l10n_fr_secure_sequence_number is not None:
+ domain = AND([domain, [('l10n_fr_secure_sequence_number', '>', first_order.l10n_fr_secure_sequence_number)]])
+ elif date_start:
+ #the first time we compute the closing, we consider only from the installation of the module
+ domain = AND([domain, [('date_order', '>=', date_start)]])
+
+ orders = self.env['pos.order'].search(domain, order='date_order desc')
+
+ total_interval = sum(orders.mapped('amount_total'))
+ cumulative_total += total_interval
+
+ # We keep the reference to avoid gaps (like daily object during the weekend)
+ last_order = first_order
+ if orders:
+ last_order = orders[0]
+
+ return {'total_interval': total_interval,
+ 'cumulative_total': cumulative_total,
+ 'last_order_id': last_order.id,
+ 'last_order_hash': last_order.l10n_fr_secure_sequence_number,
+ '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}
+
+ def write(self, vals):
+ raise UserError(_('Sale Closings are not meant to be written or deleted under any circumstances.'))
+
+ 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_pos_cert/models/account_fiscal_position.py b/addons/l10n_fr_pos_cert/models/account_fiscal_position.py
new file mode 100644
index 00000000..17661e20
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/account_fiscal_position.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+from odoo import _, models
+from odoo.exceptions import UserError
+
+
+class AccountFiscalPosition(models.Model):
+ _inherit = "account.fiscal.position"
+
+ def write(self, vals):
+ if "tax_ids" in vals:
+ if self.env["pos.order"].sudo().search_count([("fiscal_position_id", "in", self.ids)]):
+ raise UserError(
+ _(
+ "You cannot modify a fiscal position used in a POS order. "
+ "You should archive it and create a new one."
+ )
+ )
+ return super(AccountFiscalPosition, self).write(vals)
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 00000000..ec67ad81
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/pos.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from datetime import datetime, timedelta
+from hashlib import sha256
+from json import dumps
+
+from odoo import models, api, fields
+from odoo.fields import Datetime
+from odoo.tools.translate import _, _lt
+from odoo.exceptions import UserError
+
+
+class pos_config(models.Model):
+ _inherit = 'pos.config'
+
+ 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'
+
+ def _check_session_timing(self):
+ self.ensure_one()
+ date_today = datetime.utcnow()
+ session_start = Datetime.from_string(self.start_at)
+ if not date_today - 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
+
+ def open_frontend_cb(self):
+ sessions_to_check = self.filtered(lambda s: s.config_id.company_id._is_accounting_unalterable())
+ sessions_to_check.filtered(lambda s: s.state == 'opening_control').start_at = fields.Datetime.now()
+ for session in sessions_to_check:
+ session._check_session_timing()
+ return super(pos_session, self).open_frontend_cb()
+
+
+ORDER_FIELDS = ['date_order', 'user_id', 'lines', 'payment_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 = _lt('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 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.l10n_fr_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
+ if obj._fields[field_str].type in ['many2many', 'one2many']:
+ field_value = field_value.sorted().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,
+ ensure_ascii=True, indent=None,
+ separators=(',',':'))
+
+ 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
+
+ def unlink(self):
+ for order in self:
+ if order.company_id._is_accounting_unalterable():
+ raise UserError(_("According to French law, you cannot delet a point of sale order."))
+ return super(pos_order, self).unlink()
+
+ def _export_for_ui(self, order):
+ res = super()._export_for_ui(order)
+ res['l10n_fr_hash'] = order.l10n_fr_hash
+ return res
+
+
+class PosOrderLine(models.Model):
+ _inherit = "pos.order.line"
+
+ 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 00000000..c701c6f3
--- /dev/null
+++ b/addons/l10n_fr_pos_cert/models/res_company.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import models, api, fields, _
+from odoo.exceptions import UserError
+from datetime import datetime
+from odoo.fields import Datetime, Date
+from odoo.tools.misc import format_date
+import pytz
+
+
+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']._lang_get(ctx['lang'])
+ 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 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
+
+ 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
+
+ def _action_check_pos_hash_integrity(self):
+ return self.env.ref('l10n_fr_pos_cert.action_report_pos_hash_integrity').report_action(self.id)
+
+ def _check_pos_hash_integrity(self):
+ """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_hash, order.name, order_reference_string, ctx_tz(order, 'write_date')]
+
+ hash_verified = True
+ msg_alert = ''
+ report_dict = {}
+ if self._is_accounting_unalterable():
+ orders = self.env['pos.order'].search([('state', 'in', ['paid', 'done', 'invoiced']), ('company_id', '=', self.id),
+ ('l10n_fr_secure_sequence_number', '!=', 0)], order="l10n_fr_secure_sequence_number ASC")
+
+ if not orders:
+ msg_alert = (_('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.company.name))
+ hash_verified = False
+
+ previous_hash = u''
+ start_order_info = []
+ for order in orders:
+ if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash):
+ msg_alert = (_('Corrupted data on point of sale order with id %s.', order.id))
+ hash_verified = False
+ break
+ previous_hash = order.l10n_fr_hash
+
+ if hash_verified:
+ 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.update({
+ 'first_order_name': start_order_info[2],
+ 'first_order_hash': start_order_info[1],
+ 'first_order_date': start_order_info[0],
+ 'last_order_name': end_order_info[2],
+ 'last_order_hash': end_order_info[1],
+ 'last_order_date': end_order_info[0],
+ })
+ return {
+ 'result': hash_verified and report_dict or 'None',
+ 'msg_alert': msg_alert or 'None',
+ 'printing_date': format_date(self.env, Date.to_string( Date.today())),
+ }