commit 9a432e947e1922504f67cafd087eb52ad0fd976c Author: mohammed-alkhazrji Date: Mon Jan 5 01:17:42 2026 +0300 frist moduls odex30 account diff --git a/dev_odex30_accounting/account_chart_of_accounts/CHANGELOG.md b/dev_odex30_accounting/account_chart_of_accounts/CHANGELOG.md new file mode 100644 index 0000000..58a83a0 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## [18.0.1.0.0] - 2026-01-02 + +### Migration from Odoo 14 +- Replaced `internal_type` with `is_view_account` boolean field +- Replaced `user_type_id` with native `account_type` selection +- Updated all domain filters for compatibility +- Migrated report from old AbstractModel structure to new account.report framework + +### Added +- Full Odoo 18 `account.report` integration +- Support for comparison periods in reports +- Journal filtering in reports +- Hierarchical account display in reports +- Automatic account code generation based on parent +- Level-based account hierarchy + +### Changed +- Report now inherits from `account.report` instead of standalone AbstractModel +- View XML updated to Odoo 18 syntax (removed `attrs`, using `invisible`) +- Dependencies updated (now requires `account_reports`) + +### Fixed +- Domain filters compatibility with Odoo 18 +- Account creation with parent relationships +- Journal account creation with proper account types + +### Technical +- Added `_parent_store` functionality for efficient hierarchy queries +- Implemented `format_value()` method for currency display +- Added balance calculation methods for reports +- Full support for multi-company environments diff --git a/dev_odex30_accounting/account_chart_of_accounts/README.md b/dev_odex30_accounting/account_chart_of_accounts/README.md new file mode 100644 index 0000000..976277e --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/README.md @@ -0,0 +1,105 @@ +# Chart of Accounts Hierarchy - Odoo 18 + +## Description +This module provides hierarchical chart of accounts with parent-child relationships for Odoo 18, +including a complete Chart of Accounts report compatible with the new Odoo 18 reporting framework. + +## Features +- ✅ Hierarchical chart of accounts with parent-child relationships +- ✅ Automatic account code generation +- ✅ View accounts support (parent accounts only) +- ✅ Fixed tree length support +- ✅ Customizable account code padding +- ✅ Integration with journal creation +- ✅ **Chart of Accounts Report** with Odoo 18 compatibility + +## Migration from Odoo 14 +This module has been fully migrated from Odoo 14 with the following major changes: + +### Account Models: +- ❌ Removed `internal_type` field +- ✅ Added `is_view_account` boolean field +- ❌ Removed `user_type_id` (Many2one) +- ✅ Now using `account_type` (Selection field) +- ✅ Updated all domain filters + +### Report Framework: +- ❌ Old: `AbstractModel` with `_name = 'account.coa.report'` +- ✅ New: `AbstractModel` inheriting from `account.report` +- ❌ Old: Simple `_get_lines()` method +- ✅ New: Advanced reporting with options, filters, and hierarchies +- ✅ Compatible with Odoo 18 `account_reports` module + +## Installation +1. Make sure `account_reports` module is installed (Odoo Enterprise) +2. Copy this module to your Odoo addons directory +3. Update the apps list: `Settings → Apps → Update Apps List` +4. Install from Apps menu: Search "Chart of Accounts Hierarchy" + +## Configuration +Navigate to: **Accounting → Configuration → Settings → Chart of Accounts Hierarchy Settings** + +Available options: +- **Automatically Generate Account Codes**: Enable auto-generation based on parent +- **Use Fixed Length**: Enforce a specific tree depth +- **Account Code Padding**: Number of digits for padding +- **Bank/Cash Prefixes**: Define account code prefixes + +## Usage + +### Creating Hierarchical Accounts +1. Go to: **Accounting → Configuration → Chart of Accounts** +2. Create a parent account: + - Check "Is View Account" + - Set the account code (e.g., `1000`) +3. Create child accounts: + - Select the parent account in "Parent Account" field + - Code will be generated automatically (e.g., `10001`, `10002`) + +### Viewing Reports +1. Go to: **Accounting → Reporting → Chart of Accounts** +2. Use filters to customize the view: + - Date range + - Journals + - Comparison periods + - Hierarchy view + +## Technical Details + +### New Fields in account.account: +- `parent_id`: Many2one to parent account +- `is_view_account`: Boolean for view accounts +- `level`: Integer showing hierarchy depth +- `auto_code`: Computed account code +- `child_ids`: One2many to child accounts +- `parent_path`: Hierarchical path (using `_parent_store`) + +### Dependencies: +- `account` (Odoo Community) +- `account_reports` (Odoo Enterprise - required for reports) + +## Troubleshooting + +### Issue: Report not showing +**Solution**: Make sure `account_reports` module is installed + +### Issue: Account codes not generating +**Solution**: Enable "Automatically Generate Account Codes" in settings + +### Issue: Cannot create view accounts +**Solution**: Check "Is View Account" when creating parent accounts + +## Support +For issues or questions, please contact your Odoo partner or open an issue on GitHub. + +## Author +Your Company + +## License +LGPL-3 + +## Version History +- **18.0.1.0.0**: Initial migration from Odoo 14 to Odoo 18 + - Complete rewrite of account models + - New reporting framework implementation + - Full compatibility with Odoo 18 accounting diff --git a/dev_odex30_accounting/account_chart_of_accounts/__init__.py b/dev_odex30_accounting/account_chart_of_accounts/__init__.py new file mode 100644 index 0000000..7db6694 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import report diff --git a/dev_odex30_accounting/account_chart_of_accounts/__manifest__.py b/dev_odex30_accounting/account_chart_of_accounts/__manifest__.py new file mode 100644 index 0000000..2d53a00 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/__manifest__.py @@ -0,0 +1,55 @@ +{ + 'name': 'Chart of Accounts Hierarchy - Odoo 18', + 'version': '18.0.1.0.0', + 'category': 'Accounting', + 'summary': 'Hierarchical Chart of Accounts with Parent-Child Relationships', + 'description': """ + Chart of Accounts Hierarchy for Odoo 18 + ======================================== + + Features: + --------- + * Hierarchical chart of accounts with parent-child relationships + * Automatic account code generation + * View accounts support (parent accounts only) + * Fixed tree length support + * Customizable account code padding + * Integration with journal creation + * Chart of Accounts report with hierarchy support + + Migration from Odoo 14: + ---------------------- + * Replaced internal_type with account_type + * Replaced user_type_id with account_type field + * Added is_view_account boolean field + * Updated all domain filters + * Migrated account.report from AbstractModel to standard Model structure + * Compatible with Odoo 18 accounting framework + """, + 'author': 'Your Company', + 'website': 'https://www.yourcompany.com', + 'depends': ['account', 'odex30_account_reports'], + 'data': [ + 'security/account_security.xml', + 'security/ir.model.access.csv', + 'views/account_account_view.xml', + 'views/res_config_settings_views.xml', + 'data/account_report_data.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'account_chart_of_accounts/static/src/js/account_type_selection_extend.js', + 'account_chart_of_accounts/static/src/js/filters_patch.js', + 'account_chart_of_accounts/static/src/js/account_report.js', + 'account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml', + ], + 'web.assets_frontend': [ + 'account_chart_of_accounts/static/src/scss/account_hierarchy.scss', + ], + }, + + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/dev_odex30_accounting/account_chart_of_accounts/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6cd4f41 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/data/account_report_data.xml b/dev_odex30_accounting/account_chart_of_accounts/data/account_report_data.xml new file mode 100644 index 0000000..64f6d8c --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/data/account_report_data.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/__init__.py b/dev_odex30_accounting/account_chart_of_accounts/models/__init__.py new file mode 100644 index 0000000..994789e --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import account_account +from . import account_journal +from . import res_config_settings diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4a97c9e Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_account.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_account.cpython-311.pyc new file mode 100644 index 0000000..9787f84 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_account.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..b472f76 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/res_config_settings.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/res_config_settings.cpython-311.pyc new file mode 100644 index 0000000..5880bb4 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/models/__pycache__/res_config_settings.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/account_account.py b/dev_odex30_accounting/account_chart_of_accounts/models/account_account.py new file mode 100644 index 0000000..0b1a7ed --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/models/account_account.py @@ -0,0 +1,187 @@ +from odoo import fields, models, api, _ +from odoo.exceptions import UserError +from odoo.osv import expression + + +class AccountAccount(models.Model): + _inherit = "account.account" + _parent_name = "parent_id" + _parent_store = True + company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, + default=lambda self: self.env.company) + account_type = fields.Selection( + selection_add=[('view', 'View')], + ondelete={'view': 'cascade'} + ) + automticAccountsCodes = fields.Boolean( + related='company_id.automticAccountsCodes', + ) + internal_group = fields.Selection( + selection_add=[('view', 'View')], + ondelete={'view': lambda recs: recs.write({'internal_group': 'off'})} + ) + + parent_id = fields.Many2one( + comodel_name='account.account', + string='Parent Account', + domain="[('account_type', '=', 'view')]" + ) + + parent_path = fields.Char(index=True) + + child_ids = fields.One2many( + 'account.account', 'parent_id', 'Child Accounts' + ) + + auto_code = fields.Char( + compute='_get_code', default='0', + store=True, size=64, index=True + ) + + level = fields.Integer( + compute="_get_level", store=True, string='Level' + ) + + + @api.depends('code', 'account_type') + def _compute_account_type(self): + + view_accounts = self.filtered(lambda acc: acc.account_type == 'view') + + other_accounts = self - view_accounts + if other_accounts: + super(AccountAccount, other_accounts)._compute_account_type() + + @api.depends('account_type') + def _compute_internal_group(self): + """Override to handle view type""" + for record in self: + if record.account_type == 'view': + record.internal_group = 'view' + else: + super(AccountAccount, record)._compute_internal_group() + + @api.model + def _name_search(self, name, domain=None, operator='ilike', limit=100, order=None): + if domain is None: + domain = [] + + # استبدال internal_type بـ account_type في Odoo 18 + domain = self._replace_internal_type_domain(domain) + + if not self.env.context.get('show_view') and not self.env.context.get('import_file'): + domain += [('account_type', '!=', 'view')] + + return super()._name_search(name, domain, operator, limit, order) + + def _replace_internal_type_domain(self, domain): + """استبدال internal_type بـ account_type""" + new_domain = [] + for condition in domain: + if isinstance(condition, (list, tuple)) and len(condition) >= 3: + if condition[0] in ('internal_type', 'type') and condition[2] == 'view': + new_domain.append(('account_type', condition[1], 'view')) + elif condition[0] in ('internal_type', 'type') and condition[1] == '!=': + new_domain.append(('account_type', '!=', 'view')) + else: + new_domain.append(condition) + else: + new_domain.append(condition) + return new_domain + + @api.depends('parent_id') + def _get_level(self): + for rec in self: + level = 0 + if rec.parent_id: + level = rec.parent_id.level + 1 + if rec.company_id.useFiexedTree and rec.company_id.chart_account_length: + if level > rec.company_id.chart_account_length: + raise UserError( + _('This account level is greater than the chart of account length.')) + rec.level = level + + @api.depends('parent_id', 'code', 'parent_id.code') + def _get_code(self): + for rec in self: + if not rec.company_id.automticAccountsCodes and rec.code: + rec.auto_code = rec.code + continue + + code = str(0) + if rec.parent_id: + default_padding = self.env.company.chart_account_padding + if rec.account_type == 'view': + default_padding = False + + parent_code = rec.parent_id.code or '' + parent_code = int(parent_code) != 0 and str(parent_code) or '' + + siblings = self.search([ + ('parent_id', '=', rec.parent_id.id), + ('id', '!=', rec.id) + ]) + + max_siblings_code = False + if siblings: + sibling_codes = [int(s.code) for s in siblings if s.code and s.code.isdigit()] + if sibling_codes: + max_siblings_code = max(sibling_codes) + + if not max_siblings_code: + code = parent_code + str(1).zfill(default_padding or 0) + else: + code = str(max_siblings_code + 1) + + rec.auto_code = code + else: + rec.auto_code = rec.code or '0' + + @api.model + def search_panel_select_range(self, field_name, **kwargs): + + if field_name != 'parent_id': + return super().search_panel_select_range(field_name, **kwargs) + + + enable_counters = kwargs.get('enable_counters', False) + search_domain = kwargs.get('search_domain', []) + + + all_view_accounts = self.search([('account_type', '=', 'view')], order='code') + + count_by_parent = {} + if enable_counters: + all_accounts = self.search(search_domain) + + for account in all_accounts: + if account.parent_id: + parent_id = account.parent_id.id + count_by_parent[parent_id] = count_by_parent.get(parent_id, 0) + 1 + + ancestor = account.parent_id.parent_id + while ancestor: + count_by_parent[ancestor.id] = count_by_parent.get(ancestor.id, 0) + 1 + ancestor = ancestor.parent_id + + + values = [] + for parent in all_view_accounts: + value = { + 'id': parent.id, + 'display_name': f"{parent.code} {parent.name}" if parent.code else parent.name, + 'parent_id': parent.parent_id.id if parent.parent_id else False, + } + + if enable_counters: + value['__count'] = count_by_parent.get(parent.id, 0) + + values.append(value) + + + result = { + 'parent_field': 'parent_id', + 'values': values, + } + + return result diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/account_journal.py b/dev_odex30_accounting/account_chart_of_accounts/models/account_journal.py new file mode 100644 index 0000000..ffc99bc --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/models/account_journal.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +class AccountJournal(models.Model): + _inherit = "account.journal" + + @api.model + def _fill_missing_values(self, vals): + journal_type = vals.get('type') + if not journal_type: + return + + company = self.env['res.company'].browse(vals['company_id']) if vals.get('company_id') else self.env.company + vals['company_id'] = company.id + + random_account = self.env['account.account'].search([('company_id', '=', company.id)], limit=1) + digits = len(random_account.code) if random_account else 6 + + if journal_type in ('bank', 'cash'): + has_liquidity_accounts = vals.get('default_account_id') + has_payment_accounts = vals.get('payment_debit_account_id') or vals.get('payment_credit_account_id') + has_profit_account = vals.get('profit_account_id') + has_loss_account = vals.get('loss_account_id') + + if journal_type == 'bank': + liquidity_account_prefix = company.bank_account_code_prefix or '' + else: + liquidity_account_prefix = company.cash_account_code_prefix or company.bank_account_code_prefix or '' + + parent = self.env['account.account'].search([('code', '=', liquidity_account_prefix)], limit=1) + if not parent: + raise UserError(_("Can not find account with code (%s).") % (liquidity_account_prefix,)) + + vals['name'] = vals.get('name') or vals.get('bank_acc_number') + + if 'code' not in vals: + vals['code'] = self.get_next_bank_cash_default_code(journal_type, company) + if not vals['code']: + raise UserError(_("Cannot generate an unused journal code. Please fill the 'Shortcode' field.")) + + if not has_liquidity_accounts: + default_account_code = self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix) + default_account_vals = self._prepare_liquidity_account_vals(company, default_account_code, vals) + default_account_vals['parent_id'] = parent.id + vals['default_account_id'] = self.env['account.account'].create(default_account_vals).id + + if not has_payment_accounts: + vals['payment_debit_account_id'] = self.env['account.account'].create({ + 'name': _("Outstanding Receipts"), + 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix), + 'reconcile': True, + 'account_type': 'asset_current', + 'company_id': company.id, + 'parent_id': parent.id, + }).id + + vals['payment_credit_account_id'] = self.env['account.account'].create({ + 'name': _("Outstanding Payments"), + 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix), + 'reconcile': True, + 'account_type': 'asset_current', + 'company_id': company.id, + 'parent_id': parent.id, + }).id + + if journal_type == 'cash' and not has_profit_account: + vals['profit_account_id'] = company.default_cash_difference_income_account_id.id + + if journal_type == 'cash' and not has_loss_account: + vals['loss_account_id'] = company.default_cash_difference_expense_account_id.id + + if 'refund_sequence' not in vals: + vals['refund_sequence'] = vals['type'] in ('sale', 'purchase') diff --git a/dev_odex30_accounting/account_chart_of_accounts/models/res_config_settings.py b/dev_odex30_accounting/account_chart_of_accounts/models/res_config_settings.py new file mode 100644 index 0000000..5812ba2 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/models/res_config_settings.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models, api, _ + +class ResCompany(models.Model): + _inherit = 'res.company' + + chart_account_length = fields.Integer(string='Chart of accounts length') + chart_account_padding = fields.Integer(string='Chart of accounts Padding') + useFiexedTree = fields.Boolean(string='Use Fixed Length Chart of accounts') + automticAccountsCodes = fields.Boolean(string='Automatically Generate Accounts Codes') + parent_bank_cash_account_id = fields.Many2one('account.account') + + @api.model + def setting_chart_of_accounts_action(self): + """Called by the 'Chart of Accounts' button of the setup bar.""" + company = self.env.company + company.sudo().set_onboarding_step_done('account_setup_coa_state') + + if company.opening_move_posted(): + return 'account.action_account_form' + + company.create_op_move_if_non_existant() + + view_id = self.env.ref('account.init_accounts_tree').id + + domain = [ + ('account_type', '!=', 'equity_unaffected'), + # ('is_view_account', '=', False), + ('company_id', '=', company.id) + ] + + return { + 'type': 'ir.actions.act_window', + 'name': _('Chart of Accounts'), + 'res_model': 'account.account', + 'view_mode': 'tree', + 'limit': 99999999, + 'search_view_id': self.env.ref('account.view_account_search').id, + 'views': [[view_id, 'list']], + 'domain': domain, + } + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + chart_account_length = fields.Integer(related='company_id.chart_account_length', readonly=False) + chart_account_padding = fields.Integer(related='company_id.chart_account_padding', readonly=False) + useFiexedTree = fields.Boolean(related='company_id.useFiexedTree', readonly=False) + automticAccountsCodes = fields.Boolean(related='company_id.automticAccountsCodes', readonly=False) + bank_account_code_prefix = fields.Char(string='Bank Prefix', related='company_id.bank_account_code_prefix', readonly=False) + cash_account_code_prefix = fields.Char(string='Cash Prefix', related='company_id.cash_account_code_prefix', readonly=False) diff --git a/dev_odex30_accounting/account_chart_of_accounts/report/__init__.py b/dev_odex30_accounting/account_chart_of_accounts/report/__init__.py new file mode 100644 index 0000000..a9997a1 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/report/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import account_report diff --git a/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..184dd41 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/account_report.cpython-311.pyc b/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/account_report.cpython-311.pyc new file mode 100644 index 0000000..21f9901 Binary files /dev/null and b/dev_odex30_accounting/account_chart_of_accounts/report/__pycache__/account_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/account_chart_of_accounts/report/account_report.py b/dev_odex30_accounting/account_chart_of_accounts/report/account_report.py new file mode 100644 index 0000000..1a1fc2f --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/report/account_report.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + + +class AccountReport(models.Model): + _inherit = 'account.report' + + filter_show_full_hierarchy = fields.Boolean( + string="Show Full Hierarchy Filter", + default=False, + help="Enable filter to display accounts in full hierarchical structure" + ) + + def _init_options_show_full_hierarchy(self, options, previous_options): + + if self.filter_show_full_hierarchy: + if 'filters' not in options: + options['filters'] = {} + options['filters']['show_full_hierarchy'] = True + options['show_full_hierarchy'] = previous_options.get('show_full_hierarchy', False) + + + @api.model + def _get_options_initializers_forced_sequence_map(self): + res = super()._get_options_initializers_forced_sequence_map() + res['_init_options_show_full_hierarchy'] = 1035 + return res + + +class TrialBalanceCustomHandler(models.AbstractModel): + _name = 'account.trial.balance.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Trial Balance Custom Handler' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + + if not options.get('show_full_hierarchy'): + return [] + + lines = [] + + parent_view_accounts = self.env['account.account'].search([ + ('parent_id', '=', False), + ('account_type', '=', 'view'), + ], order='code, name') + + for parent in parent_view_accounts: + has_children = len(parent.child_ids) > 0 + + parent_line = self._get_account_line( + report, + options, + parent, + all_column_groups_expression_totals, + is_parent=has_children, + level=0 + ) + + if parent_line: + lines.append((0, parent_line)) + + if parent_line.get('unfolded', False) and has_children: + child_lines = self._get_children_lines_recursive( + report, + options, + parent, + all_column_groups_expression_totals, + level=1 + ) + for child_line in child_lines: + lines.append((0, child_line)) + + orphan_accounts = self.env['account.account'].search([ + ('parent_id', '=', False), + ('account_type', '!=', 'view'), + ], order='code, name') + + for orphan in orphan_accounts: + orphan_line = self._get_account_line( + report, + options, + orphan, + all_column_groups_expression_totals, + is_parent=False, + level=0 + ) + + if orphan_line: + lines.append((0, orphan_line)) + + return lines + + def _get_children_lines_recursive(self, report, options, parent_account, all_column_groups_expression_totals, level=1): + lines = [] + + children = self.env['account.account'].search([ + ('parent_id', '=', parent_account.id) + ], order='code, name') + + for child in children: + has_children = len(child.child_ids) > 0 + + child_line = self._get_account_line( + report, + options, + child, + all_column_groups_expression_totals, + is_parent=has_children, + level=level + ) + + if child_line: + lines.append(child_line) + + if child_line.get('unfolded', False) and has_children: + grandchildren = self._get_children_lines_recursive( + report, + options, + child, + all_column_groups_expression_totals, + level=level + 1 + ) + lines.extend(grandchildren) + + return lines + + def _report_expand_unfoldable_line_with_groupby(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + + return { + 'lines': [], + 'offset_increment': 0, + 'has_more': False, + } + + + def _get_account_line(self, report, options, account, all_column_groups_expression_totals, is_parent=False, + level=0): + + columns = [] + for column in options['columns']: + col_group_key = column.get('column_group_key') + expression_totals = all_column_groups_expression_totals.get(col_group_key, {}) + col_value = self._compute_account_balance(account, options, column, expression_totals) + + columns.append(report._build_column_dict( + col_value, + column, + options=options + )) + + line_id = report._get_generic_line_id('account.account', account.id) + has_children = len(account.child_ids) > 0 + + line = { + 'id': line_id, + 'name': f"{account.code} {account.name}", + 'level': level + 2, + 'columns': columns, + 'account_id': account.id, + 'caret_options': 'account.account', + } + + if has_children: + line['unfoldable'] = True + line['unfolded'] = line_id in options.get('unfolded_lines', []) + line['expand_function'] = '_report_expand_unfoldable_line_with_groupby' + else: + line['unfoldable'] = False + line['unfolded'] = False + + return line + + def _compute_account_balance(self, account, options, column, expression_totals): + + if account.account_type == 'view': + children = self.env['account.account'].search([ + ('parent_path', '=like', f'{account.parent_path}%'), + ('id', '!=', account.id), + ('account_type', '!=', 'view') + ]) + + total = 0 + for child in children: + child_balance = self._get_actual_balance(child, options, column) + total += child_balance + + return total + else: + return self._get_actual_balance(account, options, column) + + def _get_actual_balance(self, account, options, column): + + domain = [ + ('account_id', '=', account.id), + ('parent_state', '=', 'posted'), + ] + + if options.get('date'): + date_from = options['date'].get('date_from') + date_to = options['date'].get('date_to') + + if date_from: + domain.append(('date', '>=', date_from)) + if date_to: + domain.append(('date', '<=', date_to)) + + aml_results = self.env['account.move.line'].read_group( + domain, + ['debit:sum', 'credit:sum', 'balance:sum'], + [] + ) + + if aml_results: + expr_label = column.get('expression_label', 'balance') + return aml_results[0].get(expr_label, 0) + + return 0 diff --git a/dev_odex30_accounting/account_chart_of_accounts/security/account_security.xml b/dev_odex30_accounting/account_chart_of_accounts/security/account_security.xml new file mode 100644 index 0000000..c2073dd --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/security/account_security.xml @@ -0,0 +1,12 @@ + + + + + + Account multi-company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + diff --git a/dev_odex30_accounting/account_chart_of_accounts/security/ir.model.access.csv b/dev_odex30_accounting/account_chart_of_accounts/security/ir.model.access.csv new file mode 100644 index 0000000..44d20bf --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_account_user,account.account.user,account.model_account_account,account.group_account_user,1,1,1,1 +access_account_account_manager,account.account.manager,account.model_account_account,account.group_account_manager,1,1,1,1 +access_account_account_invoice,account.account.invoice,account.model_account_account,account.group_account_invoice,1,0,0,0 diff --git a/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_report.js b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_report.js new file mode 100644 index 0000000..f87fc18 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_report.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { AccountReportController } from "@odex30_account_reports/components/account_report/controller"; + +patch(AccountReportController.prototype, { + + async unfoldLine(lineIndex) { + const targetLine = this.lines[lineIndex]; + + + if (this.options.show_full_hierarchy && targetLine.expand_function === '_report_expand_unfoldable_line_with_groupby') { + + const isCurrentlyUnfolded = targetLine.unfolded; + + if (isCurrentlyUnfolded) { + this.options.unfolded_lines = this.options.unfolded_lines.filter( + id => id !== targetLine.id + ); + } else { + if (!this.options.unfolded_lines.includes(targetLine.id)) { + this.options.unfolded_lines.push(targetLine.id); + } + } + + + this.saveSessionOptions(this.options); + + this.incrementCallNumber(); + + await this.reload(null, this.options); + + return; + } + + return super.unfoldLine(...arguments); + }, + + + foldLine(lineIndex) { + const targetLine = this.lines[lineIndex]; + + + if (this.options.show_full_hierarchy && targetLine.expand_function === '_report_expand_unfoldable_line_with_groupby') { + + this.options.unfolded_lines = this.options.unfolded_lines.filter( + id => id !== targetLine.id + ); + + + this.saveSessionOptions(this.options); + + this.incrementCallNumber(); + + this.reload(null, this.options); + + return; // إيقاف السلوك الافتراضي + } + + return super.foldLine(...arguments); + } +}); diff --git a/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_type_selection_extend.js b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_type_selection_extend.js new file mode 100644 index 0000000..b9c838d --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/account_type_selection_extend.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { AccountTypeSelection } from "@account/components/account_type_selection/account_type_selection"; +import { patch } from "@web/core/utils/patch"; + +patch(AccountTypeSelection.prototype, { + get hierarchyOptions() { + const opts = this.options; + return [ + { name: _t('Balance Sheet') }, + { name: _t('Assets'), children: opts.filter(x => x[0] && x[0].startsWith('asset')) }, + { name: _t('Liabilities'), children: opts.filter(x => x[0] && x[0].startsWith('liability')) }, + { name: _t('Equity'), children: opts.filter(x => x[0] && x[0].startsWith('equity')) }, + { name: _t('Profit & Loss') }, + { name: _t('Income'), children: opts.filter(x => x[0] && x[0].startsWith('income')) }, + { name: _t('Expense'), children: opts.filter(x => x[0] && x[0].startsWith('expense')) }, + { + name: _t('Other'), + children: opts.filter(x => x[0] && (x[0] === 'off_balance' || x[0] === 'view')) + }, + ]; + } +}); diff --git a/dev_odex30_accounting/account_chart_of_accounts/static/src/js/filters_patch.js b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/filters_patch.js new file mode 100644 index 0000000..fcdc340 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/static/src/js/filters_patch.js @@ -0,0 +1,15 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { AccountReportFilters } from "@odex30_account_reports/components/account_report/filters/filters"; + +patch(AccountReportFilters.prototype, { + + async toggleFullHierarchy() { + const newValue = !this.controller.options.show_full_hierarchy; + await this.controller.updateOption('show_full_hierarchy', newValue); + this.controller.saveSessionOptions(this.controller.options); + await this.applyFilters('show_full_hierarchy'); + } + +}); diff --git a/dev_odex30_accounting/account_chart_of_accounts/static/src/scss/account_hierarchy.scss b/dev_odex30_accounting/account_chart_of_accounts/static/src/scss/account_hierarchy.scss new file mode 100644 index 0000000..bb42e73 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/static/src/scss/account_hierarchy.scss @@ -0,0 +1,39 @@ +.o_search_panel.account_account { + .o_search_panel_field.parent_id { + .o_search_panel_category_value { + .o_toggle_fold { + margin-right: 15px; + cursor: pointer; + } + + header { + display: flex; + align-items: center; + padding: 3px 8px; + + &:hover { + color: #f0f0f0; + } + + .o_search_panel_label_title { + flex: 1; + } + + .badge { + margin-left: auto; + font-size: 0.8em; + } + } + + // Levels + &[data-level="0"] { + font-weight: bold; + } + + &[data-level="1"] header { padding-left: 20px; } + &[data-level="2"] header { padding-left: 35px; } + &[data-level="3"] header { padding-left: 50px; } + &[data-level="4"] header { padding-left: 65px; } + } + } +} diff --git a/dev_odex30_accounting/account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml b/dev_odex30_accounting/account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml new file mode 100644 index 0000000..a43714c --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/static/src/xml/filter_full_hierarchy.xml @@ -0,0 +1,24 @@ + + + + + + + +
+ +
+
+
+ +
+ +
diff --git a/dev_odex30_accounting/account_chart_of_accounts/views/account_account_view.xml b/dev_odex30_accounting/account_chart_of_accounts/views/account_account_view.xml new file mode 100644 index 0000000..2565afb --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/views/account_account_view.xml @@ -0,0 +1,128 @@ + + + + + + account.account.form.inherit.hierarchy + account.account + + + + + + + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + account.account.tree.inherit + account.account + + + + + + + + + + account.account.search.inherit + account.account + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/dev_odex30_accounting/account_chart_of_accounts/views/res_config_settings_views.xml b/dev_odex30_accounting/account_chart_of_accounts/views/res_config_settings_views.xml new file mode 100644 index 0000000..12479e8 --- /dev/null +++ b/dev_odex30_accounting/account_chart_of_accounts/views/res_config_settings_views.xml @@ -0,0 +1,87 @@ + + + + + res.config.settings.view.form.inherit + res.config.settings + + + + + + + +
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + diff --git a/dev_odex30_accounting/exp_asset_base/.idea/exp_asset_base.iml b/dev_odex30_accounting/exp_asset_base/.idea/exp_asset_base.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/.idea/exp_asset_base.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/.idea/inspectionProfiles/profiles_settings.xml b/dev_odex30_accounting/exp_asset_base/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/.idea/misc.xml b/dev_odex30_accounting/exp_asset_base/.idea/misc.xml new file mode 100644 index 0000000..445c93b --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/.idea/modules.xml b/dev_odex30_accounting/exp_asset_base/.idea/modules.xml new file mode 100644 index 0000000..33cef9b --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/.idea/workspace.xml b/dev_odex30_accounting/exp_asset_base/.idea/workspace.xml new file mode 100644 index 0000000..5caca8c --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/.idea/workspace.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + 1697652146581 + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/__init__.py b/dev_odex30_accounting/exp_asset_base/__init__.py new file mode 100644 index 0000000..f97d742 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from . import models +from . import reports diff --git a/dev_odex30_accounting/exp_asset_base/__manifest__.py b/dev_odex30_accounting/exp_asset_base/__manifest__.py new file mode 100644 index 0000000..133b49f --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/__manifest__.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + 'name': 'Asset Extend', + 'summary': 'Adding new features to asset', + 'description': ''' +Adding the following Features: +============================== + +Alter asset Form + +Alter Asset category Form + +Alter Product Form + +Add asset operations (Transfer, Sell/Dispose, Maitenenace, Assesment) + ''', + 'version': '1.0.0', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': 'Expert Co. Ltd.', + 'website': 'http://www.exp-sa.com', + 'license': 'AGPL-3', + 'application': False, + 'installable': True, + 'depends': [ + 'odex30_account_asset', + 'hr', 'barcodes', 'report_xlsx' + ], + 'data': [ + 'security/groups.xml', + 'security/ir.model.access.csv', + 'data/asset_data.xml', + 'data/asset_cron.xml', + 'reports/reports.xml', + 'reports/asset_barcode_pdf_report.xml', + 'reports/asset_barcode_zpl_report.xml', + 'views/account_asset_view.xml', + 'views/account_asset_adjustment_view.xml', + 'views/menus.xml', + # 'views/asset_modify_views.xml', + # 'views/asset_pause_views.xml', + # 'views/asset_sell_views.xml', + ], +} diff --git a/dev_odex30_accounting/exp_asset_base/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9a547cf Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/data/asset_cron.xml b/dev_odex30_accounting/exp_asset_base/data/asset_cron.xml new file mode 100644 index 0000000..6602fe3 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/data/asset_cron.xml @@ -0,0 +1,11 @@ + + + + Asset Reminder + + code + model._asset_cron() + 1 + days + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/data/asset_data.xml b/dev_odex30_accounting/exp_asset_base/data/asset_data.xml new file mode 100644 index 0000000..f4b7831 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/data/asset_data.xml @@ -0,0 +1,41 @@ + + + + + Asset Sequence + asset.seq + ASS/%(range_year)s/ + + + + + 6 + + + + Good + + + Scarp + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/i18n/ar.po b/dev_odex30_accounting/exp_asset_base/i18n/ar.po new file mode 100644 index 0000000..ae988a1 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/i18n/ar.po @@ -0,0 +1,1410 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * exp_asset_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-09-03 08:16+0000\n" +"PO-Revision-Date: 2023-09-03 08:16+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: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__asset_adjustment_count +msgid "# of Adjustments" +msgstr "الجرد" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "A gross increase has been created" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__account_asset_counterpart_id +msgid "Account Asset Counterpart" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__account_depreciation_id +msgid "Account Depreciation" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__account_depreciation_expense_id +msgid "Account Depreciation Expense" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__gain_account_id +msgid "Account used to write the journal item in case of gain" +msgstr "الحساب المستخدم لكتابة بند دفتر اليومية في حالة الربح" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__loss_account_id +msgid "Account used to write the journal item in case of loss" +msgstr "الحساب المستخدم لكتابة بند دفتر اليومية في حالة الخسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__action +msgid "Action" +msgstr "إجراء" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_needaction +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_state +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__adjustment_id +msgid "Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__adjustment_line_ids +msgid "Adjustment Line" +msgstr "تفاصيل الجرد" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Adjustments" +msgstr "الجرد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__account_analytic_id +msgid "Analytic Account" +msgstr "الحساب التحليلي" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_form +msgid "Approve" +msgstr "إعتماد" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__state__approve +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_pause__state__approve +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__state__approve +msgid "Approved" +msgstr "إعتماد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__asset_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__asset_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__asset_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__asset_id +msgid "Asset" +msgstr "الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:0 +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_adjustment +#: model:ir.model,name:exp_asset_base.model_account_asset_adjustment +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_line_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_tree +#, python-format +msgid "Asset Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_adjustment_line +msgid "Asset Adjustment Line" +msgstr "تفاصيل جرد الأصول" + +#. module: exp_asset_base +#: model:res.groups,name:exp_asset_base.group_asset_assessment +msgid "Asset Assessment" +msgstr "إعادة التقييم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__account_asset_id +msgid "Asset Gross Increase Account" +msgstr "حساب زيادة قيمة الأصل" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_location +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__complete_name +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_location_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_manufacturer_form +msgid "Asset Location" +msgstr "مواقع الأصول" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_manufacturer +msgid "Asset Manufacturer" +msgstr "تصنيع الأصل" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_modify +msgid "Asset Modification" +msgstr "تعديل الأصل" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_pause +msgid "Asset Pause" +msgstr "إيقاف الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__asset_picture +msgid "Asset Picture" +msgstr "صورة الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_register_report_xlsx.py:0 +#, python-format +msgid "Asset Register" +msgstr "سجل الأصول" + +#. module: exp_asset_base +#: model:ir.actions.server,name:exp_asset_base.asset_cron_ir_actions_server +#: model:ir.cron,cron_name:exp_asset_base.asset_cron +#: model:ir.cron,name:exp_asset_base.asset_cron +msgid "Asset Reminder" +msgstr "" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_sell +msgid "Asset Sell" +msgstr "بيع الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__asset_status +msgid "Asset Status" +msgstr "حالة العهد" + +#. module: exp_asset_base +#: model:ir.model.constraint,message:exp_asset_base.constraint_account_asset_asset_barcode_uniq +msgid "Asset barcode must be unique." +msgstr "يجب أن يكون باكود الأصل فريدً." + +#. module: exp_asset_base +#: model:ir.actions.report,name:exp_asset_base.action_report_asset_register_xlsx +msgid "Asset register XLSX" +msgstr "سجل الاصول (اكسل)" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "Asset unpaused" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset +msgid "Asset/Revenue Recognition" +msgstr "أصل/ إيرادات مقدمة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_adjustment +msgid "Assets Adjustment" +msgstr "جرد الاصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_graph +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_pivot +msgid "Assets Analysis" +msgstr "تحليل الأصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_location +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_location +msgid "Assets Locations" +msgstr "المواقع" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_management_main +msgid "Assets Management" +msgstr "إدارة الأصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_manufacturer +msgid "Assets Manufacturer" +msgstr "الشركات المُصنعة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.asset_modify_menu +msgid "Assets Modification" +msgstr "تعديل الأصول" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_operation +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_operation_main +msgid "Assets Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.asset_pause_menu +msgid "Assets Pause" +msgstr "إيقاف الأصول" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.asset_sell_menu +msgid "Assets Sell" +msgstr "بيع الأصول" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset__status__available +msgid "Available" +msgstr "متوفر" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__barcode +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Barcode" +msgstr "الباركود" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment___barcode_scanned +msgid "Barcode Scanned" +msgstr "الباركود" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +msgid "Barcode..." +msgstr "باركود..." + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Basic Info" +msgstr "المعلومات الأساسية" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__type__model +msgid "By Model" +msgstr "النموذج" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__type__product +msgid "By Product" +msgstr "المنتج" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__state__cancel +msgid "Cancel" +msgstr "إلغاء" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:0 +#, python-format +msgid "Categories" +msgstr "فئات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__child_id +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_location_form +msgid "Child Locations" +msgstr "المواقع الفرعية" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__code +msgid "Code" +msgstr "الكود" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__company_id +msgid "Company" +msgstr "الشركة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_configuration_main +msgid "Configurations" +msgstr "الإعدادات" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_form +msgid "Confirm" +msgstr "تأكيد" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__state__confirm +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_pause__state__confirm +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__state__confirm +msgid "Confirmed" +msgstr "تم الإعتماد" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_search +msgid "Cost Center" +msgstr "مركز التكلفة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__currency_id +msgid "Currency" +msgstr "العملة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__location_id +msgid "Current Location" +msgstr "الموقع الحالي" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_purchase_tree +msgid "Custody Status" +msgstr "حالة العهدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__invoice_id +msgid "Customer Invoice" +msgstr "فاتورة العميل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__date +msgid "Date" +msgstr "التاريخ" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__value_residual +msgid "Depreciable Amount" +msgstr "القيمة القابلة للإستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__value_residual +msgid "Depreciable Value" +msgstr "القيمة القابلة للإستهلاك" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_pivot +msgid "Depreciation" +msgstr "إستهلاك" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_depreciation_analysis +#: model:ir.ui.menu,name:exp_asset_base.menu_account_Depreciation_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_pivot +msgid "Depreciation Analysis" +msgstr "تحليل الإستهلاكات" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_graph +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_pivot +msgid "Depreciation Date" +msgstr "تاريخ الإستهلاك" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "Depreciation board modified" +msgstr "" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +msgid "Details" +msgstr "التفاصيل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx__display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx__display_name +msgid "Display Name" +msgstr "الاسم المعروض" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__action__dispose +msgid "Dispose" +msgstr "إهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__state__done +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__state__done +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_pause__state__done +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__state__done +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_form +msgid "Done" +msgstr "تم" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__state__draft +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__state__draft +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_pause__state__draft +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__state__draft +msgid "Draft" +msgstr "مسودة" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:0 +#, python-format +msgid "Duration" +msgstr "الفترة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__exist +msgid "Exist?" +msgstr "موجود?" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:0 +#, python-format +msgid "Fixed Asset Register" +msgstr "سجل الأصول" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_follower_ids +msgid "Followers" +msgstr "المتابعون" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_channel_ids +msgid "Followers (Channels)" +msgstr "المتابعون (القنوات)" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعون (الشركاء)" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__activity_type_icon +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:0 +#, python-format +msgid "From: " +msgstr "من: " + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__gain_or_loss__gain +msgid "Gain" +msgstr "ربح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__gain_account_id +msgid "Gain Account" +msgstr "حساب الأرباح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__gain_or_loss +msgid "Gain Or Loss" +msgstr "الربح أو الخسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__gain_value +msgid "Gain Value" +msgstr "قيمة الربح" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment_line__asset_status__good +msgid "Good" +msgstr "جيد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx__id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx__id +msgid "ID" +msgstr "المُعرف" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى استثناء النشاط" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_unread +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_needaction +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_unread +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة تحتاج لرؤيتها." + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_has_error +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment__state__in_progress +msgid "In Progress" +msgstr "جاري" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +msgid "Increase Accounts" +msgstr "حسابات الزيادة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__invoice_line_id +msgid "Invoice Line" +msgstr "بند الفاتورة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx____last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx____last_update +msgid "Last Modified on" +msgstr "آخر تعديل في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__limit +msgid "Limit" +msgstr "حد" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_location_form +msgid "Location Name..." +msgstr "إسم الموقع..." + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Lock" +msgstr "إغلاق" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__gain_or_loss__loss +msgid "Loss" +msgstr "خسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__loss_account_id +msgid "Loss Account" +msgstr "حساب الخسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفق الرئيسي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__manufacturer_id +#: model:ir.ui.menu,name:exp_asset_base.menu_account_manufacturer +msgid "Manufacturer" +msgstr "المُصنع" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_has_error +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__model +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__model_id +msgid "Model" +msgstr "النموذج" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +msgid "Modification" +msgstr "التعديل" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +msgid "Modification reason" +msgstr "سبب التعديل" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +msgid "Modification..." +msgstr "التعديل ...." + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_modify +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_tree +msgid "Modify Asset" +msgstr "تعديل الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__method_period__1 +msgid "Months" +msgstr "عدد الشهور" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__name +msgid "Name" +msgstr "الإسم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__need_date +msgid "Need Date" +msgstr "تحتاج لتاريخ" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset__status__new +msgid "New" +msgstr "جديد" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +msgid "New Values" +msgstr "القيمة الجديدة" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__value_residual +msgid "New residual amount for the asset" +msgstr "المبلغ المتبقي الجديد للأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__salvage_value +msgid "New salvage amount for the asset" +msgstr "مبلغ الخردة الجديد للأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_summary +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__next_maintenance_date +msgid "Next Maintenance Date" +msgstr "تاريخ الصيانة القادم" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__gain_or_loss__no +msgid "No" +msgstr "لأ" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_adjustment.py:0 +#, python-format +msgid "No asset found with the selected barcode" +msgstr "لم يتم العثور على أصل بالباركود المدخل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__salvage_value +msgid "Not Depreciable Amount" +msgstr "قيمة التخريد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__note +msgid "Note" +msgstr "ملاحظة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__method_number +msgid "Number of Depreciations" +msgstr "عدد الإهلاكات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__method_period +msgid "Number of Months in a Period" +msgstr "عدد الشهور في الفترة" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__asset_adjustment_count +msgid "Number of adjustments" +msgstr "عدد عمليات الجرد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الاخطاء" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "عدد الرسائل التي تتطلب إجراء" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__message_unread_counter +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__message_unread_counter +msgid "Number of unread messages" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_location__type__ordinary +msgid "Ordinary" +msgstr "عادي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__parent_id +msgid "Parent" +msgstr "بارينت" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__parent_path +msgid "Parent Path" +msgstr "المسار الأصلي" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:0 +#: model:ir.model,name:exp_asset_base.model_asset_pause +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_form +#, python-format +msgid "Pause Asset" +msgstr "إيقاف الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__date +msgid "Pause date" +msgstr "تاريخ الإيقاف" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__product_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__product_id +msgid "Product" +msgstr "المنتج" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_products +msgid "Products" +msgstr "المنتجات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__name +msgid "Reason" +msgstr "السبب" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__receive_date +msgid "Receive Date" +msgstr "تاريخ الإستلام" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +msgid "Reject" +msgstr "رفض" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_reporting_main +msgid "Reporting" +msgstr "التقارير" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__responsible_dept_id +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_search +msgid "Responsible Department" +msgstr "الإدارة المسئولة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__responsible_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__activity_user_id +msgid "Responsible User" +msgstr "المستخدم المسؤول" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "" +"Reverse the depreciation entries posted in the future in order to modify the" +" depreciation" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل القصيرة" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_adjustment_line__asset_status__scrap +msgid "Scrap" +msgstr "تالف" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__select_invoice_line_id +msgid "Select Invoice Line" +msgstr "إختار بند الفاتورة" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__asset_sell__action__sell +msgid "Sell" +msgstr "بيع" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:0 +#: model:ir.model,name:exp_asset_base.model_asset_sell +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_tree +#, python-format +msgid "Sell Asset" +msgstr "بيع الإصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__serial_no +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line__serial_no +msgid "Serial No" +msgstr "الرقم التسلسلي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__service_provider_id +msgid "Service Provider" +msgstr "مزود الخدمة" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_modify_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_pause_form +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_sell_form +msgid "Set to Draft" +msgstr "تعيين كمسودة" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +msgid "Start" +msgstr "بدأ" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__state +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__state +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__state +msgid "State" +msgstr "الحالة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__status +msgid "Status" +msgstr "الحالة" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__activity_state +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"الحالة على أساس الأنشطة\n" +"المتأخرة: تاريخ الاستحقاق مر\n" +"اليوم: تاريخ النشاط هو اليوم\n" +"المخطط: الأنشطة المستقبلية." + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__gain_value +msgid "" +"Technical field to know if we should display the fields for the creation of " +"gross increase asset" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__gain_or_loss +msgid "" +"Technical field to know is there was a gain or a loss in the selling of the " +"asset" +msgstr "" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:0 +#, python-format +msgid "The %s with barcode %s has schedule maintenance today ,please follow." +msgstr "%s بالكود %s لديه صيانة مجدولة اليوم,الرجاء المتابعة." + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__method_period +msgid "The amount of time between two depreciations" +msgstr "مقدار الوقت بين انخفاضين" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__asset_id +msgid "The asset to be modified by this wizard" +msgstr "الأصل الذي سيتم تعديله بواسطة هذا المعالج" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__invoice_id +msgid "" +"The disposal invoice is needed in order to generate the closing journal " +"entry." +msgstr "" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:0 +#, python-format +msgid "The warrant period of %s with barcode %s has end!" +msgstr "فترة الضمان للأصل %s بالباركود %s قد إنتهت!" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__invoice_line_id +msgid "There are multiple lines that could be the related to this asset" +msgstr "هناك بنود متعددة يمكن أن تكون مرتبطة بهذا الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:0 +#, python-format +msgid "To: " +msgstr "الى: " + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__type +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__type +msgid "Type" +msgstr "النوع" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط الاستثنائي المسجل." + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset__state__unlock +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Unlock" +msgstr "إعادة الفتح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_unread +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_unread +msgid "Unread Messages" +msgstr "الرسائل الجديدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__message_unread_counter +msgid "Unread Messages Counter" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "Value decrease for: %(asset)s" +msgstr "" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#: code:addons/exp_asset_base/models/asset_modify.py:0 +#, python-format +msgid "Value increase for: %(asset)s" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment___barcode_scanned +msgid "Value of the last barcode scanned." +msgstr "قيمة آخر باركود ممسوح ضوئيًا." + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_vendors +msgid "Vendors" +msgstr "المورد" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_location__type__view +msgid "View" +msgstr "عرض" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.view_account_asset_form +msgid "Warranty" +msgstr "ضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__warranty_contract +msgid "Warranty Contract" +msgstr "عقد الضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__warranty_end_date +msgid "Warranty End Date" +msgstr "تاريخ نهاية الضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__warranty_period +msgid "Warranty Period(Months)" +msgstr "فترة الضمان (شهور)" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_modify__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_pause__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_base.field_asset_sell__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_adjustment__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_location__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_manufacturer__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_modify__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_asset_pause__website_message_ids +#: model:ir.model.fields,help:exp_asset_base.field_asset_sell__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset__state +msgid "" +"When an asset is created, the status is 'Draft'.\n" +"If the asset is confirmed, the status goes in 'Running' and the depreciation lines can be posted in the accounting.\n" +"The 'On Hold' status can be set manually when you want to pause the depreciation of an asset for some time.\n" +"You can manually close an asset when the depreciation is over. If the last line of depreciation is posted, the asset automatically goes in that status." +msgstr "" + +#. module: exp_asset_base +#: model:ir.model.fields.selection,name:exp_asset_base.selection__account_asset_modify__method_period__12 +msgid "Years" +msgstr "سنوات" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/asset_sell.py:0 +#, python-format +msgid "" +"You cannot automate the journal entry for an asset that has a running gross " +"increase. Please use 'Dispose' on the increase(s)." +msgstr "" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_adjustment.py:0 +#, python-format +msgid "You should enter the asset status for all assets that marked as exist." +msgstr "يجب تحديد حالة الأصل لجميع الأصول الموجودة." + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_asset_abstract_report_xlsx +msgid "asset_abstract_report_xlsx" +msgstr "" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_report_asset_register_xlsx +msgid "report_asset_register_xlsx" +msgstr "" \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/i18n/ar_001.po b/dev_odex30_accounting/exp_asset_base/i18n/ar_001.po new file mode 100644 index 0000000..66304d1 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/i18n/ar_001.po @@ -0,0 +1,912 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * exp_asset_custody +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-15 06:13+0000\n" +"PO-Revision-Date: 2023-10-15 06:13+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: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__asset_operation_count +msgid "# Done Operations" +msgstr "العمليات المعتمدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_needaction +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_state +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__amount +msgid "Amount" +msgstr "المبلغ" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__asset_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset" +msgstr "الأصل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset Account" +msgstr "حساب الأصل" + +#. module: exp_asset_custody +#: model:ir.actions.report,name:exp_asset_custody.action_asset_adjustment_report +#: model:ir.model,name:exp_asset_custody.model_account_asset_adjustment +msgid "Asset Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset_multi_operation +msgid "Asset Multi Operation" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset_operation +msgid "Asset Operation" +msgstr "" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset.py:0 +#, python-format +msgid "Asset Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset Operations in done state" +msgstr "العمليات المعتمدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__asset_status +msgid "Asset Status" +msgstr "حالة العهد" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_operation.py:0 +#, python-format +msgid "Asset is required to confirm this operation." +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset +msgid "Asset/Revenue Recognition" +msgstr "إثبات الأصل/الربح" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Assets Adjustment Report" +msgstr "تقرير جرد الأصول" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_assignment +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +msgid "Assets Assignment" +msgstr "إسناد عهدة" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_release +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +msgid "Assets Release" +msgstr "إرجاع عهدة" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_transfer +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Assets Transfer" +msgstr "نقل العهدة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__status__assigned +msgid "Assigned" +msgstr "مسند" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__assignment +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__assignment +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_assignment +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_assignment +msgid "Assignment" +msgstr "أسناد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Assignment Info" +msgstr "معلومات الإسناد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.hr_Employee_form_inherit0 +msgid "Assignments" +msgstr "العهد العينية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__barcode +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__barcode +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Barcode" +msgstr "باركود" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation___barcode_scanned +msgid "Barcode Scanned" +msgstr "باركود" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Barcode..." +msgstr "الباركود..." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__department +msgid "By Department" +msgstr "الإدارة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__employee +msgid "By Employee" +msgstr "الموظف" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__location +msgid "By Location" +msgstr "الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__cancel +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__cancel +msgid "Cancel" +msgstr "إلغاء" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Confirm" +msgstr "تأكيد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__create_uid +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__create_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Current" +msgstr "الحالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__department_id +msgid "Current Department" +msgstr "الإدارة الحالية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__employee_id +msgid "Current Employee" +msgstr "الموظف الحالي" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Current Info" +msgstr "المعلومات الحالية" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_form +msgid "Custody Info" +msgstr "معلومات العهدة" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_operation_main +msgid "Custody Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__custody_period +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__custody_period +msgid "Custody Period" +msgstr "فترة العهدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__custody_type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__custody_type +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Custody Type" +msgstr "نوع العهدة" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_custody_operation +msgid "Custody operations" +msgstr "عمليات العهد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__date +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Date" +msgstr "التاريخ" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Date:" +msgstr "التاريخ:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_department_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Department" +msgstr "الإدارة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Description:" +msgstr "الوصف:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_hr_employee__display_name +msgid "Display Name" +msgstr "الاسم المعروض" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__done +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__done +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Done" +msgstr "المنتهية" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__draft +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__draft +msgid "Draft" +msgstr "مسودة" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_hr_employee +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_employee_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Employee" +msgstr "الموظف" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_follower_ids +msgid "Followers" +msgstr "المتابعون" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_channel_ids +msgid "Followers (Channels)" +msgstr "المتابعون (القنوات)" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعون (الشركاء)" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_type_icon +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_type__general +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_type__general +msgid "General" +msgstr "عام" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__asset_status__good +msgid "Good" +msgstr "جيد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Group By..." +msgstr "تجميع بـ..." + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_hr_employee__id +msgid "ID" +msgstr "المُعرف" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى استثناء النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_needaction +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_unread +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_needaction +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_unread +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة تحتاج لرؤيتها." + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__in_progress +msgid "In Progress" +msgstr "جاري" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_hr_employee____last_update +msgid "Last Modified on" +msgstr "آخر تعديل في" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__write_uid +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__write_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_location_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Location" +msgstr "الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفقات" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose an asset in all operation line." +msgstr "" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose custody period for all operation lines." +msgstr "الرجاء التاكد من إدخال فترة العهدة في جميع العمليات." + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose custody type for all operation lines." +msgstr "الرجاء التاكد من إدخال نوع العهدة في جميع العمليات." + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you enter the return date for all temporary custodies." +msgstr "الرجاء التاكد من إدخال تاريخ الإرجاع للعهد المؤقتة." + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__manual +msgid "Manual" +msgstr "يدوي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_error +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Missing Assets:" +msgstr "الأصول المفقودة:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__model_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Model" +msgstr "النموذج" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__multi_operation_id +msgid "Multi Operation" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_assignment +msgid "Multiple Assignment" +msgstr "إسناد العهد" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_release +msgid "Multiple Release" +msgstr "إرجاع العهد" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_transfer +msgid "Multiple Transfer" +msgstr "نقل العهد" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_multi_operation +msgid "Multiple operations" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__my_activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__name +msgid "Name" +msgstr "الإسم" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "New" +msgstr "جديد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Department" +msgstr "الإدارة الجديدة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Employee" +msgstr "الموظف الجديد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Location" +msgstr "الموقع الجديد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_summary +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "No asset found with the selected barcode" +msgstr "لم يتم العثور على أصل لهذا الباركود" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__note +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__note +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Note" +msgstr "ملاحظة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset__asset_operation_count +msgid "Number of done asset operations" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الاخطاء" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "عدد الرسائل التي تتطلب إجراء" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_unread_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_unread_counter +msgid "Number of unread messages" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_operation.py:0 +#, python-format +msgid "Only draft operations can be deleted." +msgstr "لا يمكن حذف العملية الا في الحالة مبدئية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__operation_ids +msgid "Operation" +msgstr "عملية" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_operation_analysis +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_operation_graph +msgid "Operation Analysis" +msgstr "تحليل العمليات" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_form +msgid "Operations" +msgstr "العمليات" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__pending +msgid "Pending" +msgstr "معلق" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_period__permanent +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_period__permanent +msgid "Permanent" +msgstr "دائمة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_type__personal +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_type__personal +msgid "Personal" +msgstr "شخصية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_hr_employee__petty_cash_counts +msgid "Petty Cash Counts" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__product_id +msgid "Product" +msgstr "المنتج" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__purpose +msgid "Purpose" +msgstr "الغرض" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Reject" +msgstr "رفض" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__release +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__release +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_release +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_release +msgid "Release" +msgstr "إرجاع" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Release Info" +msgstr "معلومات الإرجاع" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +msgid "Release Location" +msgstr "موقع الإرجاع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__user_id +msgid "Responsible" +msgstr "المسئول" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__responsible_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__department_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Responsible Department" +msgstr "الإدارة المسئولة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__responsible_user_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_user_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Responsible User" +msgstr "الموظف المسئول" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__return_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__return_date +msgid "Return Date" +msgstr "تاريخ الإرجاع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل القصيرة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__status__scrap +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__asset_status__scrap +msgid "Scrap" +msgstr "تالف" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Serial No" +msgstr "المتسلسل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Set to Draft" +msgstr "تعيين كمسودة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Signatures:" +msgstr "" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Start" +msgstr "بدأ" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__state +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__state +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "State" +msgstr "الحالة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__status +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__asset_statuso +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Status" +msgstr "الحالة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_state +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "الأنشطة المستقبيلة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__submit +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Submit" +msgstr "إرسال" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_period__temporary +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_period__temporary +msgid "Temporary" +msgstr "مؤقتة" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset.py:0 +#, python-format +msgid "The period of %s is finished %s." +msgstr "فترة %s قد إنتهت %s." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__transfer +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__transfer +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_transfer +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_transfer +msgid "Transfer" +msgstr "نقل" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__type +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Type" +msgstr "النوع" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط الاستثنائي المسجل." + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Type:" +msgstr "النوع:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_unread +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_unread +msgid "Unread Messages" +msgstr "الرسائل الجديدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_unread_counter +msgid "Unread Messages Counter" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation___barcode_scanned +msgid "Value of the last barcode scanned." +msgstr "قيمة آخر باركود ممسوح ضوئيًا." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__asset_status__good_v +msgid "Very Good" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__website_message_ids +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "You can not confirm operation without lines." +msgstr "لا يمكن تأكيد العملية دون إدخال التفاصيل" + +#. module: exp_asset_base +#: model:ir.actions.report,name:exp_asset_base.label_barcode_account_asset +msgid "Asset Barcode (ZPL)" +msgstr "باركود الأصل (ZPL)" + +#. module: exp_asset_base +#: model:ir.actions.report,print_report_name:exp_asset_base.report_account_asset_barcode +msgid "'Assets barcode - %s' % (object.name)" +msgstr "'باركود الأصول - %s' % (object.name)" + +#. module: exp_asset_base +#: model:ir.actions.report,name:exp_asset_base.report_account_asset_barcode +msgid "Asset Barcode (PDF)" +msgstr "باركود الأصل (PDF)" + +#. module: exp_asset_base +#: model_terms:ir.ui.view,arch_db:exp_asset_base.report_asset_barcode +msgid "No barcode available" +msgstr "لا يوجد باركود متاح" \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/i18n/ar_SY.po b/dev_odex30_accounting/exp_asset_base/i18n/ar_SY.po new file mode 100644 index 0000000..89cf5bc --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/i18n/ar_SY.po @@ -0,0 +1,1481 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * exp_asset_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0-20190819\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-15 04:52+0000\n" +"PO-Revision-Date: 2021-03-15 04:52+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: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_asset_operation_count +msgid "# Done Operations" +msgstr "العمليات المعتمدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_gross_increase_count +msgid "# Gross Increases" +msgstr "الزيادات الإجمالية" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_asset_adjustment_count +msgid "# of Adjustments" +msgstr "الجرد" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:620 +#, python-format +msgid "A gross increase has been created" +msgstr "تم زيادة اجمالي قيمة الاصل" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_gain_account_id +msgid "Account used to write the journal item in case of gain" +msgstr "الحساب المستخدم عند إنشاء القيود في حالة الربح" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_loss_account_id +msgid "Account used to write the journal item in case of loss" +msgstr "الحساب المستخدم عند توليد القيود في حالة الخسارة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Accounting" +msgstr "الحسابات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_adjustment_id +msgid "Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_adjustment_line_ids +msgid "Adjustment Line" +msgstr "تفاصيل الجرد" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Adjustments" +msgstr "الجرد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_already_depreciated_amount_import +msgid "Already Depreciated Amount Import" +msgstr "القيمة المهلكة مسبقاً" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_amount +msgid "Amount" +msgstr "الزمن" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_parent_id +msgid "An asset has a parent when it is the result of gaining value" +msgstr "الأصل يكون له أب عندما يكون نتيجة لاكتساب القيمة" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,modify_action:0 +#: selection:account.asset.operation,modify_action:0 +msgid "Assessment" +msgstr "إعادة تقييم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_assessment_gain_account_id +msgid "Assessment Gain Account" +msgstr "حساب أرباح إعادة التقييم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_assessment_loss_account_id +msgid "Assessment Loss Account" +msgstr "حساب خسارة إعادة التقييم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_asset_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_asset_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_asset_id +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Asset" +msgstr "الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:656 +#, python-format +msgid "Asset %s" +msgstr "الأصل %s" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Asset Account" +msgstr "حساب الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:272 +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_adjustment +#: model:ir.model,name:exp_asset_base.model_account_asset_adjustment +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_line_tree +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_tree +#, python-format +msgid "Asset Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_adjustment_line +msgid "Asset Adjustment Line" +msgstr "تفاصيل جرد الأصول" + +#. module: exp_asset_base +#: model:res.groups,name:exp_asset_base.group_asset_assessment +msgid "Asset Assessment" +msgstr "إعادة تقييم الأصول" + +#. module: exp_asset_base +#: model:ir.actions.report,name:exp_asset_base.action_report_asset_depr_register_xlsx +msgid "Asset Depreciation register XLSX" +msgstr "Asset Depreciation register XLSX" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_account_asset_counterpart_id +msgid "Asset Gain Account" +msgstr "حساب الأرباح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_account_asset_id +msgid "Asset Gross Increase Account" +msgstr "حساب زيادة قيمة الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_asset_method_time +msgid "Asset Method Time" +msgstr "طريقة إحتساب مدة الإستهلاك" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "Asset Modify" +msgstr "تعديل بيانات الأصل" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_multi_operation +msgid "Asset Multi Operation" +msgstr "العمليات المتعددة" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:253 +#, python-format +msgid "Asset Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Asset Operations in done state" +msgstr "العمليات في الحالة تم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_asset_picture +msgid "Asset Picture" +msgstr "صورة الأصل" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_register_report_xlsx.py:14 +#: model:ir.actions.act_window,name:exp_asset_base.act_report_asset_register +#: model:ir.ui.menu,name:exp_asset_base.menu_report_asset_register +#, python-format +msgid "Asset Register" +msgstr "سجل الأصول" + +#. module: exp_asset_base +#: model:ir.actions.server,name:exp_asset_base.asset_cron_ir_actions_server +#: model:ir.cron,cron_name:exp_asset_base.asset_cron +#: model:ir.cron,name:exp_asset_base.asset_cron +msgid "Asset Reminder" +msgstr "Asset Reminder" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_asset_status +msgid "Asset Status" +msgstr "حالة العهد" + +#. module: exp_asset_base +#: sql_constraint:account.asset.asset:0 +msgid "Asset barcode must be unique." +msgstr "يجب أن يكون باكود الأصل فريدً." + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_category +msgid "Asset category" +msgstr "فئة الأصل" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_depreciation_line +msgid "Asset depreciation line" +msgstr "بند إستهلاك الأصل" + +#. module: exp_asset_base +#: model:ir.actions.report,name:exp_asset_base.action_report_asset_register_xlsx +msgid "Asset register XLSX" +msgstr "Asset register XLSX" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:546 +#, python-format +msgid "Asset sold or disposed. Accounting entry awaiting for validation." +msgstr "بيع/إتلاف الاصل. القيد المحاسبي في إنتظار الإعتماد." + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_asset +msgid "Asset/Revenue Recognition" +msgstr "أصل/ إيرادات مقدمة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_adjustment +msgid "Assets Adjustment" +msgstr "جرد الاصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_graph +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_graph +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_graph +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_pivot +msgid "Assets Analysis" +msgstr "تحليل الأصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_location +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_location +msgid "Assets Locations" +msgstr "المواقع" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_management_main +msgid "Assets Management" +msgstr "إدارة الأصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_manufacturer +msgid "Assets Manufacturer" +msgstr "الشركات المُصنعة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +msgid "Assets Operation" +msgstr "عمليات الأصول" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_operation_main +msgid "Assets Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_receive +msgid "Assets Receive" +msgstr "إستلام الأصل" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_asset +msgid "Assets Register" +msgstr "سجل الأصل" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_assent_category +msgid "Assets Types" +msgstr "نوع الأصل" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,type:0 +#: selection:account.asset.operation,type:0 +msgid "Assignment" +msgstr "إسناد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_barcode +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_barcode +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Barcode" +msgstr "باركود" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +msgid "Barcode..." +msgstr "بارمود..." + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Basic Info" +msgstr "Basic Info" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_basic_operation +msgid "Basic operations" +msgstr "العمليات الأساسية" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_book_value +msgid "Book Value" +msgstr "القيمة الدفترية" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,type:0 +msgid "By Category" +msgstr "التصنيف" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,type:0 +msgid "By Department" +msgstr "الإدارة" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,type:0 +msgid "By Employee" +msgstr "الموظف" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,type:0 +msgid "By Location" +msgstr "الموقع" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,type:0 +msgid "By Product" +msgstr "المنتج" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,state:0 +#: selection:account.asset.multi.operation,state:0 +#: selection:account.asset.operation,state:0 +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_modify_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_receive_tree +msgid "Cancel" +msgstr "إلغاء" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:46 +#, python-format +msgid "Categories" +msgstr "فئات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_category_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_category_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_category_ids +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Category" +msgstr "الفئة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Category of asset" +msgstr "فئة الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_children_ids +msgid "Children" +msgstr "الفروع" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_code +msgid "Code" +msgstr "الكود" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_report_asset_register_form +msgid "Compute" +msgstr "حساب" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Compute Depreciation" +msgstr "حساب الإهلاك" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_configuration_main +msgid "Configurations" +msgstr "الإعدادات" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_modify_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_receive_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_sell_dispose_tree +msgid "Confirm" +msgstr "تأكيد" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_search +msgid "Cost Center" +msgstr "مركز التكلفة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_account_analytic_id +msgid "Cost Centers" +msgstr " مراكز التكلفة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_create_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_create_date +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_currency_id +msgid "Currency" +msgstr "العملة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_location_id +msgid "Current Location" +msgstr "الموقع الحالي" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_purchase_tree +msgid "Custody Status" +msgstr "حالة العهدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_invoice_id +msgid "Customer Invoice" +msgstr "الفاتورة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_date +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_date +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Date" +msgstr "التاريخ" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_value_residual +msgid "Depreciable Amount" +msgstr "القيمة القابلة للإستهلاك" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Depreciated Amount" +msgstr "قيمة الإستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_ids +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Depreciation" +msgstr "إستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_account_depreciation_id +msgid "Depreciation Account" +msgstr "حساب الإستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_depreciation_amount +msgid "Depreciation Amount" +msgstr "قيمة الإستهلاك" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_depreciation_analysis +#: model:ir.ui.menu,name:exp_asset_base.menu_account_Depreciation_graph +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_graph +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_depreciation_pivot +msgid "Depreciation Analysis" +msgstr "تحليل الإستهلاكات" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Depreciation Board" +msgstr "لوحة الإستهلاكات" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_account_depreciation_expense_id +msgid "Depreciation Expense Account" +msgstr "حساب مصروفات الإستهلاك" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Depreciation Information" +msgstr "معلومات الإستهلاك" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Depreciation Lines" +msgstr "بنود الإستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_depreciation_number_import +msgid "Depreciation Number Import" +msgstr "عدد الإستهلاكات السابقة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_report_asset_register_form +msgid "Depreciations" +msgstr "الإستهلاكات" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +msgid "Details" +msgstr "التفاصيل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_depreciation_report_xlsx_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_display_name +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx_display_name +msgid "Display Name" +msgstr "الاسم المعروض" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:531 +#, python-format +msgid "Disposal" +msgstr "إتلاف" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,sell_dispose_action:0 +#: selection:account.asset.operation,sell_dispose_action:0 +msgid "Dispose" +msgstr "إتلاف" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,state:0 +#: selection:account.asset.multi.operation,state:0 +#: selection:account.asset.operation,state:0 +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Done" +msgstr "منتهي" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,state:0 +#: selection:account.asset.multi.operation,state:0 +#: selection:account.asset.operation,state:0 +msgid "Draft" +msgstr "مسودة" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:43 +#, python-format +msgid "Duration" +msgstr "الفترة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_end_date +msgid "End Date" +msgstr "تاريخ الإنتهاء" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_method_end +msgid "Ending date" +msgstr "تاريخ الإنتهاء" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,modify_action:0 +#: selection:account.asset.operation,modify_action:0 +msgid "Enhancement" +msgstr "تحسين" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_exist +msgid "Exist?" +msgstr "موجود?" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Existing Depreciation Schedule" +msgstr "الإستهلاكات التي تمت مسبقاً" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Existing Depreciations" +msgstr "الإستهلاكات السابقة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "First Depreciation Date" +msgstr "تاريخ أول إستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_first_depreciation_date_import +msgid "First Depreciation Date Import" +msgstr "Fتاريخ أول إستهلاك" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:14 +#, python-format +msgid "Fixed Asset Register" +msgstr "سجل الأصول" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:43 +#, python-format +msgid "From: " +msgstr "من: " + +#. module: exp_asset_base +#: selection:account.asset.operation,gain_or_loss:0 +msgid "Gain" +msgstr "ربح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_gain_account_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_gain_account_id +msgid "Gain Account" +msgstr "حساب الأرباح" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_gain_or_loss +msgid "Gain Or Loss" +msgstr "ربح/خسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_gain_value +msgid "Gain Value" +msgstr "قيمة الربح" + +#. module: exp_asset_base +#: selection:account.asset.adjustment.line,asset_status:0 +msgid "Good" +msgstr "جيد" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:294 +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +#, python-format +msgid "Gross Increase" +msgstr "زيادة القيمة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_gross_increase_value +msgid "Gross Increase Value" +msgstr "قيمة الزيادة الإجمالية" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Group By..." +msgstr "تجميع حسب.." + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_depreciation_report_xlsx_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_id +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx_id +msgid "ID" +msgstr "المعرف" + +#. module: exp_asset_base +#: selection:account.asset.adjustment,state:0 +#: selection:account.asset.multi.operation,state:0 +msgid "In Progress" +msgstr "جاري" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_depreciation_number_import +msgid "In case of an import from another software, \n" +" provide the number of depreciations already done before starting with Odoo." +msgstr "في حالة الإستيراد من برنامج آخر, \n" +" قم بإدخال عدد الإستهلاكات التي تم تنفيذها قبل البدأ ب أودو." + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_already_depreciated_amount_import +msgid "In case of an import from another software, \n" +" you might need to use this field to have the right depreciation table report. \n" +" This is the value that was already depreciated with entries not computed from this model" +msgstr "في حالة الإستيراد من برنامج آخر, \n" +" قد تحتاج لإدخال هذه الحقول للحصول على تقرير إستهلاكات صحيح. \n" +" هذه القيمة تتم معالجتها بإستخدام القيود المحاسبية" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_first_depreciation_date_import +msgid "In case of an import from another software, provide the first depreciation date in it." +msgstr "في حالة الإستيراد من برنامج آخر, قم يتحديد تاريخ أول إستهلاك تم مسبقاً." + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "Increase Accounts" +msgstr "حساب الزيادة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Invoice" +msgstr "الفاتورة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_invoice_line_id +msgid "Invoice Line" +msgstr "عنصر الفاتور" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "Invoicing" +msgstr "تحرير الفواتير" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_product_product_is_asset +#: model:ir.model.fields,field_description:exp_asset_base.field_product_template_is_asset +msgid "Is Asset" +msgstr "عبارة عن أصل" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Items" +msgstr "العناصر" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_abstract_report_xlsx___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_depreciation_report_xlsx___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation___last_update +#: model:ir.model.fields,field_description:exp_asset_base.field_report_report_asset_register_xlsx___last_update +msgid "Last Modified on" +msgstr "آخر تعديل في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_write_uid +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_write_date +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_limit +msgid "Limit" +msgstr "حد" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Lock" +msgstr "اغلاق" + +#. module: exp_asset_base +#: selection:account.asset.operation,gain_or_loss:0 +msgid "Loss" +msgstr "خسارة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_loss_account_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_loss_account_id +msgid "Loss Account" +msgstr "حساب الخسارة" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:102 +#, python-format +msgid "Make sure you choose an asset in all operation line." +msgstr "تأكد من إختيار الأصل في جميع عناصر العملية." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:108 +#, python-format +msgid "Make sure you have a depreciable amount when you modify asset." +msgstr "تأكد من غدخال القيمة القابلة للإستهلاك في عملية تعديل بيانات الأصل." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:110 +#, python-format +msgid "Make sure you have not depreciable amount when you modify asset." +msgstr "تأكد من إدخال قيمة التخريد في عملية تعديل بيانات الأصل." + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_manual +msgid "Manual" +msgstr "يدوى" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_manufacturer_id +#: model:ir.ui.menu,name:exp_asset_base.menu_account_manufacturer +msgid "Manufacturer" +msgstr "المُصنع" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:106 +#, python-format +msgid "Method end is required when you modify asset with end method time." +msgstr "يجب إدخال تاريخ إيقاف الإستهلاك إذا كانت طريق ." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:104 +#, python-format +msgid "Method number is required when you modify asset with number method time." +msgstr "يجب إدخال عدد الغستهلاكات في حال كانت طريقة الإستهلاك بإستخدام عدد القيود ." + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_model +msgid "Model" +msgstr "النموذج" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,type:0 +#: selection:account.asset.operation,type:0 +#: model:ir.ui.menu,name:exp_asset_base.menu_account_modification +msgid "Modification" +msgstr "التعديلات" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,modify_action:0 +#: selection:account.asset.operation,modify_action:0 +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Modify" +msgstr "تحرير" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_modify_action +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_modify_action +msgid "Modify Action" +msgstr "نوع التعديل" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_modify +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_modify_wiz +#: model:ir.model,name:exp_asset_base.model_account_asset_modify +msgid "Modify Asset" +msgstr "تعديل الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_multi_operation_id +msgid "Multi Operation" +msgstr "عمليات متعددة" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_multi_operation +msgid "Multiple Operations" +msgstr "عمليات متعددة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_multi_operation +msgid "Multiple operations" +msgstr "عمليات متعددة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_manufacturer_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_name +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_name +msgid "Name" +msgstr "الاسم" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_need_date +msgid "Need Date" +msgstr "تحتاج لتاريخ" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "New Values" +msgstr "القيمة الجديدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_next_maintenance_date +msgid "Next Maintenance Date" +msgstr "تاريخ الصيانة القادم" + +#. module: exp_asset_base +#: selection:account.asset.operation,gain_or_loss:0 +msgid "No" +msgstr "لأ" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_adjustment.py:87 +#: code:addons/exp_asset_base/models/account_asset_operation.py:124 +#, python-format +msgid "No asset found with the selected barcode" +msgstr "لم يتم العثور على أصل بالباركود المدخل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_salvage_value +msgid "Not Depreciable Amount" +msgstr "قيمة التخريد" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_note +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_note +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "Note" +msgstr "ملاحظة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_method_number +msgid "Number of Depreciations" +msgstr "عدد الإستهلاكات" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_asset_adjustment_count +msgid "Number of adjustments" +msgstr "عدد عمليات الجرد" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_gross_increase_count +msgid "Number of assets made to increase the value of the asset" +msgstr "عدد عمليات الزيادة في القيمة الغجمالية للأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_asset_operation_count +msgid "Number of done asset operations" +msgstr "عدد العمليات" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:367 +#, python-format +msgid "Only draft operations can be deleted." +msgstr "يمكن حذف العمليات المبدئية فقط." + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_depreciation_line_operation +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_operation_ids +msgid "Operation" +msgstr "عملية" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_account_asset_operation_analysis +#: model:ir.ui.menu,name:exp_asset_base.menu_account_operation_graph +msgid "Operation Analysis" +msgstr "تحليل العمليات" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Operations" +msgstr "العمليات" + +#. module: exp_asset_base +#: selection:account.asset.location,type:0 +msgid "Ordinary" +msgstr "عادي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_parent_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_parent_id +msgid "Parent" +msgstr "الأصل" + +#. module: exp_asset_base +#: selection:account.asset.operation,state:0 +msgid "Pending" +msgstr "في الإنتظار" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_period +msgid "Period" +msgstr "الفترة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_method_period +msgid "Period Length" +msgstr "طول الفترة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_product_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_product_id +msgid "Product" +msgstr "المنتج" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_product_template +msgid "Product Template" +msgstr "قالب المنتج" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_products +msgid "Products" +msgstr "المنتجات" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Purchase Info" +msgstr "معلومات الشراء" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_graph +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_pivot +msgid "Purchase Value" +msgstr "قيمة الشراء" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Purchase value" +msgstr "قيمة الشراء" + +#. module: exp_asset_base +#: selection:account.asset.operation,type:0 +msgid "Receive" +msgstr "إستلام" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_assent_receive +msgid "Receive Assets" +msgstr "إستلام الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_receive_date +msgid "Receive Date" +msgstr "تاريخ الإستلام" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_location_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_location_id +msgid "Receive location" +msgstr "موقع إستلام الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_ref +msgid "Reference" +msgstr "المرجع" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_receive_tree +msgid "Reject" +msgstr "رفض" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,type:0 +#: selection:account.asset.operation,type:0 +msgid "Release" +msgstr "إعادة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_depreciation_report_id +msgid "Report" +msgstr "التقرير" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_reporting_main +msgid "Reporting" +msgstr "اعداد التقرير " + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Residual" +msgstr "صافي المتبقي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_user_id +msgid "Responsible" +msgstr "مسئول" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_department_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_responsible_department_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_department_id +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_search +msgid "Responsible Department" +msgstr "الإدارة المسئولة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_responsible_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_category_responsible_user_id +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_responsible_user_id +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Responsible User" +msgstr "الموظف المسئول" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:531 +#, python-format +msgid "Sale" +msgstr "بيع" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "Save" +msgstr "حفظ" + +#. module: exp_asset_base +#: selection:account.asset.adjustment.line,asset_status:0 +msgid "Scrap" +msgstr "تالف" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_select_invoice_line_id +msgid "Select Invoice Line" +msgstr "إختيار عنصر الفاتورة" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,sell_dispose_action:0 +#: selection:account.asset.operation,sell_dispose_action:0 +msgid "Sell" +msgstr "بيع" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_sell_dispose_action +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_sell_dispose_action +msgid "Sell Dispose Action" +msgstr "نو العملية" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Sell or Dispose" +msgstr "بيع أو تخريد" + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,type:0 +#: selection:account.asset.operation,type:0 +msgid "Sell/Disposal" +msgstr "بيع/إتلاف" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_sell_dispose +msgid "Sell/Dispose" +msgstr "بيع/إتلاف" + +#. module: exp_asset_base +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_sell_dispose +#: model:ir.actions.act_window,name:exp_asset_base.action_asset_sell_dispose_wiz +#: model:ir.model,name:exp_asset_base.model_account_asset_sell_dispose +msgid "Sell/Dispose Asset" +msgstr "بيع/إتلاف الأصل" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_line_serial_no +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_serial_no +msgid "Serial No" +msgstr "الرقم التسلسلي" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_service_provider_id +msgid "Service Provider" +msgstr "مزود الخدمة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_modify_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_receive_tree +msgid "Set to Draft" +msgstr "تعيين كمسودة" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_modify_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_receive_tree +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_sell_dispose_tree +msgid "Setup" +msgstr "الإعداد" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.asset_adjustment_form +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_multi_operation_form +msgid "Start" +msgstr "بدأ" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_report_asset_register_start_date +msgid "Start Date" +msgstr "تاريخ البدء" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_state +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_state +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "State" +msgstr "المحافظة" + +#. module: exp_asset_base +#: selection:account.asset.operation,state:0 +msgid "Submit" +msgstr "إرسال" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_book_value +msgid "Sum of the depreciable value, the salvage value and the book value of all value increase items" +msgstr "مجموع قيم الإستهلاك, قيمة التخريد و القية الدفترية لجميع عناصر زيادة القيمة" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_suspend_depreciation +msgid "Suspend Depreciation" +msgstr "إيقاف الإستهلاك" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_gain_value +msgid "Technical field to know if we should display the fields for the creation of gross increase asset" +msgstr "Technical field to know if we should display the fields for the creation of gross increase asset" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_gain_or_loss +msgid "Technical field to know is there was a gain or a loss in the selling of the asset" +msgstr "Technical field to know is there was a gain or a loss in the selling of the asset" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:348 +#, python-format +msgid "The %s with barcode %s has schedule maintenance today ,please follow." +msgstr "%s بالكود %s لديه صيانة مجدولة اليوم,الرجاء المتابعة." + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_asset_children_ids +msgid "The children are the gains in value of this asset" +msgstr "The children are the gains in value of this asset" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_invoice_id +msgid "The disposal invoice is needed in order to generate the closing journal entry." +msgstr "فاتورة الإتلاف مطلوبة بإنشاء قيد الإغلاق." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset.py:358 +#, python-format +msgid "The warrant period of %s with barcode %s has end!" +msgstr "فترة الضمان للأصل %s بالباركود %s قد إنتهت!" + +#. module: exp_asset_base +#: model:ir.model.fields,help:exp_asset_base.field_account_asset_operation_invoice_line_id +msgid "There are multiple lines that could be the related to this asset" +msgstr "There are multiple lines that could be the related to this asset" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Time Method Based On" +msgstr "طريقة الوقت مبنية على" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/reports/asset_depreciation_report_xlsx.py:43 +#, python-format +msgid "To: " +msgstr "الى: " + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_invoice_amount +msgid "Total" +msgstr "الإجمالي " + +#. module: exp_asset_base +#: selection:account.asset.multi.operation,type:0 +#: selection:account.asset.operation,type:0 +msgid "Transfer" +msgstr "نقل العهدة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_adjustment_type +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_type +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_multi_operation_type +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_type +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_search +msgid "Type" +msgstr "النوع" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Unlock" +msgstr "إعادة الفتح" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:640 +#, python-format +msgid "Value decrease for: %s" +msgstr "تقليل قيمة: %s" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:591 +#: code:addons/exp_asset_base/models/account_asset_operation.py:597 +#, python-format +msgid "Value increase for: %s" +msgstr "زيادة قيمة: %s" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Vendor" +msgstr "المورد" + +#. module: exp_asset_base +#: model:ir.ui.menu,name:exp_asset_base.menu_account_asset_vendors +msgid "Vendors" +msgstr "الموردين" + +#. module: exp_asset_base +#: selection:account.asset.location,type:0 +msgid "View" +msgstr "عرض" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Warranty" +msgstr "ضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_warranty_contract +msgid "Warranty Contract" +msgstr "عقد الضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_warranty_end_date +msgid "Warranty End Date" +msgstr "تاريخ نهاية الضمان" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_asset_warranty_period +msgid "Warranty Period(Months)" +msgstr "فترة الضمان (شهور)" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:457 +#, python-format +msgid "You can't automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s)." +msgstr "لا يمكنك إنشاء قيود محاسبية تلقائيا لاصل لديه قيم زيادة تحت التنفيذ. الرجاء إتلاف جميع الزيادات أولاً." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:114 +#, python-format +msgid "You have to choose invoice line when selling invoice has more than one line." +msgstr "يجب إختيار عنصر الفاتورة في حال كانت فاتورة البيع تحتوي على أكثر من عنصر." + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_adjustment.py:66 +#, python-format +msgid "You should enter the asset status for all assets that marked as exist." +msgstr "يجب تحديد حالة الأصل لجميع الأصول الموجودة." + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_location +msgid "account.asset.location" +msgstr "account.asset.location" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_manufacturer +msgid "account.asset.manufacturer" +msgstr "account.asset.manufacturer" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_operation +msgid "account.asset.operation" +msgstr "account.asset.operation" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_account_asset_receive +msgid "account.asset.receive" +msgstr "account.asset.receive" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "e.g. Laptop iBook" +msgstr "مثال: كمبيوتر محمول" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:112 +#, python-format +msgid "invoice is required when you selling the asset." +msgstr "يجب إختيار فاتورة البيع." + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_operation_form +msgid "months" +msgstr "أشهر" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_asset_abstract_report_xlsx +msgid "report.asset_abstract_report_xlsx" +msgstr "report.asset_abstract_report_xlsx" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_asset_depreciation_report_xlsx +msgid "report.asset_depreciation_report_xlsx" +msgstr "report.asset_depreciation_report_xlsx" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_report_asset_register_xlsx +msgid "report.report_asset_register_xlsx" +msgstr "report.report_asset_register_xlsx" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_asset_register +msgid "report_asset_register" +msgstr "report_asset_register" + +#. module: exp_asset_base +#: model:ir.model,name:exp_asset_base.model_report_asset_register_depreciation +msgid "report_asset_register_depreciation" +msgstr "report_asset_register_depreciation" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_location_account_analytic_id +msgid "Cost Center" +msgstr "مركز التكلفة" + +#. module: exp_asset_base +#: model:ir.model.fields,field_description:exp_asset_base.field_account_asset_operation_move_id +msgid "move" +msgstr "القيد" + +#. module: exp_asset_base +#: code:addons/exp_asset_base/models/account_asset_operation.py:178 +#, python-format +msgid "Asset should be one on lines." +msgstr "يجب ان يظهر الﻷصل مرة واحدو فقط في العملية" + +#. module: exp_asset_base +#: model:ir.ui.view,arch_db:exp_asset_base.view_account_asset_asset_form +msgid "Receive" +msgstr "إستلام" diff --git a/dev_odex30_accounting/exp_asset_base/models/__init__.py b/dev_odex30_accounting/exp_asset_base/models/__init__.py new file mode 100644 index 0000000..f360068 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from . import account_asset +from . import account_asset_adjustment +from . import asset_modify +from . import asset_pause +from . import asset_sell diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..dd4271c Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset.cpython-311.pyc new file mode 100644 index 0000000..355bc9a Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset_adjustment.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset_adjustment.cpython-311.pyc new file mode 100644 index 0000000..57b6a2c Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/account_asset_adjustment.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_modify.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_modify.cpython-311.pyc new file mode 100644 index 0000000..2f36c07 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_modify.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_pause.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_pause.cpython-311.pyc new file mode 100644 index 0000000..a614719 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_pause.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_sell.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_sell.cpython-311.pyc new file mode 100644 index 0000000..9ab37cb Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/models/__pycache__/asset_sell.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/models/account_asset.py b/dev_odex30_accounting/exp_asset_base/models/account_asset.py new file mode 100644 index 0000000..86509f8 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/account_asset.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import models, fields, api, _ +from datetime import datetime +from odoo.osv import expression +from dateutil.relativedelta import relativedelta +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + + +class AccountAssetManufacturer(models.Model): + _name = 'account.asset.manufacturer' + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = 'Asset Manufacturer' + + name = fields.Char(required=True, tracking=True) + + +class AccountAssetLocation(models.Model): + _name = 'account.asset.location' + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = 'Asset Location' + _parent_name = "parent_id" + _parent_store = True + _rec_name = 'complete_name' + _order = 'complete_name' + + name = fields.Char(required=True, tracking=True) + complete_name = fields.Char('Asset Location', compute='_compute_complete_name', tracking=True, store=True , recursive=True) + code = fields.Char(required=True, tracking=True) + type = fields.Selection(selection=[('ordinary', 'Ordinary'), ('view', 'View')], default='ordinary', required=True, + tracking=True) + parent_id = fields.Many2one(string='Parent', comodel_name='account.asset.location', domain=[('type', '=', 'view')], + tracking=True) + parent_path = fields.Char(index=True, tracking=True) + child_id = fields.One2many('account.asset.location', 'parent_id', 'Child Locations', tracking=True) + + @api.depends('name', 'parent_id.complete_name') + def _compute_complete_name(self): + for location in self: + if location.parent_id: + location.complete_name = '%s / %s' % (location.parent_id.complete_name, location.name) + else: + location.complete_name = location.name + + +class AccountAssetAsset(models.Model): + _name = 'account.asset' + _inherit = ['account.asset', "mail.thread", "mail.activity.mixin"] + + asset_picture = fields.Binary( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + serial_no = fields.Char( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + model = fields.Char( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + manufacturer_id = fields.Many2one( + comodel_name='account.asset.manufacturer', + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + barcode = fields.Char( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, index=True, copy=False, + ) + note = fields.Text( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + receive_date = fields.Date( + states={'draft': [('readonly', False)]}, + readonly=True, + ) + service_provider_id = fields.Many2one( + comodel_name='res.partner', + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + next_maintenance_date = fields.Date( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, + ) + warranty_period = fields.Integer( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, string="Warranty Period(Months)" + ) + warranty_end_date = fields.Date( + compute='_compute_warranty_end_date', + readonly=True, store=True, + ) + warranty_contract = fields.Binary( + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, ) + value_residual = fields.Monetary( + string='Depreciable Value', + compute='_compute_value_residual', + store=True) + product_id = fields.Many2one(comodel_name='product.product', + domain=[('property_account_expense_id.can_create_asset', '=', True), + ('property_account_expense_id.account_type', '=', 'asset_fixed')], + states={'draft': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, tracking=True) + responsible_dept_id = fields.Many2one(comodel_name='hr.department', string='Responsible Department', + states={'draft': [('readonly', False)], 'model': [('readonly', False)], + 'unlock': [('readonly', False)]}, + readonly=True, + default=lambda self: self.env.user.employee_id.department_id.id, + tracking=True) + responsible_user_id = fields.Many2one( + comodel_name='res.users', + states={'draft': [('readonly', False)], 'model': [('readonly', False)], 'unlock': [('readonly', False)]}, + readonly=True, default=lambda self: self.env.user, tracking=True) + asset_adjustment_count = fields.Integer( + compute='_asset_adjustment_count', + string='# of Adjustments', + help="Number of adjustments") + + status = fields.Selection( + selection=[('new', 'New'), ('available', 'Available')], + default='new', tracking=True) + location_id = fields.Many2one(comodel_name='account.asset.location', string='Current Location', tracking=True) + state = fields.Selection(selection_add=[('unlock', 'Unlock')]) + limit = fields.Float(tracking=True) + + _sql_constraints = [ + ('asset_barcode_uniq', 'unique (barcode)', 'Asset barcode must be unique.') + ] + + def validate(self): + """Validate asset and set it to running state""" + self.ensure_one() # تأكد أن العملية على record واحد فقط + + # تحقق من الشروط + + # غيّر الحالة (tracking سيعمل تلقائياً) + self.write({'state': 'open'}) + + # رسالة + asset_type_labels = { + 'purchase': _('Asset'), + 'sale': _('Deferred revenue'), + 'expense': _('Deferred expense'), + } + + label = asset_type_labels.get(self.asset_type, _('Asset')) + message = _('%s has been validated and is now running.') % label + + self.message_post(body=message) + + return True + + # يجب تحويله إلى analytic_distribution + @api.onchange('location_id') + def onchange_location_id(self): + if self.location_id and self.location_id.analytic_distribution: + self.analytic_distribution = self.location_id.analytic_distribution + + # @api.onchange('location_id') + # def onchange_location_id(self): + # if self.analytic_distribution and self.location_id.account_analytic_id: + # self.analytic_distribution = self.location_id.account_analytic_id.id + + # def action_asset_modify(self): + # """ Returns an action opening the asset modification wizard. + # """ + # self.ensure_one() + # return { + # 'name': _('Modify Asset'), + # 'view_mode': 'form', + # 'res_model': 'account.asset.modify', + # 'type': 'ir.actions.act_window', + # 'target': 'current', + # 'context': { + # 'default_asset_id': self.id, + # }, + # } + + def action_asset_pause(self): + """ Returns an action opening the asset pause wizard.""" + self.ensure_one() + return { + 'name': _('Pause Asset'), + 'view_mode': 'form', + 'res_model': 'asset.pause', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'context': { + 'default_asset_id': self.id, + }, + } + + def action_set_to_close(self): + """ Returns an action opening the asset pause wizard.""" + self.ensure_one() + + return { + 'name': _('Sell Asset'), + 'view_mode': 'form', + 'res_model': 'asset.sell', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'context': { + 'default_asset_id': self.id, + }, + } + + def act_unlock(self): + self.state = 'unlock' + + def act_lock(self): + self.state = 'open' + + def _asset_adjustment_count(self): + for asset in self: + asset.asset_adjustment_count = len( + self.env['account.asset.adjustment.line'].search([('asset_id', '=', asset.id)])) + + def open_asset_adjustment(self): + return { + 'name': _('Asset Adjustment'), + 'view_mode': 'tree', + 'res_model': 'account.asset.adjustment.line', + 'type': 'ir.actions.act_window', + 'domain': [('asset_id', '=', self.id)], + 'flags': {'search_view': True, 'action_buttons': False}, + } + + @api.depends('acquisition_date', 'warranty_period') + def _compute_warranty_end_date(self): + for asset in self: + if asset.acquisition_date: + asset.warranty_end_date = asset.acquisition_date + relativedelta(months=asset.warranty_period) + + @api.depends('name', 'barcode') + def name_get(self): + return [(r.id, r.name + (r.barcode and '-' + r.barcode or '')) for r in self] + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + args = args or [] + domain = [] + if name: + domain = ['|', ('barcode', operator, name), ('name', operator, name)] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = ['&', '!'] + domain[1:] + assets = self.search(domain + args, limit=limit) + return assets.name_get() + + @api.model + def create(self, values): + if values.get('state', False) != 'model': + values['serial_no'] = self.env['ir.sequence'].with_context( + ir_sequence_date=values.get('acquisition_date', fields.Date.today())).next_by_code('asset.seq') + return super(AccountAssetAsset, self).create(values) + + def action_save_model(self): + action = super(AccountAssetAsset, self).action_save_model() + action['context']['default_asset_type'] = self.asset_type + return action + + @api.model + def _asset_cron(self): + today = fields.Date.today() + for asset in self.search([('next_maintenance_date', '=', today)]): + self.env['mail.activity'].sudo().create({ + 'res_model_id': self.env.ref('account_asset.model_account_asset_asset').id, + 'res_id': asset.id, + 'user_id': asset.responsible_user_id.id, + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': _('The %s with barcode %s has schedule maintenance today ,please follow.') % ( + asset.name, asset.barcode), + 'date_deadline': asset.next_maintenance_date, + }) + for asset in self.search([('warranty_end_date', '=', today)]): + self.env['mail.activity'].sudo().create({ + 'res_model_id': self.env.ref('account_asset.model_account_asset_asset').id, + 'res_id': asset.id, + 'user_id': asset.responsible_user_id.id, + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': _('The warrant period of %s with barcode %s has end!') % (asset.name, asset.barcode), + 'date_deadline': asset.warranty_end_date, + }) + + @api.onchange('model_id', 'original_value') + def _onchange_model_id(self): + super(AccountAssetAsset, self)._onchange_model_id() + if self.model_id and self.original_value <= self.model_id.limit: + self.method_number = 0 diff --git a/dev_odex30_accounting/exp_asset_base/models/account_asset_adjustment.py b/dev_odex30_accounting/exp_asset_base/models/account_asset_adjustment.py new file mode 100644 index 0000000..93dc1c1 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/account_asset_adjustment.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import api, fields, models, exceptions, _ +from odoo.exceptions import UserError + + +class AccountAssetAdjustment(models.Model): + _name = 'account.asset.adjustment' + _inherit = ['barcodes.barcode_events_mixin',"mail.thread", "mail.activity.mixin"] + _description = 'Asset Adjustment' + + name = fields.Char( + states={'draft': [('readonly', False)]}, + readonly=True, required=True,tracking=True + ) + date = fields.Date( + default=fields.Date.context_today, + index=True, copy=False, readonly=True, required=True,tracking=True, + states={'draft': [('readonly', False)]} + ) + type = fields.Selection( + selection=[('product', 'By Product'), + ('model', 'By Model')], + states={'draft': [('readonly', False)]}, + readonly=True,tracking=True + ) + product_id = fields.Many2one( + comodel_name='product.product', + domain=[('property_account_expense_id.can_create_asset', '=', True), + ('property_account_expense_id.account_type', '=', 'asset_fixed')], + states={'draft': [('readonly', False)]}, + readonly=True,tracking=True + ) + model_id = fields.Many2one( + comodel_name='account.asset', + domain=[('asset_type', '=', 'purchase'), ('state', '=', 'model')], + states={'draft': [('readonly', False)]}, + readonly=True,tracking=True + ) + barcode = fields.Char( + states={'in_progress': [('readonly', False)]}, + readonly=True,tracking=True + ) + adjustment_line_ids = fields.One2many( + 'account.asset.adjustment.line', 'adjustment_id', + states={'in_progress': [('readonly', False)]}, + readonly=True,tracking=True + ) + state = fields.Selection( + selection=[('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('done', 'Done'), + ('cancel', 'Cancel')], + required=True, default='draft',tracking=True + ) + + def build_domain(self): + return (self.type == 'product' and [('product_id', '=', self.product_id.id)]) or \ + (self.type == 'model' and [('model_id', '=', self.model_id.id)]) or [] + + def act_progress(self): + domain = self.build_domain() + assets = self.env['account.asset'].search(domain+[('asset_type', '=', 'purchase'), + ('state', '!=', 'model'), ('parent_id', '=', False)]) + self.adjustment_line_ids = [(0, 0, {'asset_id': s.id}) for s in assets] + self.state = 'in_progress' + + def act_done(self): + if self.adjustment_line_ids.search([('adjustment_id', '=', self.id), ('exist', '=', True), ('asset_status2', '=', False)]): + raise UserError(_('You should enter the asset status for all assets that marked as exist.')) + self.barcode = False + self.state = 'done' + + def act_cancel(self): + self.state = 'cancel' + + def act_draft(self): + self.state = 'draft' + self.adjustment_line_ids.unlink() + + @api.onchange('type') + def onchange_type(self): + self.product_id = False + self.model_id = False + + def on_barcode_scanned(self, barcode): + if barcode: + line = self.adjustment_line_ids.filtered(lambda x: x.barcode == barcode) + if not line: + raise UserError(_('No asset found with the selected barcode')) + for l in line: + l.exist = True + + @api.onchange('barcode') + def onchange_barcode(self): + self.on_barcode_scanned(self.barcode) + + +class AccountAssetAdjustmentLine(models.Model): + _name = 'account.asset.adjustment.line' + _description = 'Asset Adjustment Line' + + adjustment_id = fields.Many2one(comodel_name='account.asset.adjustment',tracking=True) + asset_id = fields.Many2one(comodel_name='account.asset',tracking=True) + barcode = fields.Char(related='asset_id.barcode',tracking=True) + serial_no = fields.Char(related='asset_id.serial_no',tracking=True) + asset_status = fields.Selection(selection=[('good', 'Good'), ('scrap', 'Scrap')],tracking=True) + asset_status2 = fields.Many2one( + comodel_name='asset.state2', + string='Asset Status',tracking=True) + exist = fields.Boolean(string="Exist?",tracking=True) +class AccountAssestatus(models.Model): + _name = 'asset.state2' + name = fields.Char(string='Name') +# diff --git a/dev_odex30_accounting/exp_asset_base/models/asset_modify.py b/dev_odex30_accounting/exp_asset_base/models/asset_modify.py new file mode 100644 index 0000000..57c11ad --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/asset_modify.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- + + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class AssetModify(models.Model): + _name = 'account.asset.modify' + _description = 'Modify Asset' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Text(string='Reason',tracking=True) + asset_id = fields.Many2one(string="Asset", comodel_name='account.asset', domain="[('asset_type', '=', 'purchase'), ('state', '!=', 'model'), ('parent_id', '=', False)]",required=True, help="The asset to be modified by this wizard", ondelete="cascade",tracking=True) + method_number = fields.Integer(string='Number of Depreciations', required=True,tracking=True) + method_period = fields.Selection([('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', help="The amount of time between two depreciations",tracking=True) + value_residual = fields.Monetary(string="Depreciable Amount", help="New residual amount for the asset",tracking=True) + salvage_value = fields.Monetary(string="Not Depreciable Amount", help="New salvage amount for the asset",tracking=True) + currency_id = fields.Many2one(related='asset_id.currency_id') + date = fields.Date(default=fields.Date.today(), string='Date',tracking=True) + need_date = fields.Boolean(compute="_compute_need_date") + gain_value = fields.Boolean(compute="_compute_gain_value", help="Technical field to know if we should display the fields for the creation of gross increase asset") + account_asset_id = fields.Many2one('account.account', string="Asset Gross Increase Account",tracking=True) + account_asset_counterpart_id = fields.Many2one('account.account',tracking=True) + account_depreciation_id = fields.Many2one('account.account',tracking=True) + account_depreciation_expense_id = fields.Many2one('account.account',tracking=True) + state = fields.Selection(selection=[('draft', 'Draft'),('confirm', 'Confirmed'),('approve', 'Approved'),('done', 'Done')],default='draft',readonly=True,tracking=True) + + @api.model + def create(self, vals): + if 'asset_id' in vals: + asset = self.env['account.asset'].browse(vals['asset_id']) + if asset.depreciation_move_ids.filtered(lambda m: m.state == 'posted' and not m.reversal_move_id and m.date > fields.Date.today()): + raise UserError(_('Reverse the depreciation entries posted in the future in order to modify the depreciation')) + if 'method_number' not in vals: + vals.update({'method_number': len(asset.depreciation_move_ids.filtered(lambda move: move.state != 'posted')) or 1}) + if 'method_period' not in vals: + vals.update({'method_period': asset.method_period}) + if 'salvage_value' not in vals: + vals.update({'salvage_value': asset.salvage_value}) + if 'value_residual' not in vals: + vals.update({'value_residual': asset.value_residual}) + if 'account_asset_id' not in vals: + vals.update({'account_asset_id': asset.account_asset_id.id}) + if 'account_depreciation_id' not in vals: + vals.update({'account_depreciation_id': asset.account_depreciation_id.id}) + if 'account_depreciation_expense_id' not in vals: + vals.update({'account_depreciation_expense_id': asset.account_depreciation_expense_id.id}) + return super(AssetModify, self).create(vals) + + def act_confirm(self): + self.state = 'confirm' + + def act_approve(self): + self.state = 'approve' + + def act_draft(self): + self.state = 'draft' + + def modify(self): + """ Modifies the duration of asset for calculating depreciation + and maintains the history of old values, in the chatter. + """ + old_values = { + 'method_number': self.asset_id.method_number, + 'method_period': self.asset_id.method_period, + 'value_residual': self.asset_id.value_residual, + 'salvage_value': self.asset_id.salvage_value, + } + + asset_vals = { + 'method_number': self.method_number, + 'method_period': self.method_period, + 'value_residual': self.value_residual, + 'salvage_value': self.salvage_value, + } + if self.need_date: + asset_vals.update({ + 'prorata_date': self.date, + }) + if self.env.context.get('resume_after_pause'): + asset_vals.update({'state': 'open'}) + self.asset_id.message_post(body=_("Asset unpaused")) + else: + self = self.with_context(ignore_prorata=True) + + current_asset_book = self.asset_id.value_residual + self.asset_id.salvage_value + after_asset_book = self.value_residual + self.salvage_value + increase = after_asset_book - current_asset_book + + new_residual = min(current_asset_book - min(self.salvage_value, self.asset_id.salvage_value), self.value_residual) + new_salvage = min(current_asset_book - new_residual, self.salvage_value) + residual_increase = max(0, self.value_residual - new_residual) + salvage_increase = max(0, self.salvage_value - new_salvage) + + if residual_increase or salvage_increase: + move = self.env['account.move'].create({ + 'journal_id': self.asset_id.journal_id.id, + 'date': fields.Date.today(), + 'line_ids': [ + (0, 0, { + 'account_id': self.account_asset_id.id, + 'debit': residual_increase + salvage_increase, + 'credit': 0, + 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name), + }), + (0, 0, { + 'account_id': self.account_asset_counterpart_id.id, + 'debit': 0, + 'credit': residual_increase + salvage_increase, + 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name), + }), + ], + }) + move.action_post() + asset_increase = self.env['account.asset'].create({ + 'name': self.asset_id.name + ': ' + self.name, + 'currency_id': self.asset_id.currency_id.id, + 'company_id': self.asset_id.company_id.id, + 'asset_type': self.asset_id.asset_type, + 'method': self.asset_id.method, + 'method_number': self.method_number, + 'method_period': self.method_period, + 'acquisition_date': self.date, + 'value_residual': residual_increase, + 'salvage_value': salvage_increase, + 'original_value': residual_increase + salvage_increase, + 'account_asset_id': self.account_asset_id.id, + 'account_depreciation_id': self.account_depreciation_id.id, + 'account_depreciation_expense_id': self.account_depreciation_expense_id.id, + 'journal_id': self.asset_id.journal_id.id, + 'parent_id': self.asset_id.id, + 'original_move_line_ids': [(6, 0, move.line_ids.filtered(lambda r: r.account_id == self.account_asset_id).ids)], + }) + asset_increase.validate() + + subject = _('A gross increase has been created') + ': %s' % (asset_increase.id, asset_increase.name) + self.asset_id.message_post(body=subject) + if increase < 0: + if self.env['account.move'].search([('asset_id', '=', self.asset_id.id), ('state', '=', 'draft'), ('date', '<=', self.date)]): + raise UserError('There are unposted depreciations prior to the selected operation date, please deal with them first.') + move = self.env['account.move'].create(self.env['account.move']._prepare_move_for_asset_depreciation({ + 'amount': -increase, + 'asset_id': self.asset_id, + 'move_ref': _('Value decrease for: %(asset)s', asset=self.asset_id.name), + 'date': self.date, + 'asset_remaining_value': 0, + 'asset_depreciated_value': 0, + 'asset_value_change': True, + })).action_post() + + asset_vals.update({ + 'value_residual': new_residual, + 'salvage_value': new_salvage, + }) + self.asset_id.write(asset_vals) + self.asset_id.compute_depreciation_board() + self.asset_id.children_ids.write({ + 'method_number': asset_vals['method_number'], + 'method_period': asset_vals['method_period'], + }) + for child in self.asset_id.children_ids: + child.compute_depreciation_board() + tracked_fields = self.env['account.asset'].fields_get(old_values.keys()) + changes, tracking_value_ids = self.asset_id._message_track(tracked_fields, old_values) + if changes: + self.asset_id.message_post(body=_('Depreciation board modified') + '
' + self.name, tracking_value_ids=tracking_value_ids) + return self.write({'state':'done'}) + + @api.depends('asset_id', 'value_residual', 'salvage_value') + def _compute_need_date(self): + for record in self: + value_changed = record.value_residual + record.salvage_value != record.asset_id.value_residual + record.asset_id.salvage_value + record.need_date = (self.env.context.get('resume_after_pause') and record.asset_id.prorata) or value_changed + + @api.depends('asset_id', 'value_residual', 'salvage_value') + def _compute_gain_value(self): + for record in self: + record.gain_value = record.value_residual + record.salvage_value > record.asset_id.value_residual + record.asset_id.salvage_value diff --git a/dev_odex30_accounting/exp_asset_base/models/asset_pause.py b/dev_odex30_accounting/exp_asset_base/models/asset_pause.py new file mode 100644 index 0000000..9a0598b --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/asset_pause.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + + +from odoo import api, fields, models, _ + + +class AssetPause(models.Model): + _name = 'asset.pause' + _description = 'Pause Asset' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _rec_name = 'asset_id' + + date = fields.Date(string='Pause date', required=True, default=fields.Date.today(),tracking=True) + asset_id = fields.Many2one('account.asset', domain="[('asset_type', '=', 'purchase'), ('state', '!=', 'model'), ('parent_id', '=', False)]",required=True,tracking=True) + state = fields.Selection(selection=[('draft', 'Draft'),('confirm', 'Confirmed'),('approve', 'Approved'),('done', 'Done')],default='draft',readonly=True,tracking=True) + + def act_confirm(self): + self.state = 'confirm' + + def act_approve(self): + self.state = 'approve' + + def act_draft(self): + self.state = 'draft' + + def do_action(self): + for record in self: + record.asset_id.pause(pause_date=record.date) + return self.write({'state':'done'}) diff --git a/dev_odex30_accounting/exp_asset_base/models/asset_sell.py b/dev_odex30_accounting/exp_asset_base/models/asset_sell.py new file mode 100644 index 0000000..1f234b3 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/models/asset_sell.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class AssetSell(models.Model): + _name = 'asset.sell' + _description = 'Sell Asset' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _rec_name = 'asset_id' + + asset_id = fields.Many2one('account.asset', domain="[('asset_type', '=', 'purchase'), ('state', '!=', 'model'), ('parent_id', '=', False)]",required=True,tracking=True) + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + + action = fields.Selection([('sell', 'Sell'), ('dispose', 'Dispose')], required=True, default='sell',tracking=True) + invoice_id = fields.Many2one('account.move', string="Customer Invoice", help="The disposal invoice is needed in order to generate the closing journal entry.", domain="[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')]",tracking=True) + invoice_line_id = fields.Many2one('account.move.line', help="There are multiple lines that could be the related to this asset", domain="[('move_id', '=', invoice_id), ('exclude_from_invoice_tab', '=', False)]",tracking=True) + select_invoice_line_id = fields.Boolean(compute="_compute_select_invoice_line_id") + gain_account_id = fields.Many2one('account.account', domain="[('deprecated', '=', False), ('company_id', '=', company_id),('type','!=','view')]", related='company_id.gain_account_id', help="Account used to write the journal item in case of gain", readonly=False,tracking=True) + loss_account_id = fields.Many2one('account.account', domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", related='company_id.loss_account_id', help="Account used to write the journal item in case of loss", readonly=False,tracking=True) + + gain_or_loss = fields.Selection([('gain', 'Gain'), ('loss', 'Loss'), ('no', 'No')], compute='_compute_gain_or_loss', help="Technical field to know is there was a gain or a loss in the selling of the asset",tracking=True) + state = fields.Selection(selection=[('draft', 'Draft'),('confirm', 'Confirmed'),('approve', 'Approved'),('done', 'Done')],default='draft',readonly=True,tracking=True) + + def act_confirm(self): + self.state = 'confirm' + + def act_approve(self): + self.state = 'approve' + + def act_draft(self): + self.state = 'draft' + + @api.depends('invoice_id', 'action') + def _compute_select_invoice_line_id(self): + for record in self: + record.select_invoice_line_id = record.action == 'sell' and len(record.invoice_id.invoice_line_ids) > 1 + + @api.onchange('action') + def _onchange_action(self): + if self.action == 'sell' and self.asset_id.children_ids.filtered(lambda a: a.state in ('draft', 'open') or a.value_residual > 0): + raise UserError(_("You cannot automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s).")) + + @api.depends('asset_id', 'invoice_id', 'invoice_line_id') + def _compute_gain_or_loss(self): + for record in self: + line = record.invoice_line_id or len(record.invoice_id.invoice_line_ids) == 1 and record.invoice_id.invoice_line_ids or self.env['account.move.line'] + if record.asset_id.value_residual < abs(line.balance): + record.gain_or_loss = 'gain' + elif record.asset_id.value_residual > abs(line.balance): + record.gain_or_loss = 'loss' + else: + record.gain_or_loss = 'no' + + def do_action(self): + self.ensure_one() + invoice_line = self.env['account.move.line'] if self.action == 'dispose' else self.invoice_line_id or self.invoice_id.invoice_line_ids + return self.asset_id.set_to_close(invoice_line_id=invoice_line, date=invoice_line.move_id.invoice_date) diff --git a/dev_odex30_accounting/exp_asset_base/reports/__init__.py b/dev_odex30_accounting/exp_asset_base/reports/__init__.py new file mode 100644 index 0000000..954b437 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/__init__.py @@ -0,0 +1,4 @@ +from . import abstract_report_xlsx +from . import asset_register_report_xlsx +#from . import asset_depreciation_report_xlsx +#from . import asset_register_report \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/reports/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..fdf3267 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/reports/__pycache__/abstract_report_xlsx.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/abstract_report_xlsx.cpython-311.pyc new file mode 100644 index 0000000..a03e20a Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/abstract_report_xlsx.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/reports/__pycache__/asset_register_report_xlsx.cpython-311.pyc b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/asset_register_report_xlsx.cpython-311.pyc new file mode 100644 index 0000000..f9abb33 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/reports/__pycache__/asset_register_report_xlsx.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_base/reports/abstract_report_xlsx.py b/dev_odex30_accounting/exp_asset_base/reports/abstract_report_xlsx.py new file mode 100644 index 0000000..1738e11 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/abstract_report_xlsx.py @@ -0,0 +1,688 @@ + +# Author: Julien Coux +# Copyright 2016 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class AbstractReportXslx(models.AbstractModel): + _name = 'report.asset_abstract_report_xlsx' + _inherit = 'report.report_xlsx.abstract' + _description = 'asset_abstract_report_xlsx' + + def get_workbook_options(self): + vals = super(AbstractReportXslx, self).get_workbook_options() + vals.update({"constant_memory": True}) + return vals + + def generate_xlsx_report(self, workbook, data, objects): + # Initialize report variables + report_data = { + "workbook": None, + "sheet": None, # main sheet which will contains report + "columns": None, # columns of the report + "row_pos": None, # row_pos must be incremented at each writing lines + "formats": None, + } + self._define_formats(workbook, report_data) + # Get report data + report_name = self._get_report_name(objects, data=data) + report_footer = self._get_report_footer() + filters = self._get_report_filters(objects) + report_data["columns"] = self._get_report_columns(objects) + report_data["workbook"] = workbook + report_data["sheet"] = workbook.add_worksheet(report_name[:31]) + self._set_column_width(report_data) + # Fill report + report_data["row_pos"] = 0 + self._write_report_title(report_name, report_data) + self._write_filters(filters, report_data) + self._generate_report_content(workbook, objects, data, report_data) + self._write_report_footer(report_footer, report_data) + + def _define_formats(self, workbook, report_data): + """Add cell formats to current workbook. + Those formats can be used on all cell. + Available formats are : + * format_bold + * format_right + * format_right_bold_italic + * format_header_left + * format_header_center + * format_header_right + * format_header_amount + * format_amount + * format_percent_bold_italic + """ + currency_id = self.env["res.company"]._default_currency_id() + report_data["formats"] = { + "format_bold": workbook.add_format({"bold": True}), + "format_right": workbook.add_format({"align": "right"}), + "format_left": workbook.add_format({"align": "left"}), + "format_center": workbook.add_format({'align': 'center'}), + "format_right_bold_italic": workbook.add_format( + {"align": "right", "bold": True, "italic": True} + ), + "format_header_left": workbook.add_format( + {"bold": True, "border": True, "bg_color": "#FFFFCC"} + ), + "format_header_center": workbook.add_format( + {"bold": True, "align": "center", "border": True, "bg_color": "#FFFFCC"} + ), + "format_header_right": workbook.add_format( + {"bold": True, "align": "right", "border": True, "bg_color": "#FFFFCC"} + ), + "format_header_amount": workbook.add_format( + {"bold": True, "border": True, "bg_color": "#FFFFCC"} + ).set_num_format("#,##0." + "0" * currency_id.decimal_places), + "format_amount": workbook.add_format().set_num_format( + "#,##0." + "0" * currency_id.decimal_places + ), + "format_amount_bold": workbook.add_format({"bold": True}).set_num_format( + "#,##0." + "0" * currency_id.decimal_places + ), + "format_percent_bold_italic": workbook.add_format( + {"bold": True, "italic": True} + ).set_num_format("#,##0.00%"), + } + + def _set_column_width(self, report_data): + """Set width for all defined columns. + Columns are defined with `_get_report_columns` method. + """ + for position, column in report_data["columns"].items(): + report_data["sheet"].set_column(position, position, column["width"]) + + def _write_report_title(self, title, report_data): + """Write report title on current line using all defined columns width. + Columns are defined with `_get_report_columns` method. + """ + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + len(report_data["columns"]) - 1, + title, + report_data["formats"]["format_bold"], + ) + report_data["row_pos"] += 3 + + def _write_report_footer(self, footer, report_data): + """Write report footer . + Columns are defined with `_get_report_columns` method. + """ + if footer: + report_data["row_pos"] += 1 + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + len(report_data["columns"]) - 1, + footer, + report_data["formats"]["format_left"], + ) + report_data["row_pos"] += 1 + + def _write_filters(self, filters, report_data): + """Write one line per filters on starting on current line. + Columns number for filter name is defined + with `_get_col_count_filter_name` method. + Columns number for filter value is define + with `_get_col_count_filter_value` method. + """ + col_name = 1 + col_count_filter_name = self._get_col_count_filter_name() + col_count_filter_value = self._get_col_count_filter_value() + col_value = col_name + col_count_filter_name + 1 + for title, value in filters: + report_data["sheet"].merge_range( + report_data["row_pos"], + col_name, + report_data["row_pos"], + col_name + col_count_filter_name - 1, + title, + report_data["formats"]["format_header_left"], + ) + report_data["sheet"].merge_range( + report_data["row_pos"], + col_value, + report_data["row_pos"], + col_value + col_count_filter_value - 1, + value, + ) + report_data["row_pos"] += 1 + report_data["row_pos"] += 2 + + def write_array_title(self, title, report_data): + """Write array title on current line using all defined columns width. + Columns are defined with `_get_report_columns` method. + """ + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + len(report_data["columns"]) - 1, + title, + report_data["formats"]["format_bold"], + ) + report_data["row_pos"] += 1 + + def write_array_header(self, report_data): + """Write array header on current line using all defined columns name. + Columns are defined with `_get_report_columns` method. + """ + for col_pos, column in report_data["columns"].items(): + report_data["sheet"].write( + report_data["row_pos"], + col_pos, + column["header"], + report_data["formats"]["format_header_center"], + ) + report_data["row_pos"] += 1 + + def write_line(self, line_object, report_data): + """Write a line on current line using all defined columns field name. + Columns are defined with `_get_report_columns` method. + """ + for col_pos, column in report_data["columns"].items(): + value = getattr(line_object, column["field"]) + cell_type = column.get("type", "string") + if cell_type == "many2one": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value.name or "", + report_data["formats"]["format_right"], + ) + elif cell_type == "string": + if ( + hasattr(line_object, "account_group_id") + and line_object.account_group_id + ): + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_bold"], + ) + else: + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, value or "" + ) + elif cell_type == "amount": + if ( + hasattr(line_object, "account_group_id") + and line_object.account_group_id + ): + cell_format = report_data["formats"]["format_amount_bold"] + else: + cell_format = report_data["formats"]["format_amount"] + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), cell_format + ) + elif cell_type == "amount_currency": + if line_object.currency_id: + format_amt = self._get_currency_amt_format(line_object, report_data) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + report_data["row_pos"] += 1 + + def write_line_from_dict(self, line_dict, report_data): + """Write a line on current line""" + for col_pos, column in report_data["columns"].items(): + value = line_dict.get(column["field"], False) + cell_type = column.get("type", "string") + if cell_type == "string": + if ( + line_dict.get("account_group_id", False) + and line_dict["account_group_id"] + ): + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_bold"], + ) + else: + if ( + not isinstance(value, str) + and not isinstance(value, bool) + and not isinstance(value, int) + ): + value = value and value.strftime("%d/%m/%Y") + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, value or "" + ) + elif cell_type == "amount": + if ( + line_dict.get("account_group_id", False) + and line_dict["account_group_id"] + ): + cell_format = report_data["formats"]["format_amount_bold"] + else: + cell_format = report_data["formats"]["format_amount"] + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), cell_format + ) + elif cell_type == "amount_currency": + if line_dict.get("currency_name", False): + format_amt = self._get_currency_amt_format_dict( + line_dict, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif cell_type == "currency_name": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_right"], + ) + report_data["row_pos"] += 1 + + def write_initial_balance(self, my_object, label, report_data): + """Write a specific initial balance line on current line + using defined columns field_initial_balance name. + Columns are defined with `_get_report_columns` method. + """ + col_pos_label = self._get_col_pos_initial_balance_label() + report_data["sheet"].write( + report_data["row_pos"], + col_pos_label, + label, + report_data["formats"]["format_right"], + ) + for col_pos, column in report_data["columns"].items(): + if column.get("field_initial_balance"): + value = getattr(my_object, column["field_initial_balance"]) + cell_type = column.get("type", "string") + if cell_type == "string": + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, value or "" + ) + elif cell_type == "amount": + report_data["sheet"].write_number( + report_data["row_pos"], + col_pos, + float(value), + report_data["formats"]["format_amount"], + ) + elif cell_type == "amount_currency": + if my_object.currency_id: + format_amt = self._get_currency_amt_format( + my_object, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif column.get("field_currency_balance"): + value = getattr(my_object, column["field_currency_balance"]) + cell_type = column.get("type", "string") + if cell_type == "many2one": + if my_object.currency_id: + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value.name or "", + report_data["formats"]["format_right"], + ) + report_data["row_pos"] += 1 + + def write_initial_balance_from_dict(self, my_object, label, report_data): + """Write a specific initial balance line on current line + using defined columns field_initial_balance name. + Columns are defined with `_get_report_columns` method. + """ + col_pos_label = self._get_col_pos_initial_balance_label() + report_data["sheet"].write( + report_data["row_pos"], + col_pos_label, + label, + report_data["formats"]["format_right"], + ) + for col_pos, column in report_data["columns"].items(): + if column.get("field_initial_balance"): + value = my_object.get(column["field_initial_balance"], False) + cell_type = column.get("type", "string") + if cell_type == "string": + report_data["sheet"].write_string( + report_data["row_pos"], col_pos, value or "" + ) + elif cell_type == "amount": + report_data["sheet"].write_number( + report_data["row_pos"], + col_pos, + float(value), + report_data["formats"]["format_amount"], + ) + elif cell_type == "amount_currency": + if my_object["currency_id"]: + format_amt = self._get_currency_amt_format( + my_object, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif column.get("field_currency_balance"): + value = my_object.get(column["field_currency_balance"], False) + cell_type = column.get("type", "string") + if cell_type == "many2one": + if my_object["currency_id"]: + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value.name or "", + report_data["formats"]["format_right"], + ) + report_data["row_pos"] += 1 + + def write_ending_balance(self, my_object, name, label, report_data): + """Write a specific ending balance line on current line + using defined columns field_final_balance name. + Columns are defined with `_get_report_columns` method. + """ + for i in range(0, len(report_data["columns"])): + report_data["sheet"].write( + report_data["row_pos"], + i, + "", + report_data["formats"]["format_header_right"], + ) + row_count_name = self._get_col_count_final_balance_name() + col_pos_label = self._get_col_pos_final_balance_label() + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + row_count_name - 1, + name, + report_data["formats"]["format_header_left"], + ) + report_data["sheet"].write( + report_data["row_pos"], + col_pos_label, + label, + report_data["formats"]["format_header_right"], + ) + for col_pos, column in report_data["columns"].items(): + if column.get("field_final_balance"): + value = getattr(my_object, column["field_final_balance"]) + cell_type = column.get("type", "string") + if cell_type == "string": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_header_right"], + ) + elif cell_type == "amount": + report_data["sheet"].write_number( + report_data["row_pos"], + col_pos, + float(value), + report_data["formats"]["format_header_amount"], + ) + elif cell_type == "amount_currency": + if my_object.currency_id: + format_amt = self._get_currency_amt_header_format( + my_object, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif column.get("field_currency_balance"): + value = getattr(my_object, column["field_currency_balance"]) + cell_type = column.get("type", "string") + if cell_type == "many2one": + if my_object.currency_id: + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value.name or "", + report_data["formats"]["format_header_right"], + ) + report_data["row_pos"] += 1 + + def write_ending_balance_from_dict(self, my_object, name, label, report_data): + """Write a specific ending balance line on current line + using defined columns field_final_balance name. + Columns are defined with `_get_report_columns` method. + """ + for i in range(0, len(report_data["columns"])): + report_data["sheet"].write( + report_data["row_pos"], + i, + "", + report_data["formats"]["format_header_right"], + ) + row_count_name = self._get_col_count_final_balance_name() + col_pos_label = self._get_col_pos_final_balance_label() + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + row_count_name - 1, + name, + report_data["formats"]["format_header_left"], + ) + report_data["sheet"].write( + report_data["row_pos"], + col_pos_label, + label, + report_data["formats"]["format_header_right"], + ) + for col_pos, column in report_data["columns"].items(): + if column.get("field_final_balance"): + value = my_object.get(column["field_final_balance"], False) + cell_type = column.get("type", "string") + if cell_type == "string": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_header_right"], + ) + elif cell_type == "amount": + report_data["sheet"].write_number( + report_data["row_pos"], + col_pos, + float(value), + report_data["formats"]["format_header_amount"], + ) + elif cell_type == "amount_currency": + if my_object["currency_id"] and value: + format_amt = self._get_currency_amt_format_dict( + my_object, report_data + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), format_amt + ) + elif column.get("field_currency_balance"): + value = my_object.get(column["field_currency_balance"], False) + cell_type = column.get("type", "string") + if cell_type == "many2one": + if my_object["currency_id"]: + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_header_right"], + ) + elif cell_type == "currency_name": + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_data["formats"]["format_header_right"], + ) + report_data["row_pos"] += 1 + + def _get_currency_amt_format(self, line_object, report_data): + """ Return amount format specific for each currency. """ + if "account_group_id" in line_object and line_object["account_group_id"]: + format_amt = report_data["formats"]["format_amount_bold"] + field_prefix = "format_amount_bold" + else: + format_amt = report_data["formats"]["format_amount"] + field_prefix = "format_amount" + if "currency_id" in line_object and line_object.get("currency_id", False): + field_name = "{}_{}".format(field_prefix, line_object["currency_id"].name) + if hasattr(self, field_name): + format_amt = getattr(self, field_name) + else: + format_amt = report_data["workbook"].add_format() + report_data["field_name"] = format_amt + format_amount = "#,##0." + ( + "0" * line_object["currency_id"].decimal_places + ) + format_amt.set_num_format(format_amount) + return format_amt + + def _get_currency_amt_format_dict(self, line_dict, report_data): + """ Return amount format specific for each currency. """ + if line_dict.get("account_group_id", False) and line_dict["account_group_id"]: + format_amt = report_data["formats"]["format_amount_bold"] + field_prefix = "format_amount_bold" + else: + format_amt = report_data["formats"]["format_amount"] + field_prefix = "format_amount" + if line_dict.get("currency_id", False) and line_dict["currency_id"]: + if isinstance(line_dict["currency_id"], int): + currency = self.env["res.currency"].browse(line_dict["currency_id"]) + else: + currency = line_dict["currency_id"] + field_name = "{}_{}".format(field_prefix, currency.name) + if hasattr(self, field_name): + format_amt = getattr(self, field_name) + else: + format_amt = report_data["workbook"].add_format() + report_data["field_name"] = format_amt + format_amount = "#,##0." + ("0" * currency.decimal_places) + format_amt.set_num_format(format_amount) + return format_amt + + def _get_currency_amt_header_format(self, line_object, report_data): + """ Return amount header format for each currency. """ + format_amt = report_data["formats"]["format_header_amount"] + if line_object.currency_id: + field_name = "format_header_amount_%s" % line_object.currency_id.name + if hasattr(self, field_name): + format_amt = getattr(self, field_name) + else: + format_amt = report_data["workbook"].add_format( + {"bold": True, "border": True, "bg_color": "#FFFFCC"} + ) + report_data["field_name"] = format_amt + format_amount = "#,##0." + ( + "0" * line_object.currency_id.decimal_places + ) + format_amt.set_num_format(format_amount) + return format_amt + + def _get_currency_amt_header_format_dict(self, line_object, report_data): + """ Return amount header format for each currency. """ + format_amt = report_data["formats"]["format_header_amount"] + if line_object["currency_id"]: + field_name = "format_header_amount_%s" % line_object["currency_name"] + if hasattr(self, field_name): + format_amt = getattr(self, field_name) + else: + format_amt = report_data["workbook"].add_format( + {"bold": True, "border": True, "bg_color": "#FFFFCC"} + ) + report_data["field_name"] = format_amt + format_amount = "#,##0." + ( + "0" * line_object["currency_id"].decimal_places + ) + format_amt.set_num_format(format_amount) + return format_amt + + def _generate_report_content(self, workbook, report, data, report_data): + """ + Allow to fetch report content to be displayed. + """ + raise NotImplementedError() + + def _get_report_complete_name(self, report, prefix, data=None): + if report.company_id: + suffix = " - {} - {}".format( + report.company_id.name, report.company_id.currency_id.name + ) + return prefix + suffix + return prefix + + def _get_report_name(self, report, data=False): + """ + Allow to define the report name. + Report name will be used as sheet name and as report title. + :return: the report name + """ + raise NotImplementedError() + + def _get_report_footer(self): + """ + Allow to define the report footer. + :return: the report footer + """ + return False + + def _get_report_columns(self, report): + """ + Allow to define the report columns + which will be used to generate report. + :return: the report columns as dict + :Example: + { + 0: {'header': 'Simple column', + 'field': 'field_name_on_my_object', + 'width': 11}, + 1: {'header': 'Amount column', + 'field': 'field_name_on_my_object', + 'type': 'amount', + 'width': 14}, + } + """ + raise NotImplementedError() + + def _get_report_filters(self, report): + """ + :return: the report filters as list + :Example: + [ + ['first_filter_name', 'first_filter_value'], + ['second_filter_name', 'second_filter_value'] + ] + """ + raise NotImplementedError() + + def _get_col_count_filter_name(self): + """ + :return: the columns number used for filter names. + """ + raise NotImplementedError() + + def _get_col_count_filter_value(self): + """ + :return: the columns number used for filter values. + """ + raise NotImplementedError() + + def _get_col_pos_initial_balance_label(self): + """ + :return: the columns position used for initial balance label. + """ + raise NotImplementedError() + + def _get_col_count_final_balance_name(self): + """ + :return: the columns number used for final balance name. + """ + raise NotImplementedError() + + def _get_col_pos_final_balance_label(self): + """ + :return: the columns position used for final balance label. + """ + raise NotImplementedError() \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_pdf_report.xml b/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_pdf_report.xml new file mode 100644 index 0000000..469a1ed --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_pdf_report.xml @@ -0,0 +1,50 @@ + + + + + + + + + Asset Barcode (PDF) + account.asset + qweb-pdf + exp_asset_base.report_assetbarcode + exp_asset_base.report_assetbarcode + 'Assets barcode - %s' % (object.name) + + report + + + diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_zpl_report.xml b/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_zpl_report.xml new file mode 100644 index 0000000..f883b84 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_barcode_zpl_report.xml @@ -0,0 +1,29 @@ + + + + + + Asset Barcode (ZPL) + account.asset + qweb-text + exp_asset_base.label_barcode_account_asset_view + exp_asset_base.label_barcode_account_asset_view + + report + + + diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_depreciation_report_xlsx.py b/dev_odex30_accounting/exp_asset_base/reports/asset_depreciation_report_xlsx.py new file mode 100644 index 0000000..639f9c7 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_depreciation_report_xlsx.py @@ -0,0 +1,126 @@ +# Copyright 2018 Forest and Biomass Romania +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import _, models +from dateutil.rrule import rrule, MONTHLY +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF +from datetime import datetime + +class AssetDepreciationReportXslx(models.AbstractModel): + _name = 'report.asset_depreciation_report_xlsx' + _inherit = 'report.asset_abstract_report_xlsx' + _description = 'asset_depreciation_report_xlsx' + + def _get_report_name(self, report): + return _('Fixed Asset Register') + + def _get_report_columns(self, report): + return { + 0: {'width': 5}, + 1: {'width': 12}, + 2: {'width': 20}, + 3: {'width': 12}, + 4: {'width': 12}, + 5: {'width': 12}, + 6: {'width': 12}, + 7: {'width': 12}, + 8: {'width': 12}, + 9: {'width': 12}, + 10: {'width': 12}, + 11: {'width': 12}, + 12: {'width': 12}, + 13: {'width': 12}, + 14: {'width': 12}, + 15: {'width': 12}, + 16: {'width': 12}, + 17: {'width': 12}, + 18: {'width': 12}, + 19: {'width': 12}, + } + + def _get_report_filters(self, report): + return [ + [ + _('Duration'), _('From: ') + report.start_date + ' ' + _('To: ') + report.end_date + ], + [ + _('Categories'), ', '.join([c.name for c in report.category_ids]), + ], + ] + + def _get_col_count_filter_name(self): + return 1 + + def _get_col_count_filter_value(self): + return 3 + + def _generate_report_content(self, workbook, report): + # For each category + depreciation_line = self.env['account.asset.depreciation.line'] + + start_date = datetime.strptime(report.start_date, DF) + end_date = datetime.strptime(report.end_date, DF) + periods = [dt.strftime("%b-%y") for dt in rrule(MONTHLY, dtstart=start_date, until=end_date)] + + seq = 1 + depreciation_register = self.env['report_asset_register_depreciation'] + for category in report.category_ids: + # Write category info + self.sheet.write(self.row_pos, 0, seq, self.format_header1) + self.sheet.merge_range(self.row_pos, 1, self.row_pos, 2, category.name, self.format_header1) + percentage = 100/(category.method_number*category.method_period/12) + self.sheet.write(self.row_pos, 3, str(round(percentage,2))+"%", self.format_header1) + self.sheet.write(self.row_pos, 4, "", self.format_header1) + self.sheet.merge_range(self.row_pos, 5, self.row_pos, 4+len(periods), "DEPRECIATION BY MONTH", self.format_header1) + self.sheet.write(self.row_pos, 5+len(periods), "Depr'n.", self.format_header1) + self.sheet.write(self.row_pos, 6+len(periods), "Accu Deprn.", self.format_header1) + self.sheet.write(self.row_pos, 7+len(periods), "NBV", self.format_header1) + self.row_pos += 1 + + #Write Header + self.sheet.write(self.row_pos, 0, "", self.format_header2) + self.sheet.write(self.row_pos, 1, "Date", self.format_header2) + self.sheet.write(self.row_pos, 2, "Description of Asset", self.format_header2) + self.sheet.write(self.row_pos, 3, "Ref. Doc.", self.format_header2) + self.sheet.write(self.row_pos, 4, "Amount", self.format_header2) + column_pos = 4 + for p in periods: + column_pos += 1 + self.sheet.write(self.row_pos, column_pos, p, self.format_header2) + self.sheet.write(self.row_pos, column_pos+1, report.start_date+'-'+report.end_date, self.format_header2) + self.sheet.write(self.row_pos, column_pos+2, report.end_date, self.format_header2) + self.sheet.write(self.row_pos, column_pos+3, 'After '+report.end_date, self.format_header2) + self.row_pos += 1 + + #Write Asset + asset_ids = self.env['account.asset'].search([('category_id', '=', category.id), + ('date', '<=', report.end_date)]) + for asset in asset_ids: + + self.sheet.write(self.row_pos, 0, "", self.format_center) + self.sheet.write(self.row_pos, 1, asset.date, self.format_center) + self.sheet.write(self.row_pos, 2, asset.name, self.format_center) + self.sheet.write(self.row_pos, 3, asset.invoice_id.name, self.format_center) + self.sheet.write(self.row_pos, 4, asset.value, self.format_center) + + column_pos = 4 + fy_depreciation = 0 + for p in periods: + column_pos += 1 + depreciation = depreciation_register.search([('report_id', '=', report.id), + ('asset_id', '=', asset.id), + ('period', '=', p)]) + amount = sum([d.amount for d in depreciation]) + fy_depreciation += amount + self.sheet.write(self.row_pos, column_pos, amount, self.format_center) + self.sheet.write(self.row_pos, column_pos+1, fy_depreciation, self.format_center) + accu = depreciation_line.read_group([('depreciation_date', '<=', report.end_date), + ('asset_id','=', asset.id)], + ['asset_id', 'amount'], ['asset_id']) + accu_depreciation = accu and accu[0].get('amount') or 0 + self.sheet.write(self.row_pos, column_pos+2, accu_depreciation, self.format_center) + self.sheet.write(self.row_pos, column_pos+3, asset.value - accu_depreciation, self.format_center) + self.row_pos += 1 + seq += 1 + self.row_pos += 1 + diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.py b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.py new file mode 100644 index 0000000..469f763 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.py @@ -0,0 +1,74 @@ +# © 2016 Julien Coux (Camptocamp) +# © 2018 Forest and Biomass Romania SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields, api +from dateutil.rrule import rrule, MONTHLY +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF +from datetime import datetime, timedelta + +class AssetRegisterReport(models.TransientModel): + """ Here, we just define class fields. + For methods, go more bottom at this file. + + The class hierarchy is : + * AssetRegisterReport + ** Asset Category + *** Asset + **** AssetRegisterReportDepreciation + """ + _name = 'report_asset_register' + + # Filters fields, used for data computation + category_ids = fields.Many2many('account.asset.category') + start_date = fields.Date() + end_date = fields.Date() + depreciation_ids = fields.One2many('report_asset_register_depreciation', 'report_id') + + @api.one + def compute_data_for_report(self): + self.depreciation_ids.unlink() + depreciation = self.env['account.asset.depreciation.line'] + depreciation_register = self.env['report_asset_register_depreciation'] + asset = self.env['account.asset'].search([('category_id', 'in', self.category_ids.ids), + ('date', '<=', self.end_date)]) + start_date = datetime.strptime(self.start_date, DF).replace(day=1) + end_date = datetime.strptime(self.end_date, DF) + timedelta(days=1) + dates = [dt for dt in rrule(MONTHLY, dtstart=start_date, until=end_date)] + dates[0] = datetime.strptime(self.start_date, DF) + + for p in range(0, len(dates)): + start = dates[p] + end = p != len(dates)-1 and dates[p+1] or end_date + line = depreciation.search([('depreciation_date', '>=', start.strftime(DF)), + ('depreciation_date', '<', end.strftime(DF)), + ('asset_id', 'in', asset.ids), + ('move_id', '!=', False)]) + for l in line: + val = { + 'report_id': self.id, + 'asset_id': l.asset_id.id, + 'period': start.strftime("%b-%y"), + 'amount': l.amount, + 'date': l.depreciation_date, + + } + depreciation_register.create(val) + + @api.multi + def print_report(self): + self.ensure_one() + return self.env['ir.actions.report'].search( + [('report_name', '=', 'asset_depreciation_report_xlsx'), + ('report_type', '=', 'xlsx')], limit=1).report_action(self) + + +class AssetRegisterReportDepreciation(models.TransientModel): + _name = 'report_asset_register_depreciation' + + report_id = fields.Many2one('report_asset_register', ondelete='cascade', index=True) + asset_id = fields.Many2one('account.asset') + period = fields.Char() + amount = fields.Float() + date = fields.Date() + diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.xml b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.xml new file mode 100644 index 0000000..e743ab6 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report.xml @@ -0,0 +1,59 @@ + + + + + report_asset_register.tree + report_asset_register + + + + + + + + + + + report_asset_register.form + report_asset_register + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Asset Register + report_asset_register + form + tree,form + + + + +
\ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/reports/asset_register_report_xlsx.py b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report_xlsx.py new file mode 100644 index 0000000..8ae70b3 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/asset_register_report_xlsx.py @@ -0,0 +1,126 @@ +# Copyright 2018 Forest and Biomass Romania +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import models, _ +from datetime import datetime +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF + + +class AssetRegisterReportXslx(models.AbstractModel): + _name = 'report.report_asset_register_xlsx' + _inherit = 'report.asset_abstract_report_xlsx' + _description = 'report_asset_register_xlsx' + + def _get_report_name(self, report, data=False): + return _('Asset Register') + + def _get_report_columns(self, report): + return { + 0: {'header': 'RN', 'width': 5}, + 1: {'header': 'Serial Number', 'width': 20}, + 2: {'header': 'Asset Name', 'width': 20}, + 3: {'header': 'Model Number', 'width': 14}, + 4: {'header': 'Asset Location', 'width': 14}, + 5: {'header': 'User', 'width': 14}, + 6: {'header': 'Supplier', 'width': 14}, + 7: {'header': 'Invoice Number', 'width': 20}, + 8: {'header': 'Invoice Date', 'width': 12}, + 9: {'header': 'Value', 'type': 'amount', 'width': 14}, + 10: {'header': 'Received Date', 'width': 16}, + 11: {'header': 'Warranty', 'width': 20}, + 12: {'header': 'Registration Number', 'width': 20}, + 13: {'header': 'Registration Date', 'width': 20}, + 14: {'header': '# Existing Depreciation', 'width': 20}, + 15: {'header': 'Existing Depreciation', 'width': 20}, + 16: {'header': 'First Depreciation', 'width': 20}, + 17: {'header': 'Depreciation %', 'width': 16}, + 18: {'header': 'Up to Date', 'width': 12}, + 19: {'header': 'Expired Days', 'width': 14}, + 20: {'header': 'Depreciation', 'width': 14}, + 21: {'header': 'Accumulated Depreciation', 'width': 20}, + 22: {'header': 'Net Value', 'width': 14}, + } + + def _get_report_filters(self, report): + return [] + + def _get_col_count_filter_name(self): + return 0 + + def _get_col_count_filter_value(self): + return 0 + + def _generate_report_content(self, workbook, report, data, report_data): + # For each asset + seq = 1 + # Header + report_data["sheet"].write(report_data["row_pos"], 0, "رقم", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 1, "رقم الأصل", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 2, "الاسم العلمي", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 3, "رقم التصنيع/الهيكل", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 4, "موقع الأصل", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 5, "المستخدم", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 6, "المورد", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 7, "رقم الفاتورة", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 8, "تاريخ الفاتورة", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 9, "القيمة", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 10, "تاريخ الإستلام", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 11, "تفاصيل الضمان", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 12, "رقم القيد", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 13, "تاريخ القيد", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 14, "عدد الاستهلاكات السابقة", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 15, "قيمة الاستهلاك السابق", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 16, "تاريخ اول اسستهلاك", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 17, "نسبة الاستهلاك", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 18, "تاريخ الاحتساب", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 19, "ايام الاستهلاك", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 20, "استهلاك السنة", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 21, "الاستهلاك التراكمي", report_data["formats"]["format_header_center"]) + report_data["sheet"].write(report_data["row_pos"], 22, "صافي القيمة الدفترية", report_data["formats"]["format_header_center"]) + report_data["row_pos"] += 1 + + self.write_array_header(report_data) + for asset in report: + # Write asset line + report_data["sheet"].write_number(report_data["row_pos"], 0, seq, report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 1, asset.serial_no or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 2, asset.name or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 3, asset.model or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 4, asset.location_id.name or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 5, asset.responsible_user_id.name or '', report_data["formats"]["format_center"]) + partner = [] + invoice = [] + for orginal_move in asset.original_move_line_ids: + partner.append(orginal_move.partner_id.name) + invoice.append(orginal_move.move_id.name) + report_data["sheet"].write_string(report_data["row_pos"], 6, partner or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 7, invoice or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 8, asset.acquisition_date and asset.acquisition_date.strftime("%d/%m/%Y") or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_number(report_data["row_pos"], 9, float(asset.original_value), report_data["formats"]["format_amount"]) + report_data["sheet"].write_string(report_data["row_pos"], 10, asset.receive_date and asset.receive_date.strftime("%d/%m/%Y") or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 11, asset.warranty_contract or '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 12, '', report_data["formats"]["format_center"]) + report_data["sheet"].write_string(report_data["row_pos"], 13, '', report_data["formats"]["format_center"]) + + report_data["sheet"].write_number(report_data["row_pos"], 14, asset.depreciation_number_import, report_data["formats"]["format_amount"]) + report_data["sheet"].write_number(report_data["row_pos"], 15, asset.already_depreciated_amount_import, report_data["formats"]["format_amount"]) + report_data["sheet"].write_string(report_data["row_pos"], 16, asset.first_depreciation_date_import and asset.first_depreciation_date_import.strftime("%d/%m/%Y") or '', report_data["formats"]["format_center"]) + + percentage = 100/(asset.method_number*int(asset.method_period)/12) + report_data["sheet"].write_number(report_data["row_pos"], 17, float(percentage), report_data["formats"]["format_amount"]) + + posted_depreciation_move_ids = asset.depreciation_move_ids.filtered( + lambda x: x.state == 'posted' and not x.asset_value_change and not x.reversal_move_id).sorted( + key=lambda l: l.date) + calc_date = posted_depreciation_move_ids[-1].date + report_data["sheet"].write_string(report_data["row_pos"], 18, calc_date and calc_date.strftime("%d/%m/%Y") or '', report_data["formats"]["format_center"]) + first_dept = asset.first_depreciation_date_import or asset.receive_date or asset.date + delta = calc_date and first_dept and calc_date-first_dept + days = delta and delta.days or 0 + report_data["sheet"].write_number(report_data["row_pos"], 19, float(days), report_data["formats"]["format_amount"]) + report_data["sheet"].write_number(report_data["row_pos"], 20, float((asset.original_value-asset.already_depreciated_amount_import)*percentage/100), report_data["formats"]["format_amount"]) + report_data["sheet"].write_number(report_data["row_pos"], 21, float(asset.original_value-asset.value_residual), report_data["formats"]["format_amount"]) + report_data["sheet"].write_number(report_data["row_pos"], 22, float(asset.book_value), report_data["formats"]["format_amount"]) + seq += 1 + report_data["row_pos"] += 1 + diff --git a/dev_odex30_accounting/exp_asset_base/reports/reports.xml b/dev_odex30_accounting/exp_asset_base/reports/reports.xml new file mode 100644 index 0000000..cb2ae08 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/reports/reports.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/dev_odex30_accounting/exp_asset_base/security/groups.xml b/dev_odex30_accounting/exp_asset_base/security/groups.xml new file mode 100644 index 0000000..cfa5db2 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/security/groups.xml @@ -0,0 +1,23 @@ + + + + Asset Assessment + + + + + Assets Managment + + + User + + + + Manager + + + + + + + diff --git a/dev_odex30_accounting/exp_asset_base/security/ir.model.access.csv b/dev_odex30_accounting/exp_asset_base/security/ir.model.access.csv new file mode 100644 index 0000000..a17f139 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_asset_manufacturer,account.asset.manufacturer,model_account_asset_manufacturer,account.group_account_readonly,1,0,0,0 +access_asset_manufacturer_manager,account.asset.manufacturer,model_account_asset_manufacturer,account.group_account_manager,1,1,1,1 +access_asset_location,account.asset.location,model_account_asset_location,account.group_account_readonly,1,0,0,0 +access_asset_location_manager,account.asset.location,model_account_asset_location,account.group_account_manager,1,1,1,1 +access_asset_adjustment,account.asset.adjustment,model_account_asset_adjustment,account.group_account_readonly,1,0,0,0 +access_asset_adjustment_manager,account.asset.adjustment,model_account_asset_adjustment,account.group_account_manager,1,1,1,1 +access_asset_adjustment_line,account.asset.adjustment.line,model_account_asset_adjustment_line,account.group_account_readonly,1,0,0,0 +access_asset_adjustment_line_manager,account.asset.adjustment.line,model_account_asset_adjustment_line,account.group_account_manager,1,1,1,1 +access_account_asset_modify,access.account.asset.modify,model_account_asset_modify,account.group_account_user,1,1,1,1 +access_asset_pause,access.asset.pause,model_asset_pause,account.group_account_user,1,1,1,1 +access_asset_sell,access.asset.sell,model_asset_sell,account.group_account_user,1,1,1,1 +access_asset_state_operation5_manager,account.state.4multi.operation,model_asset_state2,,1,1,1,1 \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/static/description/assets.png b/dev_odex30_accounting/exp_asset_base/static/description/assets.png new file mode 100644 index 0000000..88fbda0 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/static/description/assets.png differ diff --git a/dev_odex30_accounting/exp_asset_base/static/description/icon.png b/dev_odex30_accounting/exp_asset_base/static/description/icon.png new file mode 100644 index 0000000..4141f52 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_base/static/description/icon.png differ diff --git a/dev_odex30_accounting/exp_asset_base/views/account_asset_adjustment_view.xml b/dev_odex30_accounting/exp_asset_base/views/account_asset_adjustment_view.xml new file mode 100644 index 0000000..f1e748a --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/account_asset_adjustment_view.xml @@ -0,0 +1,94 @@ + + + + account.asset.adjustment.line.tree + account.asset.adjustment.line + + + + + + + + + + + + + + account.asset.adjustment.tree + account.asset.adjustment + + + + + + + + + + + + account.asset.adjustment.form + account.asset.adjustment + +
+
+
+ +
+

+ + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Asset Adjustment + account.asset.adjustment + list,form + + +
\ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_base/views/account_asset_view.xml b/dev_odex30_accounting/exp_asset_base/views/account_asset_view.xml new file mode 100644 index 0000000..83e5dc5 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/account_asset_view.xml @@ -0,0 +1,343 @@ + + + + + + + Assets Manufacturer + account.asset.manufacturer + list,form + + + account.asset.manufacturer.form + account.asset.manufacturer + +
+ + + + + + + +
+
+ + + + + Assets Locations + account.asset.location + list,form + + + account.asset.location.form + account.asset.location + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + account.asset.search + account.asset + + + + ['|', ('name','ilike',self), ('barcode','ilike',self)] + + + + + + + + + + + + + + + account.asset.form + account.asset + + +
+
+ +
+ +
+
+ +
+ + - + + +
+ + + + + + + + + + + + + + + + + +
+ + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + + + + + + + + + + + + + + + +
+
+ + + account.asset.asset.inherit.kanban + account.asset + 1 + + + + + + + + + + + + + +
+
+
+ Asset +
+ +
+
+ + + + + +
+
+
+ +
+
+ + + + + +
+
+ + + +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + + account.asset.inherit.tree + account.asset + 1 + + + + + + + + + + + + + Assets + account.asset + [('asset_type', '=', 'purchase'), ('state', '!=', 'model'), ('parent_id', '=', False)] + + {'asset_type': 'purchase', 'default_asset_type': 'purchase'} + + + + + + account.asset.pivot + account.asset + + + + + + + + + + + + account.asset.graph + account.asset + + + + + + + + + + + + Assets Analysis + graph,pivot + + account.asset + + + + + account.asset.depreciation.pivot + account.move + + + + + + + + + + + account.asset.graph + account.move + + + + + + + + + + + Depreciation Analysis + graph,pivot + + account.move + [('asset_id', '!=', False)] + +
diff --git a/dev_odex30_accounting/exp_asset_base/views/asset_modify_views.xml b/dev_odex30_accounting/exp_asset_base/views/asset_modify_views.xml new file mode 100644 index 0000000..bd1e6e5 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/asset_modify_views.xml @@ -0,0 +1,77 @@ + + + + + account.asset.modify.tree + account.asset.modify + + + + + + + + + + + + + account.asset.modify.form + account.asset.modify + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Asset Modification + account.asset.modify + tree,form + + + + +
diff --git a/dev_odex30_accounting/exp_asset_base/views/asset_pause_views.xml b/dev_odex30_accounting/exp_asset_base/views/asset_pause_views.xml new file mode 100644 index 0000000..275ea9e --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/asset_pause_views.xml @@ -0,0 +1,52 @@ + + + + + asset.pause.tree + asset.pause + + + + + + + + + + + asset.pause.form + asset.pause + +
+
+
+ + + + + + + + + + +
+
+ + + Asset Pause + asset.pause + tree,form + + + + +
diff --git a/dev_odex30_accounting/exp_asset_base/views/asset_sell_views.xml b/dev_odex30_accounting/exp_asset_base/views/asset_sell_views.xml new file mode 100644 index 0000000..6a3e1a8 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/asset_sell_views.xml @@ -0,0 +1,61 @@ + + + + + asset.sell.tree + asset.sell + + + + + + + + + + + asset.sell.form + asset.sell + +
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + Asset Sell + asset.sell + tree,form + + + + +
diff --git a/dev_odex30_accounting/exp_asset_base/views/menus.xml b/dev_odex30_accounting/exp_asset_base/views/menus.xml new file mode 100644 index 0000000..d6189dc --- /dev/null +++ b/dev_odex30_accounting/exp_asset_base/views/menus.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/exp_asset_custody/__init__.py b/dev_odex30_accounting/exp_asset_custody/__init__.py new file mode 100644 index 0000000..fca4a5f --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from . import models \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/__manifest__.py b/dev_odex30_accounting/exp_asset_custody/__manifest__.py new file mode 100644 index 0000000..76291d8 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# © 2016 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + 'name': 'Asset Custody Management', + 'summary': 'Custody Operations for Asset', + 'description': ''' +Manage Assets transfer between locations, departments and employees + ''', + 'version': '1.0.0', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': 'Expert Co. Ltd.', + 'website': 'http://www.exp-sa.com', + 'license': 'AGPL-3', + 'application': False, + 'installable': True, + 'depends': [ + 'exp_asset_base' + ], + 'data': [ + 'data/asset_data.xml', + 'security/ir.model.access.csv', + 'views/account_asset_view.xml', + 'views/account_asset_adjustment_view.xml', + 'views/account_asset_custody_multi_operation.xml', + 'views/account_asset_custody_operation_view.xml', + 'reports/asset_adjustment_report.xml', + 'views/menus.xml' + ], +} diff --git a/dev_odex30_accounting/exp_asset_custody/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7c44e9e Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/data/asset_data.xml b/dev_odex30_accounting/exp_asset_custody/data/asset_data.xml new file mode 100644 index 0000000..efe41aa --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/data/asset_data.xml @@ -0,0 +1,34 @@ + + + + + Asset Operation Sequence + asset.operation.seq + ASS OP/%(range_year)s/ + + + + + 6 + + + Asset Multi Operation Sequence + asset.multi.operation.seq + ASS MOP/%(range_year)s/ + + + + + 6 + + + good + + + scarp + + + available + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/i18n/ar.po b/dev_odex30_accounting/exp_asset_custody/i18n/ar.po new file mode 100644 index 0000000..9fbb6aa --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/i18n/ar.po @@ -0,0 +1,883 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * exp_asset_custody +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e-20210105\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-19 04:38+0000\n" +"PO-Revision-Date: 2021-05-19 04:38+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: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__asset_operation_count +msgid "# Done Operations" +msgstr "العمليات المعتمدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_needaction +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_exception_decoration +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_state +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_type_icon +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__amount +msgid "Amount" +msgstr "المبلغ" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__asset_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset" +msgstr "الأصل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset Account" +msgstr "حساب الأصل" + +#. module: exp_asset_custody +#: model:ir.actions.report,name:exp_asset_custody.action_asset_adjustment_report +#: model:ir.model,name:exp_asset_custody.model_account_asset_adjustment +msgid "Asset Adjustment" +msgstr "جرد الأصول" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset_multi_operation +msgid "Asset Multi Operation" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset.py:0 +#, python-format +msgid "Asset Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Asset Operations in done state" +msgstr "العمليات المعتمدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__asset_status +msgid "Asset Status" +msgstr "حالة العهد" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset +msgid "Asset/Revenue Recognition" +msgstr "أصل/ إيرادات مقدمة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Assets Adjustment Report" +msgstr "تقرير جرد الأصول" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_assignment +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +msgid "Assets Assignment" +msgstr "إسناد عهدة" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_operation_main +msgid "Assets Operations" +msgstr "عمليات الأصول" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_release +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +msgid "Assets Release" +msgstr "إرجاع عهدة" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_transfer +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Assets Transfer" +msgstr "نقل العهدة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__status__assigned +msgid "Assigned" +msgstr "مسند" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__status__reserved +msgid "Reserved" +msgstr "محجوز" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__assignment +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__assignment +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_assignment +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_assignment +msgid "Assignment" +msgstr "أسناد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Assignment Info" +msgstr "معلومات الإسناد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_attachment_count +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__barcode +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__barcode +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Barcode" +msgstr "باركود" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation___barcode_scanned +msgid "Barcode Scanned" +msgstr "باركود" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Barcode..." +msgstr "الباركود..." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__department +msgid "By Department" +msgstr "الإدارة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__employee +msgid "By Employee" +msgstr "الموظف" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_adjustment__type__location +msgid "By Location" +msgstr "الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__cancel +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__cancel +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_assignment_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "Cancel" +msgstr "إلغاء" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_assignment_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "Confirm" +msgstr "تأكيد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__create_uid +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__create_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Current" +msgstr "الحالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__department_id +msgid "Current Department" +msgstr "الإدارة الحالية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__employee_id +msgid "Current Employee" +msgstr "الموظف الحالي" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Current Info" +msgstr "المعلومات الحالية" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_form +msgid "Custody Info" +msgstr "معلومات العهدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__custody_period +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__custody_period +msgid "Custody Period" +msgstr "فترة العهدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__custody_type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__custody_type +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Custody Type" +msgstr "نوع العهدة" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_custody_operation +msgid "Custody operations" +msgstr "عمليات العهد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__date +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Date" +msgstr "التاريخ" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Date:" +msgstr "التاريخ:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_department_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Department" +msgstr "الإدارة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Description:" +msgstr "الوصف:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__display_name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__display_name +msgid "Display Name" +msgstr "الاسم المعروض" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__done +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__done +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Done" +msgstr "المنتهية" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__draft +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__draft +msgid "Draft" +msgstr "مسودة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_employee_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_employee_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Employee" +msgstr "الموظف" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_follower_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_follower_ids +msgid "Followers" +msgstr "المتابعون" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_channel_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_channel_ids +msgid "Followers (Channels)" +msgstr "المتابعون (القنوات)" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_partner_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعون (الشركاء)" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_type_icon +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_type__general +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_type__general +msgid "General" +msgstr "عام" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__asset_status__good +msgid "Good" +msgstr "جيد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Group By..." +msgstr "تجميع بـ..." + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__id +msgid "ID" +msgstr "المُعرف" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_exception_icon +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_exception_icon +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى استثناء النشاط" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_needaction +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_unread +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_needaction +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_unread +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة تحتاج لرؤيتها." + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_sms_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_error +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__state__in_progress +msgid "In Progress" +msgstr "جاري" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_is_follower +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation____last_update +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation____last_update +msgid "Last Modified on" +msgstr "آخر تعديل في" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__write_uid +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__write_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__new_location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__current_location_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__new_location_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_search +msgid "Location" +msgstr "الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_main_attachment_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفقات" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose an asset in all operation line." +msgstr "" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose custody period for all operation lines." +msgstr "الرجاء التاكد من إدخال فترة العهدة في جميع العمليات." + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you choose custody type for all operation lines." +msgstr "الرجاء التاكد من إدخال نوع العهدة في جميع العمليات." + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "Make sure you enter the return date for all temporary custodies." +msgstr "الرجاء التاكد من إدخال تاريخ الإرجاع للعهد المؤقتة." + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__manual +msgid "Manual" +msgstr "يدوي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_error +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Missing Assets:" +msgstr "الأصول المفقودة:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__model_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Model" +msgstr "النموذج" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__multi_operation_id +msgid "Multi Operation" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_assignment +msgid "Multiple Assignment" +msgstr "إسناد العهد" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_release +msgid "Multiple Release" +msgstr "إرجاع العهد" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_multi_asset_transfer +msgid "Multiple Transfer" +msgstr "نقل العهد" + +#. module: exp_asset_custody +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_multi_operation +msgid "Multiple operations" +msgstr "العمليات المتعددة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__name +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__name +msgid "Name" +msgstr "الإسم" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "New" +msgstr "جديد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Department" +msgstr "الإدارة الجديدة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Employee" +msgstr "الموظف الجديد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "New Location" +msgstr "الموقع الجديد" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_date_deadline +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_summary +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_type_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "No asset found with the selected barcode" +msgstr "لم يتم العثور على أصل لهذا الباركود" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__note +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__note +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Note" +msgstr "ملاحظة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_needaction_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset__asset_operation_count +msgid "Number of done asset operations" +msgstr "" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_error_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الاخطاء" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_needaction_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_needaction_counter +msgid "Number of messages which requires an action" +msgstr "عدد الرسائل التي تتطلب إجراء" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_has_error_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__message_unread_counter +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__message_unread_counter +msgid "Number of unread messages" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_operation.py:0 +#, python-format +msgid "Only draft operations can be deleted." +msgstr "لا يمكن حذف العملية الا في الحالة مبدئية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__operation_ids +msgid "Operation" +msgstr "عملية" + +#. module: exp_asset_custody +#: model:ir.actions.act_window,name:exp_asset_custody.action_account_asset_operation_analysis +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_operation_graph +msgid "Operation Analysis" +msgstr "تحليل العمليات" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_form +msgid "Operations" +msgstr "العمليات" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__pending +msgid "Pending" +msgstr "معلق" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_period__permanent +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_period__permanent +msgid "Permanent" +msgstr "دائمة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_type__personal +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_type__personal +msgid "Personal" +msgstr "شخصية" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__product_id +msgid "Product" +msgstr "المنتج" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__purpose +msgid "Purpose" +msgstr "الغرض" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Reject" +msgstr "رفض" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__release +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__release +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_release +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_release +msgid "Release" +msgstr "إرجاع" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Release Info" +msgstr "معلومات الإرجاع" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +msgid "Release Location" +msgstr "موقع الإرجاع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__user_id +msgid "Responsible" +msgstr "المسئول" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__responsible_department_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__department_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Responsible Department" +msgstr "الإدارة المسئولة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__activity_user_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__responsible_user_id +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__activity_user_id +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Responsible User" +msgstr "الموظف المسئول" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__return_date +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__return_date +msgid "Return Date" +msgstr "تاريخ الإرجاع" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_has_sms_error +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل القصيرة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__status__scrap +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__asset_status__scrap +msgid "Scrap" +msgstr "تالف" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Serial No" +msgstr "المتسلسل" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_assignment_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "Set to Draft" +msgstr "تعيين كمسودة" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_assignment_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_release_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_transfer_tree +msgid "Setup" +msgstr "الإعداد" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Signatures:" +msgstr "" + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_assignment_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_release_form +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_multi_transfer_form +msgid "Start" +msgstr "بدأ" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__state +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__state +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "State" +msgstr "الحالة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset__status +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Status" +msgstr "الحالة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_state +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "الأنشطة المستقبيلة" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__state__submit +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_assignment_tree +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_form +msgid "Submit" +msgstr "إرسال" + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset__custody_period__temporary +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__custody_period__temporary +msgid "Temporary" +msgstr "مؤقتة" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset.py:0 +#, python-format +msgid "The period of %s is finished %s." +msgstr "فترة %s قد إنتهت %s." + +#. module: exp_asset_custody +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_multi_operation__type__transfer +#: model:ir.model.fields.selection,name:exp_asset_custody.selection__account_asset_operation__type__transfer +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_asset_transfer +#: model:ir.ui.menu,name:exp_asset_custody.menu_account_multi_asset_transfer +msgid "Transfer" +msgstr "نقل" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_adjustment__type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__type +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__type +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.view_account_asset_operation_search +msgid "Type" +msgstr "النوع" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__activity_exception_decoration +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط الاستثنائي المسجل." + +#. module: exp_asset_custody +#: model_terms:ir.ui.view,arch_db:exp_asset_custody.asset_adjustment_report +msgid "Type:" +msgstr "النوع:" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_unread +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_unread +msgid "Unread Messages" +msgstr "الرسائل الجديدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__message_unread_counter +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__message_unread_counter +msgid "Unread Messages Counter" +msgstr "عدد الرسائل الجديدة" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation___barcode_scanned +msgid "Value of the last barcode scanned." +msgstr "قيمة آخر باركود ممسوح ضوئيًا." + +#. module: exp_asset_custody +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_multi_operation__website_message_ids +#: model:ir.model.fields,field_description:exp_asset_custody.field_account_asset_operation__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع" + +#. module: exp_asset_custody +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_multi_operation__website_message_ids +#: model:ir.model.fields,help:exp_asset_custody.field_account_asset_operation__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع" + +#. module: exp_asset_custody +#: code:addons/exp_asset_custody/models/account_asset_multi_operation.py:0 +#, python-format +msgid "You can not confirm operation without lines." +msgstr "لا يمكن تأكيد العملية دون إدخال التفاصيل" + +#. module: exp_asset_custody +#: model:ir.model,name:exp_asset_custody.model_account_asset_operation +msgid "account.asset.operation" +msgstr "" diff --git a/dev_odex30_accounting/exp_asset_custody/models/__init__.py b/dev_odex30_accounting/exp_asset_custody/models/__init__.py new file mode 100644 index 0000000..0cdea78 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from . import account_asset +from . import account_asset_operation +from . import account_asset_adjustment +from . import account_asset_multi_operation \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..94d25a7 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset.cpython-311.pyc new file mode 100644 index 0000000..549c2da Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_adjustment.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_adjustment.cpython-311.pyc new file mode 100644 index 0000000..c48e013 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_adjustment.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_multi_operation.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_multi_operation.cpython-311.pyc new file mode 100644 index 0000000..2cadc16 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_multi_operation.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_operation.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_operation.cpython-311.pyc new file mode 100644 index 0000000..5749f40 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/models/__pycache__/account_asset_operation.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody/models/account_asset.py b/dev_odex30_accounting/exp_asset_custody/models/account_asset.py new file mode 100644 index 0000000..546e3cc --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/models/account_asset.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import models, fields, api, _ + + +class AccountAssetAsset(models.Model): + _inherit = 'account.asset' + + custody_type = fields.Selection( + selection=[('personal', 'Personal'), ('general', 'General')], + ) + custody_period = fields.Selection( + selection=[('temporary', 'Temporary'), ('permanent', 'Permanent')], + ) + purpose = fields.Html() + return_date = fields.Date() + department_id = fields.Many2one( + comodel_name='hr.department', + string='Current Department' + ) + employee_id = fields.Many2one( + comodel_name='hr.employee', + string='Current Employee' + ) + status = fields.Selection( + selection_add=[('reserved', 'Reserved'), ('assigned', 'Assigned'), ('scrap', 'Scrap')] + ) + asset_operation_count = fields.Integer( + compute='_asset_operation_count', + string='# Done Operations', + help="Number of done asset operations" + ) + + def _asset_operation_count(self): + for asset in self: + asset.asset_operation_count = len( + self.env['account.asset.operation'].search([('asset_id', '=', asset.id), ('state', '=', 'done')])) + + def open_asset_operation(self): + return { + 'name': _('Asset Operations'), + 'view_type': 'form', + 'view_mode': 'tree,form', + 'res_model': 'account.asset.operation', + 'type': 'ir.actions.act_window', + 'domain': [('asset_id', '=', self.id)], + 'view_id': self.env.ref('exp_asset_custody.view_account_asset_operation_tree').id, + 'views': [(self.env.ref('exp_asset_custody.view_account_asset_operation_tree').id, 'tree'), + (self.env.ref('exp_asset_custody.view_account_asset_operation_form').id, 'form')], + 'context': {'active_model': False, 'search_default_done': True}, + 'flags': {'search_view': True, 'action_buttons': False}, + } + + @api.model + def _asset_cron(self): + super(AccountAssetAsset, self)._asset_cron() + today = fields.Date.today() + for asset in self.search([('return_date', '=', today)]): + self.env['mail.activity'].sudo().create({ + 'res_model_id': self.env.ref('account_asset.model_account_asset_asset').id, + 'res_id': asset.id, + 'user_id': asset.responsible_user_id.id, + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': _('The period of %s is finished %s.') % (asset.name, asset.return_date), + 'date_deadline': asset.return_date, + }) diff --git a/dev_odex30_accounting/exp_asset_custody/models/account_asset_adjustment.py b/dev_odex30_accounting/exp_asset_custody/models/account_asset_adjustment.py new file mode 100644 index 0000000..3978758 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/models/account_asset_adjustment.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import api, fields, models, exceptions, _ + + +class AccountAssetAdjustment(models.Model): + _inherit = 'account.asset.adjustment' + + type = fields.Selection( + selection_add=[('department', 'By Department'), + ('employee', 'By Employee'), + ('location', 'By Location')], + states={'draft': [('readonly', False)]}, + readonly=True, + ) + department_id = fields.Many2one( + comodel_name='hr.department', + states={'draft': [('readonly', False)]}, + readonly=True, + ) + employee_id = fields.Many2one( + comodel_name='hr.employee', + states={'draft': [('readonly', False)]}, + readonly=True, + ) + location_id = fields.Many2one( + comodel_name='account.asset.location', + states={'draft': [('readonly', False)]}, + readonly=True, + ) + + def build_domain(self): + return self.type == 'employee' and [('employee_id', '=', self.employee_id.id)] or \ + (self.type == 'department' and [('department_id', '=', self.department_id.id)]) or \ + (self.type == 'location' and [('location_id', '=', self.location_id.id)]) or \ + super(AccountAssetAdjustment, self).build_domain() + + @api.onchange('type') + def onchange_type(self): + self.employee_id = False + self.department_id = False + self.location_id = False + super(AccountAssetAdjustment, self).onchange_type() diff --git a/dev_odex30_accounting/exp_asset_custody/models/account_asset_multi_operation.py b/dev_odex30_accounting/exp_asset_custody/models/account_asset_multi_operation.py new file mode 100644 index 0000000..2d45e74 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/models/account_asset_multi_operation.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import models, fields, api, exceptions, _ + +class AccountAssetMultiOperation(models.Model): + _name = 'account.asset.multi.operation' + _description = 'Asset Multi Operation' + _inherit = ['mail.thread', 'mail.activity.mixin', 'barcodes.barcode_events_mixin'] + + name = fields.Char( + states={'draft': [('readonly', False)]}, + default='/', readonly=True + ) + date = fields.Date( + default=fields.Date.context_today, + index=True, copy=False, readonly=True, required=True, + states={'draft': [('readonly', False)]} + ) + type = fields.Selection( + selection=[('assignment', 'Assignment'), + ('release', 'Release'), + ('transfer', 'Transfer')], + states={'draft': [('readonly', False)]}, + readonly=True, required=True + ) + barcode = fields.Char( + states={'in_progress': [('readonly', False)]}, + readonly=True, + ) + responsible_user_id = fields.Many2one( + comodel_name='res.users', + default=lambda self: self.env.user, + states={'draft': [('readonly', False)]}, + readonly=True, required=True + ) + responsible_department_id = fields.Many2one( + comodel_name='hr.department', + ) + new_employee_id = fields.Many2one( + comodel_name='hr.employee', + states={'draft': [('readonly', False)]}, + readonly=True, string='Employee', + ) + new_department_id = fields.Many2one( + comodel_name='hr.department', + states={'draft': [('readonly', False)]}, + readonly=True, string='Department', + ) + new_location_id = fields.Many2one( + comodel_name='account.asset.location', + states={'draft': [('readonly', False)]}, + readonly=True, string='Location', + ) + manual = fields.Boolean() + note = fields.Text( + states={'draft': [('readonly', False)]}, + readonly=True, required=True + ) + state = fields.Selection( + selection=[('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('done', 'Done'), + ('cancel', 'Cancel')], + required=True, default='draft' + ) + operation_ids = fields.One2many( + 'account.asset.operation', 'multi_operation_id', + states={'in_progress': [('readonly', False)]}, + readonly=False, + ) + + @api.model + def create(self, values): + values['name'] = self.env['ir.sequence'].with_context( + ir_sequence_date=values['date']).next_by_code('asset.multi.operation.seq') + return super(AccountAssetMultiOperation, self).create(values) + + def act_progress(self): + self.state = 'in_progress' + + def act_confirm(self): + if not self.operation_ids: + raise exceptions.Warning(_('You can not confirm operation without lines.')) + for opt in self.operation_ids: + self.check_required_fields(opt) + opt.act_confirm() + self.state = 'done' + + def act_reject(self): + self.state = 'cancel' + + def act_draft(self): + self.state = 'draft' + + def get_asset_domain(self): + if self.type == 'assignment': + return [('status', 'in', ['new','available'])] + elif self.type in ['transfer', 'release']: + return [('status', 'in', ['assigned'])] + return [('status', 'in', ['new', 'available', 'assigned', 'scrap'])] + + @api.onchange('new_employee_id') + def onchange_new_employee(self): + self.new_department_id = self.new_employee_id.department_id.id + + @api.onchange('barcode') + def onchange_barcode(self): + self.on_barcode_scanned(self.barcode) + + def on_barcode_scanned(self, barcode): + if barcode: + operation_vals = self.get_operation_vals() + domain = self.get_asset_domain() + assets = self.barcode and self.env['account.asset'].search(domain + [('barcode', '=', barcode)]) + self.barcode = False + if not assets: + raise exceptions.Warning(_('No asset found with the selected barcode')) + for s in assets: + operation_vals.update({ + 'asset_id': s.id, + 'current_employee_id': s.employee_id.id, + 'current_department_id': s.department_id.id, + 'current_location_id': s.location_id.id, + #'state': 'submit', + }) + self.operation_ids = [(0, 0, operation_vals)] + + def get_operation_vals(self): + return { + 'state': 'draft', + 'type': self.type, + 'date': self.date, + 'note': self.note, + 'multi_operation_id': self.id, + 'user_id': self.responsible_user_id.id, + 'new_employee_id': self.new_employee_id.id, + 'new_department_id': self.new_department_id.id, + 'new_location_id': self.new_location_id.id, + } + + def check_required_fields(self, operation): + if not operation.asset_id: + raise exceptions.Warning(_('Make sure you choose an asset in all operation line.')) + elif not operation.return_date and operation.custody_period == 'temporary': + raise exceptions.Warning(_('Make sure you enter the return date for all temporary custodies.')) + elif not operation.custody_type and operation.type in ['assignment', 'transfer']: + raise exceptions.Warning(_('Make sure you choose custody type for all operation lines.')) + elif not operation.custody_period and operation.type in ['assignment', 'transfer']: + raise exceptions.Warning(_('Make sure you choose custody period for all operation lines.')) \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/models/account_asset_operation.py b/dev_odex30_accounting/exp_asset_custody/models/account_asset_operation.py new file mode 100644 index 0000000..8cca74d --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/models/account_asset_operation.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# © Copyright (C) 2021 Expert Co. Ltd() + +from odoo import models, fields, api, exceptions, _ + + +class AccountAssetOperation(models.Model): + _name = 'account.asset.operation' + _description = 'Asset Operation' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + multi_operation_id = fields.Many2one(comodel_name='account.asset.multi.operation') + name = fields.Char(required=True, default='/') + type = fields.Selection(selection=[('assignment', 'Assignment'), ('release', 'Release'), + ('transfer', 'Transfer')], required=True) + date = fields.Date(default=fields.Date.context_today, index=True, copy=False, readonly=True, required=True, + states={'draft': [('readonly', False)]}) + user_id = fields.Many2one(comodel_name='res.users',default=lambda self: self.env.user, + states={'draft': [('readonly', False)]},string='Responsible',readonly=True) + asset_id = fields.Many2one(comodel_name='account.asset') + statea = fields.Selection(related='asset_id.status') + barcode = fields.Char(compute="_compute_related_fields", store=True) + department_id = fields.Many2one(comodel_name='hr.department',string='Responsible Department', + compute="_compute_related_fields", store=True) + model_id = fields.Many2one(comodel_name='account.asset',compute="_compute_related_fields",store=True) + state = fields.Selection(selection=[('draft', 'Draft'),('submit', 'Submit'),('done', 'Done'),('pending', 'Pending') + ,('cancel', 'Cancel')],required=True, default='draft') + note = fields.Text(states={'draft': [('readonly', False)]},readonly=True,) + # Asset Custody Operation + custody_type = fields.Selection(selection=[('personal', 'Personal'), ('general', 'General')], + states={'draft': [('readonly', False)]},readonly=True,) + custody_period = fields.Selection(selection=[('temporary', 'Temporary'), ('permanent', 'Permanent')], + states={'draft': [('readonly', False)]},readonly=True) + return_date = fields.Date(states={'draft': [('readonly', False)]},readonly=True) + current_employee_id = fields.Many2one(comodel_name='hr.employee', + states={'draft': [('readonly', False)]},readonly=True, string='Employee') + current_department_id = fields.Many2one(comodel_name='hr.department',states={'draft': [('readonly', False)]}, + readonly=True, string='Department', ) + current_location_id = fields.Many2one(comodel_name='account.asset.location',states={'draft': [('readonly', False)]}, + readonly=True, string='Location') + new_employee_id = fields.Many2one(comodel_name='hr.employee', string='Employee') + new_department_id = fields.Many2one(comodel_name='hr.department', string='Department', ) + new_location_id = fields.Many2one(comodel_name='account.asset.location', string='Location') + amount = fields.Float(states={'draft': [('readonly', False)]}, readonly=True, ) + asset_status = fields.Selection(selection=[('good', 'Good'), ('scrap', 'Scrap')], + states={'draft': [('readonly', False)]}, readonly=True, ) + asset_statuso = fields.Selection(related='asset_id.status') + asset_status2 = fields.Many2one( + comodel_name='asset.states', + string='Asset Status',states={'draft': [('readonly', False)]}) + product_id = fields.Many2one(comodel_name='product.product', + domain=[('property_account_expense_id.can_create_asset', '=', True), + ('property_account_expense_id.user_type_id.internal_group', '=', 'asset')], + states={'draft': [('readonly', False)]}, readonly=True) + + def action_read_operation(self): + self.ensure_one() + return { + 'name': self.display_name, + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.asset.operation', + 'res_id': self.id, + } + + @api.depends('asset_id', 'asset_id.model_id', 'asset_id.responsible_dept_id', 'asset_id.barcode') + def _compute_related_fields(self): + for operation in self: + operation.barcode = operation.asset_id.barcode + operation.model_id = operation.asset_id.model_id.id + operation.department_id = operation.asset_id.responsible_dept_id.id + + @api.model + def create(self, values): + values['name'] = self.env['ir.sequence'].with_context( + ir_sequence_date=values['date']).next_by_code('asset.operation.seq') + return super(AccountAssetOperation, self).create(values) + + def unlink(self): + if self.search([('state', '!=', 'draft'), ('id', 'in', self.ids)]): + raise exceptions.Warning(_('Only draft operations can be deleted.')) + return super(AccountAssetOperation, self).unlink() + + @api.model + def _get_tracked_fields(self): + fields = self.fields_get() + dels = [f for f in fields if f in models.LOG_ACCESS_COLUMNS or f.startswith('_') or f == 'id'] + for x in dels: + del fields[x] + return set(fields) + + @api.onchange('new_employee_id') + def onchange_new_employee(self): + self.new_department_id = self.new_employee_id.department_id.id + + @api.onchange('asset_id') + def onchange_asset(self): + self.current_employee_id = self.asset_id.employee_id.id + self.current_department_id = self.asset_id.department_id.id + self.current_location_id = self.asset_id.location_id.id + + def act_submit(self): + self.state = 'submit' + + def act_confirm(self): + if not self.asset_id: + raise exceptions.Warning(_('Asset is required to confirm this operation.')) + if self.type in ('assignment', 'release', 'transfer'): + self.custody_confirm() + self.state = 'done' + + def act_reject(self): + self.state = 'pending' + + def act_cancel(self): + self.state = 'cancel' + + def act_draft(self): + self.state = 'draft' + + def custody_confirm(self): + self.asset_id.employee_id = self.new_employee_id.id + self.asset_id.department_id = self.new_department_id.id + self.asset_id.location_id = self.new_location_id.id + self.asset_id.custody_type = self.custody_type + self.asset_id.custody_period = self.custody_period + self.asset_id.return_date = self.return_date + self.asset_id.purpose = self.note + if self.type == 'assignment': + self.asset_id.status = 'assigned' + elif self.type == 'release': + self.asset_id.status = 'available' + + +""" + def sell_dispose_confirm(self): + super(AccountAssetOperation, self).sell_dispose_confirm() + self.asset_id.custody_type = False + self.asset_id.custody_period = False + self.asset_id.purpose = False + self.asset_id.return_date = False + self.asset_id.department_id = False + self.asset_id.employee_id = False + self.asset_id.location_id = False + self.asset_id.status = False + """ +class AccountAssestatus(models.Model): + _name = 'asset.states' + name = fields.Char(string='Name') diff --git a/dev_odex30_accounting/exp_asset_custody/reports/asset_adjustment_report.xml b/dev_odex30_accounting/exp_asset_custody/reports/asset_adjustment_report.xml new file mode 100644 index 0000000..a73e10d --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/reports/asset_adjustment_report.xml @@ -0,0 +1,232 @@ + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/security/ir.model.access.csv b/dev_odex30_accounting/exp_asset_custody/security/ir.model.access.csv new file mode 100644 index 0000000..9e08178 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_asset_operation,account.asset.operation,model_account_asset_operation,account.group_account_readonly,1,0,0,0 +access_asset_operation_manager,account.asset.operation,model_account_asset_operation,account.group_account_manager,1,1,1,1 +access_asset_multi_operation,account.asset.multi.operation,model_account_asset_multi_operation,account.group_account_readonly,1,0,0,0 +access_asset_multi_operation_manager,account.asset.multi.operation,model_account_asset_multi_operation,account.group_account_manager,1,1,1,1 +access_asset_state_operation_manager,account.state.multi.operation,model_asset_states,,1,1,1,1 \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/static/description/icon.png b/dev_odex30_accounting/exp_asset_custody/static/description/icon.png new file mode 100644 index 0000000..4141f52 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody/static/description/icon.png differ diff --git a/dev_odex30_accounting/exp_asset_custody/views/account_asset_adjustment_view.xml b/dev_odex30_accounting/exp_asset_custody/views/account_asset_adjustment_view.xml new file mode 100644 index 0000000..74b7a95 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/views/account_asset_adjustment_view.xml @@ -0,0 +1,23 @@ + + + + account.asset.adjustment.form + account.asset.adjustment + + + + + + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_multi_operation.xml b/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_multi_operation.xml new file mode 100644 index 0000000..bb94bfb --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_multi_operation.xml @@ -0,0 +1,274 @@ + + + + + account.asset.multi.operation.tree + account.asset.multi.operation + + + + + + + + + + + + + + account.asset.multi.assignment.form + account.asset.multi.operation + +
+
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + account.asset.multi.release.form + account.asset.multi.operation + +
+
+
+ +
+

+ + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + account.asset.multi.transfer.form + account.asset.multi.operation + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Multiple Assignment + account.asset.multi.operation + list,form + {'default_manual':1, 'default_type':'assignment'} + [('type','=','assignment')] + + + + Multiple Release + account.asset.multi.operation + list,form + {'default_manual':1, 'default_type':'release'} + [('type','=','release')] + + + + Multiple Transfer + account.asset.multi.operation + list,form + {'default_manual':1, 'default_type':'transfer'} + [('type','=','transfer')] + + +
\ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_operation_view.xml b/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_operation_view.xml new file mode 100644 index 0000000..13aa20f --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/views/account_asset_custody_operation_view.xml @@ -0,0 +1,337 @@ + + + + + + account.asset.operation.search + account.asset.operation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + account.asset.operation.tree + account.asset.operation + + + + + + + + + + + + + + account.asset.operation.form + account.asset.operation + +
+
+
+ +
+

+ + - + +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + account.asset.assignment.tree + account.asset.operation + + + + + + + + + + + + + + + + + + + + + + + + Assets Assignment + account.asset.operation + list,form + + + {'default_type': 'assignment', 'default_custody_type': 'personal', 'default_custody_period': 'permanent'} + + [('type', '=', 'assignment')] + + + + account.asset.release.tree + account.asset.operation + + + + + + + + + + + + + + + + + + + + + + Assets Release + account.asset.operation + list,form + + {'default_type': 'release'} + [('type', '=', 'release')] + + + + account.asset.transfer.tree + account.asset.operation + + + + + + + + + + + + + + + + + + + + + + + + + Assets Transfer + account.asset.operation + list,form + + {'default_type': 'transfer'} + [('type', '=', 'transfer')] + + + + + + Operation Analysis + graph,pivot,list + + + account.asset.operation + {'search_default_group_by_type': True} + + + asset_operation_form3.extend + account.asset.multi.operation + + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + + + asset_operation_form5.extend + account.asset.operation + + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + exp_asset_base.group_assets_manager + + + + +
diff --git a/dev_odex30_accounting/exp_asset_custody/views/account_asset_view.xml b/dev_odex30_accounting/exp_asset_custody/views/account_asset_view.xml new file mode 100644 index 0000000..f011c6b --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/views/account_asset_view.xml @@ -0,0 +1,51 @@ + + + + + account.asset.search + account.asset + + + + + + + + + + + + + + + + + account.asset.form + account.asset + + +
+ +
+ + + + + + + + + + + + + + + + +
+
+ +
diff --git a/dev_odex30_accounting/exp_asset_custody/views/menus.xml b/dev_odex30_accounting/exp_asset_custody/views/menus.xml new file mode 100644 index 0000000..d1c4ed7 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody/views/menus.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/exp_asset_custody_link/__init__.py b/dev_odex30_accounting/exp_asset_custody_link/__init__.py new file mode 100644 index 0000000..5305644 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody_link/__manifest__.py b/dev_odex30_accounting/exp_asset_custody_link/__manifest__.py new file mode 100644 index 0000000..9f6872b --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +{ + 'name': "HR Custody Integration With Asset Custody", + + 'summary': """ + - Assign Asset as a custody to an employee + - Release Asset as a custody from an employee + """, + 'version': '14.0', + 'sequence': 4, + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'website': 'http://exp-sa.com', + 'license': 'AGPL-3', + 'author': 'Expert Co. Ltd.', + 'depends': ['exp_employee_custody','exp_asset_custody','purchase'], + + # always loaded + 'data': [ + 'security/ir.model.access.csv', + 'views/employee_custody_action.xml', + ], + +} diff --git a/dev_odex30_accounting/exp_asset_custody_link/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody_link/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9bdedac Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody_link/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody_link/i18n/ar_001.po b/dev_odex30_accounting/exp_asset_custody_link/i18n/ar_001.po new file mode 100644 index 0000000..9468ab6 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/i18n/ar_001.po @@ -0,0 +1,266 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * exp_asset_custody_link +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-31 13:21+0000\n" +"PO-Revision-Date: 2023-01-31 13:21+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: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__asset_id +msgid "Asset" +msgstr "الأصل" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__asset_assign_count +msgid "Asset Assignment" +msgstr "إسناد أصل" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__asset_custody_line +msgid "Asset Custody Line" +msgstr "بند أصل" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__asset_line_ids +msgid "Asset Line" +msgstr "بند الأصول" + +#. module: exp_asset_custody_link +#: model_terms:ir.ui.view,arch_db:exp_asset_custody_link.employee_custody_form_view_inherit +msgid "Asset Lines" +msgstr "بند الأصول" + +#. module: exp_asset_custody_link +#: model:ir.model,name:exp_asset_custody_link.model_account_asset_operation +msgid "Asset Operation" +msgstr "عمليات الأصول" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__asset_release_count +msgid "Asset Release" +msgstr "إرجاع عهدة" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/account_asset_operation.py:0 +#, python-format +msgid "Asset is required to confirm this operation." +msgstr "الأصل مطلوب لتأكيد هذه العملية" + +#. module: exp_asset_custody_link +#: model:ir.actions.act_window,name:exp_asset_custody_link.action_account_asset_assignment1 +msgid "Assets Assignment" +msgstr "إسناد عهدة" + +#. module: exp_asset_custody_link +#: model:ir.actions.act_window,name:exp_asset_custody_link.action_account_asset_release1 +msgid "Assets Release" +msgstr "إرجاع عهدة" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__asset_custody_line__type__assignment +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__assign +#, python-format +msgid "Assignment" +msgstr "إسناد" + +#. module: exp_asset_custody_link +#: model_terms:ir.ui.view,arch_db:exp_asset_custody_link.employee_custody_form_view_inherit +msgid "Assignments" +msgstr "الإسناد" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__create_uid +msgid "Created by" +msgstr "" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__create_date +msgid "Created on" +msgstr "" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__custody_period +msgid "Custody Period" +msgstr "فترة العهدة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__custody_type +msgid "Custody Type" +msgstr "نوع العهدة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__date +msgid "Date" +msgstr "التاريخ" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__direct +#, python-format +msgid "Direct Manager" +msgstr "المدير المباشر" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_account_asset_operation__display_name +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__display_name +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__display_name +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_mail_followers__display_name +msgid "Display Name" +msgstr "الاسم المعروض" + +#. module: exp_asset_custody_link +#: model:ir.model,name:exp_asset_custody_link.model_mail_followers +msgid "Document Followers" +msgstr "متابعو المستند" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__draft +#, python-format +msgid "Draft" +msgstr "مسودة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_account_asset_operation__emp_asset_custody_id +msgid "Emp Asset Custody" +msgstr "" + +#. module: exp_asset_custody_link +#: model:ir.model,name:exp_asset_custody_link.model_custom_employee_custody +msgid "Employee custody" +msgstr "العهد الغير مالية" + +#. module: exp_asset_custody_link +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__asset_custody_line__custody_type__general +msgid "General" +msgstr "عام" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__admin +#, python-format +msgid "Human Resources Manager" +msgstr "تصديق الموارد البشرية" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_account_asset_operation__id +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__id +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__id +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_mail_followers__id +msgid "ID" +msgstr "المُعرف" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_account_asset_operation____last_update +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line____last_update +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody____last_update +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_mail_followers____last_update +msgid "Last Modified on" +msgstr "آخر تعديل في" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__write_date +msgid "Last Updated on" +msgstr "" + +#. module: exp_asset_custody_link +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__asset_custody_line__custody_period__permanent +msgid "Permanent" +msgstr "مستديمة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__asset_custody_line__custody_type__personal +msgid "Personal" +msgstr "شخصية" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#, python-format +msgid "Please Select an asset" +msgstr "الرجاء إختيار أصل" + +#. module: exp_asset_custody_link +#: model_terms:ir.ui.view,arch_db:exp_asset_custody_link.employee_custody_form_view_inherit +msgid "Purchase Request" +msgstr "طلب شراء" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__refuse +#, python-format +msgid "Refuse" +msgstr "رفض" + +#. module: exp_asset_custody_link +#: model_terms:ir.ui.view,arch_db:exp_asset_custody_link.employee_custody_form_view_inherit +msgid "Releases" +msgstr "رفض" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__return_date +msgid "Return Date" +msgstr "تاريخ الإرجاع" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__done +#, python-format +msgid "Return Done" +msgstr "تم ارجاع العهده" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_custom_employee_custody__state +msgid "State" +msgstr "الحالة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__asset_custody_line__custody_period__temporary +msgid "Temporary" +msgstr "موقتة" + +#. module: exp_asset_custody_link +#: model:ir.model.fields,field_description:exp_asset_custody_link.field_asset_custody_line__type +msgid "Type" +msgstr "النوع" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__wait +#, python-format +msgid "Wait Assignment" +msgstr "إنتظار الإرجاع" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__wait_release +#, python-format +msgid "Wait Release" +msgstr "إنتظار الإسناد" + +#. module: exp_asset_custody_link +#: model:ir.model,name:exp_asset_custody_link.model_asset_custody_line +msgid "asset.custody.line" +msgstr "" + +#. module: exp_asset_custody_link +#: code:addons/exp_asset_custody_link/models/employee_custody.py:0 +#: model:ir.model.fields.selection,name:exp_asset_custody_link.selection__custom_employee_custody__state__submit +#, python-format +msgid "send" +msgstr "إرسال" diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/__init__.py b/dev_odex30_accounting/exp_asset_custody_link/models/__init__.py new file mode 100644 index 0000000..83a7fdf --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import account_asset_operation +from . import employee_custody \ No newline at end of file diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f166c2a Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/account_asset_operation.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/account_asset_operation.cpython-311.pyc new file mode 100644 index 0000000..b8a5880 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/account_asset_operation.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/employee_custody.cpython-311.pyc b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/employee_custody.cpython-311.pyc new file mode 100644 index 0000000..a769fe2 Binary files /dev/null and b/dev_odex30_accounting/exp_asset_custody_link/models/__pycache__/employee_custody.cpython-311.pyc differ diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/account_asset_operation.py b/dev_odex30_accounting/exp_asset_custody_link/models/account_asset_operation.py new file mode 100644 index 0000000..0b55f82 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/models/account_asset_operation.py @@ -0,0 +1,71 @@ +from odoo import models, fields, api, _, exceptions +class AccountAssetOperation(models.Model): + _inherit = 'account.asset.operation' + + emp_asset_custody_id = fields.Many2one(comodel_name='custom.employee.custody') + + def act_confirm(self): + module = self.env['ir.module.module'].sudo() + exp_petty_cash_module = module.search( + [('state', '=', 'installed'), ('name', '=', 'exp_employee_custody')]) + if not self.asset_id: + raise exceptions.Warning(_('Asset is required to confirm this operation.')) + if self.type in ('assignment', 'release', 'transfer'): + self.custody_confirm() + self.state = 'done' + if self.type == 'assignment': + self.asset_id.status = 'assigned' + if exp_petty_cash_module: + custody = self.env['custom.employee.custody'].search([('id', '=', self.emp_asset_custody_id.id)]) + print("===============================================================", custody) + for cus in custody: + operation = self.env['account.asset.operation'].search( + [('emp_asset_custody_id', '=', cus.id), ('type', '=', 'assignment')]) + # print("----------", operation) + # print("----------", operation.state) + if all(ope.state in 'done' for ope in operation): + # print("----------", operation.state) + cus.write({'state': 'assign'}) + elif self.type == 'release': + self.asset_id.status = 'available' + # self.asset_id.status = self.asset_status == 'good' and 'available' or 'scrap' + if exp_petty_cash_module: + custody = self.env['custom.employee.custody'].search([('id', '=', self.emp_asset_custody_id.id)]) + for cus in custody: + operation = self.env['account.asset.operation'].search( + [('emp_asset_custody_id', '=', cus.id), ('type', '=', 'release')]) + if all(ope.state in 'done' for ope in operation): + cus.write({'state': 'done'}) + + + # def custody_confirm(self): + # + # self.asset_id.employee_id = self.new_employee_id.id + # self.asset_id.department_id = self.new_department_id.id + # self.asset_id.location_id = self.new_location_id.id + # self.asset_id.custody_type = self.custody_type + # self.asset_id.custody_period = self.custody_period + # self.asset_id.return_date = self.return_date + # self.asset_id.purpose = self.note +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + petty_cash_count1 = fields.Integer(compute='_compute_assignment_no') + + def _compute_assignment_no(self): + for emp in self: + items = self.env['account.asset.operation'].sudo().search([ + ('asset_statuso','=','assigned'),('new_employee_id', '=',self.id ) + ]) + emp.petty_cash_count1 = len(items) + + def get_assignment(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Assignments', + 'view_mode': 'tree', + 'res_model': 'account.asset.operation', + 'domain': [('asset_statuso','=','assigned'),('new_employee_id', '=', self.id)], + 'context': "{'create': False}" + } diff --git a/dev_odex30_accounting/exp_asset_custody_link/models/employee_custody.py b/dev_odex30_accounting/exp_asset_custody_link/models/employee_custody.py new file mode 100644 index 0000000..cef70e6 --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/models/employee_custody.py @@ -0,0 +1,125 @@ +from odoo import models, fields, api, _, exceptions +from odoo import SUPERUSER_ID + + +# from datetime import datetime , date + + +class EmployeeCustody(models.Model): + _inherit = 'custom.employee.custody' + + state = fields.Selection(selection=[ + ("draft", _("Draft")), + ("submit", _("send")), + ("direct", _("Direct Manager")), + ("admin", _("Human Resources Manager")), + ("wait", _("Wait Assignment")), + ("assign", _("Assignment")), + ("wait_release", _("Wait Release")), + ("done", _("Return Done")), + ("refuse", _("Refuse")) + ], default='draft') + + asset_line_ids = fields.One2many('asset.custody.line', 'asset_custody_line', required=True) + asset_assign_count = fields.Integer(compute='_asset_assign_count', string='Asset Assignment') + asset_release_count = fields.Integer(compute='_asset_release_count', string='Asset Release') + + def create_asset_custody(self): + for i in self.asset_line_ids: + data = { + 'date': self.current_date, + 'asset_id': i.asset_id.id, + 'type': 'assignment', + 'custody_type': i.custody_type, + 'custody_period': i.custody_period, + 'state': 'draft', + 'user_id': self.env.uid, + 'new_employee_id': self.employee_id.id, + 'new_department_id': self.department_id.id, + 'emp_asset_custody_id': self.id, + + } + self.env['account.asset.operation'].create(data) + + def asset_custody_release(self): + for i in self.asset_line_ids: + data = { + 'name': i.asset_id.name, + 'date': self.current_date, + 'asset_id': i.asset_id.id, + 'type': 'release', + 'custody_type': i.custody_type, + 'custody_period': i.custody_period, + 'state': 'draft', + 'user_id': self.env.uid, + 'current_employee_id': self.employee_id.id, + 'new_employee_id': self.employee_id.id, + 'current_department_id': self.department_id.id, + 'emp_asset_custody_id': self.id, + + } + self.env['account.asset.operation'].create(data) + + def _asset_assign_count(self): + self.asset_assign_count = len( + self.env['asset.custody.line'].search([('asset_custody_line', '=', self.id)])) + + def _asset_release_count(self): + self.asset_release_count = len( + self.env['asset.custody.line'].search([('asset_custody_line', '=', self.id)])) + + def approve(self): + if not self.asset_line_ids: + raise exceptions.Warning(_('Please Select an asset')) + self.create_asset_custody() + self.write({'state': 'wait'}) + + def done(self): + self.asset_custody_release() + self.state = "wait_release" + + +class EmployeeCustodyLine(models.Model): + _name = 'asset.custody.line' + + # Asset custody fields + type = fields.Selection([('assignment', 'Assignment')]) + custody_type = fields.Selection(selection=[('personal', 'Personal'), ('general', 'General')]) + custody_period = fields.Selection(selection=[('temporary', 'Temporary'), ('permanent', 'Permanent')]) + return_date = fields.Date() + date = fields.Date() + asset_id = fields.Many2one('account.asset') + asset_custody_line = fields.Many2one(comodel_name='custom.employee.custody') # Inverse field + + @api.model + def create(self, vals): + res = super(EmployeeCustodyLine, self).create(vals) + if res.asset_id and res.asset_id.status in ['new', 'available']: + res.asset_id.write({'status': 'reserved'}) + return res + + def unlink(self): + assets = self.mapped('asset_id') + result = super(EmployeeCustodyLine, self).unlink() + if result and assets: + assets.write({'status': 'available'}) + return result + + +class Followers(models.Model): + _inherit = 'mail.followers' + + @api.model + def create(self, vals): + if 'res_model' in vals and 'res_id' in vals and 'partner_id' in vals: + dups = self.env['mail.followers'].search( + [('res_model', '=', vals.get('res_model')), ('res_id', '=', vals.get('res_id')), + ('partner_id', '=', vals.get('partner_id'))]) + + if len(dups): + for p in dups: + p.unlink() + + res = super(Followers, self).create(vals) + + return res diff --git a/dev_odex30_accounting/exp_asset_custody_link/security/ir.model.access.csv b/dev_odex30_accounting/exp_asset_custody_link/security/ir.model.access.csv new file mode 100644 index 0000000..356298c --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/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_employee_asset_custody_line,access_employee_custody_asset_line,model_asset_custody_line,base.group_user,1,1,1,1 diff --git a/dev_odex30_accounting/exp_asset_custody_link/views/employee_custody_action.xml b/dev_odex30_accounting/exp_asset_custody_link/views/employee_custody_action.xml new file mode 100644 index 0000000..f93852c --- /dev/null +++ b/dev_odex30_accounting/exp_asset_custody_link/views/employee_custody_action.xml @@ -0,0 +1,95 @@ + + + + Assets Assignment + account.asset.operation + list,form + { 'create': False } + [('emp_asset_custody_id', '=', active_id),('type', '=', 'assignment')] + + + + + Assets Release + account.asset.operation + list,form + { 'create': False } + [('emp_asset_custody_id', '=', active_id),('type', '=', 'release')] + + + + + Employee Custody With Asset + custom.employee.custody + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + +
+
+ + hr_employee.extend1.form2 + hr.employee + + + + + + + + + +
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/__init__.py b/dev_odex30_accounting/odex30_account_accountant/__init__.py new file mode 100644 index 0000000..a31a691 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/__init__.py @@ -0,0 +1,46 @@ + +from . import models +from . import wizard + +from odoo import Command + +import logging + +_logger = logging.getLogger(__name__) + + +def _odex30_account_accountant_post_init(env): + country_code = env.company.country_id.code + if country_code: + module_list = [] + + # SEPA zone countries will be using SEPA + sepa_zone = env.ref('base.sepa_zone', raise_if_not_found=False) + sepa_zone_country_codes = sepa_zone and sepa_zone.mapped('country_ids.code') or [] + + if country_code in sepa_zone_country_codes: + module_list.extend(['account_iso20022', 'account_bank_statement_import_camt']) + + module_ids = env['ir.module.module'].search([('name', 'in', module_list), ('state', '=', 'uninstalled')]) + if module_ids: + module_ids.sudo().button_install() + + for company in env['res.company'].search([('chart_template', '!=', False)], order="parent_path"): + ChartTemplate = env['account.chart.template'].with_company(company) + ChartTemplate._load_data({ + 'res.company': ChartTemplate._get_account_accountant_res_company(company.chart_template), + }) + + +def uninstall_hook(env): + # Disable the basic group to remove access menus defined in account + group_basic = env.ref('account.group_account_basic') + group_manager = env.ref('account.group_account_manager') + if group_basic: + group_basic.write({ + 'users': [Command.clear()], + 'category_id': env.ref("base.module_category_hidden").id, + }) + group_manager.write({ + 'implied_ids': [Command.unlink(group_basic.id)], + }) diff --git a/dev_odex30_accounting/odex30_account_accountant/__manifest__.py b/dev_odex30_accounting/odex30_account_accountant/__manifest__.py new file mode 100644 index 0000000..9506e4b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/__manifest__.py @@ -0,0 +1,63 @@ +{ + 'name': 'Invoicing', + 'version': '1.1', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'sequence': 30, + 'summary': 'Manage financial and analytic accounting', + 'description': """ + Accounting Access Rights + ======================== + It gives the Administrator user access to all accounting features such as journal items and the chart of accounts. + + It assigns manager and user access rights to the Administrator for the accounting application and only user rights to the Demo user. + """, + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'depends': ['account', 'web_tour'], + 'data': [ + 'data/ir_cron.xml', + 'data/digest_data.xml', + 'data/odex30_account_accountant_tour.xml', + + 'security/ir.model.access.csv', + 'security/odex30_account_accountant_security.xml', + + 'views/odex30_account_account_views.xml', + 'views/account_fiscal_year_view.xml', + 'views/account_journal_dashboard_views.xml', + 'views/account_move_views.xml', + 'views/account_payment_views.xml', + 'views/odex30_account_reconcile_views.xml', + 'views/account_reconcile_model_views.xml', + 'views/odex30_account_accountant_menuitems.xml', + 'views/digest_views.xml', + 'views/res_config_settings_views.xml', + 'views/product_views.xml', + 'views/bank_rec_widget_views.xml', + 'views/report_invoice.xml', + + 'wizard/account_change_lock_date.xml', + 'wizard/account_auto_reconcile_wizard.xml', + 'wizard/account_reconcile_wizard.xml', + 'wizard/reconcile_model_wizard.xml', + ], + + 'installable': True, + 'auto_install': True, + 'post_init_hook': '_odex30_account_accountant_post_init', + 'uninstall_hook': "uninstall_hook", + 'assets': { + 'web.assets_backend': [ + 'odex30_account_accountant/static/src/js/tours/account_accountant.js', + 'odex30_account_accountant/static/src/components/**/*', + 'odex30_account_accountant/static/src/**/*.xml', + ], + 'web.assets_unit_tests': [ + 'odex30_account_accountant/static/tests/**/*', + ('remove', 'odex30_account_accountant/static/tests/tours/**/*'), + ], + 'web.assets_tests': [ + 'odex30_account_accountant/static/tests/tours/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c547485 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml b/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml new file mode 100644 index 0000000..666229e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/data/digest_data.xml @@ -0,0 +1,23 @@ + + + + + True + + + + + Tip: Bulk update journal items + 900 + + +
+ Tip: Bulk update journal items +

From any list view, select multiple records and the list becomes editable. If you update a cell, selected records are updated all at once. Use this feature to update multiple journal entries from the General Ledger, or any Journal view.

+ +
+
+
+ +
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml b/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml new file mode 100644 index 0000000..3398c92 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/data/ir_cron.xml @@ -0,0 +1,11 @@ + + + + Try to reconcile automatically your statement lines + + code + model._cron_try_auto_reconcile_statement_lines(batch_size=100) + 1 + days + + diff --git a/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml b/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml new file mode 100644 index 0000000..4346f60 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/data/odex30_account_accountant_tour.xml @@ -0,0 +1,11 @@ + + + + odex30_account_accountant_tour + 50 + Good job! You went through all steps of this tour. +
See how to manage your customer invoices in the Customers/Invoices menu + ]]>
+
+
diff --git a/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po b/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po new file mode 100644 index 0000000..580292e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/i18n/ar.po @@ -0,0 +1,2950 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_accountant +# +# Translators: +# Martin Trigaux, 2024 +# Mustafa J. Kadhem , 2024 +# Wil Odoo, 2025 +# Malaz Abuidris , 2025 +# Weblate , 2025. +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-16 18:48+0000\n" +"PO-Revision-Date: 2025-11-10 12:44+0000\n" +"Last-Translator: Weblate \n" +"Language-Team: Arabic \n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" +"X-Generator: Weblate 5.12.2\n" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"%(display_name_html)s with an open amount of %(open_amount)s will be fully " +"reconciled by the transaction." +msgstr "" +"%(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s ستتم تسويته تماماً " +"بواسطة المعاملة. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"%(display_name_html)s with an open amount of %(open_amount)s will be reduced " +"by %(amount)s." +msgstr "" +"%(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s سيتم تقليله بـ %" +"(amount)s. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_form +msgid "-> Reconcile" +msgstr "-> تسوية " + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_0 +msgid "Tip: Bulk update journal items" +msgstr "نصيحة: قم بتحديث بنود اليومية بالجملة" + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1 +msgid "" +"Tip: Find an Accountant or register your Accounting " +"Firm" +msgstr "" +"نصيحة: اعثر على محاسب ليقوم بتسجيل شركة المحاسبة " +"الخاصة بك" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_reconcile_model_form_inherit_account_accountant +msgid "" +"\n" +" Run manually" +msgstr "" +"\n" +" التشغيل يدوياً " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Lock transactions up to specific dates, inclusive" +msgstr "قفل المعاملات حتى تواريخ محددة، شاملة التواريخ نفسها " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit +msgid "1 Bank Transaction" +msgstr "معاملة بنكية واحدة 1" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit +msgid "Bank Statement" +msgstr "كشف حساب بنكي " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "" +" in " +msgstr "" +" في " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "" +" in " +msgstr "" +" في " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" This change is irreversible\n" +" " +msgstr "" +"\n" +" لا يمكن التراجع عن هذا " +"التغيير\n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" ; \n" +" " +msgstr "" +"\n" +" ; \n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" ; \n" +" " +msgstr "" +"\n" +" ; \n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" ; \n" +" " +msgstr "" +"\n" +" ; \n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" ; \n" +" " +msgstr "" +"\n" +" ; \n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" but allow exceptions\n" +" " +msgstr "" +"\n" +" ولكن يُسمَح بالاستثناءات\n" +" " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"\n" +" after a tax closing\n" +" " +msgstr "" +"\n" +" بعد الإقفال الضريبي\n" +" " + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1 +msgid "Find an Accountant" +msgstr "اعثر على محاسب" + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1 +msgid "Register your Accounting Firm" +msgstr "" +"قم بتسجيل شركة المحاسبة الخاصة بك" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid " (" +msgstr " (" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid ")" +msgstr ")" + +#. module: odex30_account_accountant +#: model_terms:web_tour.tour,rainbow_man_message:odex30_account_accountant.odex30_account_accountant_tour +msgid "" +"Good job! You went through all steps of this tour.\n" +"
See how to manage your customer invoices in the Customers/" +"Invoices menu\n" +"
" +msgstr "" +"عمل رائع! لقد اجتزت كافة خطوات هذه الجولة.\n" +"
تعرّف على كيفية إدارة فواتير العملاء من قائمة العملاء/" +"فواتير العملاء\n" +"
" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Exception" +msgstr "استثناء " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model,name:odex30_account_accountant.model_account_account +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__account_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__account_id +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Account" +msgstr "الحساب " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_chart_template +msgid "Account Chart Template" +msgstr "نموذج مخطط الحساب " + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_account_group_tree +#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_group +msgid "Account Groups" +msgstr "مجموعات الحساب" + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.account_tag_action +#: model:ir.ui.menu,name:odex30_account_accountant.account_tag_menu +msgid "Account Tags" +msgstr "علامات تصنيف الحساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__transfer_from_account_id +msgid "Account Transfer From" +msgstr "استمارة تحويل الحساب " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_auto_reconcile_wizard +msgid "Account automatic reconciliation wizard" +msgstr "معالج تسوية الحساب الآلية " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_wizard +msgid "Account reconciliation wizard" +msgstr "معالج تسوية الحساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_account_id +msgid "Account used for deferred expenses" +msgstr "الحساب المستخدم للنفقات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_account_id +msgid "Account used for deferred revenues" +msgstr "الحساب المستخدم للإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__account_ids +msgid "Accounts" +msgstr "الحسابات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml:0 +msgid "Add & Close" +msgstr "إضافة وإغلاق " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml:0 +msgid "Add & New" +msgstr "إضافة وجديد " + +#. module: odex30_account_accountant +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.account_tag_action +msgid "Add a new tag" +msgstr "إضافة علامة تصنيف جديدة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "" +"After the data extraction, check and validate the bill. If no vendor has " +"been found, add one before validating." +msgstr "" +"بعد استخلاص البيانات، تحقق من الفاتورة وقم بتصديقها. في حال عدم إيجادك " +"لمورّد، قم بإضافة واحد قبل التصديق. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml:0 +msgid "All Transactions" +msgstr "كافة المعاملات " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_model_autocomplete_ids +msgid "All reconciliation models" +msgstr "كافة نماذج التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__allow_partials +msgid "Allow partials" +msgstr "السماج بالأجزاء " + +#. module: odex30_account_accountant +#: model:res.groups,name:odex30_account_accountant.group_fiscal_year +msgid "Allow to define fiscal years of more or less than a year" +msgstr "السماح بإنشاء سنوات مالية أطول أو أقصر من عام" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__amount_currency +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "Amount" +msgstr "مبلغ" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__amount_currency +msgid "Amount Currency" +msgstr "عملة المبلغ" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +msgid "Amount Due" +msgstr "المبلغ المستحق" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +msgid "Amount Due (in currency)" +msgstr "المبلغ المستحق (بالعملة) " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__amount_transaction_currency +msgid "Amount in Currency" +msgstr "المبلغ بالعملة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__amount +msgid "Amount in company currency" +msgstr "المبلغ بعملة الشركة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "" +"An entry will transfer %(amount)s from %(from_account)s to %(to_account)s." +msgstr "سيقوم القيد بتحويل %(amount)s من %(from_account)s إلى %(to_account)s. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +msgid "Analytic" +msgstr "تحليلي" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__analytic_distribution +msgid "Analytic Distribution" +msgstr "التوزيع التحليلي" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__analytic_precision +msgid "Analytic Precision" +msgstr "الدقة التحليلية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__use_anglo_saxon +msgid "Anglo-Saxon Accounting" +msgstr "المحاسبة الأنجلو-ساكسونية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__current_hard_lock_date +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__hard_lock_date +msgid "" +"Any entry up to and including that date will be postponed to a later time, " +"in accordance with its journal sequence. This lock date is irreversible and " +"does not allow any exception." +msgstr "" +"سيتم تأجيل أي قيد حتى ذلك التاريخ إلى وقت لاحق، وفقًا لتسلسل دفتر اليومية " +"الخاص به. لا يمكن التراجع عن تاريخ القفل هذا ولا يسمح بأي استثناء. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date +msgid "" +"Any entry up to and including that date will be postponed to a later time, " +"in accordance with its journal's sequence." +msgstr "" +"أي قيد إلى ذلك التاريخ سيتم تأجيله إلى وقت لاحق، وفقًا لتسلسله في دفتر " +"اليومية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__tax_lock_date +msgid "" +"Any entry with taxes up to and including that date will be postponed to a " +"later time, in accordance with its journal's sequence. The tax lock date is " +"automatically set when the tax closing entry is posted." +msgstr "" +"سيتم تأجيل أي قيد يتضمن ضرائب حتى ذلك التاريخ إلى وقت لاحق، وفقاً لتسلسل دفتر " +"اليومية الخاص به. يتم تعيين تاريخ قفل الضرائب تلقائياً عند ترحيل قيد الإقفال " +"الضريبي. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date +msgid "" +"Any purchase entry prior to and including this date will be postponed to a " +"later date, in accordance with its journal's sequence." +msgstr "" +"أي قيد شراء قبل ذلك التاريخ سيتم تأجيله إلى تاريخ لاحق، وفقاً لتسلسله في دفتر " +"اليومية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_change_lock_date__sale_lock_date +msgid "" +"Any sales entry prior to and including this date will be postponed to a " +"later date, in accordance with its journal's sequence." +msgstr "" +"أي قيد بيع قبل ذلك التاريخ سيتم تأجيله إلى تاريخ لاحق، وفقاً لتسلسله في دفتر " +"اليومية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__sign_invoice +msgid "Authorized Signatory on invoice" +msgstr "الموقِّع المصرّح له في الفاتورة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.report_invoice_document +msgid "Authorized signatory" +msgstr "الموقِّع المصرّح له " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.xml:0 +msgid "Auto-reconcile" +msgstr "التسوية التلقائية " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_auto_reconcile_wizard.py:0 +msgid "Automatically Reconciled Entries" +msgstr "القيود المسواة آلياً " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__available_reco_model_ids +msgid "Available Reco Model" +msgstr "نموذج التسوية المتاح " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml:0 +msgid "Back to" +msgstr "العودة إلى " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.xml:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__balance +msgid "Balance" +msgstr "الرصيد" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_digest_digest__kpi_account_bank_cash +msgid "Bank & Cash Moves" +msgstr "تحركات النقد والبنوك " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__bank_account +msgid "Bank Account" +msgstr "الحساب البنكي" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0 +#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_transactions +#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_transactions_kanban +msgid "Bank Reconciliation" +msgstr "التسوية البنكية" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_bank_statement +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_form_bank_rec_widget +msgid "Bank Statement" +msgstr "كشف الحساب البنكي " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0 +msgid "Bank Statement %s.pdf" +msgstr "كشف الحساب البنكي %s.pdf " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "بند كشف الحساب البنكي" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0 +msgid "Bank Statement.pdf" +msgstr "كشف الحساب البنكي.pdf" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_bank_rec_widget +msgid "Bank reconciliation widget for a single statement line" +msgstr "أداة التسوية البنكية لبند كشف حساب واحد " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Based on" +msgstr "بناءً على" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget +msgid "Cancel" +msgstr "إلغاء" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_change_lock_date +msgid "Change Lock Date" +msgstr "تغيير تاريخ الإقفال" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_reconcile_wizard__to_check +msgid "Check if you are not certain of all the information of the counterpart." +msgstr "تحقق من تأكدك من كافة المعلومات المقابلة. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_checked +msgid "Checked" +msgstr "تم تحديده " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0 +msgid "Choose a line to preview its attachments." +msgstr "قم بتحديد بند لمعاينة مرفقاته. " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_auto_reconcile_wizard__search_mode__zero_balance +msgid "Clear Account" +msgstr "تصفية الحساب " + +#. module: odex30_account_accountant +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.actions_account_fiscal_year +msgid "Click here to create a new fiscal year." +msgstr "انقر هنا لإنشاء سنة مالية جديدة." + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_1 +msgid "" +"Click here to find an accountant or if you want to list out your accounting " +"services on Odoo" +msgstr "" +"انقر هنا لإيجاد محاسب أو إذا كنت ترغب في إدراج خدماتك المحاسبية على أودو " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "" +"Click on a fetched bank transaction to start the reconciliation process." +msgstr "انقر على المعاملة المصرفية التي تم جلبها لبدء عملية التسوية. " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_res_company +msgid "Companies" +msgstr "الشركات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__company_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__company_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__company_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__company_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__company_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__company_currency_id +msgid "Company currency" +msgstr "عملة الشركة " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_res_config_settings +msgid "Config Settings" +msgstr "تهيئة الإعدادات " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Confirm the transaction." +msgstr "قم بتأكيد المعاملة. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "Congrats, you're all done!" +msgstr "تهانينا، لقد انتهيت!" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Connect your bank and get your latest transactions." +msgstr "قم بربط مصرفك لتتمكن من رؤية أحدث معاملاتك. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard +msgid "Counterpart Values" +msgstr "قيم مقابلة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__country_code +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__country_code +msgid "Country Code" +msgstr "رمز الدولة " + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_form_bank_rec_widget +msgid "Create Statement" +msgstr "إنشاء كشف الحساب" + +#. module: odex30_account_accountant +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_account_group_tree +msgid "Create a new account group" +msgstr "إنشاء مجموعة حساب جديدة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Create a new transaction." +msgstr "إنشاء معاملة جديدة. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Create model" +msgstr "إنشاء نموذج " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "" +"Create your first vendor bill.

Tip: If you don’t have one on " +"hand, use our sample bill." +msgstr "" +"أنشئ فاتورة المورّد الأولى الخاصة بك.

نصيحة: إذا لم تكن لديك " +"فاتورة في متناول اليد، فبإمكانك الاستعانة بنموذج الفاتورة لدينا. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__create_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__create_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__create_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__create_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__credit +msgid "Credit" +msgstr "الدائن" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__cron_last_check +msgid "Cron Last Check" +msgstr "آخر فحص Cron " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__currency_id +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Currency" +msgstr "العملة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_currency_id +msgid "Currency to use for reconciliation" +msgstr "العملة لاستخدامها في التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__current_hard_lock_date +msgid "Current Hard Lock" +msgstr "تاريخ القفل الثابت الحالي " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "Customer/Vendor" +msgstr "العميل/المورد" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__date +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Date" +msgstr "التاريخ" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_move_line__deferred_end_date +msgid "Date at which the deferred expense/revenue ends" +msgstr "التاريخ الذي تنتهي فيه النفقات/الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_move_line__deferred_start_date +msgid "Date at which the deferred expense/revenue starts" +msgstr "التاريخ الذي تبدأ فيه النفقات/الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__day +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__day +msgid "Days" +msgstr "أيام " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__debit +msgid "Debit" +msgstr "المدين" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__journal_default_account_id +msgid "Default Account" +msgstr "الحساب الافتراضي " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "Deferral of %s" +msgstr "تأجيل %s " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_move_ids +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_move_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit +msgid "Deferred Entries" +msgstr "القيم المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_entry_type +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_entry_type +msgid "Deferred Entry Type" +msgstr "نوع القيد المؤجل " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_move__deferred_entry_type__expense +msgid "Deferred Expense" +msgstr "النفقات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_account_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_account_id +msgid "Deferred Expense Account" +msgstr "حساب النفقات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_amount_computation_method +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_amount_computation_method +msgid "Deferred Expense Based on" +msgstr "النفقات المؤجلة بناءً على " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_expense_journal_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_expense_journal_id +msgid "Deferred Expense Journal" +msgstr "حساب الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_move__deferred_entry_type__revenue +msgid "Deferred Revenue" +msgstr "الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_account_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_account_id +msgid "Deferred Revenue Account" +msgstr "حساب الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_amount_computation_method +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_amount_computation_method +msgid "Deferred Revenue Based on" +msgstr "الإيرادات المؤجلة بناءً على " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__deferred_revenue_journal_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__deferred_revenue_journal_id +msgid "Deferred Revenue Journal" +msgstr "دفتر يومية الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Deferred expense" +msgstr "النفقة المؤجلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Deferred expense entries:" +msgstr "قيود النفقات المؤجلة: " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Deferred revenue" +msgstr "الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Deferred revenue entries:" +msgstr "قيود الإيرادات المؤجلة: " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Define fiscal years of more or less than one year" +msgstr "تحديد السنوات المالية التي تزيد أو تقل عن السنة." + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Deposits" +msgstr "الدفعات المقدّمة" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_digest_digest +msgid "Digest" +msgstr "الموجز " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "Discard" +msgstr "إهمال " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Discount Amount" +msgstr "مبلغ الخصم " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Discount Date" +msgstr "تاريخ الخصم" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Discuss" +msgstr "المناقشة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__display_allow_partials +msgid "Display Allow Partials" +msgstr "عرض خيار السماح بعمليات التسوية الجزئية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__display_name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__display_name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__display_name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_stroked_amount_currency +msgid "Display Stroked Amount Currency" +msgstr "عرض عملة المبلغ المشطوب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__display_stroked_balance +msgid "Display Stroked Balance" +msgstr "عرض الرصيد المشطوب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__sign_invoice +msgid "Display signing field on invoices" +msgstr "عرض حقل التوقيع على الفاتورة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__distribution_analytic_account_ids +msgid "Distribution Analytic Account" +msgstr "حساب التوزيع التحليلي " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/digest.py:0 +msgid "Do not have access, skip this data for user's digest email" +msgstr "لا تملك صلاحيات الوصول. تخط هذه البيانات لبريد الملخص للمستخدم. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +msgid "Document" +msgstr "المستند " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "Draft Entries" +msgstr "القيود في حالة المسودة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode +msgid "Edit Mode" +msgstr "وضع التحرير " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_amount +msgid "Edit Mode Amount" +msgstr "وضع التحرير للمبلغ " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_reco_currency_id +msgid "Edit Mode Reco Currency" +msgstr "وضع التحرير لعملة التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__edit_mode_amount_currency +msgid "Edit mode amount" +msgstr "وضع التحرير للمبلغ " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__date_to +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__deferred_end_date +msgid "End Date" +msgstr "تاريخ الانتهاء" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_fiscal_year__date_to +msgid "Ending Date, included in the fiscal year." +msgstr "تاريخ الانتهاء، ضمن السنة المالية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_company__invoicing_switch_threshold +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__invoicing_switch_threshold +msgid "" +"Every payment and invoice before this date will receive the 'From Invoicing' " +"status, hiding all the accounting entries related to it. Use this option " +"after installing Accounting if you were using only Invoicing before, before " +"importing all your actual accounting data in to Odoo." +msgstr "" +"سوف يكون لكل فاتورة وعملية دفع قبل هذا التاريخ حالة ’من تطبيق الفوترة‘ والتي " +"ستخفي كافة القيود المحاسبية المتعلقة بها. استخدم هذا الخيار بعد تثبيت تطبيق " +"المحاسبة إذا كنت تستخدم تطبيق الفوترة وحده من قبل، قبل إدخالك لكافة بياناتك " +"المحاسبية الفعلية في أودو. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_duration +msgid "Exception Duration" +msgstr "مدة الاستثناء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_needed_fields +msgid "Exception Needed Fields" +msgstr "الحقول التي تحتاج إلى استثناء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_reason +msgid "Exception Reason" +msgstr "سبب الاستثناء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_applies_to +msgid "Exception applies" +msgstr "يمكن تطبيق الاستثناء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__exception_needed +msgid "Exception needed" +msgstr "بحاجة إلى استثناء " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "Exchange Difference: %s" +msgstr "فرق سعر الصرف: %s" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_fiscal_year +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Fiscal Year" +msgstr "سنة مالية" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.action_account_fiscal_year_form +msgid "Fiscal Year 2018" +msgstr "السنة المالية 2018" + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.actions_account_fiscal_year +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__group_fiscal_year +#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_fiscal_year +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Fiscal Years" +msgstr "السنوات المالية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__fiscalyear_last_day +msgid "Fiscalyear Last Day" +msgstr "آخر أيام السنة المالية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__fiscalyear_last_month +msgid "Fiscalyear Last Month" +msgstr "آخر شهور السنة المالية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__flag +msgid "Flag" +msgstr "إبلاغ" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "For everyone:" +msgstr "للجميع: " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "For me:" +msgstr "لي: " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__force_partials +msgid "Force Partials" +msgstr "فرض عمليات التسوية الجزئية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__force_price_included_taxes +msgid "Force Price Included Taxes" +msgstr "فرض الأسعار شاملة الضريبة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__transaction_currency_id +msgid "Foreign Currency" +msgstr "عملة أجنبية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__form_index +msgid "Form Index" +msgstr "فهرس النموذج" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__from_date +msgid "From" +msgstr "من" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "From Trade Payable accounts" +msgstr "من الحسابات الدائنة التجارية " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "From Trade Receivable accounts" +msgstr "من الحسابات المدينة التجارية " + +#. module: odex30_account_accountant +#: model_terms:digest.tip,tip_description:odex30_account_accountant.digest_tip_account_accountant_0 +msgid "" +"From any list view, select multiple records and the list becomes editable. " +"If you update a cell, selected records are updated all at once. Use this " +"feature to update multiple journal entries from the General Ledger, or any " +"Journal view." +msgstr "" +"قم باختيار سجلات متعددة من أي نافذة عرض القائمة، وستصبح القائمة قابلة " +"للتحرير. إذا قمت بتحديث إحدى الخلايا، يتم تحديث كافة السجلات المختارة دفعة " +"واحدة. استخدم هذه الخاصية لتحديث عدة بنود في اليومية من دفتر الأستاذ العام " +"أو أي نافذة عرض لليومية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__full_months +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__full_months +msgid "Full Months" +msgstr "أشهر كاملة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__generate_deferred_expense_entries_method +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__generate_deferred_expense_entries_method +msgid "Generate Deferred Expense Entries" +msgstr "إنشاء قيود النفقات المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__generate_deferred_revenue_entries_method +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__generate_deferred_revenue_entries_method +msgid "Generate Deferred Revenue Entries" +msgstr "إنشاء قيود الإيرادات المؤجلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Generate Entries" +msgstr "إنشاء القيود" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Group By" +msgstr "تجميع حسب" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__group_tax_id +msgid "Group Tax" +msgstr "مجموعة الضريبة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__hard_lock_date +msgid "Hard Lock" +msgstr "تاريخ القفل الثابت " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__has_abnormal_deferred_dates +msgid "Has Abnormal Deferred Dates" +msgstr "يحتوي على تواريخ مؤجلة غير معتادة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__has_deferred_moves +msgid "Has Deferred Moves" +msgstr "يحتوي على حركات مؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_error +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__st_line_checked +msgid "" +"If this checkbox is not ticked, it means that the user was not sure of all " +"the related information at the time of the creation of the move and that the " +"move needs to be checked again." +msgstr "" +"إذا لم يكن هذا المربع محدداً، هذا يعني أن المستخدم لم يكن متأكداً من كافة " +"المعلومات ذات الصلة في الوقت الذي أُنشئت فيه الحركة، وأنه يجب التحقق من " +"الحركة مجدداً. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "In Company Currency" +msgstr "بعملة الشركة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "In Currency" +msgstr "بالعملة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "In Foreign Currency" +msgstr "بالعملة الأجنبية " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +msgid "Incoming" +msgstr "واردة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/res_config_settings.py:0 +msgid "" +"Incorrect fiscal year date: day is out of range for month. Month: %(month)s; " +"Day: %(day)s" +msgstr "" +"تاريخ السنة المالية غير صحيح: اليوم المدخل غير موجود في هذا الشهر. الشهر: %" +"(month)s;اليوم:%(day)s " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__index +msgid "Index" +msgstr "الفهرس " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/demo/odex30_account_accountant_demo.py:0 +msgid "Insurance 12 months" +msgstr "ضمان لمدة 12 شهر " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__invalid +msgid "Invalid" +msgstr "غير صالح " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Invalid statements" +msgstr "كشوفات الحساب غير صالحة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__state +msgid "" +"Invalid: The bank transaction can't be validate since the suspense account " +"is still involved\n" +"Valid: The bank transaction can be validated.\n" +"Reconciled: The bank transaction has already been processed. Nothing left to " +"do." +msgstr "" +"غير صالح: لا يمكن تصديق المعاملة البنكية بما أن الحساب المعلق لا يزال " +"موجوداً\n" +"صالح: يمكن تصديق المعاملة البنكية.\n" +"تمت التسوية: لقد تمت تسوية المعاملة البنكية بالفعل. لم يتبقَّ شيء للقيام به. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Invoice Date" +msgstr "تاريخ الفاتورة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__invoicing_switch_threshold +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__invoicing_switch_threshold +msgid "Invoicing Switch Threshold" +msgstr "الحد الأدنى لتبديل الفوترة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__is_multi_currency +msgid "Is Multi Currency" +msgstr "متعدد العملات " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_rec_pay_account +msgid "Is Rec Pay Account" +msgstr "حساب دفع التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_is_reconciled +msgid "Is Reconciled" +msgstr "تمت تسويته " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_write_off_required +msgid "Is a write-off move required to reconcile" +msgstr "حركة الشطب مطلوبة للمساواة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__is_transfer_required +msgid "Is an account transfer required" +msgstr "تحويل الحساب مطلوب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__transfer_warning_message +msgid "Is an account transfer required to reconcile" +msgstr "تحويل الحساب مطلوب للتسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__lock_date_violated_warning_message +msgid "Is the date violating the lock date of moves" +msgstr "التاريخ يتعدى على تاريخ إقفال الحركات " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "It is not possible to decrease or remove the Hard Lock Date." +msgstr "لا يمكن تقديم تاريخ القفل الثابت أو إزالته. " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_journal +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__journal_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_journal_id +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__journal_currency_id +msgid "Journal Currency" +msgstr "عملة اليومية " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_move +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__move_id +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_move_line +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Journal Items" +msgstr "عناصر اليومية" + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_move_line_posted_unreconciled +msgid "Journal Items to reconcile" +msgstr "عناصر دفتر اليومية المُراد تسويتها " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Journal items where matching number isn't set" +msgstr "عناصر دفتر اليومية التي لم يتم تعيين رقم مطابق لها " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "" +"Journal items where the account allows reconciliation no matter the residual " +"amount" +msgstr "" +"بنود دفتر اليومية حيث يسمح الحساب بالتسوية بغض النظر عن المبلغ المتبقي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_journal_id +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_journal_id +msgid "Journal used for deferred entries" +msgstr "دفتر اليومية المستخدم للقيود المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_digest_digest__kpi_account_bank_cash_value +msgid "Kpi Account Bank Cash Value" +msgstr "حساب المؤشر الرئيسي للأداء للقيمة النقدية للبنك " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__label +msgid "Label" +msgstr "بطاقة عنوان" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Last Day" +msgstr "اليوم الأخير" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +msgid "Last Statement" +msgstr "آخر كشف حساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__write_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__write_uid +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__write_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__write_date +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +msgid "Latest Statement" +msgstr "أحدث كشف حساب " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Legal signatory" +msgstr "الطرف الموقِّع القانوني " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Let’s go back to the dashboard." +msgstr "فلنعد إلى لوحة البيانات. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__line_ids +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__line_ids +msgid "Line" +msgstr "البند " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_bank_rec_widget_line +msgid "Line of the bank reconciliation widget" +msgstr "بند أداة التسوية البنكية " + +#. module: odex30_account_accountant +#: model:ir.ui.menu,name:odex30_account_accountant.menu_action_change_lock_date +msgid "Lock Dates" +msgstr "تواريخ الإقفال" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date +msgid "Lock Everything" +msgstr "قفل الكل " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date_for_everyone +msgid "Lock Everything For Everyone" +msgstr "إقفال كل شيء للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__fiscalyear_lock_date_for_me +msgid "Lock Everything For Me" +msgstr "إقفال كل شيء بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_view_account_change_lock_date +msgid "Lock Journal Entries" +msgstr "إقفال قيود اليومية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date +msgid "Lock Purchases" +msgstr "إقفال المشتريات " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date_for_everyone +msgid "Lock Purchases For Everyone" +msgstr "إقفال المشتريات للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__purchase_lock_date_for_me +msgid "Lock Purchases For Me" +msgstr "إقفال المشتريات بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date +msgid "Lock Sales" +msgstr "إقفال المبيعات " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date_for_everyone +msgid "Lock Sales For Everyone" +msgstr "إقفال المبيعات للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__sale_lock_date_for_me +msgid "Lock Sales For Me" +msgstr "إقفال المبيعات بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date +msgid "Lock Tax Return" +msgstr "إقفال الإقرار الضريبي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date_for_everyone +msgid "Lock Tax Return For Everyone" +msgstr "إقفال الإقرار الضريبي للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__tax_lock_date_for_me +msgid "Lock Tax Return For Me" +msgstr "إقفال الإقرار الضريبي بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_main_attachment_id +msgid "Main Attachment" +msgstr "المرفق الرئيسي" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Manual Operations" +msgstr "العمليات اليدوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_expense_entries_method__manual +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_revenue_entries_method__manual +msgid "Manually & Grouped" +msgstr "يدوياً ومجمع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__manually_modified +msgid "Manually Modified" +msgstr "تم التعديل يدوياً " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js:0 +msgid "Match" +msgstr "مطابقة" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Match Existing Entries" +msgstr "مطابقة القيود الموجودة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Matched" +msgstr "تمت المطابقة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_payment.py:0 +msgid "Matched Transactions" +msgstr "المعاملات المتطابقة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Matching" +msgstr "مطابقة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__matching_rules_allow_auto_reconcile +msgid "Matching Rules Allow Auto Reconcile" +msgstr "تسمح قواعد المطابقة بالتسوية التلقائية " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget +msgid "Memo" +msgstr "مذكرة " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_ir_ui_menu +msgid "Menu" +msgstr "القائمة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_expense_amount_computation_method +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__deferred_revenue_amount_computation_method +msgid "Method used to compute the amount of deferred entries" +msgstr "الطريقة المستخدمة لاحتساب مبلغ القيود المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__generate_deferred_expense_entries_method +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__generate_deferred_revenue_entries_method +msgid "Method used to generate deferred entries" +msgstr "الطريقة المستخدمة لإنشاء القيود المؤجلة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_fiscalyear_lock_date_exception_for_everyone_id +msgid "Min Fiscalyear Lock Date Exception For Everyone" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال السنة المالية للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_fiscalyear_lock_date_exception_for_me_id +msgid "Min Fiscalyear Lock Date Exception For Me" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال السنة المالية بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_purchase_lock_date_exception_for_everyone_id +msgid "Min Purchase Lock Date Exception For Everyone" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال المشتريات للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_purchase_lock_date_exception_for_me_id +msgid "Min Purchase Lock Date Exception For Me" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال المشتريات بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_sale_lock_date_exception_for_everyone_id +msgid "Min Sale Lock Date Exception For Everyone" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال المبيعات للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_sale_lock_date_exception_for_me_id +msgid "Min Sale Lock Date Exception For Me" +msgstr "الحد الأدنى لاستثناء تاريخ إقفال المبيعات بالنسبة لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_tax_lock_date_exception_for_everyone_id +msgid "Min Tax Lock Date Exception For Everyone" +msgstr "الحد الأدنى لاستثناء تاريخ الإقفال الضريبي للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__min_tax_lock_date_exception_for_me_id +msgid "Min Tax Lock Date Exception For Me" +msgstr "الحد الأدنى لاستثناء تاريخ الإقفال الضريبي بالنسبة لي " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "Misc" +msgstr "متنوعات" + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_ir_model +msgid "Models" +msgstr "النماذج" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_expense_amount_computation_method__month +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__deferred_revenue_amount_computation_method__month +msgid "Months" +msgstr "شهور " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "More" +msgstr "المزيد " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__move_attachment_ids +msgid "Move Attachment" +msgstr "نقل المرفق " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__move_line_ids +msgid "Move lines to reconcile" +msgstr "بنود الحركات بانتظار التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__name +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__narration +msgid "Narration" +msgstr "" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Need an irreversible lock to ensure inalterability, for all users?" +msgstr "هل تحتاج إلى قفل دائم لضمان عدم قابلية التغيير لكافة المستخدمين؟ " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "New" +msgstr "جديد" + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_bank_statement_line_form_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget +msgid "New Transaction" +msgstr "معاملة جديدة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0 +msgid "No attachments linked." +msgstr "لم يتم ربط أي مرفقات. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "No statement" +msgstr "لا يوجد كشف حساب " + +#. module: odex30_account_accountant +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions_kanban +msgid "No transactions matching your filters were found." +msgstr "لم يتم العثور على أي معاملات تطابق عوامل التصفية الخاصة بك. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Not Matched" +msgstr "غير مطابق " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Not locked" +msgstr "لم يتم إقفاله " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_tree_bank_rec_widget +msgid "Notes" +msgstr "الملاحظات" + +#. module: odex30_account_accountant +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions +#: model_terms:ir.actions.act_window,help:odex30_account_accountant.action_bank_statement_line_transactions_kanban +msgid "Nothing to do here!" +msgstr "لا شيء لتفعله هنا! " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Now, we'll create your first invoice (accountant)" +msgstr "والآن، سوف نقوم بإنشاء فاتورتك الأولى (محاسب) " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_expense_entries_method__on_validation +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__res_company__generate_deferred_revenue_entries_method__on_validation +msgid "On bill validation" +msgstr "عند تصديق فاتورة المورّد " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "Only Billing Administrators are allowed to change lock dates!" +msgstr "مديرو الفوترة وحدهم المصرح لهم بتغيير تواريخ الإقفال! " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "" +"Only partial reconciliation is possible. Proceed in multiple steps if you " +"want to full reconcile." +msgstr "" +"يُسمَح بالتسوية الجزئية فقط. يمكنك الاستمرار بعدة خطوات إذا أردت التسوية " +"الكلية. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml:0 +msgid "Open attachment in pop out" +msgstr "فتح المرفق في نافذة منبثقة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "Open balance of %(amount)s" +msgstr "رصيد مفتوح بقيمة %(amount)s " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "Original Deferred Entries" +msgstr "القيود المؤجلة الأصلية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__deferred_original_move_ids +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__deferred_original_move_ids +msgid "Original Invoices" +msgstr "الفواتير الأصلية " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Originator Tax" +msgstr "ضريبة المُنشئ " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_search_bank_rec_widget +msgid "Outgoing" +msgstr "الصادرة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__to_partner_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__partner_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_id +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_quick_create_form_bank_rec_widget +msgid "Partner" +msgstr "الشريك" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_currency_id +msgid "Partner Currency" +msgstr "عملة الشريك " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__partner_name +msgid "Partner Name" +msgstr "اسم الشريك" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_payable_account_id +msgid "Partner Payable Account" +msgstr "حساب الشريك الدائن " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_payable_amount +msgid "Partner Payable Amount" +msgstr "مبلغ الشريك الدائن " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_receivable_account_id +msgid "Partner Receivable Account" +msgstr "حساب الشريك المدين " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__partner_receivable_amount +msgid "Partner Receivable Amount" +msgstr "مبلغ الشريك المدين " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__partner_ids +msgid "Partners" +msgstr "الشركاء" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Payable" +msgstr "الدائن" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Payable:" +msgstr "الدائن: " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_payment_form_inherit_account_accountant +msgid "Payment Matching" +msgstr "مطابقة المدفوعات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__payment_state_before_switch +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__payment_state_before_switch +msgid "Payment State Before Switch" +msgstr "حالة الدفع قبل التحويل " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Payments" +msgstr "الدفعات" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +msgid "Payments Matching" +msgstr "مطابقة المدفوعات" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_auto_reconcile_wizard__search_mode__one_to_one +msgid "Perfect Match" +msgstr "مطابق تماماً " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "Please set the deferred accounts in the accounting settings." +msgstr "يرجى تعيين الحسابات المؤجلة في إعدادات المحاسبة. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "Please set the deferred journal in the accounting settings." +msgstr "يرجى إعداد دفتر اليومية المؤجل في إعدادات المحاسبة. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__predict_bill_product +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__predict_bill_product +msgid "Predict Bill Product" +msgstr "توقع منتج الفاتورة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Predict vendor bill product" +msgstr "توقع منتج فاتورة المورّد " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_model +msgid "" +"Preset to create journal entries during a invoices and payments matching" +msgstr "الإعداد المسبق لإنشاء قيود يومية خلال مطابقة الفواتير والدفعات" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__rating_ids +msgid "Ratings" +msgstr "التقييمات " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Reason..." +msgstr "السبب..." + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Receivable" +msgstr "المدين" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Receivable:" +msgstr "المدين: " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__search_mode +#: model:ir.ui.menu,name:odex30_account_accountant.menu_account_reconcile +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_payment_tree +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_tree +msgid "Reconcile" +msgstr "تسوية" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_wizard +msgid "Reconcile & open" +msgstr "تسوية وفتح " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_account_id +msgid "Reconcile Account" +msgstr "تسوية الحساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__reconcile_model_id +msgid "Reconcile Model" +msgstr "نموذج التسوية " + +#. module: odex30_account_accountant +#: model:ir.actions.act_window,name:odex30_account_accountant.action_open_auto_reconcile_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_auto_reconcile_wizard +msgid "Reconcile automatically" +msgstr "التسوية تلقائياً " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_auto_reconcile_wizard__search_mode +msgid "" +"Reconcile journal items with opposite balance or clear accounts with a zero " +"balance" +msgstr "" +"تسوية بنود دفتر اليومية ذات الرصيد المعاكس أو تصفية الحسابات ذات الرصيد " +"الصفري " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__reconciled +msgid "Reconciled" +msgstr "تمت التسوية" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__reco_model_id +msgid "Reconciliation model" +msgstr "نموذج التسوية " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "Record cost of goods sold in your journal entries" +msgstr "سجّل تكاليف البضاعة المباعة في قيود اليومية الخاصة بك " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__ref +msgid "Ref" +msgstr "المرجع " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Reference" +msgstr "الرقم المرجعي " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit +msgid "Related Purchase(s)" +msgstr "عمليات الشراء ذات الصلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_form_inherit +msgid "Related Sale(s)" +msgstr "المبيعات ذات الصلة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Reset" +msgstr "إعادة الضبط " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Residual" +msgstr "المتبقي" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Residual in Currency" +msgstr "المتبقي بالعملة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__return_todo_command +msgid "Return Todo Command" +msgstr "إرجاع أمر قائمة المهام " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Review" +msgstr "مراجعة" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Revoke" +msgstr "إلغاء " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_reconcile_model_line +msgid "Rules for the reconciliation model" +msgstr "قواعد نموذج التسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل النصية القصيرة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "Save" +msgstr "حفظ" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget +msgid "Save & Close" +msgstr "حفظ وإغلاق" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_form_bank_rec_widget +msgid "Save & New" +msgstr "حفظ و جديد" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Search Journal Items to Reconcile" +msgstr "البحث عن القيود اليومية المُراد تسويتها " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_res_config_settings__signing_user +msgid "" +"Select a user here to override every signature on invoice by this user's " +"signature" +msgstr "" +"قم بتحديد مستخدم هنا لاستبدال كل توقيع في الفاتورة بتوقيع هذا المستخدم " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__selected_aml_ids +msgid "Selected Aml" +msgstr "Aml المحددة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__selected_reco_model_id +msgid "Selected Reco Model" +msgstr "تحديد نموذج التسوية " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Set an amount." +msgstr "قم بتعيين مبلغ. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Set as Checked" +msgstr "التعيين كتمّ التحقق منه " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/js/tours/odex30_account_accountant.js:0 +msgid "Set the payment reference." +msgstr "قم بتعيين مرجع الدفع. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_change_lock_date__show_draft_entries_warning +msgid "Show Draft Entries Warning" +msgstr "إظهار تحذير القيود بحالة المسودة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__show_signature_area +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__show_signature_area +msgid "Show Signature Area" +msgstr "إظهار مكان التوقيع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__module_sign +msgid "Sign" +msgstr "توقيع" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__signature +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__signature +msgid "Signature" +msgstr "التوقيع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_config_settings__signing_user +msgid "Signature used to sign all the invoice" +msgstr "التوقيع المستَخدَم للتوقيع على الفاتورة بأكملها " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement_line__signing_user +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move__signing_user +msgid "Signer" +msgstr "الطرف الموقِّع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_res_company__signing_user +msgid "Signing User" +msgstr "المُستخدِم الموقِّع " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__single_currency_mode +msgid "Single Currency Mode" +msgstr "وضع العملة الواحدة " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_id +msgid "Source Aml" +msgstr "Aml المصدرية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_move_id +msgid "Source Aml Move" +msgstr "حركة Aml المصدرية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_aml_move_name +msgid "Source Aml Move Name" +msgstr "اسم حركة Aml المصدرية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_amount_currency +msgid "Source Amount Currency" +msgstr "عملة المبلغ المصدري " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_balance +msgid "Source Balance" +msgstr "الرصيد المصدري " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_credit +msgid "Source Credit" +msgstr "مصدر الائتمان" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_debit +msgid "Source Debit" +msgstr "مصدر الخصم" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__source_rate +msgid "Source Rate" +msgstr "" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_id +msgid "St Line" +msgstr "بند كشف الحساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__st_line_transaction_details +msgid "St Line Transaction Details" +msgstr "تفاصيل معاملة بند كشف الحساب " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_fiscal_year__date_from +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_move_line__deferred_start_date +msgid "Start Date" +msgstr "تاريخ البدء " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_fiscal_year__date_from +msgid "Start Date, included in the fiscal year." +msgstr "تاريخ البداية، ضمن السنة المالية. " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__state +msgid "State" +msgstr "الحالة" + +#. module: odex30_account_accountant +#: model:ir.actions.server,name:odex30_account_accountant.action_bank_statement_attachment +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Statement" +msgstr "كشف الحساب" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Statement Line" +msgstr "بند كشف الحساب" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/demo/odex30_account_accountant_demo.py:0 +msgid "Subscription 12 months" +msgstr "اشتراك لمدة 12 شهر " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_amount_currency +msgid "Suggestion Amount Currency" +msgstr "عملة المبلغ المقترح " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_balance +msgid "Suggestion Balance" +msgstr "الرصيد المقترح " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__suggestion_html +msgid "Suggestion Html" +msgstr "Html المقترح " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +msgid "Suggestions" +msgstr "الاقتراحات " + +#. module: odex30_account_accountant +#: model:ir.model,name:odex30_account_accountant.model_account_tax +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__tax_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_ids +msgid "Tax" +msgstr "الضريبة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_base_amount_currency +msgid "Tax Base Amount Currency" +msgstr "عملة المبلغ الأساسي للضريبة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Tax Grids" +msgstr "شبكات الضرائب" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_repartition_line_id +msgid "Tax Repartition Line" +msgstr "بند التوزيع الضريبي" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__tax_tag_ids +msgid "Tax Tag" +msgstr "علامة تصنيف الضريبة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js:0 +msgid "Taxes" +msgstr "الضرائب" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "That's on average" +msgstr "هذا في المتوسط" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget__country_code +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__country_code +msgid "" +"The ISO country code in two chars. \n" +"You can use this field for quick search." +msgstr "" +"كود الدولة حسب المعيار الدولي أيزو المكون من حرفين.\n" +"يمكنك استخدام هذا الحقل لإجراء بحث سريع." + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__amount_transaction_currency +msgid "" +"The amount expressed in an optional other currency if it is a multi-currency " +"entry." +msgstr "يتم عرض المبلغ بعملة اختيارية أخرى إذا كان قيداً متعدد العملات. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "" +"The amount of the write-off of a single credit line should be strictly " +"negative." +msgstr "يجب أن يكون مبلغ الشطب لبند ائتماني واحد قيمة سالبة فقط. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "" +"The amount of the write-off of a single debit line should be strictly " +"positive." +msgstr "يجب أن يكون مبلغ الشطب لبند خصم واحد قيمة موجبة فقط. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "The amount of the write-off of a single line cannot be 0." +msgstr "لا يمكن أن يكون مبلغ الشطب لبند واحد 0. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "" +"The date you set violates the lock date of one of your entry. It will be " +"overriden by the following date : %(replacement_date)s" +msgstr "" +"التاريخ الذي قمت بتحديده يتضارب مع تاريخ الإقفال لإحدى قيودك. سيتم تجاوزه من " +"قِبَل التاريخ التالي: %(replacement_date)s" + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement_line__deferred_move_ids +#: model:ir.model.fields,help:odex30_account_accountant.field_account_move__deferred_move_ids +msgid "The deferred entries created by this invoice" +msgstr "القيود المؤجلة التي تم إنشاؤها من قِبَل هذه الفاتورة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0 +msgid "The ending date must not be prior to the starting date." +msgstr "يجب ألا يقع تاريخ الانتهاء قبل تاريخ البداية. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"The invoice %(display_name_html)s with an open amount of %(open_amount)s " +"will be entirely paid by the transaction." +msgstr "" +"الفاتورة %(display_name_html)s التي بها مبلغ مفتوح قيمته %(open_amount)s " +"سيتم دفعها كاملة بواسطة المعاملة. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"The invoice %(display_name_html)s with an open amount of %(open_amount)s " +"will be reduced by %(amount)s." +msgstr "" +"الفاتورة %(display_name_html)s مع مبلغ مفتوح قدره %(open_amount)s سيتم " +"تقليله بـ %(amount)s. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "" +"The invoices before this date will not be taken into account as accounting " +"entries" +msgstr "لن يتم اعتبار الفواتير قبل هذا التاريخ كقيود محاسبية " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_bank_rec_widget_line__transaction_currency_id +msgid "The optional other currency if it is a multi-currency entry." +msgstr "العملة الاختيارية الأخرى إذا كان القيد متعدد العملات." + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement_line__deferred_original_move_ids +#: model:ir.model.fields,help:odex30_account_accountant.field_account_move__deferred_original_move_ids +msgid "The original invoices that created the deferred entries" +msgstr "الفواتير الأصلية التي قامت بإنشاء القيود المؤجلة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "" +"The system will try to predict the product on vendor bill lines based on the " +"label of the line" +msgstr "سيحاول النظام توقع المنتج في بنود فاتورة المورد بناءً على عنوان البند. " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_change_lock_date +msgid "" +"There are still draft entries in the period you want to lock.\n" +" You should either post or delete them." +msgstr "" +"لا تزال هناك قيود بحالة المسودة في الفترة التي تريد إقفالها.\n" +" عليك إما ترحيلها أو حذفها. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_bank_statement.py:0 +msgid "" +"This bank transaction has been automatically validated using the " +"reconciliation model '%s'." +msgstr "" +"هذه المعاملة البنكية قد تم تصديقها تلقائياً باستخدام نموذج التسوية '%s'. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "" +"This bank transaction is locked up tighter than a squirrel in a nut factory! " +"You can't hit the reset button on it. So, do you want to \"unreconcile\" it " +"instead?" +msgstr "" +"هذه المعاملة البنكية مغلقة بإحكام أكثر من السنجاب في مصنع الجوز! لا يمكنك " +"الضغط على زر إعادة ضبطها. لذا، أترغب في \"إلغاءها\" عوضاً عن ذلك؟ " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "This can only be used on journal items" +msgstr "يمكن استخدام ذلك فقط في عناصر اليومية " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_reconcile_model_line.py:0 +msgid "" +"This reconciliation model can't be used in the manual reconciliation widget " +"because its configuration is not adapted" +msgstr "" +"لا يمكن استخدام نموذج التسوية في أداة التسوية اليدوية لعدم اعتماد تهيئته " + +#. module: odex30_account_accountant +#: model:digest.tip,name:odex30_account_accountant.digest_tip_account_accountant_0 +msgid "Tip: Bulk update journal items" +msgstr "نصيحة: قم بتحديث قيود اليومية بالجملة " + +#. module: odex30_account_accountant +#: model:digest.tip,name:odex30_account_accountant.digest_tip_account_accountant_1 +msgid "Tip: Find an Accountant or register your Accounting Firm" +msgstr "نصيحة: ابحث عن محاسب أو قم بتسجيل شركتك المحاسبة الخاصة بك " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_auto_reconcile_wizard__to_date +msgid "To" +msgstr "إلى" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_reconcile_wizard__to_check +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "To Check" +msgstr "للتحقق منه " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_kanban_bank_rec_widget +msgid "To check" +msgstr "للتحقق منه " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.res_config_settings_view_form +msgid "To enhance authenticity, add a signature to your invoices" +msgstr "لتعزيز صحة مستنداتك، أضف توقيعاً إلى فواتيرك " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__todo_command +msgid "Todo Command" +msgstr "أمر قائمة المهام " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Total Balance" +msgstr "الرصيد الكلي " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Total Credit" +msgstr "إجمالي الائتمان" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Total Debit" +msgstr "إجمالي الدين" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Total Residual" +msgstr "إجمالي المتبقي " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_move_line_reconcile_tree +msgid "Total Residual in Currency" +msgstr "إجمالي المتبقي بالعملة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_line_search_bank_rec_widget +msgid "Transaction" +msgstr "معاملة" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__transaction_currency_id +msgid "Transaction Currency" +msgstr "عملة المعاملة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "Transaction Details" +msgstr "تفاصيل المعاملة " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_bank_statement_tree +msgid "Transactions" +msgstr "المعاملات " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "Transfer from %s" +msgstr "التحويل من %s" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "Transfer to %s" +msgstr "التحويل إلى %s" + +#. module: odex30_account_accountant +#: model:ir.actions.server,name:odex30_account_accountant.auto_reconcile_bank_statement_line_ir_actions_server +msgid "Try to reconcile automatically your statement lines" +msgstr "حاول تسوية بنود كشف حسابك تلقائياً " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "Unreconciled" +msgstr "غير المسواة" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/res_company.py:0 +msgid "Unreconciled statements lines" +msgstr "بنود كشف الحساب غير المسواة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget__state__valid +msgid "Valid" +msgstr "صالح" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard +msgid "Validate" +msgstr "تصديق " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_list_bank_rec_widget +msgid "View" +msgstr "أداة العرض" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "View Reconciled Entries" +msgstr "عرض القيود المسواة " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "View models" +msgstr "عرض النماذج " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_account_bank_statement__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع الإلكتروني " + +#. module: odex30_account_accountant +#: model:ir.model.fields,help:odex30_account_accountant.field_account_bank_statement__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع الإلكتروني " + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_move_line_reconcile_search +msgid "With residual" +msgstr "مع متبقي " + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__wizard_id +msgid "Wizard" +msgstr "المعالج" + +#. module: odex30_account_accountant +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget__company_currency_id +#: model:ir.model.fields,field_description:odex30_account_accountant.field_bank_rec_widget_line__company_currency_id +msgid "Wizard Company Currency" +msgstr "معالج عملة الشركة " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "Write-Off" +msgstr "شطب" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "Write-Off Entry" +msgstr "شطب القيد " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0 +msgid "" +"You can not have an overlap between two fiscal years, please correct the " +"start and/or end dates of your fiscal years." +msgstr "" +"لا يمكن أن يكون هناك تداخل بين سنتين ماليتين، الرجاء تصحيح تواريخ بدء و/أو " +"انتهاء سنواتك المالية. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_reconcile_wizard.py:0 +msgid "You can only reconcile entries with up to two different accounts: %s" +msgstr "يمكنك فقط تسوية القيود حتى حسابين مختلفين: %s" + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget.py:0 +msgid "You can't hit the reset button on a secured bank transaction." +msgstr "لا يمكنك الضغط على زر إعادة الضبط في معاملة بنكية مؤمَّنة. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "" +"You cannot change the account for a deferred line in %(move_name)s if it has " +"already been deferred." +msgstr "" +"لا يمكنك تغيير الحساب للبند المؤجل %(move_name)s إذا كان مؤجلاً بالفعل. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "You cannot create a deferred entry with a start date but no end date." +msgstr "لا يمكنك إنشاء قيد مؤجل مع تاريخ بدء ودون تاريخ انتهاء. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "" +"You cannot create a deferred entry with a start date later than the end date." +msgstr "لا يمكنك إنشاء قيد مؤجل مع تاريخ بدء أبعد من تاريخ انتهاء. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "You cannot generate deferred entries for a miscellaneous journal entry." +msgstr "لا يمكنك إنشاء قيود مؤجلة لقيد يومية من المتفرقات. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_fiscal_year.py:0 +msgid "You cannot have a fiscal year on a child company." +msgstr "لا يمكن أن يكون لديك عام مالي في شركة تابعة. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/account_move.py:0 +msgid "" +"You cannot reset to draft an invoice that is grouped in deferral entry. You " +"can create a credit note instead." +msgstr "" +"لا يمكنك إعادة تعيين فاتورة قد تم تجميعها في قيد مؤجل إلى حالة المسودة. " +"يمكنك إنشاء إشعار دائن عوضاً عن ذلك. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "You cannot set a Lock Date in the future." +msgstr "لا يمكنك تعيين تاريخ إقفال في المستقبل. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "You might want to %(btn_start)sfully reconcile%(btn_end)s the document." +msgstr "قد ترغب بإجراء %(btn_start)sالتسوية الكلية%(btn_end)s للمستند. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"You might want to make a %(btn_start)spartial reconciliation%(btn_end)s " +"instead." +msgstr "قد ترغب بإجراء %(btn_start)sالتسوية الجزئية%(btn_end)s عوضاً عن ذلك. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "You might want to record a %(btn_start)spartial payment%(btn_end)s." +msgstr "قد ترغب بتسجيل %(btn_start)sالتسوية الجزئية%(btn_end)s. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/models/bank_rec_widget_line.py:0 +msgid "" +"You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s." +msgstr "قد ترغب بتعيين الفاتورة كـ %(btn_start)sمدفوعة بالكامل%(btn_end)s. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "You need to select a duration for the exception." +msgstr "عليك تحديد مدة للاستثناء. " + +#. module: odex30_account_accountant +#. odoo-python +#: code:addons/odex30_account_accountant/wizard/account_change_lock_date.py:0 +msgid "You need to select who the exception applies to." +msgstr "عليك تحديد الأفراد الذين ينطبق عليهم الاستثناء. " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "You reconciled" +msgstr "لقد قمت بتسوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__aml +msgid "aml" +msgstr "aml" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__auto_balance +msgid "auto_balance" +msgstr "auto_balance" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.view_account_reconcile_model_widget_wizard +msgid "e.g. Bank Fees" +msgstr "مثال: الرسوم البنكية " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__early_payment +msgid "early_payment" +msgstr "early_payment" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__exchange_diff +msgid "exchange_diff" +msgstr "exchange_diff" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__1h +msgid "for 1 hour" +msgstr "لمدة ساعة واحدة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__15min +msgid "for 15 minutes" +msgstr "لمدة 15 دقيقة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__24h +msgid "for 24 hours" +msgstr "لمدة 24 ساعة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__5min +msgid "for 5 minutes" +msgstr "لمدة 5 دقائق " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_applies_to__everyone +msgid "for everyone" +msgstr "للجميع " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_applies_to__me +msgid "for me" +msgstr "لي " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__account_change_lock_date__exception_duration__forever +msgid "forever" +msgstr "للأبد " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml:0 +msgid "in" +msgstr "في" + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__liquidity +msgid "liquidity" +msgstr "السيولة " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__manual +msgid "manual" +msgstr "اليدوية " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__new_aml +msgid "new_aml" +msgstr "new_aml" + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "seconds per transaction." +msgstr "ثوان لكل معاملة. " + +#. module: odex30_account_accountant +#: model:ir.model.fields.selection,name:odex30_account_accountant.selection__bank_rec_widget_line__flag__tax_line +msgid "tax_line" +msgstr "tax_line" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +msgid "to check" +msgstr "للتفقد" + +#. module: odex30_account_accountant +#: model_terms:ir.ui.view,arch_db:odex30_account_accountant.account_journal_dashboard_kanban_view +msgid "to reconcile" +msgstr "بانتظار التسوية " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "transaction in" +msgstr "معاملة في " + +#. module: odex30_account_accountant +#. odoo-javascript +#: code:addons/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml:0 +msgid "transactions in" +msgstr "معاملات في " diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__init__.py b/dev_odex30_accounting/odex30_account_accountant/models/__init__.py new file mode 100644 index 0000000..ca01d9a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_account +from . import account_bank_statement +from . import account_chart_template +from . import account_fiscal_year +from . import account_journal_dashboard +from . import account_move +from . import account_payment +from . import account_reconcile_model +from . import account_reconcile_model_line +from . import account_tax +from . import digest +from . import res_config_settings +from . import res_company +from . import bank_rec_widget +from . import bank_rec_widget_line +from . import ir_ui_menu +from . import ir_model diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..79af41b Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc new file mode 100644 index 0000000..7111947 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_account.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc new file mode 100644 index 0000000..7c50e6a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_bank_statement.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc new file mode 100644 index 0000000..a080f33 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_chart_template.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc new file mode 100644 index 0000000..e83ec3c Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_fiscal_year.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc new file mode 100644 index 0000000..3031d95 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_journal_dashboard.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc new file mode 100644 index 0000000..3f915e0 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_move.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc new file mode 100644 index 0000000..554b4f6 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_payment.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc new file mode 100644 index 0000000..382e5c7 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc new file mode 100644 index 0000000..08eb771 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_reconcile_model_line.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc new file mode 100644 index 0000000..c2663ab Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/account_tax.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc new file mode 100644 index 0000000..2ab1c38 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc new file mode 100644 index 0000000..44ad487 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/bank_rec_widget_line.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc new file mode 100644 index 0000000..57baa46 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/digest.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc new file mode 100644 index 0000000..e8af2c8 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_model.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc new file mode 100644 index 0000000..e820a3e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/ir_ui_menu.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc new file mode 100644 index 0000000..aedf37f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_company.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc new file mode 100644 index 0000000..8a54df3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/models/__pycache__/res_config_settings.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_account.py b/dev_odex30_accounting/odex30_account_accountant/models/account_account.py new file mode 100644 index 0000000..d60cbf1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_account.py @@ -0,0 +1,14 @@ +import ast +from odoo import models + + +class AccountAccount(models.Model): + _inherit = "account.account" + + def action_open_reconcile(self): + self.ensure_one() + action_values = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled') + domain = ast.literal_eval(action_values['domain']) + domain.append(('account_id', '=', self.id)) + action_values['domain'] = domain + return action_values diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py new file mode 100644 index 0000000..934c889 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_bank_statement.py @@ -0,0 +1,223 @@ +import logging + +from odoo import _, api, fields, models +from odoo.addons.base.models.res_bank import sanitize_account_number +from odoo.exceptions import UserError +from odoo.tools import html2plaintext + +from dateutil.relativedelta import relativedelta +from itertools import product +from lxml import etree +from markupsafe import Markup + +_logger = logging.getLogger(__name__) + +class AccountBankStatement(models.Model): + _name = "account.bank.statement" + _inherit = ['mail.thread.main.attachment', 'account.bank.statement'] + + def action_open_bank_reconcile_widget(self): + self.ensure_one() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + name=self.name, + default_context={ + 'search_default_statement_id': self.id, + 'search_default_journal_id': self.journal_id.id, + }, + extra_domain=[('statement_id', '=', self.id)] + ) + + def action_generate_attachment(self): + ir_actions_report_sudo = self.env['ir.actions.report'].sudo() + statement_report_action = self.env.ref('account.action_report_account_statement') + for statement in self: + statement_report = statement_report_action.sudo() + content, _content_type = ir_actions_report_sudo._render_qweb_pdf(statement_report, res_ids=statement.ids) + statement.attachment_ids |= self.env['ir.attachment'].create({ + 'name': _("Bank Statement %s.pdf", statement.name) if statement.name else _("Bank Statement.pdf"), + 'type': 'binary', + 'mimetype': 'application/pdf', + 'raw': content, + 'res_model': statement._name, + 'res_id': statement.id, + }) + return statement_report_action.report_action(docids=self) + +class AccountBankStatementLine(models.Model): + _inherit = 'account.bank.statement.line' + + + cron_last_check = fields.Datetime() + + def action_save_close(self): + return {'type': 'ir.actions.act_window_close'} + + def action_save_new(self): + action = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_bank_statement_line_form_bank_rec_widget') + action['context'] = {'default_journal_id': self._context['default_journal_id']} + return action + + + @api.model + def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True): + action_reference = 'odex30_account_accountant.action_bank_statement_line_transactions' + ('_kanban' if kanban_first else '') + action = self.env['ir.actions.act_window']._for_xml_id(action_reference) + + action.update({ + 'name': name or _("Bank Reconciliation"), + 'context': default_context or {}, + 'domain': [('state', '!=', 'cancel')] + (extra_domain or []), + }) + + return action + + def action_open_recon_st_line(self): + self.ensure_one() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + name=self.name, + default_context={ + 'default_statement_id': self.statement_id.id, + 'default_journal_id': self.journal_id.id, + 'default_st_line_id': self.id, + 'search_default_id': self.id, + }, + ) + + def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0): + + def _compute_st_lines_to_reconcile(configured_company): + + remaining_line_id = None + limit = batch_size + 1 if batch_size else None + domain = [ + ('is_reconciled', '=', False), + ('create_date', '>', start_time.date() - relativedelta(months=3)), + ('company_id', 'in', configured_company.ids), + ] + st_lines = self.search(domain, limit=limit, order="cron_last_check ASC NULLS FIRST, id") + if batch_size and len(st_lines) > batch_size: + remaining_line_id = st_lines[batch_size].id + st_lines = st_lines[:batch_size] + return st_lines, remaining_line_id + + start_time = fields.Datetime.now() + + configured_company = children_company = self.env['account.reconcile.model'].search_fetch([ + ('auto_reconcile', '=', True), + ('rule_type', 'in', ('writeoff_suggestion', 'invoice_matching')), + ], ['company_id']).company_id + if not configured_company: + return + while children_company := children_company.child_ids: + configured_company += children_company + + st_lines, remaining_line_id = (self, None) if self else _compute_st_lines_to_reconcile(configured_company) + + if not st_lines: + return + + + self.env.cr.execute("SELECT 1 FROM account_bank_statement_line WHERE id in %s FOR UPDATE", [tuple(st_lines.ids)]) + + nb_auto_reconciled_lines = 0 + for index, st_line in enumerate(st_lines): + if limit_time and fields.Datetime.now().timestamp() - start_time.timestamp() > limit_time: + remaining_line_id = st_line.id + st_lines = st_lines[:index] + break + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_trigger_matching_rules() + if wizard.state == 'valid' and wizard.matching_rules_allow_auto_reconcile: + try: + wizard._action_validate() + if st_line.is_reconciled: + st_line.move_id.message_post(body=_( + "This bank transaction has been automatically validated using the reconciliation model '%s'.", + ', '.join(st_line.move_id.line_ids.reconcile_model_id.mapped('name')), + )) + nb_auto_reconciled_lines += 1 + except UserError as e: + _logger.info("Failed to auto reconcile statement line %s due to user error: %s", + st_line.id, + str(e) + ) + continue + + st_lines.write({'cron_last_check': start_time}) + + # If the next statement line has never been auto reconciled yet, force the trigger. + if remaining_line_id: + remaining_st_line = self.env['account.bank.statement.line'].browse(remaining_line_id) + if nb_auto_reconciled_lines or not remaining_st_line.cron_last_check: + self.env.ref('odex30_account_accountant.auto_reconcile_bank_statement_line')._trigger() + + def _retrieve_partner(self): + self.ensure_one() + + # Retrieve the partner from the statement line. + if self.partner_id: + return self.partner_id + + # Retrieve the partner from the bank account. + if self.account_number: + account_number_nums = sanitize_account_number(self.account_number) + if account_number_nums: + domain = [('sanitized_acc_number', 'ilike', account_number_nums)] + for extra_domain in ([('company_id', 'parent_of', self.company_id.id)], [('company_id', '=', False)]): + bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain) + if len(bank_accounts.partner_id) == 1: + return bank_accounts.partner_id + else: + # We have several partner with same account, possibly some archived partner + # so try to filter out inactive partner and if one remains, select this one + bank_accounts = bank_accounts.filtered(lambda bacc: bacc.partner_id.active) + if len(bank_accounts) == 1: + return bank_accounts.partner_id + + # Retrieve the partner from the partner name. + if self.partner_name: + # using 'complete_name' instead of 'name', + # as 'complete_name' is the first search criteria in _rec_names_search, + # and trigram indexed accordingly. + domains = product( + [ + ('complete_name', '=ilike', self.partner_name), + ('complete_name', 'ilike', self.partner_name), + ], + [ + ('company_id', 'parent_of', self.company_id.id), + ('company_id', '=', False), + ], + ) + for domain in domains: + partner = self.env['res.partner'].search(list(domain) + [('parent_id', '=', False)], limit=2) + if len(partner) == 1: + return partner + # Retrieve the partner from the 'reconcile models'. + rec_models = self.env['account.reconcile.model'].search([ + *self.env['account.reconcile.model']._check_company_domain(self.company_id), + ('rule_type', '!=', 'writeoff_button'), + ]) + for rec_model in rec_models: + partner = rec_model._get_partner_from_mapping(self) + if partner and rec_model._is_applicable_for(self, partner): + return partner + + return self.env['res.partner'] + + def _get_st_line_strings_for_matching(self, allowed_fields=None): + + self.ensure_one() + + st_line_text_values = [] + if not allowed_fields or 'payment_ref' in allowed_fields: + if self.payment_ref: + st_line_text_values.append(self.payment_ref) + if not allowed_fields or 'narration' in allowed_fields: + value = html2plaintext(self.narration or "") + if value: + st_line_text_values.append(value) + if not allowed_fields or 'ref' in allowed_fields: + if self.ref: + st_line_text_values.append(self.ref) + return st_line_text_values diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py b/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py new file mode 100644 index 0000000..166ba8f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_chart_template.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.models.chart_template import template +from odoo import models + +class AccountChartTemplate(models.AbstractModel): + _inherit = 'account.chart.template' + + def _get_account_accountant_res_company(self, chart_template): + company = self.env.company + data = self._get_chart_template_data(chart_template) + company_data = data['res.company'].get(company.id, {}) + + # Pre-reload to ensure the necessary xmlids for the load exist in case they were deleted or not created yet. + required_data = {k: v for k, v in data.items() if k in ['account.journal', 'account.account']} + self._pre_reload_data(company, data['template_data'], required_data) + + return { + company.id: { + 'deferred_expense_journal_id': company.deferred_expense_journal_id.id or company_data.get('deferred_expense_journal_id'), + 'deferred_revenue_journal_id': company.deferred_revenue_journal_id.id or company_data.get('deferred_revenue_journal_id'), + 'deferred_expense_account_id': company.deferred_expense_account_id.id or company_data.get('deferred_expense_account_id'), + 'deferred_revenue_account_id': company.deferred_revenue_account_id.id or company_data.get('deferred_revenue_account_id'), + } + } + + def _get_chart_template_data(self, chart_template): + + data = super()._get_chart_template_data(chart_template) + + for _company_id, company_data in data['res.company'].items(): + company_data['deferred_expense_journal_id'] = ( + company_data.get('deferred_expense_journal_id') + or next((xid for xid, d in data['account.journal'].items() if d['type'] == 'general'), None) + ) + + company_data['deferred_revenue_journal_id'] = ( + company_data.get('deferred_revenue_journal_id') + or next((xid for xid, d in data['account.journal'].items() if d['type'] == 'general'), None) + ) + + company_data['deferred_expense_account_id'] = ( + company_data.get('deferred_expense_account_id') + or next((xid for xid, d in data['account.account'].items() if d['account_type'] == 'asset_current'), None) + ) + + company_data['deferred_revenue_account_id'] = ( + company_data.get('deferred_revenue_account_id') + or next((xid for xid, d in data['account.account'].items() if d['account_type'] == 'liability_current'), None) + ) + + return data diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py b/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py new file mode 100644 index 0000000..01555d4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_fiscal_year.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +from odoo.exceptions import ValidationError +from odoo import api, fields, models, _ + + +from datetime import datetime + + +class AccountFiscalYear(models.Model): + _name = 'account.fiscal.year' + _description = 'Fiscal Year' + + name = fields.Char(string='Name', required=True) + date_from = fields.Date(string='Start Date', required=True, + help='Start Date, included in the fiscal year.') + date_to = fields.Date(string='End Date', required=True, + help='Ending Date, included in the fiscal year.') + company_id = fields.Many2one('res.company', string='Company', required=True, + default=lambda self: self.env.company) + + @api.constrains('date_from', 'date_to', 'company_id') + def _check_dates(self): + + for fy in self: + # Starting date must be prior to the ending date + date_from = fy.date_from + date_to = fy.date_to + if date_to < date_from: + raise ValidationError(_('The ending date must not be prior to the starting date.')) + if fy.company_id.parent_id: + raise ValidationError(_('You cannot have a fiscal year on a child company.')) + + domain = [ + ('id', '!=', fy.id), + ('company_id', '=', fy.company_id.id), + '|', '|', + '&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_from), + '&', ('date_from', '<=', fy.date_to), ('date_to', '>=', fy.date_to), + '&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_to), + ] + + if self.search_count(domain) > 0: + raise ValidationError(_('You can not have an overlap between two fiscal years, please correct the start and/or end dates of your fiscal years.')) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py new file mode 100644 index 0000000..85f14c9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_journal_dashboard.py @@ -0,0 +1,62 @@ +from odoo import models + + +class account_journal(models.Model): + _inherit = "account.journal" + + def action_open_reconcile(self): + self.ensure_one() + + if self.type in ('bank', 'cash', 'credit'): + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={ + 'default_journal_id': self.id, + 'search_default_journal_id': self.id, + 'search_default_not_matched': True, + }, + ) + else: + # Open reconciliation view for customers/suppliers + return self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled') + + def action_open_to_check(self): + self.ensure_one() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={ + 'search_default_to_check': True, + 'search_default_journal_id': self.id, + 'default_journal_id': self.id, + }, + ) + + def action_open_bank_transactions(self): + self.ensure_one() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={ + 'search_default_journal_id': self.id, + 'default_journal_id': self.id + }, + kanban_first=False, + ) + + def action_open_reconcile_statement(self): + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={ + 'search_default_statement_id': self.env.context.get('statement_id'), + }, + ) + + def open_action(self): + # EXTENDS account + # set default action for liquidity journals in dashboard + + if self.type in ('bank', 'cash', 'credit') and not self._context.get('action_name'): + self.ensure_one() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=[('line_ids.account_id', '=', self.default_account_id.id)], + default_context={ + 'default_journal_id': self.id, + 'search_default_journal_id': self.id, + }, + ) + return super().open_action() diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_move.py b/dev_odex30_accounting/odex30_account_accountant/models/account_move.py new file mode 100644 index 0000000..7f549b8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_move.py @@ -0,0 +1,836 @@ +import calendar +from contextlib import contextmanager +from itertools import chain +from dateutil.relativedelta import relativedelta +import logging +import re + +from odoo import fields, models, api, _, Command +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools import SQL, float_compare + + +_logger = logging.getLogger(__name__) + + +DEFERRED_DATE_MIN = '1900-01-01' +DEFERRED_DATE_MAX = '9999-12-31' + + +class AccountMove(models.Model): + _inherit = "account.move" + + # Technical field to keep the value of payment_state when switching from invoicing to accounting + # (using invoicing_switch_threshold setting field). It allows keeping the former payment state, so that + # we can restore it if the user misconfigured the switch date and wants to change it. + payment_state_before_switch = fields.Char(string="Payment State Before Switch", copy=False) + + # Deferred management fields + deferred_move_ids = fields.Many2many( + string="Deferred Entries", + comodel_name='account.move', + relation='account_move_deferred_rel', + column1='original_move_id', + column2='deferred_move_id', + help="The deferred entries created by this invoice", + copy=False, + ) + deferred_original_move_ids = fields.Many2many( + string="Original Invoices", + comodel_name='account.move', + relation='account_move_deferred_rel', + column1='deferred_move_id', + column2='original_move_id', + help="The original invoices that created the deferred entries", + copy=False, + ) + deferred_entry_type = fields.Selection( + string="Deferred Entry Type", + selection=[ + ('expense', 'Deferred Expense'), + ('revenue', 'Deferred Revenue'), + ], + compute='_compute_deferred_entry_type', + copy=False, + ) + + signing_user = fields.Many2one( + string='Signer', + comodel_name='res.users', + compute='_compute_signing_user', store=True, + copy=False, + ) + show_signature_area = fields.Boolean(compute='_compute_signature') + signature = fields.Binary(compute='_compute_signature') # can't be `related`: the sign module might not be there + + @api.depends('state', 'move_type', 'invoice_user_id') + def _compute_signing_user(self): + other_moves = self.filtered(lambda move: not move.is_sale_document()) + other_moves.signing_user = False + + is_odoobot_user = self.env.user == self.env.ref('base.user_root') + is_backend_user = self.env.user.has_group('base.group_user') + + for invoice in (self - other_moves).filtered(lambda inv: inv.state == 'posted'): + # signer priority: + # - res.user set in res.settings + # - real backend user posting the invoice + # - if odoobot: the person that initiated the invoice ie: The salesman + # - if invoice initiated by a portal user -> No signature + representative = invoice.company_id.signing_user + # checking `has_group('base.group_user')` ensure we never keep a portal user to sign + if is_odoobot_user: + user_can_sign = invoice.invoice_user_id and invoice.invoice_user_id.has_group('base.group_user') + invoice.signing_user = representative or invoice.invoice_user_id if user_can_sign else False + else: + invoice.signing_user = representative or self.env.user if is_backend_user else False + + @api.depends('state') + def _compute_signature(self): + is_portal_user = self.env.user.has_group('base.group_portal') + # Checking `company_id.sign_invoice` removes the needs to check if the sign module is installed + # Setting it to True through `res.settings` auto install the sign module + moves_not_to_sign = self.filtered( + lambda inv: not inv.company_id.sign_invoice + or inv.state in {'draft', 'cancel'} + or not inv.is_sale_document() + # Allow signature for portal user only if the invoice already went through the send&print workflow + or (is_portal_user and not inv.invoice_pdf_report_id) + ) + moves_not_to_sign.show_signature_area = False + moves_not_to_sign.signature = None + + invoice_with_signature = self - moves_not_to_sign + invoice_with_signature.show_signature_area = True + for invoice in invoice_with_signature: + invoice.signature = invoice.signing_user.sudo().sign_signature + + def _post(self, soft=True): + # Deferred management + posted = super()._post(soft) + for move in self: + if move._get_deferred_entries_method() == 'on_validation' and any(move.line_ids.mapped('deferred_start_date')): + move._generate_deferred_entries() + return posted + + def action_post(self): + # EXTENDS 'account' to trigger the CRON auto-reconciling the statement lines. + res = super().action_post() + if self.statement_line_id and not self._context.get('skip_statement_line_cron_trigger'): + self.env.ref('odex30_odex30_account_accountant.auto_reconcile_bank_statement_line')._trigger() + return res + + def button_draft(self): + if any(len(deferral_move.deferred_original_move_ids) > 1 for deferral_move in self.deferred_move_ids): + raise UserError(_("You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead.")) + reversed_moves = self.deferred_move_ids._unlink_or_reverse() + if reversed_moves: + for move in reversed_moves: + move.with_context(skip_readonly_check=True).write({ + 'date': move._get_accounting_date(move.date, move._affect_tax_report()), + }) + self.deferred_move_ids |= reversed_moves + return super().button_draft() + + def unlink(self): + # Prevent deferred moves under audit trail restriction from being unlinked + deferral_moves = self.filtered(lambda move: move._is_protected_by_audit_trail() and move.deferred_original_move_ids) + deferral_moves.deferred_original_move_ids.deferred_move_ids = False + deferral_moves._reverse_moves() + return super(AccountMove, self - deferral_moves).unlink() + + # ============================= START - Deferred Management ==================================== + + def _get_deferred_entries_method(self): + self.ensure_one() + if self.is_purchase_document(): + return self.company_id.generate_deferred_expense_entries_method + return self.company_id.generate_deferred_revenue_entries_method + + @api.depends('deferred_original_move_ids') + def _compute_deferred_entry_type(self): + for move in self: + if move.deferred_original_move_ids: + move.deferred_entry_type = 'expense' if move.deferred_original_move_ids[0].is_purchase_document() else 'revenue' + else: + move.deferred_entry_type = False + + @api.model + def _get_deferred_diff_dates(self, start, end): + """ + Returns the number of months between two dates [start, end[ + The computation is done by using months of 30 days so that the deferred amount for february + (28-29 days), march (31 days) and april (30 days) are all the same (in case of monthly computation). + See test_deferred_management_get_diff_dates for examples. + """ + if start > end: + start, end = end, start + nb_months = end.month - start.month + 12 * (end.year - start.year) + start_day, end_day = start.day, end.day + if start_day == calendar.monthrange(start.year, start.month)[1]: + start_day = 30 + if end_day == calendar.monthrange(end.year, end.month)[1]: + end_day = 30 + nb_days = end_day - start_day + return (nb_months * 30 + nb_days) / 30 + + @api.model + def _get_deferred_period_amount(self, method, period_start, period_end, line_start, line_end, balance): + """ + Returns the amount to defer for the given period taking into account the deferred method (day/month/full_months). + """ + if period_end <= line_start or period_end <= period_start: + return 0 # invalid period + if method == 'day': + amount_per_day = balance / (line_end - line_start).days + return (period_end - period_start).days * amount_per_day + elif method in ('month', 'full_months'): + if method == 'full_months': + reset_day_1 = relativedelta(day=1) + line_start, line_end = line_start + reset_day_1, line_end + reset_day_1 + period_start, period_end = period_start + reset_day_1, period_end + reset_day_1 + line_diff = self._get_deferred_diff_dates(line_end, line_start) + period_diff = self._get_deferred_diff_dates(period_end, period_start) + return period_diff / line_diff * balance if line_diff else balance + + @api.model + def _get_deferred_amounts_by_line(self, lines, periods, deferred_type): + """ + :return: a list of dictionaries containing the deferred amounts for each line and each period + E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...) + [ + {'account_id': 1, period_1: 100, period_2: 200}, + {'account_id': 1, period_1: 100, period_2: 200}, + {'account_id': 2, period_1: 300, period_2: 400}, + ] + """ + values = [] + for line in lines: + line_start = fields.Date.to_date(line['deferred_start_date']) + line_end = fields.Date.to_date(line['deferred_end_date']) + if line_end < line_start: + # This normally shouldn't happen, but if it does, would cause calculation errors later on. + # To not make the reports crash, we just set both dates to the same day. + # The user should fix the dates manually. + line_end = line_start + + columns = {} + for period in periods: + if period[2] == 'not_started' and line_start <= period[0]: + # The 'Not Started' column only considers lines starting the deferral after the report end date + columns[period] = 0.0 + continue + # periods = [Total, Not Started, Before, ..., Current, ..., Later] + # The dates to calculate the amount for the current period + period_start = max(period[0], line_start) + period_end = min(period[1], line_end) + relativedelta(days=1) # +1 to include end date of report + + columns[period] = self._get_deferred_period_amount( + self.env.company.deferred_expense_amount_computation_method if deferred_type == "expense" else self.env.company.deferred_revenue_amount_computation_method, + period_start, period_end, + line_start, line_end + relativedelta(days=1), # +1 to include the end date of the line + line['balance'] + ) + + values.append({ + **self.env['account.move.line']._get_deferred_amounts_by_line_values(line), + **columns, + }) + return values + + @api.model + def _get_deferred_lines(self, line, deferred_account, deferred_type, period, ref, force_balance=None, grouping_field='account_id'): + """ + :return: a list of Command objects to create the deferred lines of a single given period + """ + deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0] + balance = deferred_amounts[period] if force_balance is None else force_balance + return [ + Command.create({ + **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, ref, line.analytic_distribution, line), + 'partner_id': line.partner_id.id, + 'product_id': line.product_id.id, + }) + for (account, coeff) in [(deferred_amounts[grouping_field], 1), (deferred_account, -1)] + ] + + def _generate_deferred_entries(self): + """ + Generates the deferred entries for the invoice. + """ + self.ensure_one() + if self.state != 'posted': + return + if self.is_entry(): + raise UserError(_("You cannot generate deferred entries for a miscellaneous journal entry.")) + deferred_type = "expense" if self.is_purchase_document(include_receipts=True) else "revenue" + deferred_account = self.company_id.deferred_expense_account_id if deferred_type == "expense" else self.company_id.deferred_revenue_account_id + deferred_journal = self.company_id.deferred_expense_journal_id if deferred_type == "expense" else self.company_id.deferred_revenue_journal_id + if not deferred_journal: + raise UserError(_("Please set the deferred journal in the accounting settings.")) + if not deferred_account: + raise UserError(_("Please set the deferred accounts in the accounting settings.")) + + moves_vals_to_create = [] + lines_vals_to_create = [] + lines_periods = [] + for line in self.line_ids.filtered(lambda l: l.deferred_start_date and l.deferred_end_date): + periods = line._get_deferred_periods() + if not periods: + continue + + start_date = line.deferred_start_date + end_date = line.deferred_end_date + accounting_date = line.date + + # When using the 'full_months' computation method, every consumed month counts as a full month. + # We therefore need to subtract one month from the end date for the following check on dates. + if line.company_id.deferred_expense_amount_computation_method == 'full_months': + # We need to add one day to the end date since it's excluded by _get_deferred_diff_dates(). + if self._get_deferred_diff_dates(start_date.replace(day=1), end_date + relativedelta(days=1)) < 2: + end_date += relativedelta(months=-1) + + # When all move line dates (start, end, accounting) are within the same month, we skip the line. + # It would otherwise lead to the creation of both a reversal and a deferral move that would cancel each other out. + if start_date.replace(day=1) == end_date.replace(day=1) == accounting_date.replace(day=1): + continue + + ref = _("Deferral of %s", line.move_id.name or '') + + moves_vals_to_create.append({ + 'move_type': 'entry', + 'deferred_original_move_ids': [Command.set(line.move_id.ids)], + 'journal_id': deferred_journal.id, + 'company_id': self.company_id.id, + 'partner_id': line.partner_id.id, + 'auto_post': 'at_date', + 'ref': ref, + 'name': False, + 'date': line.move_id.date, + }) + lines_vals_to_create.append([ + self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line) + for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)] + ]) + lines_periods.append((line, periods)) + # create the deferred moves + moves_fully_deferred = self.create(moves_vals_to_create) + # We write the lines after creation, to make sure the `deferred_original_move_ids` is set. + # This way we can avoid adding taxes for deferred moves. + for move_fully_deferred, lines_vals in zip(moves_fully_deferred, lines_vals_to_create): + for line_vals in lines_vals: + # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids) + line_vals['move_id'] = move_fully_deferred.id + self.env['account.move.line'].create(list(chain(*lines_vals_to_create))) + + deferral_moves_vals = [] + deferral_moves_line_vals = [] + # Create the deferred entries for the periods [deferred_start_date, deferred_end_date] + for (line, periods), move_vals in zip(lines_periods, moves_vals_to_create): + remaining_balance = line.balance + for period_index, period in enumerate(periods): + # For the last deferral move the balance is forced to remaining balance to avoid rounding errors + force_balance = remaining_balance if period_index == len(periods) - 1 else None + deferred_amounts = self._get_deferred_amounts_by_line(line, [period], deferred_type)[0] + balance = deferred_amounts[period] if force_balance is None else force_balance + remaining_balance -= line.currency_id.round(balance) + deferral_moves_vals.append({**move_vals, 'date': period[1]}) + deferral_moves_line_vals.append([ + { + **self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, move_vals['ref'], line.analytic_distribution, line), + 'partner_id': line.partner_id.id, + 'product_id': line.product_id.id, + } + for (account, coeff) in [(deferred_amounts['account_id'], 1), (deferred_account, -1)] + ]) + + deferral_moves = self.create(deferral_moves_vals) + for deferral_move, lines_vals in zip(deferral_moves, deferral_moves_line_vals): + for line_vals in lines_vals: + # This will link the moves to the lines. Instead of move.write('line_ids': lines_ids) + line_vals['move_id'] = deferral_move.id + self.env['account.move.line'].create(list(chain(*deferral_moves_line_vals))) + + # Avoid having deferral moves with a total amount of 0. + to_unlink = deferral_moves.filtered(lambda move: move.currency_id.is_zero(move.amount_total)) + to_unlink.unlink() + + (moves_fully_deferred + deferral_moves - to_unlink)._post(soft=True) + + def open_deferred_entries(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _("Deferred Entries"), + 'res_model': 'account.move.line', + 'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)], + 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list')], + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + } + } + + def open_deferred_original_entry(self): + self.ensure_one() + action = { + 'type': 'ir.actions.act_window', + 'name': _("Original Deferred Entries"), + 'res_model': 'account.move.line', + 'domain': [('id', 'in', self.deferred_original_move_ids.line_ids.ids)], + 'views': [(False, 'list'), (False, 'form')], + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + } + } + if len(self.deferred_original_move_ids) == 1: + action.update({ + 'res_model': 'account.move', + 'res_id': self.deferred_original_move_ids[0].id, + 'views': [(False, 'form')], + }) + return action + + # ============================= END - Deferred management ====================================== + + def action_open_bank_reconciliation_widget(self): + return self.statement_line_id._action_open_bank_reconciliation_widget( + default_context={ + 'search_default_journal_id': self.statement_line_id.journal_id.id, + 'search_default_statement_line_id': self.statement_line_id.id, + 'default_st_line_id': self.statement_line_id.id, + } + ) + + def action_open_bank_reconciliation_widget_statement(self): + return self.statement_line_id._action_open_bank_reconciliation_widget( + extra_domain=[('statement_id', 'in', self.statement_id.ids)], + ) + + def action_open_business_doc(self): + if self.statement_line_id: + return self.action_open_bank_reconciliation_widget() + else: + action = super().action_open_business_doc() + # prevent propagation of the following keys + action['context'] = action.get('context', {}) | { + 'preferred_aml_value': None, + 'preferred_aml_currency_id': None, + } + return action + + def _get_mail_thread_data_attachments(self): + res = super()._get_mail_thread_data_attachments() + res += self.statement_line_id.statement_id.attachment_ids + return res + + @contextmanager + def _get_edi_creation(self): + with super()._get_edi_creation() as move: + previous_lines = move.invoice_line_ids + yield move.with_context(disable_onchange_name_predictive=True) + for line in move.invoice_line_ids - previous_lines: + line._onchange_name_predictive() + + +class AccountMoveLine(models.Model): + _name = "account.move.line" + _inherit = "account.move.line" + + move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment') + + # Deferred management fields + deferred_start_date = fields.Date( + string="Start Date", + compute='_compute_deferred_start_date', store=True, readonly=False, + index='btree_not_null', + copy=False, + help="Date at which the deferred expense/revenue starts" + ) + deferred_end_date = fields.Date( + string="End Date", + index='btree_not_null', + copy=False, + help="Date at which the deferred expense/revenue ends" + ) + has_deferred_moves = fields.Boolean(compute='_compute_has_deferred_moves') + has_abnormal_deferred_dates = fields.Boolean(compute='_compute_has_abnormal_deferred_dates') + + def _order_to_sql(self, order, query, alias=None, reverse=False): + sql_order = super()._order_to_sql(order, query, alias, reverse) + preferred_aml_residual_value = self._context.get('preferred_aml_value') + preferred_aml_currency_id = self._context.get('preferred_aml_currency_id') + if preferred_aml_residual_value and preferred_aml_currency_id and order == self._order: + currency = self.env['res.currency'].browse(preferred_aml_currency_id) + # using round since currency.round(55.55) = 55.550000000000004 + preferred_aml_residual_value = round(preferred_aml_residual_value, currency.decimal_places) + sql_residual_currency = self._field_to_sql(alias or self._table, 'amount_residual_currency', query) + sql_currency = self._field_to_sql(alias or self._table, 'currency_id', query) + return SQL( + "ROUND(%(residual_currency)s, %(decimal_places)s) = %(value)s " + "AND %(currency)s = %(currency_id)s DESC, %(order)s", + residual_currency=sql_residual_currency, + decimal_places=currency.decimal_places, + value=preferred_aml_residual_value, + currency=sql_currency, + currency_id=currency.id, + order=sql_order, + ) + return sql_order + + def copy_data(self, default=None): + data_list = super().copy_data(default=default) + for line, values in zip(self, data_list): + if 'move_reverse_cancel' in self._context: + values['deferred_start_date'] = line.deferred_start_date + values['deferred_end_date'] = line.deferred_end_date + return data_list + + def write(self, vals): + """ Prevent changing the account of a move line when there are already deferral entries. + """ + if 'account_id' in vals: + for line in self: + if ( + line.has_deferred_moves + and line.deferred_start_date + and line.deferred_end_date + and vals['account_id'] != line.account_id.id + ): + raise UserError(_( + "You cannot change the account for a deferred line in %(move_name)s if it has already been deferred.", + move_name=line.move_id.display_name + )) + return super().write(vals) + + # ============================= START - Deferred management ==================================== + def _compute_has_deferred_moves(self): + for line in self: + line.has_deferred_moves = line.move_id.deferred_move_ids + + @api.depends('deferred_start_date', 'deferred_end_date') + def _compute_has_abnormal_deferred_dates(self): + + for line in self: + line.has_abnormal_deferred_dates = ( + line.deferred_start_date + and line.deferred_end_date + and float_compare( + self.env['account.move']._get_deferred_diff_dates(line.deferred_start_date, line.deferred_end_date + relativedelta(days=1)) % 1, # end date is included + 1 / 30, + precision_digits=2 + ) == 0 + ) + + def _has_deferred_compatible_account(self): + self.ensure_one() + return ( + self.move_id.is_purchase_document(include_receipts=True) + and + self.account_id.account_type in ('expense', 'expense_depreciation', 'expense_direct_cost') + ) or ( + self.move_id.is_sale_document(include_receipts=True) + and + self.account_id.account_type in ('income', 'income_other') + ) + + @api.onchange('deferred_start_date') + def _onchange_deferred_start_date(self): + if not self._has_deferred_compatible_account(): + self.deferred_start_date = False + + @api.onchange('deferred_end_date') + def _onchange_deferred_end_date(self): + if not self._has_deferred_compatible_account(): + self.deferred_end_date = False + + @api.depends('deferred_end_date', 'move_id.invoice_date', 'move_id.state') + def _compute_deferred_start_date(self): + for line in self: + if not line.deferred_start_date and line.move_id.invoice_date and line.deferred_end_date: + line.deferred_start_date = line.move_id.invoice_date + + @api.constrains('deferred_start_date', 'deferred_end_date', 'account_id') + def _check_deferred_dates(self): + for line in self: + if line.deferred_start_date and not line.deferred_end_date: + raise UserError(_("You cannot create a deferred entry with a start date but no end date.")) + elif line.deferred_start_date and line.deferred_end_date and line.deferred_start_date > line.deferred_end_date: + raise UserError(_("You cannot create a deferred entry with a start date later than the end date.")) + + @api.model + def _get_deferred_ends_of_month(self, start_date, end_date): + + dates = [] + while start_date <= end_date: + start_date = start_date + relativedelta(day=31) # Go to end of month + dates.append(start_date) + start_date = start_date + relativedelta(days=1) # Go to first day of next month + return dates + + def _get_deferred_periods(self): + + self.ensure_one() + periods = [ + (max(self.deferred_start_date, date.replace(day=1)), min(date, self.deferred_end_date), 'current') + for date in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date) + ] + if not periods or len(periods) == 1 and periods[0][0].replace(day=1) == self.date.replace(day=1): + return [] + else: + return periods + + @api.model + def _get_deferred_amounts_by_line_values(self, line): + return { + 'account_id': line['account_id'], + # line either be a dict with ids (coming from SQL query), or a real account.move.line object + 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id, + 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id, + 'balance': line['balance'], + 'move_id': line['move_id'], + } + + @api.model + def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None): + return { + 'account_id': account_id, + # line either be a dict with ids (coming from SQL query), or a real account.move.line object + 'product_id': line['product_id'] if isinstance(line, dict) else line['product_id'].id, + 'product_category_id': line['product_category_id'] if isinstance(line, dict) else line['product_category_id'].id, + 'balance': balance, + 'name': ref, + 'analytic_distribution': analytic_distribution, + } + + + def _get_computed_taxes(self): + if self.move_id.deferred_original_move_ids: + + return self.tax_ids + return super()._get_computed_taxes() + + def _compute_attachment(self): + for record in self: + record.move_attachment_ids = self.env['ir.attachment'].search(expression.OR(record._get_attachment_domains())) + + def action_reconcile(self): + + self = self.filtered(lambda x: x.balance or x.amount_currency) # noqa: PLW0642 + if not self: + return + + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=self.ids, + ).new({}) + return wizard._action_open_wizard() if (wizard.is_write_off_required or wizard.force_partials) else wizard.reconcile() + + def _get_predict_postgres_dictionary(self): + lang = self._context.get('lang') and self._context.get('lang')[:2] + return {'fr': 'french'}.get(lang, 'english') + + @api.model + def _build_predictive_query(self, move_id, additional_domain=None): + move_query = self.env['account.move']._where_calc([ + ('move_type', '=', move_id.move_type), + ('state', '=', 'posted'), + ('partner_id', '=', move_id.partner_id.id), + ('company_id', '=', move_id.journal_id.company_id.id or self.env.company.id), + ]) + move_query.order = 'account_move.invoice_date' + move_query.limit = int(self.env["ir.config_parameter"].sudo().get_param( + "account.bill.predict.history.limit", + '100', + )) + return self.env['account.move.line']._where_calc([ + ('move_id', 'in', move_query), + ('display_type', '=', 'product'), + ] + (additional_domain or [])) + + def _predicted_field(self, name, partner_id, field, query=None, additional_queries=None): + + if not name or not partner_id: + return False + + psql_lang = self._get_predict_postgres_dictionary() + description = name + ' account_move_line' # give more priority to main query than additional queries + move_id = self.env.context.get('predicted_field_move', self.move_id) + parsed_description = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", description) + parsed_description = ' | '.join(parsed_description.split()) + + try: + main_source = (query if query is not None else self._build_predictive_query(move_id)).select( + SQL("%s AS prediction", field), + SQL( + "setweight(to_tsvector(%s, account_move_line.name), 'B') || setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document", + psql_lang + ), + ) + if "(" in field.code: # aggregate function + main_source = SQL("%s %s", main_source, SQL("GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id")) + + self.env.cr.execute(SQL(""" + WITH account_move_line AS MATERIALIZED (%(account_move_line)s), + + source AS (%(source)s), + + ranking AS ( + SELECT prediction, ts_rank(source.document, query_plain) AS rank + FROM source, to_tsquery(%(lang)s, %(description)s) query_plain + WHERE source.document @@ query_plain + ) + + SELECT prediction, MAX(rank) AS ranking, COUNT(*) + FROM ranking + GROUP BY prediction + ORDER BY ranking DESC, count DESC + LIMIT 2 + """, + account_move_line=self._build_predictive_query(move_id).select(SQL('*')), + source=SQL('(%s)', SQL(') UNION ALL (').join([main_source] + (additional_queries or []))), + lang=psql_lang, + description=parsed_description, + )) + result = self.env.cr.dictfetchall() + if result: + # Only confirm the prediction if it's at least 10% better than the second one + if len(result) > 1 and result[0]['ranking'] < 1.1 * result[1]['ranking']: + return False + return result[0]['prediction'] + except Exception: + + _logger.exception('Error while predicting invoice line fields') + return False + + def _predict_taxes(self): + field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)') + query = self._build_predictive_query(self.move_id) + query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel') + query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids') + query.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE') + predicted_tax_ids = self._predicted_field(self.name, self.partner_id, field, query) + if predicted_tax_ids == [None]: + return False + if predicted_tax_ids is not False and set(predicted_tax_ids) != set(self.tax_ids.ids): + return predicted_tax_ids + return False + + @api.model + def _predict_specific_tax(self, move, name, partner, amount_type, amount, type_tax_use): + field = SQL('array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)') + query = self._build_predictive_query(move) + query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel') + query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids') + query.add_where(""" + account_move_line__tax_rel__tax_ids.active IS NOT FALSE + AND account_move_line__tax_rel__tax_ids.amount_type = %s + AND account_move_line__tax_rel__tax_ids.type_tax_use = %s + AND account_move_line__tax_rel__tax_ids.amount = %s + """, (amount_type, type_tax_use, amount)) + return self.with_context(predicted_field_move=move)._predicted_field(name, partner, field, query) + + def _predict_product(self): + predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1')) + if predict_product and self.company_id.predict_bill_product: + query = self._build_predictive_query(self.move_id, ['|', ('product_id', '=', False), ('product_id.active', '=', True)]) + predicted_product_id = self._predicted_field(self.name, self.partner_id, SQL('account_move_line.product_id'), query) + if predicted_product_id and predicted_product_id != self.product_id.id: + return predicted_product_id + return False + + def _predict_account(self): + field = SQL('account_move_line.account_id') + if self.move_id.is_purchase_document(True): + excluded_group = 'income' + else: + excluded_group = 'expense' + account_query = self.env['account.account']._where_calc([ + *self.env['account.account']._check_company_domain(self.move_id.company_id or self.env.company), + ('deprecated', '=', False), + ('internal_group', 'not in', (excluded_group, 'off')), + ('account_type', 'not in', ('liability_payable', 'asset_receivable')), + ]) + account_name = self.env['account.account']._field_to_sql('account_account', 'name') + psql_lang = self._get_predict_postgres_dictionary() + additional_queries = [SQL(account_query.select( + SQL("account_account.id AS account_id"), + SQL("setweight(to_tsvector(%(psql_lang)s, %(account_name)s), 'B') AS document", psql_lang=psql_lang, account_name=account_name), + ))] + query = self._build_predictive_query(self.move_id, [('account_id', 'in', account_query)]) + + predicted_account_id = self._predicted_field(self.name, self.partner_id, field, query, additional_queries) + if predicted_account_id and predicted_account_id != self.account_id.id: + return predicted_account_id + return False + + @api.onchange('name') + def _onchange_name_predictive(self): + if ((self.move_id.quick_edit_mode or self.move_id.move_type == 'in_invoice') and self.name and self.display_type == 'product' + and not self.env.context.get('disable_onchange_name_predictive', False)): + + if not self.product_id: + predicted_product_id = self._predict_product() + if predicted_product_id: + # We only update the price_unit, tax_ids and name in case they evaluate to False + protected_fields = ['price_unit', 'tax_ids', 'name'] + to_protect = [self._fields[fname] for fname in protected_fields if self[fname]] + with self.env.protecting(to_protect, self): + self.product_id = predicted_product_id + + # In case no product has been set, the account and taxes + # will not depend on any product and can thus be predicted + if not self.product_id: + # Predict account. + predicted_account_id = self._predict_account() + if predicted_account_id: + self.account_id = predicted_account_id + + # Predict taxes + predicted_tax_ids = self._predict_taxes() + if predicted_tax_ids: + self.tax_ids = [Command.set(predicted_tax_ids)] + + def _read_group_select(self, aggregate_spec, query): + # Enable to use HAVING clause that sum rounded values depending on the + # currency precision settings. Limitation: we only handle a having + # clause of one element with that specific method :sum_rounded. + fname, __, func = models.parse_read_group_spec(aggregate_spec) + if func != 'sum_rounded': + return super()._read_group_select(aggregate_spec, query) + currency_alias = query.make_alias(self._table, 'currency_id') + query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL( + "%s = %s", + self._field_to_sql(self._table, 'currency_id', query), + SQL.identifier(currency_alias, 'id'), + )) + + return SQL( + 'SUM(ROUND(%s, %s))', + self._field_to_sql(self._table, fname, query), + self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query), + ) + + def _read_group_groupby(self, groupby_spec, query): + # enable grouping by :abs_rounded on fields, which is useful when trying + # to match positive and negative amounts + if ':' in groupby_spec: + fname, method = groupby_spec.split(':') + if method == 'abs_rounded': + # rounds with the used currency settings + currency_alias = query.make_alias(self._table, 'currency_id') + query.add_join('LEFT JOIN', currency_alias, 'res_currency', SQL( + "%s = %s", + self._field_to_sql(self._table, 'currency_id', query), + SQL.identifier(currency_alias, 'id'), + )) + + return SQL( + 'ROUND(ABS(%s), %s)', + self._field_to_sql(self._table, fname, query), + self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query), + ) + + return super()._read_group_groupby(groupby_spec, query) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py b/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py new file mode 100644 index 0000000..d441aca --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_payment.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +import ast +from odoo import models, _ + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + def action_open_manual_reconciliation_widget(self): + + self.ensure_one() + action_values = self.env['ir.actions.act_window']._for_xml_id('odex30_account_accountant.action_move_line_posted_unreconciled') + if self.partner_id: + context = ast.literal_eval(action_values['context']) + context.update({'search_default_partner_id': self.partner_id.id}) + if self.partner_type == 'customer': + context.update({'search_default_trade_receivable': 1}) + elif self.partner_type == 'supplier': + context.update({'search_default_trade_payable': 1}) + action_values['context'] = context + return action_values + + def button_open_statement_lines(self): + + self.ensure_one() + + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=[('id', 'in', self.reconciled_statement_line_ids.ids)], + default_context={ + 'create': False, + 'default_st_line_id': self.reconciled_statement_line_ids.ids[-1], + }, + name=_("Matched Transactions") + ) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py new file mode 100644 index 0000000..7b0652d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model.py @@ -0,0 +1,485 @@ +from odoo import fields, models, Command, tools +from odoo.tools import SQL + +import re +from collections import defaultdict +from dateutil.relativedelta import relativedelta + + +class AccountReconcileModel(models.Model): + _inherit = 'account.reconcile.model' + + #################################################### + # RECONCILIATION PROCESS + #################################################### + + def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line): + + self.ensure_one() + currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id + vals_list = [] + for line in self.line_ids: + vals = line._apply_in_bank_widget(residual_amount_currency, partner, st_line) + amount_currency = vals['amount_currency'] + + if currency.is_zero(amount_currency): + continue + + vals_list.append(vals) + residual_amount_currency -= amount_currency + + return vals_list + + + def _apply_rules(self, st_line, partner): + + available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted() + + for rec_model in available_models: + + if not rec_model._is_applicable_for(st_line, partner): + continue + + if rec_model.rule_type == 'invoice_matching': + rules_map = rec_model._get_invoice_matching_rules_map() + for rule_index in sorted(rules_map.keys()): + for rule_method in rules_map[rule_index]: + candidate_vals = rule_method(st_line, partner) + if not candidate_vals: + continue + + if candidate_vals.get('amls'): + res = rec_model._get_invoice_matching_amls_result(st_line, partner, candidate_vals) + if res: + return { + **res, + 'model': rec_model, + } + else: + return { + **candidate_vals, + 'model': rec_model, + } + + elif rec_model.rule_type == 'writeoff_suggestion': + return { + 'model': rec_model, + 'status': 'write_off', + 'auto_reconcile': rec_model.auto_reconcile, + } + return {} + + def _is_applicable_for(self, st_line, partner): + + self.ensure_one() + + # Filter on journals, amount nature, amount and partners + # All the conditions defined in this block are non-match conditions. + if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids) + or (self.match_nature == 'amount_received' and st_line.amount < 0) + or (self.match_nature == 'amount_paid' and st_line.amount > 0) + or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max) + or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min) + or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min)) + or (self.match_partner and not partner) + or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids) + or (self.match_partner and self.match_partner_category_ids and not (partner.category_id & self.match_partner_category_ids)) + ): + return False + + # Filter on label, note and transaction_type + for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]: + rule_term = (self['match_' + rule_field + '_param'] or '').lower() + record_term = (record[record_field] or '').lower() + + # This defines non-match conditions + if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term) + or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term) + or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term)) + ): + return False + + return True + + def _get_invoice_matching_amls_domain(self, st_line, partner): + aml_domain = st_line._get_default_amls_matching_domain() + + if st_line.amount > 0.0: + aml_domain.append(('balance', '>', 0.0)) + else: + aml_domain.append(('balance', '<', 0.0)) + + currency = st_line.foreign_currency_id or st_line.currency_id + if self.match_same_currency: + aml_domain.append(('currency_id', '=', currency.id)) + + if partner: + aml_domain.append(('partner_id', '=', partner.id)) + + if self.past_months_limit: + date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit) + aml_domain.append(('date', '>=', fields.Date.to_string(date_limit))) + + return aml_domain + + def _get_st_line_text_values_for_matching(self, st_line): + + self.ensure_one() + allowed_fields = [] + if self.match_text_location_label: + allowed_fields.append('payment_ref') + if self.match_text_location_note: + allowed_fields.append('narration') + if self.match_text_location_reference: + allowed_fields.append('ref') + return st_line._get_st_line_strings_for_matching(allowed_fields=allowed_fields) + + def _get_invoice_matching_st_line_tokens(self, st_line): + + st_line_text_values = self._get_st_line_text_values_for_matching(st_line) + significant_token_size = 4 + numerical_tokens = [] + exact_tokens = set() # preventing duplicates + text_tokens = [] + for text_value in st_line_text_values: + split_text = (text_value or '').split() + # Exact tokens + exact_tokens.add(text_value) + exact_tokens.update( + token for token in split_text + if len(token) >= significant_token_size + ) + # Text tokens + tokens = [ + ''.join(x for x in token if re.match(r'[0-9a-zA-Z\s]', x)) + for token in split_text + ] + + # Numerical tokens + for token in tokens: + # The token is too short to be significant. + if len(token) < significant_token_size: + continue + + text_tokens.append(token) + + formatted_token = ''.join(x for x in token if x.isdecimal()) + + # The token is too short after formatting to be significant. + if len(formatted_token) < significant_token_size: + continue + + numerical_tokens.append(formatted_token) + + return numerical_tokens, list(exact_tokens), text_tokens + + def _get_invoice_matching_amls_candidates(self, st_line, partner): + + def get_order_by_clause(prefix=SQL()): + direction = SQL(' DESC') if self.matching_order == 'new_first' else SQL(' ASC') + return SQL(", ").join( + SQL("%s%s%s", prefix, SQL(field), direction) + for field in ('date_maturity', 'date', 'id') + ) + + assert self.rule_type == 'invoice_matching' + self.env['account.move'].flush_model() + self.env['account.move.line'].flush_model() + + aml_domain = self._get_invoice_matching_amls_domain(st_line, partner) + query = self.env['account.move.line']._where_calc(aml_domain) + tables = query.from_clause + where_clause = query.where_clause or SQL("TRUE") + + aml_cte = SQL() + sub_queries: list[SQL] = [] + numerical_tokens, exact_tokens, _text_tokens = self._get_invoice_matching_st_line_tokens(st_line) + if numerical_tokens or exact_tokens: + aml_cte = SQL(''' + WITH aml_cte AS ( + SELECT + account_move_line.id as account_move_line_id, + account_move_line.date as account_move_line_date, + account_move_line.date_maturity as account_move_line_date_maturity, + account_move_line.name as account_move_line_name, + account_move_line__move_id.name as account_move_line__move_id_name, + account_move_line__move_id.ref as account_move_line__move_id_ref + FROM %s + JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id + WHERE %s + ) + ''', tables, where_clause) + if numerical_tokens: + for table_alias, field in ( + ('account_move_line', 'name'), + ('account_move_line__move_id', 'name'), + ('account_move_line__move_id', 'ref'), + ): + sub_queries.append(SQL(r''' + SELECT + account_move_line_id as id, + account_move_line_date as date, + account_move_line_date_maturity as date_maturity, + UNNEST( + REGEXP_SPLIT_TO_ARRAY( + SUBSTRING( + REGEXP_REPLACE(%(field)s, '[^0-9\s]', '', 'g'), + '\S(?:.*\S)*' + ), + '\s+' + ) + ) AS token + FROM aml_cte + WHERE %(field)s IS NOT NULL + ''', field=SQL("%s_%s", SQL(table_alias), SQL(field)))) + if exact_tokens: + for table_alias, field in ( + ('account_move_line', 'name'), + ('account_move_line__move_id', 'name'), + ('account_move_line__move_id', 'ref'), + ): + sub_queries.append(SQL(''' + SELECT + account_move_line_id as id, + account_move_line_date as date, + account_move_line_date_maturity as date_maturity, + %(field)s AS token + FROM aml_cte + WHERE %(field)s != '' + ''', field=SQL("%s_%s", SQL(table_alias), SQL(field)))) + if sub_queries: + order_by = get_order_by_clause(prefix=SQL('sub.')) + candidate_ids = [r[0] for r in self.env.execute_query(SQL( + ''' + %s + SELECT + sub.id, + COUNT(*) AS nb_match + FROM (%s) AS sub + WHERE sub.token IN %s + GROUP BY sub.date_maturity, sub.date, sub.id + HAVING COUNT(*) > 0 + ORDER BY nb_match DESC, %s + ''', + aml_cte, + SQL(" UNION ALL ").join(sub_queries), + tuple(numerical_tokens + exact_tokens), + order_by, + ))] + if candidate_ids: + return { + 'allow_auto_reconcile': True, + 'amls': self.env['account.move.line'].browse(candidate_ids), + } + elif self.match_text_location_label or self.match_text_location_note or self.match_text_location_reference: + # In the case any of the Label, Note or Reference matching rule has been toggled, and the query didn't return + # any candidates, the model should not try to mount another aml instead. + return + + if not partner: + st_line_currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id + if st_line_currency == self.company_id.currency_id: + aml_amount_field = SQL('amount_residual') + else: + aml_amount_field = SQL('amount_residual_currency') + + order_by = get_order_by_clause(prefix=SQL('account_move_line.')) + rows = self.env.execute_query(SQL( + ''' + SELECT account_move_line.id + FROM %s + WHERE + %s + AND account_move_line.currency_id = %s + AND ROUND(account_move_line.%s, %s) = ROUND(%s, %s) + ORDER BY %s + ''', + tables, + where_clause, + st_line_currency.id, + aml_amount_field, + st_line_currency.decimal_places, + -st_line.amount_residual, + st_line_currency.decimal_places, + order_by, + )) + amls = self.env['account.move.line'].browse([row[0] for row in rows]) + else: + amls = self.env['account.move.line'].search(aml_domain, order=get_order_by_clause().code) + + if amls: + return { + 'allow_auto_reconcile': False, + 'amls': amls, + } + + def _get_invoice_matching_rules_map(self): + + rules_map = defaultdict(list) + rules_map[10].append(self._get_invoice_matching_amls_candidates) + return rules_map + + def _get_partner_from_mapping(self, st_line): + + self.ensure_one() + + if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'): + return self.env['res.partner'] + + for partner_mapping in self.partner_mapping_line_ids: + match_payment_ref = True + if partner_mapping.payment_ref_regex: + match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if st_line.payment_ref else False + + match_narration = True + if partner_mapping.narration_regex: + match_narration = re.match( + partner_mapping.narration_regex, + tools.html2plaintext(st_line.narration or '').rstrip(), + flags=re.DOTALL, # Ignore '/n' set by online sync. + ) + + if match_payment_ref and match_narration: + return partner_mapping.partner_id + return self.env['res.partner'] + + def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals): + def _create_result_dict(amls_values_list, status): + if 'rejected' in status: + return + + result = {'amls': self.env['account.move.line']} + for aml_values in amls_values_list: + result['amls'] |= aml_values['aml'] + + if 'allow_write_off' in status and self.line_ids: + result['status'] = 'write_off' + + if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile: + result['auto_reconcile'] = True + + return result + + st_line_currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency'] + sign = 1 if st_line_amount > 0.0 else -1 + + amls = candidate_vals['amls'] + amls_values_list = [] + amls_with_epd_values_list = [] + same_currency_mode = amls.currency_id == st_line_currency + for aml in amls: + aml_values = { + 'aml': aml, + 'amount_residual': aml.amount_residual, + 'amount_residual_currency': aml.amount_residual_currency, + } + + amls_values_list.append(aml_values) + + # Manage the early payment discount. + if aml.move_id._is_eligible_for_early_payment_discount(st_line_currency, st_line.date): + + rate = abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0 + amls_with_epd_values_list.append({ + **aml_values, + 'amount_residual': st_line.company_currency_id.round(aml.discount_amount_currency / rate), + 'amount_residual_currency': aml.discount_amount_currency, + }) + else: + amls_with_epd_values_list.append(aml_values) + + def match_batch_amls(amls_values_list): + if not same_currency_mode: + return None, [] + + kepts_amls_values_list = [] + sum_amount_residual_currency = 0.0 + for aml_values in amls_values_list: + + if st_line_currency.compare_amounts(st_line_amount, -aml_values['amount_residual_currency']) == 0: + # Special case: the amounts are the same, submit the line directly. + return 'perfect', [aml_values] + + if st_line_currency.compare_amounts(sign * (st_line_amount + sum_amount_residual_currency), 0.0) > 0: + # Here, we still have room for other candidates ; so we add the current one to the list we keep. + # Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates + # is an exact match, which would then be preferred on the current candidates. + kepts_amls_values_list.append(aml_values) + sum_amount_residual_currency += aml_values['amount_residual_currency'] + + if st_line_currency.is_zero(sign * (st_line_amount + sum_amount_residual_currency)): + return 'perfect', kepts_amls_values_list + elif kepts_amls_values_list: + return 'partial', kepts_amls_values_list + else: + return None, [] + + # Try to match a batch with the early payment feature. Only a perfect match is allowed. + match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list) + if match_type != 'perfect': + kepts_amls_values_list = [] + + # Try to match the amls having the same currency as the statement line. + if not kepts_amls_values_list: + _match_type, kepts_amls_values_list = match_batch_amls(amls_values_list) + + # Try to match the whole candidates. + if not kepts_amls_values_list: + kepts_amls_values_list = amls_values_list + + # Try to match the amls having the same currency as the statement line. + if kepts_amls_values_list: + status = self._check_rule_propositions(st_line, kepts_amls_values_list) + result = _create_result_dict(kepts_amls_values_list, status) + if result: + return result + + def _check_rule_propositions(self, st_line, amls_values_list): + + self.ensure_one() + + if not self.allow_payment_tolerance: + return {'allow_write_off', 'allow_auto_reconcile'} + + st_line_currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency'] + amls_amount_curr = sum( + st_line._prepare_counterpart_amounts_using_st_line_rate( + aml_values['aml'].currency_id, + aml_values['amount_residual'], + aml_values['amount_residual_currency'], + )['amount_currency'] + for aml_values in amls_values_list + ) + sign = 1 if st_line_amount_curr > 0.0 else -1 + amount_curr_after_rec = st_line_currency.round( + sign * (amls_amount_curr + st_line_amount_curr) + ) + + if st_line_currency.is_zero(amount_curr_after_rec): + return {'allow_auto_reconcile'} + + + if amount_curr_after_rec > 0.0: + return {'allow_auto_reconcile'} + + if self.payment_tolerance_param == 0: + return {'rejected'} + + + if self.payment_tolerance_type == 'fixed_amount' and st_line_currency.compare_amounts(-amount_curr_after_rec, self.payment_tolerance_param) <= 0: + return {'allow_write_off', 'allow_auto_reconcile'} + + reconciled_percentage_left = (abs(amount_curr_after_rec / amls_amount_curr)) * 100.0 + if self.payment_tolerance_type == 'percentage' and st_line_currency.compare_amounts(reconciled_percentage_left, self.payment_tolerance_param) <= 0: + return {'allow_write_off', 'allow_auto_reconcile'} + + return {'rejected'} + + def run_auto_reconciliation(self): + + + cron_limit_time = tools.config['limit_time_real_cron'] or -1 + limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180 + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(limit_time=limit_time) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py new file mode 100644 index 0000000..4be6936 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_reconcile_model_line.py @@ -0,0 +1,117 @@ +from odoo import models, Command, _ +from odoo.exceptions import UserError + +import re + +from math import copysign + + +class AccountReconcileModelLine(models.Model): + _inherit = 'account.reconcile.model.line' + + def _prepare_aml_vals(self, partner): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line. + + :param partner: The partner to be linked to the journal item. + :return: A python dictionary. + """ + self.ensure_one() + + taxes = self.tax_ids + if taxes and partner: + fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(partner) + if fiscal_position: + taxes = fiscal_position.map_tax(taxes) + + values = { + 'name': self.label, + 'partner_id': partner.id, + 'analytic_distribution': self.analytic_distribution, + 'tax_ids': [Command.set(taxes.ids)], + 'reconcile_model_id': self.model_id.id, + } + if self.account_id: + values['account_id'] = self.account_id.id + return values + + def _apply_in_manual_widget(self, residual_amount_currency, partner, currency): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line used by the manual reconciliation widget. + + Note: 'journal_id' is added to the returned dictionary even if it is a related readonly field. + It's a hack for the manual reconciliation widget. Indeed, a single journal entry will be created for each + journal. + + :param residual_amount_currency: The current balance expressed in the account's currency. + :param partner: The partner to be linked to the journal item. + :param currency: The currency set on the account in the manual reconciliation widget. + :return: A python dictionary. + """ + self.ensure_one() + + if self.amount_type == 'percentage': + amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0)) + elif self.amount_type == 'fixed': + sign = 1 if residual_amount_currency > 0.0 else -1 + amount_currency = currency.round(self.amount * sign) + else: + raise UserError(_("This reconciliation model can't be used in the manual reconciliation widget because its " + "configuration is not adapted")) + + return { + **self._prepare_aml_vals(partner), + 'currency_id': currency.id, + 'amount_currency': amount_currency, + 'journal_id': self.journal_id.id, + } + + def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line used by the bank reconciliation widget. + + :param residual_amount_currency: The current balance expressed in the statement line's currency. + :param partner: The partner to be linked to the journal item. + :param st_line: The statement line mounted inside the bank reconciliation widget. + :return: A python dictionary. + """ + self.ensure_one() + currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id + + aml_values = {'currency_id': currency.id} + + if self.amount_type == 'percentage_st_line': + transaction_amount, transaction_currency, journal_amount, journal_currency, _company_amount, _company_currency \ + = st_line._get_accounting_amounts_and_currencies() + if self.model_id.rule_type == 'writeoff_button' and self.model_id.counterpart_type in ('sale', 'purchase'): + # The invoice should be created using the transaction currency. + aml_values['amount_currency'] = currency.round(-transaction_amount * self.amount / 100.0) + aml_values['percentage_st_line'] = self.amount / 100.0 + aml_values['currency_id'] = transaction_currency.id + else: + # The additional journal items follow the journal currency. + aml_values['amount_currency'] = currency.round(-journal_amount * self.amount / 100.0) + aml_values['currency_id'] = journal_currency.id + elif self.amount_type == 'regex': + match = re.search(self.amount_string, st_line.payment_ref) + if match: + sign = 1 if residual_amount_currency > 0.0 else -1 + decimal_separator = self.model_id.decimal_separator + try: + extracted_match_group = re.sub(r'[^\d' + decimal_separator + ']', '', match.group(1)) + extracted_balance = float(extracted_match_group.replace(decimal_separator, '.')) + aml_values['amount_currency'] = copysign(extracted_balance * sign, residual_amount_currency) + except ValueError: + aml_values['amount_currency'] = 0.0 + else: + aml_values['amount_currency'] = 0.0 + + if 'amount_currency' not in aml_values: + aml_values.update(self._apply_in_manual_widget(residual_amount_currency, partner, currency)) + else: + aml_values.update(self._prepare_aml_vals(partner)) + + if not aml_values.get('name', False): + aml_values['name'] = st_line.payment_ref + + return aml_values diff --git a/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py b/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py new file mode 100644 index 0000000..5e3dca9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/account_tax.py @@ -0,0 +1,52 @@ +from odoo import models + + +class AccountTax(models.Model): + _inherit = "account.tax" + + def _prepare_base_line_for_taxes_computation(self, record, **kwargs): + # EXTENDS 'account' + results = super()._prepare_base_line_for_taxes_computation(record, **kwargs) + results['deferred_start_date'] = self._get_base_line_field_value_from_record(record, 'deferred_start_date', kwargs, False) + results['deferred_end_date'] = self._get_base_line_field_value_from_record(record, 'deferred_end_date', kwargs, False) + return results + + def _prepare_tax_line_for_taxes_computation(self, record, **kwargs): + # EXTENDS 'account' + results = super()._prepare_tax_line_for_taxes_computation(record, **kwargs) + results['deferred_start_date'] = self._get_base_line_field_value_from_record(record, 'deferred_start_date', kwargs, False) + results['deferred_end_date'] = self._get_base_line_field_value_from_record(record, 'deferred_end_date', kwargs, False) + return results + + def _prepare_base_line_grouping_key(self, base_line): + # EXTENDS 'account' + results = super()._prepare_base_line_grouping_key(base_line) + results['deferred_start_date'] = base_line['deferred_start_date'] + results['deferred_end_date'] = base_line['deferred_end_date'] + return results + + def _prepare_base_line_tax_repartition_grouping_key(self, base_line, base_line_grouping_key, tax_data, tax_rep_data): + # EXTENDS 'account' + results = super()._prepare_base_line_tax_repartition_grouping_key(base_line, base_line_grouping_key, tax_data, tax_rep_data) + record = base_line['record'] + if ( + isinstance(record, models.Model) + and record._name == 'account.move.line' + and record._has_deferred_compatible_account() + and base_line['deferred_start_date'] + and base_line['deferred_end_date'] + and not tax_rep_data['tax_rep'].use_in_tax_closing + ): + results['deferred_start_date'] = base_line['deferred_start_date'] + results['deferred_end_date'] = base_line['deferred_end_date'] + else: + results['deferred_start_date'] = False + results['deferred_end_date'] = False + return results + + def _prepare_tax_line_repartition_grouping_key(self, tax_line): + # EXTENDS 'account' + results = super()._prepare_tax_line_repartition_grouping_key(tax_line) + results['deferred_start_date'] = tax_line['deferred_start_date'] + results['deferred_end_date'] = tax_line['deferred_end_date'] + return results diff --git a/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py new file mode 100644 index 0000000..3e4fa8d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget.py @@ -0,0 +1,1785 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict +from contextlib import contextmanager +import json +import markupsafe + +from odoo import _, api, fields, models, Command +from odoo.addons.web.controllers.utils import clean_action +from odoo.exceptions import UserError, RedirectWarning +from odoo.tools.misc import formatLang + + +class BankRecWidget(models.Model): + _name = "bank.rec.widget" + _description = "Bank reconciliation widget for a single statement line" + + # This model is never saved inside the database. + # _auto=False' & _table_query = "0" prevent the ORM to create the corresponding postgresql table. + _auto = False + _table_query = "0" + + # ==== Business fields ==== + st_line_id = fields.Many2one(comodel_name='account.bank.statement.line') + move_id = fields.Many2one( + related='st_line_id.move_id', + depends=['st_line_id'], + ) + st_line_checked = fields.Boolean( + related='st_line_id.move_id.checked', + depends=['st_line_id'], + ) + st_line_is_reconciled = fields.Boolean( + related='st_line_id.is_reconciled', + depends=['st_line_id'], + ) + st_line_journal_id = fields.Many2one( + related='st_line_id.journal_id', + depends=['st_line_id'], + ) + st_line_transaction_details = fields.Html( + compute='_compute_st_line_transaction_details', + ) + transaction_currency_id = fields.Many2one( + comodel_name='res.currency', + compute='_compute_transaction_currency_id', + ) + journal_currency_id = fields.Many2one( + comodel_name='res.currency', + compute='_compute_journal_currency_id', + ) + partner_id = fields.Many2one( + comodel_name='res.partner', + string="Partner", + compute='_compute_partner_id', + store=True, + readonly=False, + ) + line_ids = fields.One2many( + comodel_name='bank.rec.widget.line', + inverse_name='wizard_id', + compute='_compute_line_ids', + compute_sudo=False, + store=True, + readonly=False, + ) + available_reco_model_ids = fields.Many2many( + comodel_name='account.reconcile.model', + compute='_compute_available_reco_model_ids', + store=True, + readonly=False, + ) + selected_reco_model_id = fields.Many2one( + comodel_name='account.reconcile.model', + compute='_compute_selected_reco_model_id', + ) + partner_name = fields.Char( + related='st_line_id.partner_name', + ) + + company_id = fields.Many2one( + comodel_name='res.company', + related='st_line_id.company_id', + depends=['st_line_id'], + ) + + country_code = fields.Char(related='company_id.country_id.code', depends=['company_id']) + + company_currency_id = fields.Many2one( + string="Wizard Company Currency", + related='company_id.currency_id', + depends=['st_line_id'], + ) + matching_rules_allow_auto_reconcile = fields.Boolean() + + # ==== Display fields ==== + state = fields.Selection( + selection=[ + ('invalid', "Invalid"), + ('valid', "Valid"), + ('reconciled', "Reconciled"), + ], + compute='_compute_state', + store=True, + help="Invalid: The bank transaction can't be validate since the suspense account is still involved\n" + "Valid: The bank transaction can be validated.\n" + "Reconciled: The bank transaction has already been processed. Nothing left to do." + ) + is_multi_currency = fields.Boolean( + compute='_compute_is_multi_currency', + ) + + # ==== JS fields ==== + selected_aml_ids = fields.Many2many( + comodel_name='account.move.line', + compute='_compute_selected_aml_ids', + ) + todo_command = fields.Json( + store=False, + ) + return_todo_command = fields.Json( + store=False, + ) + form_index = fields.Char() + + + @api.depends('st_line_id') + def _compute_line_ids(self): + for wizard in self: + if wizard.st_line_id: + + # Liquidity line. + line_ids_commands = [ + Command.clear(), + Command.create(wizard._lines_prepare_liquidity_line()), + ] + + _liquidity_lines, _suspense_lines, other_lines = wizard.st_line_id._seek_for_lines() + for aml in other_lines: + exchange_diff_amls = (aml.matched_debit_ids + aml.matched_credit_ids) \ + .exchange_move_id.line_ids.filtered(lambda l: l.account_id != aml.account_id) + if wizard.state == 'reconciled' and exchange_diff_amls: + line_ids_commands.append( + Command.create(wizard._lines_prepare_aml_line( + aml, # Create the aml line with un-squashed amounts (aml - exchange diff) + balance=aml.balance - sum(exchange_diff_amls.mapped('balance')), + amount_currency=aml.amount_currency - sum(exchange_diff_amls.mapped('amount_currency')), + )) + ) + for exchange_diff_aml in exchange_diff_amls: + line_ids_commands.append( + Command.create(wizard._lines_prepare_aml_line(exchange_diff_aml)) + ) + else: + line_ids_commands.append(Command.create(wizard._lines_prepare_aml_line(aml))) + + wizard.line_ids = line_ids_commands + + wizard._lines_add_auto_balance_line() + + else: + + wizard.line_ids = [Command.clear()] + + @api.depends('st_line_id') + def _compute_available_reco_model_ids(self): + for wizard in self: + if wizard.st_line_id: + available_reco_models = self.env['account.reconcile.model'].search([ + ('rule_type', '=', 'writeoff_button'), + ('company_id', '=', wizard.st_line_id.company_id.id), + '|', + ('match_journal_ids', '=', False), + ('match_journal_ids', '=', wizard.st_line_id.journal_id.id), + ]) + available_reco_models = available_reco_models.filtered( + lambda x: x.counterpart_type == 'general' + or len(x.line_ids.journal_id) <= 1 + ) + wizard.available_reco_model_ids = [Command.set(available_reco_models.ids)] + else: + wizard.available_reco_model_ids = [Command.clear()] + + @api.depends('line_ids.reconcile_model_id') + def _compute_selected_reco_model_id(self): + for wizard in self: + selected_reconcile_models = wizard.line_ids.reconcile_model_id.filtered(lambda x: x.rule_type == 'writeoff_button') + if len(selected_reconcile_models) == 1: + wizard.selected_reco_model_id = selected_reconcile_models.id + else: + wizard.selected_reco_model_id = None + + @api.depends('st_line_id', 'line_ids.account_id') + def _compute_state(self): + for wizard in self: + if not wizard.st_line_id: + wizard.state = 'invalid' + elif wizard.st_line_id.is_reconciled: + wizard.state = 'reconciled' + else: + suspense_account = wizard.st_line_id.journal_id.suspense_account_id + if suspense_account in wizard.line_ids.account_id: + wizard.state = 'invalid' + else: + wizard.state = 'valid' + + @api.depends('st_line_id') + def _compute_journal_currency_id(self): + for wizard in self: + wizard.journal_currency_id = wizard.st_line_id.journal_id.currency_id \ + or wizard.st_line_id.journal_id.company_id.currency_id + + def _format_transaction_details(self): + """ Format the 'transaction_details' field of the statement line to be more readable for the end user. + + Example: + { + "debtor": { + "name": None, + "private_id": None, + }, + "debtor_account": { + "iban": "BE84103080286059", + "bank_transaction_code": None, + "credit_debit_indicator": "DBIT", + "status": "BOOK", + "value_date": "2022-12-29", + "transaction_date": None, + "balance_after_transaction": None, + }, + } + + Becomes: + debtor_account: + iban: BE84103080286059 + credit_debit_indicator: DBIT + status: BOOK + value_date: 2022-12-29 + + :return: An html representation of the transaction details. + """ + self.ensure_one() + details = self.st_line_id.transaction_details + if not details: + return + + if isinstance(details, str): + details = json.loads(details, strict=False) + + def node_to_html(header, node): + if not node: + return "" + + if isinstance(node, dict): + li_elements = markupsafe.Markup("").join(node_to_html(f"{k}: ", v) for k, v in node.items()) + value = li_elements and markupsafe.Markup('
    %s
') % li_elements + elif isinstance(node, (tuple, list)): + li_elements = markupsafe.Markup("").join(node_to_html(f"{i}: ", v) for i, v in enumerate(node, start=1)) + value = li_elements and markupsafe.Markup('
    %s
') % li_elements + else: + value = node + + if not value: + return "" + + return markupsafe.Markup('
  • %(header)s%(value)s
  • ') % { + 'header': header, + 'value': value, + } + + main_html = node_to_html('', details) + return markupsafe.Markup("
      %s
    ") % main_html + + @api.depends('st_line_id') + def _compute_st_line_transaction_details(self): + for wizard in self: + wizard.st_line_transaction_details = wizard._format_transaction_details() + + @api.depends('st_line_id') + def _compute_transaction_currency_id(self): + for wizard in self: + wizard.transaction_currency_id = wizard.st_line_id.foreign_currency_id or wizard.journal_currency_id + + @api.depends('st_line_id') + def _compute_partner_id(self): + for wizard in self: + if wizard.st_line_id: + wizard.partner_id = wizard.st_line_id._retrieve_partner() + else: + wizard.partner_id = None + + @api.depends('company_id') + def _compute_is_multi_currency(self): + self.is_multi_currency = self.env.user.has_groups('base.group_multi_currency') + + @api.depends('company_id', 'line_ids.source_aml_id') + def _compute_selected_aml_ids(self): + for wizard in self: + wizard.selected_aml_ids = [Command.set(wizard.line_ids.source_aml_id.ids)] + + # ------------------------------------------------------------------------- + # ONCHANGE METHODS + # ------------------------------------------------------------------------- + + @api.onchange('todo_command') + def _onchange_todo_command(self): + self.ensure_one() + todo_command = self.todo_command + self.todo_command = None + self.return_todo_command = None + + # Ensure the lines are well loaded. + # Suppose the initial values of 'line_ids' are 2 lines, + # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case + # the field is accessed before. + self._ensure_loaded_lines() + + method_name = todo_command['method_name'] + getattr(self, f'_js_action_{method_name}')(*todo_command.get('args', []), **todo_command.get('kwargs', {})) + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model + def new(self, values=None, origin=None, ref=None): + widget = super().new(values=values, origin=origin, ref=ref) + + # Ensure the lines are well loaded. + # Suppose the initial values of 'line_ids' are 2 lines, + # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case + # the field is accessed before. + widget.line_ids + + return widget + + # ------------------------------------------------------------------------- + # INIT + # ------------------------------------------------------------------------- + + @api.model + def fetch_initial_data(self): + # Fields. + fields = self.fields_get() + field_attributes = self.env['ir.ui.view']._get_view_field_attributes() + for field_name, field in self._fields.items(): + if field.type == 'one2many': + fields[field_name]['relatedFields'] = self[field_name]\ + .fields_get(attributes=field_attributes) + del fields[field_name]['relatedFields'][field.inverse_name] + for one2many_fieldname, one2many_field in self[field_name]._fields.items(): + if one2many_field.type == "many2many": + comodel = self.env[one2many_field.comodel_name] + fields[field_name]['relatedFields'][one2many_fieldname]['relatedFields'] = comodel \ + .fields_get(allfields=['id', 'display_name'], attributes=field_attributes) + elif field.name == 'available_reco_model_ids': + fields[field_name]['relatedFields'] = self[field_name]\ + .fields_get(allfields=['id', 'display_name'], attributes=field_attributes) + + fields['todo_command']['onChange'] = True + + # Initial values. + initial_values = {} + for field_name, field in self._fields.items(): + if field.type == 'one2many': + initial_values[field_name] = [] + else: + initial_values[field_name] = field.convert_to_read(self[field_name], self, {}) + + return { + 'initial_values': initial_values, + 'fields': fields, + } + + # ------------------------------------------------------------------------- + # LINES METHODS + # ------------------------------------------------------------------------- + + def _ensure_loaded_lines(self): + # Ensure the lines are well loaded. + # Suppose the initial values of 'line_ids' are 2 lines, + # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case + # the field is accessed before. + self.line_ids + + def _lines_turn_auto_balance_into_manual_line(self, line): + # When editing an auto_balance line, it becomes a custom manual line. + if line.flag == 'auto_balance': + line.flag = 'manual' + + def _lines_get_line_in_edit_form(self): + self.ensure_one() + + if not self.form_index: + return + + return self.line_ids.filtered(lambda x: x.index == self.form_index) + + def _lines_prepare_aml_line(self, aml, **kwargs): + self.ensure_one() + return { + 'flag': 'aml', + 'source_aml_id': aml.id, + **kwargs, + } + + def _lines_prepare_liquidity_line(self): + """ Create a line corresponding to the journal item having the liquidity account on the statement line.""" + self.ensure_one() + st_line = self.st_line_id + + # In case of a different currencies on the journal and on the transaction, we need to retrieve the transaction + # amount on the suspense line because a journal item can only have one foreign currency. Indeed, in such + # configuration, the foreign currency amount expressed in journal's currency is set on the liquidity line but + # the transaction amount is on the suspense account line. + liquidity_line, _suspense_lines, _other_lines = st_line._seek_for_lines() + + return self._lines_prepare_aml_line(liquidity_line, flag='liquidity') + + def _lines_prepare_auto_balance_line(self): + """ Create the auto_balance line if necessary in order to have fully balanced lines.""" + self.ensure_one() + st_line = self.st_line_id + + # Compute the current open balance. + transaction_amount, transaction_currency, journal_amount, _journal_currency, company_amount, _company_currency \ + = self.st_line_id._get_accounting_amounts_and_currencies() + open_amount_currency = -transaction_amount + open_balance = -company_amount + for line in self.line_ids: + if line.flag in ('liquidity', 'auto_balance'): + continue + + open_balance -= line.balance + journal_transaction_rate = abs(transaction_amount / journal_amount) if journal_amount else 0.0 + company_transaction_rate = abs(transaction_amount / company_amount) if company_amount else 0.0 + if line.currency_id == self.transaction_currency_id: + open_amount_currency -= line.amount_currency + elif line.currency_id == self.journal_currency_id: + open_amount_currency -= transaction_currency.round(line.amount_currency * journal_transaction_rate) + else: + open_amount_currency -= transaction_currency.round(line.balance * company_transaction_rate) + + # Create a new auto-balance line. + account = None + partner = self.partner_id + if partner: + name = _("Open balance of %(amount)s", amount=formatLang(self.env, transaction_amount, currency_obj=transaction_currency)) + partner_is_customer = partner.customer_rank and not partner.supplier_rank + partner_is_supplier = partner.supplier_rank and not partner.customer_rank + if partner_is_customer: + account = partner.with_company(st_line.company_id).property_account_receivable_id + elif partner_is_supplier: + account = partner.with_company(st_line.company_id).property_account_payable_id + elif st_line.amount > 0: + account = partner.with_company(st_line.company_id).property_account_receivable_id + else: + account = partner.with_company(st_line.company_id).property_account_payable_id + + if not account: + name = st_line.payment_ref + account = st_line.journal_id.suspense_account_id + + return { + 'flag': 'auto_balance', + + 'account_id': account.id, + 'name': name, + 'amount_currency': open_amount_currency, + 'balance': open_balance, + } + + def _lines_add_auto_balance_line(self): + ''' Add the line auto balancing the debit/credit. ''' + + # Drop the existing line then re-create it to ensure this line is always the last one. + line_ids_commands = [] + for auto_balance_line in self.line_ids.filtered(lambda x: x.flag == 'auto_balance'): + line_ids_commands.append(Command.unlink(auto_balance_line.id)) + + # Re-create a new auto-balance line if needed. + auto_balance_line_vals = self._lines_prepare_auto_balance_line() + if not self.company_currency_id.is_zero(auto_balance_line_vals['balance']): + line_ids_commands.append(Command.create(auto_balance_line_vals)) + self.line_ids = line_ids_commands + + def _lines_prepare_new_aml_line(self, aml, **kwargs): + return self._lines_prepare_aml_line( + aml, + flag='new_aml', + currency_id=aml.currency_id.id, + amount_currency=-aml.amount_residual_currency, + balance=-aml.amount_residual, + source_amount_currency=-aml.amount_residual_currency, + source_balance=-aml.amount_residual, + source_rate=(aml.amount_currency / aml.balance) if aml.balance else 0.0, + **kwargs, + ) + + def _lines_check_partial_amount(self, line): + if line.flag != 'new_aml': + return None + + exchange_diff_line = self.line_ids\ + .filtered(lambda x: x.flag == 'exchange_diff' and x.source_aml_id == line.source_aml_id) + auto_balance_line_vals = self._lines_prepare_auto_balance_line() + + auto_balance = auto_balance_line_vals['balance'] + current_balance = line.balance + exchange_diff_line.balance + has_enough_comp_debit = self.company_currency_id.compare_amounts(auto_balance, 0) < 0 \ + and self.company_currency_id.compare_amounts(current_balance, 0) > 0 \ + and self.company_currency_id.compare_amounts(current_balance, -auto_balance) > 0 + has_enough_comp_credit = self.company_currency_id.compare_amounts(auto_balance, 0) > 0 \ + and self.company_currency_id.compare_amounts(current_balance, 0) < 0 \ + and self.company_currency_id.compare_amounts(-current_balance, auto_balance) > 0 + + auto_amount_currency = auto_balance_line_vals['amount_currency'] + current_amount_currency = line.amount_currency + has_enough_curr_debit = line.currency_id.compare_amounts(auto_amount_currency, 0) < 0 \ + and line.currency_id.compare_amounts(current_amount_currency, 0) > 0 \ + and line.currency_id.compare_amounts(current_amount_currency, -auto_amount_currency) > 0 + has_enough_curr_credit = line.currency_id.compare_amounts(auto_amount_currency, 0) > 0 \ + and line.currency_id.compare_amounts(current_amount_currency, 0) < 0 \ + and line.currency_id.compare_amounts(-current_amount_currency, auto_amount_currency) > 0 + + if line.currency_id == self.transaction_currency_id: + if has_enough_curr_debit or has_enough_curr_credit: + amount_currency_after_partial = current_amount_currency + auto_amount_currency + + # Get the bank transaction rate. + transaction_amount, _transaction_currency, _journal_amount, _journal_currency, company_amount, _company_currency \ + = self.st_line_id._get_accounting_amounts_and_currencies() + rate = abs(company_amount / transaction_amount) if transaction_amount else 0.0 + + # Compute the amounts to make a partial. + balance_after_partial = line.company_currency_id.round(amount_currency_after_partial * rate) + new_line_balance = line.company_currency_id.round(balance_after_partial * abs(line.balance) / abs(current_balance)) + exchange_diff_line_balance = balance_after_partial - new_line_balance + return { + 'exchange_diff_line': exchange_diff_line, + 'amount_currency': amount_currency_after_partial, + 'balance': new_line_balance, + 'exchange_balance': exchange_diff_line_balance, + } + elif has_enough_comp_debit or has_enough_comp_credit: + # Compute the new value for balance. + balance_after_partial = current_balance + auto_balance + + # Get the rate of the original journal item. + rate = line.source_rate + + # Compute the amounts to make a partial. + new_line_balance = line.company_currency_id.round(balance_after_partial * abs(line.balance) / abs(current_balance)) + exchange_diff_line_balance = balance_after_partial - new_line_balance + amount_currency_after_partial = line.currency_id.round(new_line_balance * rate) + return { + 'exchange_diff_line': exchange_diff_line, + 'amount_currency': amount_currency_after_partial, + 'balance': new_line_balance, + 'exchange_balance': exchange_diff_line_balance, + } + return None + + def _do_amounts_apply_for_early_payment(self, open_amount_currency, total_early_payment_discount): + return self.transaction_currency_id.compare_amounts(open_amount_currency, total_early_payment_discount) == 0 + + def _lines_check_apply_early_payment_discount(self): + """ Try to apply the early payment discount on the currently mounted journal items. + :return: True if applied, False otherwise. + """ + all_aml_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml') + + # Get the balance without the 'new_aml' lines. + auto_balance_line_vals = self._lines_prepare_auto_balance_line() + open_balance_wo_amls = auto_balance_line_vals['balance'] + sum(all_aml_lines.mapped('balance')) + open_amount_currency_wo_amls = auto_balance_line_vals['amount_currency'] + sum(all_aml_lines.mapped('amount_currency')) + + # Get the balance after adding the 'new_aml' lines but without considering the partial amounts. + open_balance = open_balance_wo_amls - sum(all_aml_lines.mapped('source_balance')) + open_amount_currency = open_amount_currency_wo_amls - sum(all_aml_lines.mapped('source_amount_currency')) + + is_same_currency = all_aml_lines.currency_id == self.transaction_currency_id + at_least_one_aml_for_early_payment = False + + early_pay_aml_values_list = [] + total_early_payment_discount = 0.0 + + for aml_line in all_aml_lines: + aml = aml_line.source_aml_id + + if aml.move_id._is_eligible_for_early_payment_discount(self.transaction_currency_id, self.st_line_id.date): + at_least_one_aml_for_early_payment = True + total_early_payment_discount += aml.amount_currency - aml.discount_amount_currency + + early_pay_aml_values_list.append({ + 'aml': aml, + 'amount_currency': aml_line.amount_currency, + 'balance': aml_line.balance, + }) + + line_ids_create_command_list = [] + is_early_payment_applied = False + + # Cleanup the existing early payment discount lines. + for line in self.line_ids.filtered(lambda x: x.flag == 'early_payment'): + line_ids_create_command_list.append(Command.unlink(line.id)) + + if is_same_currency \ + and at_least_one_aml_for_early_payment \ + and self._do_amounts_apply_for_early_payment(open_amount_currency, total_early_payment_discount): + # == Compute the early payment discount lines == + # Remove the partials on existing lines. + for aml_line in all_aml_lines: + aml_line.amount_currency = aml_line.source_amount_currency + aml_line.balance = aml_line.source_balance + + # Add the early payment lines. + early_payment_values = self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount( + early_pay_aml_values_list, + open_balance, + ) + + for vals_list in early_payment_values.values(): + for vals in vals_list: + line_ids_create_command_list.append(Command.create({ + 'flag': 'early_payment', + 'account_id': vals['account_id'], + 'date': self.st_line_id.date, + 'name': vals['name'], + 'partner_id': vals['partner_id'], + 'currency_id': vals['currency_id'], + 'amount_currency': vals['amount_currency'], + 'balance': vals['balance'], + 'analytic_distribution': vals.get('analytic_distribution'), + 'tax_ids': vals.get('tax_ids', []), + 'tax_tag_ids': vals.get('tax_tag_ids', []), + 'tax_repartition_line_id': vals.get('tax_repartition_line_id'), + 'group_tax_id': vals.get('group_tax_id'), + })) + is_early_payment_applied = True + + if line_ids_create_command_list: + self.line_ids = line_ids_create_command_list + + return is_early_payment_applied + + def _lines_check_apply_partial_matching(self): + """ Try to apply a partial matching on the currently mounted journal items. + :return: True if applied, False otherwise. + """ + all_aml_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml') + if all_aml_lines: + last_line = all_aml_lines[-1] + + # Cleanup the existing partials if not on the last line. + line_ids_commands = [] + lines_impacted = self.env['bank.rec.widget.line'] + for aml_line in all_aml_lines: + is_partial = aml_line.display_stroked_amount_currency or aml_line.display_stroked_balance + if is_partial and not aml_line.manually_modified: + line_ids_commands.append(Command.update(aml_line.id, { + 'amount_currency': aml_line.source_amount_currency, + 'balance': aml_line.source_balance, + })) + lines_impacted |= aml_line + if line_ids_commands: + self.line_ids = line_ids_commands + self._lines_recompute_exchange_diff(lines_impacted) + + # Check for a partial reconciliation. + partial_amounts = self._lines_check_partial_amount(last_line) + + if partial_amounts: + # Make a partial: an auto-balance line is no longer necessary. + last_line.amount_currency = partial_amounts['amount_currency'] + last_line.balance = partial_amounts['balance'] + exchange_line = partial_amounts['exchange_diff_line'] + if exchange_line: + exchange_line.balance = partial_amounts['exchange_balance'] + if exchange_line.currency_id == self.company_currency_id: + exchange_line.amount_currency = exchange_line.balance + return True + + return False + + def _lines_load_new_amls(self, amls, reco_model=None): + """ Create counterpart lines for the journal items passed as parameter.""" + # Create a new line for each aml. + line_ids_commands = [] + kwargs = {'reconcile_model_id': reco_model.id} if reco_model else {} + for aml in amls: + aml_line_vals = self._lines_prepare_new_aml_line(aml, **kwargs) + line_ids_commands.append(Command.create(aml_line_vals)) + + if not line_ids_commands: + return + + self.line_ids = line_ids_commands + + def _prepare_base_line_for_taxes_computation(self, line): + """ Convert the current dictionary in order to use the generic taxes computation method defined on account.tax. + :return: A python dictionary. + """ + self.ensure_one() + tax_type = line.tax_ids[0].type_tax_use if line.tax_ids else None + is_refund = (tax_type == 'sale' and line.balance > 0.0) or (tax_type == 'purchase' and line.balance < 0.0) + + if line.force_price_included_taxes and line.tax_ids: + special_mode = 'total_included' + base_amount = line.tax_base_amount_currency + else: + special_mode = 'total_excluded' + base_amount = line.amount_currency + + return self.env['account.tax']._prepare_base_line_for_taxes_computation( + line, + price_unit=base_amount, + quantity=1.0, + is_refund=is_refund, + special_mode=special_mode, + ) + + def _prepare_tax_line_for_taxes_computation(self, line): + """ Convert the current dictionary in order to use the generic taxes computation method defined on account.tax. + :return: A python dictionary. + """ + self.ensure_one() + return self.env['account.tax']._prepare_tax_line_for_taxes_computation(line) + + def _lines_prepare_tax_line(self, tax_line_vals): + self.ensure_one() + + tax_rep = self.env['account.tax.repartition.line'].browse(tax_line_vals['tax_repartition_line_id']) + name = tax_rep.tax_id.name + if self.st_line_id.payment_ref: + name = f'{name} - {self.st_line_id.payment_ref}' + currency = self.env['res.currency'].browse(tax_line_vals['currency_id']) + amount_currency = tax_line_vals['amount_currency'] + balance = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(currency, None, amount_currency)['balance'] + + return { + 'flag': 'tax_line', + + 'account_id': tax_line_vals['account_id'], + 'date': self.st_line_id.date, + 'name': name, + 'partner_id': tax_line_vals['partner_id'], + 'currency_id': currency.id, + 'amount_currency': amount_currency, + 'balance': balance, + + 'analytic_distribution': tax_line_vals['analytic_distribution'], + 'tax_repartition_line_id': tax_rep.id, + 'tax_ids': tax_line_vals['tax_ids'], + 'tax_tag_ids': tax_line_vals['tax_tag_ids'], + 'group_tax_id': tax_line_vals['group_tax_id'], + } + + def _lines_recompute_taxes(self): + self.ensure_one() + AccountTax = self.env['account.tax'] + base_amls = self.line_ids.filtered(lambda x: x.flag == 'manual' and not x.tax_repartition_line_id) + base_lines = [self._prepare_base_line_for_taxes_computation(x) for x in base_amls] + tax_amls = self.line_ids.filtered(lambda x: x.flag == 'tax_line') + tax_lines = [self._prepare_tax_line_for_taxes_computation(x) for x in tax_amls] + AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id) + AccountTax._round_base_lines_tax_details(base_lines, self.company_id) + AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id, include_caba_tags=True) + tax_results = AccountTax._prepare_tax_lines(base_lines, self.company_id, tax_lines=tax_lines) + + line_ids_commands = [] + + # Update the base lines. + for base_line, to_update in tax_results['base_lines_to_update']: + line = base_line['record'] + amount_currency = to_update['amount_currency'] + balance = self.st_line_id\ + ._prepare_counterpart_amounts_using_st_line_rate(line.currency_id, None, amount_currency)['balance'] + + line_ids_commands.append(Command.update(line.id, { + 'balance': balance, + 'amount_currency': amount_currency, + 'tax_tag_ids': to_update['tax_tag_ids'], + })) + + # Tax lines that are no longer needed. + for tax_line_vals in tax_results['tax_lines_to_delete']: + line_ids_commands.append(Command.unlink(tax_line_vals['record'].id)) + + # Newly created tax lines. + for tax_line_vals in tax_results['tax_lines_to_add']: + line_ids_commands.append(Command.create(self._lines_prepare_tax_line(tax_line_vals))) + + # Update of existing tax lines. + for tax_line_vals, grouping_key, to_update in tax_results['tax_lines_to_update']: + new_line_vals = self._lines_prepare_tax_line({**grouping_key, **to_update}) + line_ids_commands.append(Command.update(tax_line_vals['record'].id, { + 'amount_currency': new_line_vals['amount_currency'], + 'balance': new_line_vals['balance'], + })) + + self.line_ids = line_ids_commands + + def _get_key_mapping_aml_and_exchange_diff(self, line): + if line.source_aml_id: + return 'source_aml_id', line.source_aml_id.id + return None, None + + def _reorder_exchange_and_aml_lines(self): + # Reorder to put each exchange line right after the corresponding new_aml. + new_lines_ids = [] + exchange_lines = self.line_ids.filtered(lambda x: x.flag == 'exchange_diff') + source_2_exchange_mapping = defaultdict(lambda: self.env['bank.rec.widget.line']) + for line in exchange_lines: + source_2_exchange_mapping[self._get_key_mapping_aml_and_exchange_diff(line)] |= line + for line in self.line_ids: + if line in exchange_lines: + continue + + new_lines_ids.append(line.id) + line_key = self._get_key_mapping_aml_and_exchange_diff(line) + if line_key in source_2_exchange_mapping: + new_lines_ids += source_2_exchange_mapping[line_key].mapped('id') + self.line_ids = self.env['bank.rec.widget.line'].browse(new_lines_ids) + + def _remove_related_exchange_diff_lines(self, lines): + """ Delete the exchange_diff_lines related to the lines given in parameter. + If the parameter (lines) is not set, then all exchange_diff_lines will be removed + """ + exch_diff_command_unlink = [] + for line in lines: + if line.flag == 'exchange_diff': + continue + + line_source_key, line_source_id = self._get_key_mapping_aml_and_exchange_diff(line) + if not line_source_key: + continue + exch_diff_command_unlink += [ + Command.unlink(exch_diff.id) + for exch_diff in self.line_ids.filtered(lambda x: x[line_source_key] and x[line_source_key].id == line_source_id) + ] + + if exch_diff_command_unlink: + self.line_ids = exch_diff_command_unlink + + def _lines_get_account_balance_exchange_diff(self, currency, balance, amount_currency): + # Compute the balance of the line using the rate/currency coming from the bank transaction. + amounts_in_st_curr = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate( + currency, + balance, + amount_currency, + ) + origin_balance = amounts_in_st_curr['balance'] + if currency == self.company_currency_id and self.transaction_currency_id != self.company_currency_id: + # The reconciliation will be expressed using the rate of the statement line. + origin_balance = balance + elif currency != self.company_currency_id and self.transaction_currency_id == self.company_currency_id: + # The reconciliation will be expressed using the foreign currency of the aml to cover the Mexican + # case. + origin_balance = currency\ + ._convert(amount_currency, self.transaction_currency_id, self.company_id, self.st_line_id.date) + + # Compute the exchange difference balance. + exchange_diff_balance = origin_balance - balance + if self.company_currency_id.is_zero(exchange_diff_balance): + return self.env['account.account'], 0.0 + + expense_exchange_account = self.company_id.expense_currency_exchange_account_id + income_exchange_account = self.company_id.income_currency_exchange_account_id + + if exchange_diff_balance > 0.0: + account = expense_exchange_account + else: + account = income_exchange_account + return account, exchange_diff_balance + + def _lines_get_exchange_diff_values(self, line): + if line.flag != 'new_aml': + return [] + account, exchange_diff_balance = self._lines_get_account_balance_exchange_diff(line.currency_id, line.balance, line.amount_currency) + if line.currency_id.is_zero(exchange_diff_balance): + return [] + return [{ + 'flag': 'exchange_diff', + 'source_aml_id': line.source_aml_id.id, + 'account_id': account.id, + 'date': line.date, + 'name': _("Exchange Difference: %s", line.name), + 'partner_id': line.partner_id.id, + 'currency_id': line.currency_id.id, + 'amount_currency': exchange_diff_balance if line.currency_id == self.company_currency_id else 0.0, + 'balance': exchange_diff_balance, + 'source_amount_currency': line.amount_currency, + 'source_balance': exchange_diff_balance, + }] + + def _lines_recompute_exchange_diff(self, lines): + """ Recompute the exchange_diffs for the given lines, creating some if necessary. + If lines are not given, the method will be applied on all new_amls + """ + self.ensure_one() + # If the method is called after deleting lines we should delete the related exchange diffs + deleted_lines = lines - self.line_ids + self._remove_related_exchange_diff_lines(deleted_lines) + lines = lines - deleted_lines + + exchange_diffs_aml = self.line_ids.filtered(lambda x: x.flag == 'exchange_diff').grouped('source_aml_id') + line_ids_commands = [] + reorder_needed = False + + for line in lines: + exchange_diff_values = self._lines_get_exchange_diff_values(line) + if line.source_aml_id and line.source_aml_id in exchange_diffs_aml: + line_ids_commands += [ + Command.update(exchange_diffs_aml[line.source_aml_id].id, exch_diff_val) + for exch_diff_val in exchange_diff_values + ] + else: + line_ids_commands += [ + Command.create(exch_diff_val) + for exch_diff_val in exchange_diff_values + ] + reorder_needed = True + + if line_ids_commands: + self.line_ids = line_ids_commands + if reorder_needed: + self._reorder_exchange_and_aml_lines() + + def _lines_prepare_reco_model_write_off_vals(self, reco_model, write_off_vals): + self.ensure_one() + + balance = self.st_line_id\ + ._prepare_counterpart_amounts_using_st_line_rate(self.transaction_currency_id, None, write_off_vals['amount_currency'])['balance'] + + return { + 'flag': 'manual', + + 'account_id': write_off_vals['account_id'], + 'date': self.st_line_id.date, + 'name': write_off_vals['name'], + 'partner_id': write_off_vals['partner_id'], + 'currency_id': write_off_vals['currency_id'], + 'amount_currency': write_off_vals['amount_currency'], + 'balance': balance, + 'tax_base_amount_currency': write_off_vals['amount_currency'], + 'force_price_included_taxes': True, + + 'reconcile_model_id': reco_model.id, + 'analytic_distribution': write_off_vals['analytic_distribution'], + 'tax_ids': write_off_vals['tax_ids'], + } + + # ------------------------------------------------------------------------- + # LINES UPDATE METHODS + # ------------------------------------------------------------------------- + + def _line_value_changed_account_id(self, line): + self.ensure_one() + self._lines_turn_auto_balance_into_manual_line(line) + + # Recompute taxes. + if line.flag not in ('tax_line', 'early_payment') and line.tax_ids: + self._lines_recompute_taxes() + self._lines_add_auto_balance_line() + + def _line_value_changed_date(self, line): + self.ensure_one() + if line.flag == 'liquidity' and line.date: + self.st_line_id.date = line.date + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + + def _line_value_changed_ref(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.move_id.ref = line.ref + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_record': True} + + def _line_value_changed_narration(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.move_id.narration = line.narration + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_record': True} + + def _line_value_changed_name(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.payment_ref = line.name + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + return + + self._lines_turn_auto_balance_into_manual_line(line) + + def _line_value_changed_amount_transaction_currency(self, line): + self.ensure_one() + if line.flag == 'liquidity': + if line.transaction_currency_id != self.journal_currency_id: + self.st_line_id.amount_currency = line.amount_transaction_currency + self.st_line_id.foreign_currency_id = line.transaction_currency_id + else: + self.st_line_id.amount_currency = 0.0 + self.st_line_id.foreign_currency_id = None + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + + def _line_value_changed_transaction_currency_id(self, line): + self._line_value_changed_amount_transaction_currency(line) + + def _line_value_changed_amount_currency(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.amount = line.amount_currency + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + return + + self._lines_turn_auto_balance_into_manual_line(line) + + sign = -1 if line.amount_currency < 0.0 else 1 + if line.flag == 'new_aml': + # The balance must keep the same sign as the original aml and must not exceed its original value. + line.amount_currency = sign * max(0.0, min(abs(line.amount_currency), abs(line.source_amount_currency))) + line.manually_modified = True + + # If the user remove completely the value, reset to the original balance. + if not line.amount_currency: + line.amount_currency = line.source_amount_currency + + elif not line.amount_currency: + line.amount_currency = 0.0 + + if line.currency_id == line.company_currency_id: + # Single currency: amount_currency must be equal to balance. + line.balance = line.amount_currency + elif line.flag == 'new_aml': + if line.currency_id.compare_amounts(abs(line.amount_currency), abs(line.source_amount_currency)) == 0.0: + # The value has been reset to its original value. Reset the balance as well to avoid rounding issues. + line.balance = line.source_balance + else: + # Apply the rate. + if line.source_rate: + line.balance = line.company_currency_id.round(line.amount_currency / line.source_rate) + else: + line.balance = 0.0 + elif line.flag in ('manual', 'early_payment', 'tax_line'): + if line.currency_id in (self.transaction_currency_id, self.journal_currency_id): + line.balance = self.st_line_id\ + ._prepare_counterpart_amounts_using_st_line_rate(line.currency_id, None, line.amount_currency)['balance'] + else: + line.balance = line.currency_id\ + ._convert(line.amount_currency, self.company_currency_id, self.company_id, self.st_line_id.date) + + if line.flag not in ('tax_line', 'early_payment'): + if line.tax_ids: + # Manual edition of amounts. Disable the price_included mode. + line.force_price_included_taxes = False + self._lines_recompute_taxes() + self._lines_recompute_exchange_diff(line) + + self._lines_add_auto_balance_line() + + def _line_value_changed_balance(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.amount = line.balance + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + return + + self._lines_turn_auto_balance_into_manual_line(line) + + sign = -1 if line.balance < 0.0 else 1 + if line.flag == 'new_aml': + # The balance must keep the same sign as the original aml and must not exceed its original value. + line.balance = sign * max(0.0, min(abs(line.balance), abs(line.source_balance))) + line.manually_modified = True + + # If the user remove completely the value, reset to the original balance. + if not line.balance: + line.balance = line.source_balance + + elif not line.balance: + line.balance = 0.0 + + # Single currency: amount_currency must be equal to balance. + if line.currency_id == line.company_currency_id: + line.amount_currency = line.balance + self._line_value_changed_amount_currency(line) + elif line.flag == 'exchange_diff': + self._lines_add_auto_balance_line() + else: + self._lines_recompute_exchange_diff(line) + self._lines_add_auto_balance_line() + + def _line_value_changed_currency_id(self, line): + self.ensure_one() + self._line_value_changed_amount_currency(line) + + def _line_value_changed_tax_ids(self, line): + self.ensure_one() + self._lines_turn_auto_balance_into_manual_line(line) + + if line.tax_ids: + # Adding taxes but no tax before. + if not line.tax_base_amount_currency: + line.tax_base_amount_currency = line.amount_currency + line.force_price_included_taxes = True + else: + if line.force_price_included_taxes: + # Removing taxes letting the field empty. + # If the user didn't touch the amount_currency/balance, restore the original amount. + line.amount_currency = line.tax_base_amount_currency + self._line_value_changed_amount_currency(line) + line.tax_base_amount_currency = False + + self._lines_recompute_taxes() + self._lines_add_auto_balance_line() + + def _line_value_changed_partner_id(self, line): + self.ensure_one() + if line.flag == 'liquidity': + self.st_line_id.partner_id = line.partner_id + self._action_reload_liquidity_line() + self.return_todo_command = {'reset_global_info': True, 'reset_record': True} + return + + self._lines_turn_auto_balance_into_manual_line(line) + + new_account = None + if line.partner_id: + partner_is_customer = line.partner_id.customer_rank and not line.partner_id.supplier_rank + partner_is_supplier = line.partner_id.supplier_rank and not line.partner_id.customer_rank + is_partner_receivable_amount_zero = line.partner_currency_id.is_zero(line.partner_receivable_amount) + is_partner_payable_amount_zero = line.partner_currency_id.is_zero(line.partner_payable_amount) + if partner_is_customer or not is_partner_receivable_amount_zero and is_partner_payable_amount_zero: + new_account = line.partner_receivable_account_id + elif partner_is_supplier or is_partner_receivable_amount_zero and not is_partner_payable_amount_zero: + new_account = line.partner_payable_account_id + elif self.st_line_id.amount < 0.0: + new_account = line.partner_payable_account_id or line.partner_receivable_account_id + else: + new_account = line.partner_receivable_account_id or line.partner_payable_account_id + + if new_account: + # Set the new receivable/payable account if any. + line.account_id = new_account + self._line_value_changed_account_id(line) + elif line.flag not in ('tax_line', 'early_payment') and line.tax_ids: + # Recompute taxes. + self._lines_recompute_taxes() + self._lines_add_auto_balance_line() + + def _line_value_changed_analytic_distribution(self, line): + self.ensure_one() + self._lines_turn_auto_balance_into_manual_line(line) + + if line.flag == 'liquidity': + st_line = self.st_line_id + liquidity_line, _suspense_lines, _write_off_lines = self.st_line_id._seek_for_lines() + liquidity_line.analytic_distribution = line.analytic_distribution + # We need to keep track of the statement line to avoid losing the data. + # Will be improved in master by turning _action_reload_liquidity_line into a context manager. + self.with_context(default_st_line_id=st_line.id)._action_reload_liquidity_line() + return + + # Recompute taxes. + if line.flag not in ('tax_line', 'early_payment') and any(x.analytic for x in line.tax_ids): + self._lines_recompute_taxes() + self._lines_add_auto_balance_line() + + # ------------------------------------------------------------------------- + # ACTIONS + # ------------------------------------------------------------------------- + + def _action_trigger_matching_rules(self): + self.ensure_one() + + if self.st_line_id.is_reconciled: + return + + reconcile_models = self.env['account.reconcile.model'].search([ + ('rule_type', '!=', 'writeoff_button'), + ('company_id', '=', self.company_id.id), + '|', + ('match_journal_ids', '=', False), + ('match_journal_ids', '=', self.st_line_id.journal_id.id), + ]) + matching = reconcile_models._apply_rules(self.st_line_id, self.partner_id) + + if matching.get('amls'): + reco_model = matching['model'] + # In case there is a write-off, keep the whole amount and let the write-off doing the auto-balancing. + allow_partial = matching.get('status') != 'write_off' + self._action_add_new_amls(matching['amls'], reco_model=reco_model, allow_partial=allow_partial) + if matching.get('status') == 'write_off': + reco_model = matching['model'] + self._action_select_reconcile_model(reco_model) + if matching.get('auto_reconcile'): + self.matching_rules_allow_auto_reconcile = True + return matching + + def _prepare_embedded_views_data(self): + self.ensure_one() + st_line = self.st_line_id + + context = { + 'search_view_ref': 'odex30_account_accountant.view_account_move_line_search_bank_rec_widget', + 'list_view_ref': 'odex30_account_accountant.view_account_move_line_list_bank_rec_widget', + } + + if self.partner_id: + context['search_default_partner_id'] = self.partner_id.id + + dynamic_filters = [] + + # == Dynamic Customer/Vendor filter == + journal = st_line.journal_id + + account_ids = set() + + inbound_accounts = journal._get_journal_inbound_outstanding_payment_accounts() - journal.default_account_id + outbound_accounts = journal._get_journal_outbound_outstanding_payment_accounts() - journal.default_account_id + + # Matching on debit account. + for account in inbound_accounts: + account_ids.add(account.id) + + # Matching on credit account. + for account in outbound_accounts: + account_ids.add(account.id) + + rec_pay_matching_filter = { + 'name': 'receivable_payable_matching', + 'description': _("Customer/Vendor"), + 'domain': [ + '|', + # Matching invoices. + '&', + ('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')), + ('payment_id', '=', False), + # Matching Payments. + '&', + ('account_id', 'in', tuple(account_ids)), + ('payment_id', '!=', False), + ], + 'no_separator': True, + 'is_default': False, + } + + misc_matching_filter = { + 'name': 'misc_matching', + 'description': _("Misc"), + 'domain': ['!'] + rec_pay_matching_filter['domain'], + 'is_default': False, + } + + dynamic_filters.append(rec_pay_matching_filter) + dynamic_filters.append(misc_matching_filter) + + # Stringify the domain. + for dynamic_filter in dynamic_filters: + dynamic_filter['domain'] = str(dynamic_filter['domain']) + + return { + 'amls': { + 'domain': st_line._get_default_amls_matching_domain(), + 'dynamic_filters': dynamic_filters, + 'context': context, + }, + } + + def _action_mount_st_line(self, st_line): + self.ensure_one() + self.st_line_id = st_line + self.form_index = self.line_ids[0].index if self.state == 'reconciled' else None + self._action_trigger_matching_rules() + + def _js_action_mount_st_line(self, st_line_id): + self.ensure_one() + st_line = self.env['account.bank.statement.line'].browse(st_line_id) + self._action_mount_st_line(st_line) + self.return_todo_command = self._prepare_embedded_views_data() + + def _js_action_restore_st_line_data(self, initial_data): + self.ensure_one() + initial_values = initial_data['initial_values'] + + self.st_line_id = self.env['account.bank.statement.line'].browse(initial_values['st_line_id']) + return_todo_command = initial_values['return_todo_command'] + + # Skip restore and trigger matching rules if the liquidity line was modified + liquidity_line = self.line_ids.filtered(lambda l: l.flag == 'liquidity') + initial_liquidity_line_values = next((cmd[2] for cmd in initial_values['line_ids'] if cmd[2]['flag'] == 'liquidity'), {}) + initial_liquidity_line = self.env['bank.rec.widget.line'].new(initial_liquidity_line_values) + for field in initial_liquidity_line_values.keys() - ['index', 'suggestion_html']: + if initial_liquidity_line[field] != liquidity_line[field]: + self._js_action_mount_st_line(self.st_line_id.id) + return + + # If the user goes to reco model and create a new one, we want to make it appearing when coming back. + # That's why we pop 'available_reco_model_ids' as well. + for field_name in ('id', 'st_line_id', 'todo_command', 'return_todo_command', 'available_reco_model_ids'): + initial_values.pop(field_name, None) + + st_line_domain = self.st_line_id._get_default_amls_matching_domain() + initial_values['line_ids'] = self._process_restore_lines_ids(initial_values['line_ids']) + self.update(initial_values) + + if ( + return_todo_command + and return_todo_command.get('res_model') == 'account.move' + and (created_invoice := self.env['account.move'].browse(return_todo_command['res_id'])) + and created_invoice.state == 'posted' + ): + lines = created_invoice.line_ids.filtered_domain(st_line_domain) + self._action_add_new_amls(lines) + else: + self._lines_add_auto_balance_line() + + self.return_todo_command = self._prepare_embedded_views_data() + + def _process_restore_lines_ids(self, initial_commands): + st_line_domain = self.st_line_id._get_default_amls_matching_domain() + still_available_aml_ids = self.env['account.move.line'].browse( + orm_command[2]['source_aml_id'] + for orm_command in initial_commands + if orm_command[0] == Command.CREATE and orm_command[2].get('source_aml_id') + ).filtered_domain(st_line_domain).ids + still_available_aml_ids += [None] # still available if there was no source + line_ids_commands = [Command.clear()] + for orm_command in initial_commands: + match orm_command: + case (Command.CREATE, _, values) if values.get('source_aml_id' in still_available_aml_ids): + # Discard the virtual id coming from the client + line_ids_commands.append(Command.create(values)) + case _: + line_ids_commands.append(orm_command) + return line_ids_commands + + def _action_reload_liquidity_line(self): + self.ensure_one() + self = self.with_context(default_st_line_id=self.st_line_id.id) + + self.invalidate_model() + + # Ensure the lines are well loaded. + # Suppose the initial values of 'line_ids' are 2 lines, + # "self.line_ids = [Command.create(...)]" will produce a single new line in 'line_ids' but three lines in case + # the field is accessed before. + self.line_ids + + self._action_trigger_matching_rules() + + # Focus back the liquidity line. + self._js_action_mount_line_in_edit(self.line_ids.filtered(lambda x: x.flag == 'liquidity').index) + + def _validation_lines_vals(self, line_ids_create_command_list, aml_to_exchange_diff_vals, to_reconcile): + # Check which partner to set. + lines = self.line_ids.filtered(lambda x: x.flag != 'liquidity') + partners = lines.partner_id + partner_to_set = self.env['res.partner'] + if len(partners) == 1: + # To avoid "Incompatible companies on records" error, make sure the user is linked to a main company. + allowed_companies = partners.company_id.root_id + if len(lines.company_id) == 1: + # Or the user is linked to the aml's company. + allowed_companies |= lines.company_id + # Or the user is not linked to any company. + if not partners.company_id or partners.company_id in allowed_companies: + partner_to_set = partners + + source2exchange = self.line_ids.filtered(lambda l: l.flag == 'exchange_diff').grouped('source_aml_id') + for line in self.line_ids: + if line.flag == 'exchange_diff': + continue + + amount_currency = line.amount_currency + balance = line.balance + if line.flag == 'new_aml': + to_reconcile.append((len(line_ids_create_command_list) + 1, line.source_aml_id)) + exchange_diff = source2exchange.get(line.source_aml_id) + if exchange_diff: + aml_to_exchange_diff_vals[len(line_ids_create_command_list) + 1] = { + 'amount_residual': exchange_diff.balance, + 'amount_residual_currency': exchange_diff.amount_currency, + 'analytic_distribution': exchange_diff.analytic_distribution, + } + # Squash amounts of exchange diff into corresponding new_aml + amount_currency += exchange_diff.amount_currency + balance += exchange_diff.balance + line_ids_create_command_list.append(Command.create(line._get_aml_values( + sequence=len(line_ids_create_command_list) + 1, + partner_id=partner_to_set.id if line.flag in ('liquidity', 'auto_balance') else line.partner_id.id, + amount_currency=amount_currency, + balance=balance, + ))) + + def _action_validate(self): + self.ensure_one() + # Prepare the lines to be created. + to_reconcile = [] + line_ids_create_command_list = [] + aml_to_exchange_diff_vals = {} + + self._validation_lines_vals(line_ids_create_command_list, aml_to_exchange_diff_vals, to_reconcile) + + st_line = self.st_line_id + move = st_line.move_id + + # Update the move. + move_ctx = move.with_context( + force_delete=True, + skip_readonly_check=True, + ) + move_ctx.write({'line_ids': [Command.clear()] + line_ids_create_command_list}) + + AccountMoveLine = self.env['account.move.line'] + sequence2lines = move_ctx.line_ids.grouped('sequence') + lines = [ + (sequence2lines[index], counterpart_aml) + for index, counterpart_aml in to_reconcile + ] + all_line_ids = tuple({_id for line, counterpart in lines for _id in (line + counterpart).ids}) + # Handle exchange diffs + exchange_diff_moves = None + lines_with_exch_diff = AccountMoveLine + if aml_to_exchange_diff_vals: + exchange_diff_vals_list = [] + for line, counterpart in lines: + line = line.with_prefetch(all_line_ids) + counterpart = counterpart.with_prefetch(all_line_ids) + exchange_diff_amounts = aml_to_exchange_diff_vals.get(line.sequence, {}) + exchange_analytic_distribution = exchange_diff_amounts.pop('analytic_distribution', False) + if exchange_diff_amounts: + related_exchange_diff_amls = line if exchange_diff_amounts['amount_residual'] * line.amount_residual > 0 else counterpart + exchange_diff_vals_list.append(related_exchange_diff_amls._prepare_exchange_difference_move_vals( + [exchange_diff_amounts], + exchange_date=max(line.date, counterpart.date), + exchange_analytic_distribution=exchange_analytic_distribution, + )) + lines_with_exch_diff += line + exchange_diff_moves = AccountMoveLine._create_exchange_difference_moves(exchange_diff_vals_list) + + # Perform the reconciliation. + self.env['account.move.line']\ + .with_context(no_exchange_difference_no_recursive=True)._reconcile_plan([ + (line + counterpart).with_prefetch(all_line_ids) + for line, counterpart in lines + ]) + + # Assign exchange move to partials. + for index, line in enumerate(lines_with_exch_diff): + exchange_move = exchange_diff_moves[index] + for debit_credit in ('debit', 'credit'): + partials = line[f'matched_{debit_credit}_ids'] \ + .filtered(lambda partial: partial[f'{debit_credit}_move_id'].move_id != exchange_move) + partials.exchange_move_id = exchange_move + + # Fill missing partner. + st_line_ctx = st_line.with_context(skip_account_move_synchronization=True, skip_readonly_check=True) + + # Create missing partner bank if necessary. + if st_line.account_number and st_line.partner_id: + st_line_ctx.partner_bank_id = st_line._find_or_create_bank_account() or st_line.partner_bank_id + + # Refresh analytic lines. + move.line_ids.with_context(validate_analytic=True)._inverse_analytic_distribution() + + @contextmanager + def _action_validate_method(self): + self.ensure_one() + st_line = self.st_line_id + + yield + + # The current record has been invalidated. Reload it completely. + self.st_line_id = st_line + self._ensure_loaded_lines() + self.return_todo_command = {'done': True} + + def _js_action_validate(self): + with self._action_validate_method(): + self._action_validate() + + def _action_to_check(self): + self.st_line_id.move_id.checked = False + self.invalidate_recordset(fnames=['st_line_checked']) + self._action_validate() + + def _js_action_to_check(self): + self.ensure_one() + + if self.state == 'valid': + # The validation can be performed. + with self._action_validate_method(): + self._action_to_check() + else: + # No need any validation. + self.st_line_id.move_id.checked = False + self.invalidate_recordset(fnames=['st_line_checked']) + self.return_todo_command = {'done': True} + + def _js_action_reset(self): + self.ensure_one() + st_line = self.st_line_id + + # Hashed entries shouldn't be modified; we will provide clear errors as well as redirect the user if needed. + if st_line.inalterable_hash: + if not st_line.has_reconciled_entries: + raise UserError(_("You can't hit the reset button on a secured bank transaction.")) + else: + raise RedirectWarning( + message=_("This bank transaction is locked up tighter than a squirrel in a nut factory! You can't hit the reset button on it. So, do you want to \"unreconcile\" it instead?"), + action=st_line.move_id.open_reconcile_view(), + button_text=_('View Reconciled Entries'), + ) + + st_line.action_undo_reconciliation() + + # The current record has been invalidated. Reload it completely. + self.st_line_id = st_line + self._ensure_loaded_lines() + self._action_trigger_matching_rules() + self.return_todo_command = {'done': True} + + def _js_action_set_as_checked(self): + self.ensure_one() + self.st_line_id.move_id.checked = True + self.invalidate_recordset(fnames=['st_line_checked']) + self.return_todo_command = {'done': True} + + def _action_clear_manual_operations_form(self): + self.form_index = None + + def _action_remove_lines(self, lines): + self.ensure_one() + if not lines: + return + + is_taxes_recomputation_needed = bool(lines.tax_ids) + has_new_aml = any(line.flag == 'new_aml' for line in lines) + + # Update 'line_ids'. + self.line_ids = [ + Command.unlink(line.id) + for line in lines + ] + self._remove_related_exchange_diff_lines(lines) + + # Recompute taxes and auto balance the lines. + if is_taxes_recomputation_needed: + self._lines_recompute_taxes() + if has_new_aml and not self._lines_check_apply_early_payment_discount(): + self._lines_check_apply_partial_matching() + self._lines_add_auto_balance_line() + self._action_clear_manual_operations_form() + + def _js_action_remove_line(self, line_index): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == line_index) + self._action_remove_lines(line) + + def _action_select_reconcile_model(self, reco_model): + self.ensure_one() + + # Cleanup a previously selected model. + self.line_ids = [ + Command.unlink(x.id) + for x in self.line_ids + if x.flag not in ('new_aml', 'liquidity') and x.reconcile_model_id and x.reconcile_model_id != reco_model + ] + self._lines_recompute_taxes() + + if reco_model.to_check: + self.st_line_id.move_id.checked = False + self.invalidate_recordset(fnames=['st_line_checked']) + + # Compute the residual balance on which apply the newly selected model. + auto_balance_line_vals = self._lines_prepare_auto_balance_line() + residual_balance = auto_balance_line_vals['amount_currency'] + + write_off_vals_list = reco_model._apply_lines_for_bank_widget(residual_balance, self.partner_id, self.st_line_id) + + if reco_model.rule_type == 'writeoff_button' and reco_model.counterpart_type in ('sale', 'purchase'): + invoice = self._create_invoice_from_write_off_values(reco_model, write_off_vals_list) + + action = { + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'context': {'create': False}, + 'view_mode': 'form', + 'res_id': invoice.id, + } + self.return_todo_command = clean_action(action, self.env) + else: + # Apply the newly generated lines. + self.line_ids = [ + Command.create(self._lines_prepare_reco_model_write_off_vals(reco_model, x)) + for x in write_off_vals_list + ] + + self._lines_recompute_taxes() + self._lines_add_auto_balance_line() + + def _js_action_select_reconcile_model(self, reco_model_id): + self.ensure_one() + reco_model = self.env['account.reconcile.model'].browse(reco_model_id) + self._action_select_reconcile_model(reco_model) + + def _create_invoice_from_write_off_values(self, reco_model, write_off_vals_list): + # Create a new invoice/bill and redirect the user to it. + journal = reco_model.line_ids.journal_id[:1] + + invoice_line_ids = [] + total_amount_currency = 0.0 + percentage_st_line = 0.0 + for write_off_values in write_off_vals_list: + write_off_values = dict(write_off_values) + total_amount_currency -= ( + write_off_values['amount_currency'] + if 'percentage_st_line' not in write_off_values + else 0 + ) + percentage_st_line += write_off_values.pop('percentage_st_line', 0) + write_off_values.pop('currency_id', None) + write_off_values.pop('partner_id', None) + write_off_values.pop('reconcile_model_id', None) + invoice_line_ids.append(write_off_values) + + st_line_amount = self.st_line_id.amount_currency if self.st_line_id.foreign_currency_id else self.st_line_id.amount + total_amount_currency += self.transaction_currency_id.round(st_line_amount * percentage_st_line) + + # Type of move depends on debit or credit of bank statement line and reconciliation model chosen. + if reco_model.counterpart_type == 'sale': + move_type = 'out_invoice' if total_amount_currency > 0 else 'out_refund' + else: + move_type = 'in_invoice' if total_amount_currency < 0 else 'in_refund' + + price_unit_sign = 1 if total_amount_currency < 0.0 else -1 + invoice_line_ids_commands = [] + for line_values in invoice_line_ids: + price_total = price_unit_sign * line_values.pop('amount_currency') + taxes = self.env['account.tax'].browse(line_values['tax_ids'][0][2]) + line_values['price_unit'] = self._get_invoice_price_unit_from_price_total(price_total, taxes) + invoice_line_ids_commands.append(Command.create(line_values)) + + invoice_values = { + 'invoice_date': self.st_line_id.date, + 'move_type': move_type, + 'partner_id': self.st_line_id.partner_id.id, + 'currency_id': self.transaction_currency_id.id, + 'payment_reference': self.st_line_id.payment_ref, + 'invoice_line_ids': invoice_line_ids_commands, + } + if journal: + invoice_values['journal_id'] = journal.id + + invoice = self.env['account.move'].create(invoice_values) + if not invoice.currency_id.is_zero(invoice.amount_total - total_amount_currency): + invoice._check_total_amount(abs(total_amount_currency)) + return invoice + + def _get_invoice_price_unit_from_price_total(self, price_total, taxes): + """ Determine price unit based on the total amount and taxes applied. """ + self.ensure_one() + taxes_computation = taxes._get_tax_details( + price_total, + 1.0, + precision_rounding=self.transaction_currency_id.rounding, + rounding_method=self.company_id.tax_calculation_rounding_method, + special_mode='total_included', + ) + return taxes_computation['total_excluded'] + sum(x['tax_amount'] for x in taxes_computation['taxes_data'] if x['tax'].price_include) + + def _action_add_new_amls(self, amls, reco_model=None, allow_partial=True): + self.ensure_one() + existing_amls = set(self.line_ids.filtered(lambda x: x.flag in ('new_aml', 'aml', 'liquidity')).source_aml_id) + amls = amls.filtered(lambda x: x not in existing_amls) + if not amls: + return + + self._lines_load_new_amls(amls, reco_model=reco_model) + added_lines = self.line_ids.filtered(lambda x: x.flag == 'new_aml' and x.source_aml_id in amls) + self._lines_recompute_exchange_diff(added_lines) + if not self._lines_check_apply_early_payment_discount() and allow_partial: + self._lines_check_apply_partial_matching() + self._lines_add_auto_balance_line() + self._action_clear_manual_operations_form() + + def _js_action_add_new_aml(self, aml_id): + self.ensure_one() + aml = self.env['account.move.line'].browse(aml_id) + self._action_add_new_amls(aml) + + def _action_remove_new_amls(self, amls): + self.ensure_one() + to_remove = self.line_ids.filtered(lambda x: x.flag == 'new_aml' and x.source_aml_id in amls) + self._action_remove_lines(to_remove) + + def _js_action_remove_new_aml(self, aml_id): + self.ensure_one() + aml = self.env['account.move.line'].browse(aml_id) + self._action_remove_new_amls(aml) + + def _js_action_mount_line_in_edit(self, line_index): + self.ensure_one() + self.form_index = line_index + + def _js_action_line_changed(self, form_index, field_name): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == form_index) + + # Invalidate the cache of newly set value to force the recomputation of computed fields. + value = line[field_name] + line.invalidate_recordset(fnames=[field_name], flush=False) + line[field_name] = value + + getattr(self, f'_line_value_changed_{field_name}')(line) + + def _js_action_line_set_partner_receivable_account(self, form_index): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == form_index) + line.account_id = line.partner_receivable_account_id + self._line_value_changed_account_id(line) + + def _js_action_line_set_partner_payable_account(self, form_index): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == form_index) + line.account_id = line.partner_payable_account_id + self._line_value_changed_account_id(line) + + def _js_action_redirect_to_move(self, form_index): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == form_index) + move = line.source_aml_move_id + + action = { + 'type': 'ir.actions.act_window', + 'context': {'create': False}, + 'view_mode': 'form', + } + + if move.origin_payment_id: + action.update({ + 'res_model': 'account.payment', + 'res_id': move.origin_payment_id.id, + }) + else: + action.update({ + 'res_model': 'account.move', + 'res_id': move.id, + }) + self.return_todo_command = clean_action(action, self.env) + + def _js_action_apply_line_suggestion(self, form_index): + self.ensure_one() + line = self.line_ids.filtered(lambda x: x.index == form_index) + + # Since 'balance'/'amount_currency' are both dependencies of 'suggestion_balance'/'suggestion_amount_currency', + # keep the value in variable before assigning anything to avoid an inconsistency after applying + # 'suggestion_amount_currency' but before updating 'balance'. + suggestion_amount_currency = line.suggestion_amount_currency + suggestion_balance = line.suggestion_balance + + line.amount_currency = suggestion_amount_currency + line.balance = suggestion_balance + + if line.currency_id == line.company_currency_id: + self._line_value_changed_balance(line) + else: + self._line_value_changed_amount_currency(line) + + @api.model + def collect_global_info_data(self, journal_id): + journal = self.env['account.journal'].browse(journal_id) + balance = '' + if journal.exists() and any(company in journal.company_id._accessible_branches() for company in self.env.companies): + balance = formatLang(self.env, + journal.current_statement_balance, + currency_obj=journal.currency_id or journal.company_id.sudo().currency_id) + return {'balance_amount': balance} diff --git a/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py new file mode 100644 index 0000000..98f2e9e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/bank_rec_widget_line.py @@ -0,0 +1,503 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import _, api, fields, models, Command +from odoo.osv import expression +from odoo.tools.misc import formatLang, frozendict + +import markupsafe +import uuid + + +class BankRecWidgetLine(models.Model): + _name = "bank.rec.widget.line" + _inherit = "analytic.mixin" + _description = "Line of the bank reconciliation widget" + + # This model is never saved inside the database. + # _auto=False' & _table_query = "0" prevent the ORM to create the corresponding postgresql table. + _auto = False + _table_query = "0" + + wizard_id = fields.Many2one(comodel_name='bank.rec.widget') + index = fields.Char(compute='_compute_index') + flag = fields.Selection( + selection=[ + ('liquidity', 'liquidity'), + ('new_aml', 'new_aml'), + ('aml', 'aml'), + ('exchange_diff', 'exchange_diff'), + ('tax_line', 'tax_line'), + ('manual', 'manual'), + ('early_payment', 'early_payment'), + ('auto_balance', 'auto_balance'), + ], + ) + + journal_default_account_id = fields.Many2one( + related='wizard_id.st_line_id.journal_id.default_account_id', + depends=['wizard_id'], + ) + account_id = fields.Many2one( + comodel_name='account.account', + compute='_compute_account_id', + store=True, + readonly=False, + check_company=True, + domain="""[ + ('deprecated', '=', False), + ('id', '!=', journal_default_account_id), + ('account_type', 'not in', ('asset_cash', 'off_balance')), + ]""", + ) + date = fields.Date( + compute='_compute_date', + store=True, + readonly=False, + ) + name = fields.Char( + compute='_compute_name', + store=True, + readonly=False, + ) + partner_id = fields.Many2one( + comodel_name='res.partner', + compute='_compute_partner_id', + store=True, + readonly=False, + ) + currency_id = fields.Many2one( + comodel_name='res.currency', + compute='_compute_currency_id', + store=True, + readonly=False, + ) + company_id = fields.Many2one(related='wizard_id.company_id') + country_code = fields.Char(related='company_id.country_id.code', depends=['company_id']) + company_currency_id = fields.Many2one(related='wizard_id.company_currency_id') + amount_currency = fields.Monetary( + currency_field='currency_id', + compute='_compute_amount_currency', + store=True, + readonly=False, + ) + balance = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_balance', + store=True, + readonly=False, + ) + transaction_currency_id = fields.Many2one( + related='wizard_id.st_line_id.foreign_currency_id', + depends=['wizard_id'], + ) + amount_transaction_currency = fields.Monetary( + currency_field='transaction_currency_id', + related='wizard_id.st_line_id.amount_currency', + depends=['wizard_id'], + ) + debit = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_from_balance', + ) + credit = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_from_balance', + ) + force_price_included_taxes = fields.Boolean() + tax_base_amount_currency = fields.Monetary( + currency_field='currency_id', + ) + + source_aml_id = fields.Many2one(comodel_name='account.move.line') + source_aml_move_id = fields.Many2one( + comodel_name='account.move', + compute='_compute_source_aml_fields', + store=True, + readonly=False, + ) + source_aml_move_name = fields.Char( + compute='_compute_source_aml_fields', + store=True, + readonly=False, + ) + tax_repartition_line_id = fields.Many2one( + comodel_name='account.tax.repartition.line', + compute='_compute_tax_repartition_line_id', + store=True, + readonly=False, + ) + tax_ids = fields.Many2many( + comodel_name='account.tax', + compute='_compute_tax_ids', + store=True, + readonly=False, + check_company=True, + ) + tax_tag_ids = fields.Many2many( + comodel_name='account.account.tag', + compute='_compute_tax_tag_ids', + store=True, + readonly=False, + ) + group_tax_id = fields.Many2one( + comodel_name='account.tax', + compute='_compute_group_tax_id', + store=True, + readonly=False, + ) + reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model') + source_amount_currency = fields.Monetary(currency_field='currency_id') + source_balance = fields.Monetary(currency_field='company_currency_id') + source_debit = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_from_source_balance', + ) + source_credit = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_from_source_balance', + ) + source_rate = fields.Float() + + display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency') + display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance') + + partner_currency_id = fields.Many2one( + comodel_name='res.currency', + compute='_compute_partner_info', + ) + partner_receivable_account_id = fields.Many2one( + comodel_name='account.account', + compute='_compute_partner_info', + ) + partner_payable_account_id = fields.Many2one( + comodel_name='account.account', + compute='_compute_partner_info', + ) + partner_receivable_amount = fields.Monetary( + currency_field='partner_currency_id', + compute='_compute_partner_info', + ) + partner_payable_amount = fields.Monetary( + currency_field='partner_currency_id', + compute='_compute_partner_info', + ) + + bank_account = fields.Char( + compute='_compute_bank_account', + ) + suggestion_html = fields.Html( + compute='_compute_suggestion', + sanitize=False, + ) + suggestion_amount_currency = fields.Monetary( + currency_field='currency_id', + compute='_compute_suggestion', + ) + suggestion_balance = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_suggestion', + ) + ref = fields.Char( + compute='_compute_ref_narration', + store=True, + readonly=False, + ) + narration = fields.Html( + compute='_compute_ref_narration', + store=True, + readonly=False, + ) + + manually_modified = fields.Boolean() + + def _compute_index(self): + for line in self: + line.index = uuid.uuid4() + + @api.depends('source_aml_id') + def _compute_account_id(self): + for line in self: + if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'): + line.account_id = line.source_aml_id.account_id + else: + line.account_id = line.account_id + + @api.depends('source_aml_id') + def _compute_date(self): + for line in self: + if line.flag in ('aml', 'new_aml', 'exchange_diff'): + line.date = line.source_aml_id.date + elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'): + line.date = line.wizard_id.st_line_id.date + else: + line.date = line.date + + @api.depends('source_aml_id') + def _compute_name(self): + for line in self: + if line.flag in ('aml', 'new_aml', 'liquidity'): + # In the case the source_aml_id is from a credit note, the aml might not have a name set + line.name = line.source_aml_id.name or line.source_aml_move_name + else: + line.name = line.name + + @api.depends('source_aml_id') + def _compute_partner_id(self): + for line in self: + if line.flag in ('aml', 'new_aml'): + line.partner_id = line.source_aml_id.partner_id + elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'): + line.partner_id = line.wizard_id.partner_id + else: + line.partner_id = line.partner_id + + @api.depends('source_aml_id') + def _compute_currency_id(self): + for line in self: + if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'): + line.currency_id = line.source_aml_id.currency_id + elif line.flag in ('auto_balance', 'manual', 'early_payment'): + line.currency_id = line.wizard_id.transaction_currency_id + else: + line.currency_id = line.currency_id + + @api.depends('source_aml_id') + def _compute_balance(self): + for line in self: + if line.flag in ('aml', 'liquidity'): + line.balance = line.source_aml_id.balance + else: + line.balance = line.balance + + @api.depends('source_aml_id') + def _compute_amount_currency(self): + for line in self: + if line.flag in ('aml', 'liquidity'): + line.amount_currency = line.source_aml_id.amount_currency + else: + line.amount_currency = line.amount_currency + + @api.depends('balance') + def _compute_from_balance(self): + for line in self: + line.debit = line.balance if line.balance > 0.0 else 0.0 + line.credit = -line.balance if line.balance < 0.0 else 0.0 + + @api.depends('source_balance') + def _compute_from_source_balance(self): + for line in self: + line.source_debit = line.source_balance if line.source_balance > 0.0 else 0.0 + line.source_credit = -line.source_balance if line.source_balance < 0.0 else 0.0 + + @api.depends('source_aml_id', 'account_id', 'partner_id') + def _compute_analytic_distribution(self): + cache = {} + for line in self: + if line.flag in ('liquidity', 'aml'): + line.analytic_distribution = line.source_aml_id.analytic_distribution + elif line.flag in ('tax_line', 'early_payment'): + line.analytic_distribution = line.analytic_distribution + else: + arguments = frozendict({ + "partner_id": line.partner_id.id, + "partner_category_id": line.partner_id.category_id.ids, + "account_prefix": line.account_id.code, + "company_id": line.company_id.id, + }) + if arguments not in cache: + cache[arguments] = self.env['account.analytic.distribution.model']._get_distribution(arguments) + line.analytic_distribution = cache[arguments] or line.analytic_distribution + + @api.depends('source_aml_id') + def _compute_tax_repartition_line_id(self): + for line in self: + if line.flag == 'aml': + line.tax_repartition_line_id = line.source_aml_id.tax_repartition_line_id + else: + line.tax_repartition_line_id = line.tax_repartition_line_id + + @api.depends('source_aml_id') + def _compute_tax_ids(self): + for line in self: + if line.flag == 'aml': + line.tax_ids = [Command.set(line.source_aml_id.tax_ids.ids)] + else: + line.tax_ids = line.tax_ids + + @api.depends('source_aml_id') + def _compute_tax_tag_ids(self): + for line in self: + if line.flag == 'aml': + line.tax_tag_ids = [Command.set(line.source_aml_id.tax_tag_ids.ids)] + else: + line.tax_tag_ids = line.tax_tag_ids + + @api.depends('source_aml_id') + def _compute_group_tax_id(self): + for line in self: + if line.flag == 'aml': + line.group_tax_id = line.source_aml_id.group_tax_id + else: + line.group_tax_id = line.group_tax_id + + @api.depends('currency_id', 'amount_currency', 'source_amount_currency') + def _compute_display_stroked_amount_currency(self): + for line in self: + line.display_stroked_amount_currency = \ + line.flag == 'new_aml' \ + and line.currency_id.compare_amounts(line.amount_currency, line.source_amount_currency) != 0 + + @api.depends('currency_id', 'balance', 'source_balance') + def _compute_display_stroked_balance(self): + for line in self: + line.display_stroked_balance = \ + line.flag in ('new_aml', 'exchange_diff') \ + and line.currency_id.compare_amounts(line.balance, line.source_balance) != 0 + + @api.depends('flag') + def _compute_source_aml_fields(self): + for line in self: + line.source_aml_move_id = None + line.source_aml_move_name = None + if line.flag in ('new_aml', 'liquidity'): + line.source_aml_move_id = line.source_aml_id.move_id + line.source_aml_move_name = line.source_aml_id.move_id.name + elif line.flag == 'aml': + partials = line.source_aml_id.matched_debit_ids + line.source_aml_id.matched_credit_ids + all_counterpart_lines = partials.debit_move_id + partials.credit_move_id + counterpart_lines = all_counterpart_lines - line.source_aml_id - partials.exchange_move_id.line_ids + if len(counterpart_lines) == 1: + line.source_aml_move_id = counterpart_lines.move_id + line.source_aml_move_name = counterpart_lines.move_id.name + + @api.depends('wizard_id.form_index', 'partner_id') + def _compute_partner_info(self): + for line in self: + line.partner_receivable_amount = 0.0 + line.partner_payable_amount = 0.0 + line.partner_currency_id = None + line.partner_receivable_account_id = None + line.partner_payable_account_id = None + + if not line.partner_id or line.index != line.wizard_id.form_index: + continue + + line.partner_currency_id = line.company_currency_id + partner = line.partner_id.with_company(line.wizard_id.company_id) + common_domain = [('parent_state', '=', 'posted'), ('partner_id', '=', partner.id)] + line.partner_receivable_account_id = partner.property_account_receivable_id + if line.partner_receivable_account_id: + results = self.env['account.move.line']._read_group( + domain=expression.AND([common_domain, [('account_id', '=', line.partner_receivable_account_id.id)]]), + aggregates=['amount_residual:sum'], + ) + line.partner_receivable_amount = results[0][0] + line.partner_payable_account_id = partner.property_account_payable_id + if line.partner_payable_account_id: + results = self.env['account.move.line']._read_group( + domain=expression.AND([common_domain, [('account_id', '=', line.partner_payable_account_id.id)]]), + aggregates=['amount_residual:sum'], + ) + line.partner_payable_amount = results[0][0] + + @api.depends('flag') + def _compute_bank_account(self): + for line in self: + bank_account = line.wizard_id.st_line_id.partner_bank_id.display_name or line.wizard_id.st_line_id.account_number + if line.flag == 'liquidity' and bank_account: + line.bank_account = bank_account + else: + line.bank_account = None + + @api.depends('wizard_id.form_index', 'amount_currency', 'balance') + def _compute_suggestion(self): + for line in self: + line.suggestion_html = None + line.suggestion_amount_currency = None + line.suggestion_balance = None + + if line.flag != 'new_aml' or line.index != line.wizard_id.form_index: + continue + + aml = line.source_aml_id + wizard = line.wizard_id + residual_amount_before_reco = abs(aml.amount_residual_currency) + residual_amount_after_reco = abs(aml.amount_residual_currency + line.amount_currency) + reconciled_amount = residual_amount_before_reco - residual_amount_after_reco + is_fully_reconciled = aml.currency_id.is_zero(residual_amount_after_reco) + is_invoice = aml.move_id.is_invoice(include_receipts=True) + + if is_fully_reconciled: + lines = [ + _("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be entirely paid by the transaction.") + if is_invoice else + _("%(display_name_html)s with an open amount of %(open_amount)s will be fully reconciled by the transaction.") + ] + partial_amounts = wizard._lines_check_partial_amount(line) + if partial_amounts: + lines.append( + _("You might want to record a %(btn_start)spartial payment%(btn_end)s.") + if is_invoice else + _("You might want to make a %(btn_start)spartial reconciliation%(btn_end)s instead.") + ) + line.suggestion_amount_currency = partial_amounts['amount_currency'] + line.suggestion_balance = partial_amounts['balance'] + else: + if is_invoice: + lines = [ + _("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."), + _("You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."), + ] + else: + lines = [ + _("%(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."), + _("You might want to %(btn_start)sfully reconcile%(btn_end)s the document."), + ] + line.suggestion_amount_currency = line.source_amount_currency + line.suggestion_balance = line.source_balance + + display_name_html = markupsafe.Markup(""" + + """) % { + 'display_name': aml.move_id.display_name, + } + + extra_text = markupsafe.Markup('
    ').join(lines) % { + 'amount': formatLang(self.env, reconciled_amount, currency_obj=aml.currency_id), + 'open_amount': formatLang(self.env, residual_amount_before_reco, currency_obj=aml.currency_id), + 'display_name_html': display_name_html, + 'btn_start': markupsafe.Markup( + ''), + } + line.suggestion_html = markupsafe.Markup("""
    %s
    """) % extra_text + + @api.depends('flag') + def _compute_ref_narration(self): + for line in self: + if line.flag == 'liquidity': + line.ref = line.wizard_id.st_line_id.ref + line.narration = line.wizard_id.st_line_id.narration + else: + line.ref = line.narration = None + + def _get_aml_values(self, **kwargs): + self.ensure_one() + create_dict = { + 'name': self.name, + 'account_id': self.account_id.id, + 'currency_id': self.currency_id.id, + 'amount_currency': self.amount_currency, + 'balance': self.debit - self.credit, + 'reconcile_model_id': self.reconcile_model_id.id, + 'analytic_distribution': self.analytic_distribution, + 'tax_repartition_line_id': self.tax_repartition_line_id.id, + 'tax_ids': [Command.set(self.tax_ids.ids)], + 'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)], + 'group_tax_id': self.group_tax_id.id, + **kwargs, + } + if self.flag == 'early_payment': + create_dict['display_type'] = 'epd' + return create_dict diff --git a/dev_odex30_accounting/odex30_account_accountant/models/digest.py b/dev_odex30_accounting/odex30_account_accountant/models/digest.py new file mode 100644 index 0000000..9be0abc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/digest.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models, _ +from odoo.exceptions import AccessError + + +class Digest(models.Model): + _inherit = 'digest.digest' + + kpi_account_bank_cash = fields.Boolean('Bank & Cash Moves') + kpi_account_bank_cash_value = fields.Monetary(compute='_compute_kpi_account_total_bank_cash_value') + + def _compute_kpi_account_total_bank_cash_value(self): + if not self.env.user.has_group('account.group_account_user'): + raise AccessError(_("Do not have access, skip this data for user's digest email")) + + start, end, companies = self._get_kpi_compute_parameters() + data = self.env['account.move']._read_group([ + ('date', '>=', start), + ('date', '<', end), + ('journal_id.type', 'in', ('cash', 'bank')), + ('company_id', 'in', companies.ids), + ], ['company_id'], ['amount_total:sum']) + data = dict(data) + + for record in self: + company = record.company_id or self.env.company + record.kpi_account_bank_cash_value = data.get(company) + + def _compute_kpis_actions(self, company, user): + res = super(Digest, self)._compute_kpis_actions(company, user) + res.update({'kpi_account_bank_cash': 'account.open_account_journal_dashboard_kanban?menu_id=%s' % (self.env.ref('account.menu_finance').id)}) + return res diff --git a/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py b/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py new file mode 100644 index 0000000..1ac8373 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/ir_model.py @@ -0,0 +1,14 @@ +from odoo import api, models + + +class IrModel(models.Model): + _inherit = 'ir.model' + + @api.model + def _is_valid_for_model_selector(self, model): + return model not in { + # bank.rec.widget* does not have a psql table with _auto=False & _table_query="0", + # which makes the models unusable in the model selector. + 'bank.rec.widget', + 'bank.rec.widget.line', + } and super()._is_valid_for_model_selector(model) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py b/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py new file mode 100644 index 0000000..ce62e0d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/ir_ui_menu.py @@ -0,0 +1,21 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class IrUiMenu(models.Model): + _inherit = 'ir.ui.menu' + + def _visible_menu_ids(self, debug=False): + visible_ids = super()._visible_menu_ids(debug) + # These menus should only be visible to accountants (users with group_account_readonly) and the group specified on the menu + # We want to avoid moving these menus to the new `accountant` module + if not self.env.user.has_group('account.group_account_readonly'): + accounting_menus = [ + 'odex30_account_accountant.account_tag_menu', + 'odex30_account_accountant.menu_account_group', + 'odex30_account_reports.menu_action_account_report_multicurrency_revaluation', + ] + hidden_menu_ids = {self.env.ref(r).sudo().id for r in accounting_menus if self.env.ref(r, raise_if_not_found=False)} + return visible_ids - hidden_menu_ids + return visible_ids diff --git a/dev_odex30_accounting/odex30_account_accountant/models/res_company.py b/dev_odex30_accounting/odex30_account_accountant/models/res_company.py new file mode 100644 index 0000000..2c72f7c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/res_company.py @@ -0,0 +1,208 @@ +from odoo import models, fields, _ +from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT + +from datetime import timedelta +from odoo.tools import date_utils + + +class ResCompany(models.Model): + _inherit = 'res.company' + + invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", help="Every payment and invoice before this date will receive the 'From Invoicing' status, hiding all the accounting entries related to it. Use this option after installing Accounting if you were using only Invoicing before, before importing all your actual accounting data in to Odoo.") + predict_bill_product = fields.Boolean(string="Predict Bill Product") + + sign_invoice = fields.Boolean(string='Display signing field on invoices') + signing_user = fields.Many2one(comodel_name='res.users') + + # Deferred expense management + deferred_expense_journal_id = fields.Many2one( + comodel_name='account.journal', + string="Deferred Expense Journal", + ) + deferred_expense_account_id = fields.Many2one( + comodel_name='account.account', + string="Deferred Expense Account", + ) + generate_deferred_expense_entries_method = fields.Selection( + string="Generate Deferred Expense Entries", + selection=[ + ('on_validation', 'On bill validation'), + ('manual', 'Manually & Grouped'), + ], + default='on_validation', + required=True, + ) + deferred_expense_amount_computation_method = fields.Selection( + string="Deferred Expense Based on", + selection=[ + ('day', 'Days'), + ('month', 'Months'), + ('full_months', 'Full Months'), + ], + default='month', + required=True, + ) + + # Deferred revenue management + deferred_revenue_journal_id = fields.Many2one( + comodel_name='account.journal', + string="Deferred Revenue Journal", + ) + deferred_revenue_account_id = fields.Many2one( + comodel_name='account.account', + string="Deferred Revenue Account", + ) + generate_deferred_revenue_entries_method = fields.Selection( + string="Generate Deferred Revenue Entries", + selection=[ + ('on_validation', 'On bill validation'), + ('manual', 'Manually & Grouped'), + ], + default='on_validation', + required=True, + ) + deferred_revenue_amount_computation_method = fields.Selection( + string="Deferred Revenue Based on", + selection=[ + ('day', 'Days'), + ('month', 'Months'), + ('full_months', 'Full Months'), + ], + default='month', + required=True, + ) + + def write(self, vals): + old_threshold_vals = {} + for record in self: + old_threshold_vals[record] = record.invoicing_switch_threshold + + rslt = super(ResCompany, self).write(vals) + + for record in self: + if 'invoicing_switch_threshold' in vals and old_threshold_vals[record] != vals['invoicing_switch_threshold']: + self.env['account.move.line'].flush_model(['move_id', 'parent_state']) + self.env['account.move'].flush_model(['company_id', 'date', 'state', 'payment_state', 'payment_state_before_switch']) + if record.invoicing_switch_threshold: + # If a new date was set as threshold, we switch all the + # posted moves and payments before it to 'invoicing_legacy'. + # We also reset to posted all the moves and payments that + # were 'invoicing_legacy' and were posterior to the threshold + self.env.cr.execute(""" + update account_move_line aml + set parent_state = 'posted' + from account_move move + where aml.move_id = move.id + and move.payment_state = 'invoicing_legacy' + and move.date >= %(switch_threshold)s + and move.company_id = %(company_id)s; + + update account_move + set state = 'posted', + payment_state = payment_state_before_switch, + payment_state_before_switch = null + where payment_state = 'invoicing_legacy' + and date >= %(switch_threshold)s + and company_id = %(company_id)s; + + update account_move_line aml + set parent_state = 'cancel' + from account_move move + where aml.move_id = move.id + and move.state = 'posted' + and move.date < %(switch_threshold)s + and move.company_id = %(company_id)s; + + update account_move + set state = 'cancel', + payment_state_before_switch = payment_state, + payment_state = 'invoicing_legacy' + where state = 'posted' + and date < %(switch_threshold)s + and company_id = %(company_id)s; + """, {'company_id': record.id, 'switch_threshold': record.invoicing_switch_threshold}) + else: + # If the threshold date has been emptied, we re-post all the + # invoicing_legacy entries. + self.env.cr.execute(""" + update account_move_line aml + set parent_state = 'posted' + from account_move move + where aml.move_id = move.id + and move.payment_state = 'invoicing_legacy' + and move.company_id = %(company_id)s; + + update account_move + set state = 'posted', + payment_state = payment_state_before_switch, + payment_state_before_switch = null + where payment_state = 'invoicing_legacy' + and company_id = %(company_id)s; + """, {'company_id': record.id}) + + self.env['account.move.line'].invalidate_model(['parent_state']) + self.env['account.move'].invalidate_model(['state', 'payment_state', 'payment_state_before_switch']) + + return rslt + + def compute_fiscalyear_dates(self, current_date): + """Compute the start and end dates of the fiscal year where the given 'date' belongs to. + + :param current_date: A datetime.date/datetime.datetime object. + :return: A dictionary containing: + * date_from + * date_to + * [Optionally] record: The fiscal year record. + """ + self.ensure_one() + date_str = current_date.strftime(DEFAULT_SERVER_DATE_FORMAT) + + # Search a fiscal year record containing the date. + # If a record is found, then no need further computation, we get the dates range directly. + fiscalyear = self.env['account.fiscal.year'].search([ + ('company_id', '=', self.id), + ('date_from', '<=', date_str), + ('date_to', '>=', date_str), + ], limit=1) + if fiscalyear: + return { + 'date_from': fiscalyear.date_from, + 'date_to': fiscalyear.date_to, + 'record': fiscalyear, + } + + date_from, date_to = date_utils.get_fiscal_year( + current_date, day=self.fiscalyear_last_day, month=int(self.fiscalyear_last_month)) + + date_from_str = date_from.strftime(DEFAULT_SERVER_DATE_FORMAT) + date_to_str = date_to.strftime(DEFAULT_SERVER_DATE_FORMAT) + + # Search for fiscal year records reducing the delta between the date_from/date_to. + # This case could happen if there is a gap between two fiscal year records. + # E.g. two fiscal year records: 2017-01-01 -> 2017-02-01 and 2017-03-01 -> 2017-12-31. + # => The period 2017-02-02 - 2017-02-30 is not covered by a fiscal year record. + + fiscalyear_from = self.env['account.fiscal.year'].search([ + ('company_id', '=', self.id), + ('date_from', '<=', date_from_str), + ('date_to', '>=', date_from_str), + ], limit=1) + if fiscalyear_from: + date_from = fiscalyear_from.date_to + timedelta(days=1) + + fiscalyear_to = self.env['account.fiscal.year'].search([ + ('company_id', '=', self.id), + ('date_from', '<=', date_to_str), + ('date_to', '>=', date_to_str), + ], limit=1) + if fiscalyear_to: + date_to = fiscalyear_to.date_from - timedelta(days=1) + + return {'date_from': date_from, 'date_to': date_to} + + def _get_unreconciled_statement_lines_redirect_action(self, unreconciled_statement_lines): + # OVERRIDE account + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=[('id', 'in', unreconciled_statement_lines.ids)], + name=_('Unreconciled statements lines'), + ) diff --git a/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py new file mode 100644 index 0000000..dce9496 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/models/res_config_settings.py @@ -0,0 +1,111 @@ +from datetime import date + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + fiscalyear_last_day = fields.Integer(related='company_id.fiscalyear_last_day', required=True, readonly=False) + fiscalyear_last_month = fields.Selection(related='company_id.fiscalyear_last_month', required=True, readonly=False) + use_anglo_saxon = fields.Boolean(string='Anglo-Saxon Accounting', related='company_id.anglo_saxon_accounting', readonly=False) + invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", related='company_id.invoicing_switch_threshold', readonly=False) + group_fiscal_year = fields.Boolean(string='Fiscal Years', implied_group='odex30_account_accountant.group_fiscal_year') + predict_bill_product = fields.Boolean(string="Predict Bill Product", related='company_id.predict_bill_product', readonly=False) + + sign_invoice = fields.Boolean(string='Authorized Signatory on invoice', related='company_id.sign_invoice', readonly=False) + signing_user = fields.Many2one( + comodel_name='res.users', + string="Signature used to sign all the invoice", + readonly=False, + related='company_id.signing_user', + help="Select a user here to override every signature on invoice by this user's signature" + ) + module_sign = fields.Boolean(string='Sign', compute='_compute_module_sign_status') + + # Deferred expense management + deferred_expense_journal_id = fields.Many2one( + comodel_name='account.journal', + help='Journal used for deferred entries', + readonly=False, + related='company_id.deferred_expense_journal_id', + ) + deferred_expense_account_id = fields.Many2one( + comodel_name='account.account', + help='Account used for deferred expenses', + readonly=False, + related='company_id.deferred_expense_account_id', + ) + generate_deferred_expense_entries_method = fields.Selection( + related='company_id.generate_deferred_expense_entries_method', + readonly=False, required=True, + help='Method used to generate deferred entries', + ) + deferred_expense_amount_computation_method = fields.Selection( + related='company_id.deferred_expense_amount_computation_method', + readonly=False, required=True, + help='Method used to compute the amount of deferred entries', + ) + + # Deferred revenue management + deferred_revenue_journal_id = fields.Many2one( + comodel_name='account.journal', + help='Journal used for deferred entries', + readonly=False, + related='company_id.deferred_revenue_journal_id', + ) + deferred_revenue_account_id = fields.Many2one( + comodel_name='account.account', + help='Account used for deferred revenues', + readonly=False, + related='company_id.deferred_revenue_account_id', + ) + generate_deferred_revenue_entries_method = fields.Selection( + related='company_id.generate_deferred_revenue_entries_method', + readonly=False, required=True, + help='Method used to generate deferred entries', + ) + deferred_revenue_amount_computation_method = fields.Selection( + related='company_id.deferred_revenue_amount_computation_method', + readonly=False, required=True, + help='Method used to compute the amount of deferred entries', + ) + + @api.depends('sign_invoice') + def _compute_module_sign_status(self): + sign_installed = 'sign' in self.env['ir.module.module']._installed() + for settings in self: + settings.module_sign = sign_installed or settings.company_id.sign_invoice + + @api.constrains('fiscalyear_last_day', 'fiscalyear_last_month') + def _check_fiscalyear(self): + # We try if the date exists in 2020, which is a leap year. + # We do not define the constrain on res.company, since the recomputation of the related + # fields is done one field at a time. + for wiz in self: + try: + date(2020, int(wiz.fiscalyear_last_month), wiz.fiscalyear_last_day) + except ValueError: + raise ValidationError( + _('Incorrect fiscal year date: day is out of range for month. Month: %(month)s; Day: %(day)s', + month=wiz.fiscalyear_last_month, day=wiz.fiscalyear_last_day), + ) + + @api.model_create_multi + def create(self, vals_list): + # Amazing workaround: non-stored related fields on company are a BAD idea since the 2 fields + # must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related + # fields, the inverse write is done one value at a time, and thus the constraint is verified + # one value at a time... so it is likely to fail. + for vals in vals_list: + fiscalyear_last_day = vals.pop('fiscalyear_last_day', False) or self.env.company.fiscalyear_last_day + fiscalyear_last_month = vals.pop('fiscalyear_last_month', False) or self.env.company.fiscalyear_last_month + vals = {} + if fiscalyear_last_day != self.env.company.fiscalyear_last_day: + vals['fiscalyear_last_day'] = fiscalyear_last_day + if fiscalyear_last_month != self.env.company.fiscalyear_last_month: + vals['fiscalyear_last_month'] = fiscalyear_last_month + if vals: + self.env.company.write(vals) + return super().create(vals_list) diff --git a/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv new file mode 100644 index 0000000..34dba06 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/security/ir.model.access.csv @@ -0,0 +1,12 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_account_change_lock_date","access.account.change.lock.date","model_account_change_lock_date","account.group_account_manager",1,1,1,0 +"access_account_secure_entries_wizard","access.account.secure.entries.wizard","account.model_account_secure_entries_wizard","account.group_account_user",1,1,1,0 +"access_account_auto_reconcile_wizard","access.account.auto.reconcile.wizard","model_account_auto_reconcile_wizard","account.group_account_user",1,1,1,0 +"access_account_reconcile_wizard","access.account.reconcile.wizard","model_account_reconcile_wizard","account.group_account_user",1,1,1,0 + +access_account_fiscal_year_readonly,account.fiscal.year.user,model_account_fiscal_year,account.group_account_readonly,1,0,0,0 +access_account_fiscal_year_basic,account.fiscal.year.basic,model_account_fiscal_year,account.group_account_basic,1,0,0,0 +access_account_fiscal_year_manager,account.fiscal.year.manager,model_account_fiscal_year,account.group_account_manager,1,1,1,1 + +access_bank_rec_widget,access.bank.rec.widget,model_bank_rec_widget,account.group_account_user,1,1,1,1 +access_bank_rec_widget_line,access.bank.rec.widget.line,model_bank_rec_widget_line,account.group_account_user,1,1,1,1 diff --git a/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml b/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml new file mode 100644 index 0000000..d81eca8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/security/odex30_account_accountant_security.xml @@ -0,0 +1,21 @@ + + + + + + + + + Invoicing & Banks + + + + + + + + + Allow to define fiscal years of more or less than a year + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js new file mode 100644 index 0000000..b4aabbc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/amls_list_view.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { EmbeddedListView } from "./embedded_list_view"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { useState, onWillUnmount } from "@odoo/owl"; + +export class BankRecAmlsRenderer extends ListRenderer { + setup() { + super.setup(); + this.globalState = useState(this.env.methods.getState()); + + onWillUnmount(this.saveSearchState); + } + + /** @override **/ + getRowClass(record) { + const classes = super.getRowClass(record); + const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId); + if (amlId){ + return `${classes} o_rec_widget_list_selected_item table-info`; + } + return classes; + } + + /** @override **/ + async onCellClicked(record, column, ev) { + const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId); + if (amlId) { + this.env.config.actionRemoveNewAml(record.resId); + } else { + this.env.config.actionAddNewAml(record.resId); + } + } + + /** Backup the search facets in order to restore them when the user comes back on this view. **/ + saveSearchState() { + const initParams = this.globalState.bankRecEmbeddedViewsData.amls; + const searchModel = this.env.searchModel; + initParams.exportState = {searchModel: JSON.stringify(searchModel.exportState())}; + } +} + +export const BankRecAmls = { + ...EmbeddedListView, + Renderer: BankRecAmlsRenderer, +}; + +registry.category("views").add("bank_rec_amls_list_view", BankRecAmls); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml new file mode 100644 index 0000000..9537584 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_form.xml @@ -0,0 +1,752 @@ + + + + + +
    + + + + + + + +
    +
    + + + +
    +
    + + + + +
    + + + + + + + + + + + + + + +
    +
    + + +
    + + + + + Create model + + + View models + + + +
    + +
    +
    + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + +
    + + + + + + + + + + + + + New + + + + + +
    + +
    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + : + + + + + + + + + + + + + +
    +
    +
    +
    + + + +
    + +
    +
    + + + + + +
    + + + + +
    +
    + + + + +
    +
    + +
    +
    +
    + +
    + +
    + + in + +
    + +
    +
    +
    +
    + + + +
    +
    + +
    +
    +
    + +
    + +
    + + in + +
    + +
    +
    +
    +
    + + + +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + + + +
    +
    + +
    +
    +
    + +
    +
    +
    + + + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    +
    + + - + +
    +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + +
    + +
    +
    + + + +
    +
    + +
    +
    +
    + + + +
    + + +
    +
    +
    + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.js new file mode 100644 index 0000000..b1aca6e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.js @@ -0,0 +1,26 @@ +import { KanbanRecordQuickCreate, KanbanQuickCreateController } from "@web/views/kanban/kanban_record_quick_create"; + +export class BankRecQuickCreateController extends KanbanQuickCreateController { + static template = "account.BankRecQuickCreateController"; +} + +export class BankRecQuickCreate extends KanbanRecordQuickCreate { + static template = "account.BankRecQuickCreate"; + static props = { + ...Object.fromEntries(Object.entries(KanbanRecordQuickCreate.props).filter(([k, v]) => k !== 'group')), + globalState: { type: Object, optional: true }, + }; + static components = { BankRecQuickCreateController }; + + /** + Overriden. + **/ + async getQuickCreateProps(props) { + await super.getQuickCreateProps({...props, + group: { + resModel: props.globalState.quickCreateState.resModel, + context: props.globalState.quickCreateState.context, + } + }); + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml new file mode 100644 index 0000000..c1359f7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_quick_create.xml @@ -0,0 +1,29 @@ + + + + + + + + +
    + +
    + + + + +
    +
    +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_record.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_record.js new file mode 100644 index 0000000..89aef87 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/bank_rec_record.js @@ -0,0 +1,86 @@ +/** @odoo-module **/ + +import { Record } from "@web/model/relational_model/record"; +import { RelationalModel } from "@web/model/relational_model/relational_model"; +import { parseServerValue } from "@web/model/relational_model/utils"; + +export class BankRecRecord extends Record { + + /** + * override + * Track the changed field on lines. + */ + async _update(changes) { + if(this.resModel === "bank.rec.widget.line"){ + for(const fieldName of Object.keys(changes)){ + this.model.lineIdsChangedField = fieldName; + } + } + return super._update(...arguments); + } + + async updateToDoCommand(methodName, args, kwargs) { + this.dirty = true; + + const onChangeFields = ["todo_command"]; + const changes = { + todo_command: { + method_name: methodName, + args: args, + kwargs: kwargs, + }, + }; + + const localChanges = this._getChanges( + { ...this._changes, ...changes }, + { withReadonly: true } + ); + const otherChanges = await this.model._onchange(this.config, { + changes: localChanges, + fieldNames: onChangeFields, + evalContext: this.evalContext, + }); + + const data = { ...this.data, ...changes }; + for (const fieldName in otherChanges) { + data[fieldName] = parseServerValue(this.fields[fieldName], otherChanges[fieldName]); + } + const applyChanges = () => { + Object.assign(changes, this._parseServerValues(otherChanges, this.data)); + if (Object.keys(changes).length > 0) { + this._applyChanges(changes); + } + }; + return { data, applyChanges }; + } + + /** + * Bind an action to be called when a field on lines changed. + * @param {Function} callback: The action to call taking the changed field as parameter. + */ + bindActionOnLineChanged(callback){ + this._onUpdate = async () => { + const lineIdsChangedField = this.model.lineIdsChangedField; + if(lineIdsChangedField){ + this.model.lineIdsChangedField = null; + await callback(lineIdsChangedField); + } + } + } +} + +export class BankRecRelationalModel extends RelationalModel{ + setup(params, { action, dialog, notification, rpc, user, view, company }) { + super.setup(...arguments); + this.lineIdsChangedField = null; + } + + load({ values }) { + this.root = this._createRoot(this.config, values); + } + + getInitialValues() { + return this.root._getChanges(this.root.data, { withReadonly: true }) + } +} +BankRecRelationalModel.Record = BankRecRecord; diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/embedded_list_view.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/embedded_list_view.js new file mode 100644 index 0000000..bc7ebe2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/embedded_list_view.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import { ListController } from "@web/views/list/list_controller"; +import { listView } from "@web/views/list/list_view"; + +export class BankRecEmbeddedListController extends ListController { + /** Remove the Export Cog **/ + static template = "odex30_account_accountant.BankRecEmbeddedListController"; +} + + +export class BankRecWidgetFormEmbeddedListModel extends listView.Model { + setup(params, { action, dialog, notification, rpc, user, view, company }) { + super.setup(...arguments); + this.storedDomainString = null; + } + + /** + * @override + * the list of AMLs don't need to be fetched from the server every time the form view is re-rendered. + * this disables the retrieval, while still ensuring that the search bar works. + */ + async load(params = {}) { + const currentDomain = params.domain.toString(); + if (currentDomain !== this.storedDomainString) { + this.storedDomainString = currentDomain; + return super.load(params); + } + } +} + +export const EmbeddedListView = { + ...listView, + Controller: BankRecEmbeddedListController, + Model: BankRecWidgetFormEmbeddedListModel, +}; diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.js new file mode 100644 index 0000000..56adc44 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.js @@ -0,0 +1,49 @@ +/** @odoo-module **/ +import { Component, useState } from "@odoo/owl"; + +export class BankRecFinishButtons extends Component { + static template = "odex30_account_accountant.BankRecFinishButtons"; + static props = {}; + + setup() { + this.breadcrumbs = useState(this.env.config.breadcrumbs); + } + + getJournalFilter() { + // retrieves the searchModel's searchItem for the journal + return Object.values(this.searchModel.searchItems).filter(i => i.type == "field" && i.fieldName == "journal_id")[0]; + } + + get searchModel() { + return this.env.searchModel; + } + + get otherFiltersActive() { + const facets = this.searchModel.facets; + const journalFilterItem = this.getJournalFilter(); + for (const facet of facets) { + if (facet.groupId !== journalFilterItem.groupId) { + return true; + } + } + return false; + } + + clearFilters() { + const facets = this.searchModel.facets; + const journalFilterItem = this.getJournalFilter(); + for (const facet of facets) { + if (facet.groupId !== journalFilterItem.groupId) { + this.searchModel.deactivateGroup(facet.groupId); + } + } + } + + breadcrumbBackOrDashboard() { + if (this.breadcrumbs.length > 1) { + this.env.services.action.restore(); + } else { + this.env.services.action.doAction("account.open_account_journal_dashboard_kanban", {clearBreadcrumbs: true}); + } + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml new file mode 100644 index 0000000..d13871c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/finish_buttons.xml @@ -0,0 +1,10 @@ + + + +

    All Transactions

    +

    + + Back to +

    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.js new file mode 100644 index 0000000..e14bd7e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ +import { Component, onWillStart } from "@odoo/owl"; +import { user } from "@web/core/user"; + +export class BankRecGlobalInfo extends Component { + static template = "odex30_account_accountant.BankRecGlobalInfo"; + static props = { + journalId: { type: Number }, + journalBalanceAmount: { type: String }, + }; + + setup() { + this.hasGroupReadOnly = false; + onWillStart(async () => { + this.hasGroupReadOnly = await user.hasGroup("account.group_account_readonly"); + }) + } + + /** Open the bank reconciliation report. **/ + actionOpenBankGL() { + this.env.methods.actionOpenBankGL(this.props.journalId); + } + +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.xml new file mode 100644 index 0000000..3a836f8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/global_info.xml @@ -0,0 +1,18 @@ + + + + +
    + + Balance + + +
    +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js new file mode 100644 index 0000000..fd0597e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.js @@ -0,0 +1,1243 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { scrollTo } from "@web/core/utils/scrolling"; +import { getCurrency } from "@web/core/currency"; +import { formatMonetary } from "@web/views/fields/formatters"; +import { formatDate } from "@web/core/l10n/dates"; +import { localization } from "@web/core/l10n/localization"; + +import { CallbackRecorder, useSetupAction } from "@web/search/action_hook"; +import { RelationalModel } from "@web/model/relational_model/relational_model"; +import { makeActiveField } from "@web/model/relational_model/utils"; +import { kanbanView } from "@web/views/kanban/kanban_view"; +import { KanbanController } from "@web/views/kanban/kanban_controller"; +import { KanbanRenderer } from "@web/views/kanban/kanban_renderer"; +import { KanbanRecord } from "@web/views/kanban/kanban_record"; + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { Chatter } from "@mail/chatter/web_portal/chatter"; +import { Many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field"; +import { Many2OneField } from "@web/views/fields/many2one/many2one_field"; +import { DateTimeField } from "@web/views/fields/datetime/datetime_field"; +import { CharField } from "@web/views/fields/char/char_field"; +import { AnalyticDistribution } from "@analytic/components/analytic_distribution/analytic_distribution"; +import { TagsList } from "@web/core/tags_list/tags_list"; +import { HtmlField } from "@web_editor/js/backend/html_field"; +import { RainbowMan } from "@web/core/effects/rainbow_man"; +import { Notebook } from "@web/core/notebook/notebook"; +import { user } from "@web/core/user"; + +import { BankRecRelationalModel } from "./bank_rec_record"; +import { BankRecMonetaryField } from "./monetary_field_auto_signed_amount"; +import { BankRecViewEmbedder } from "./view_embedder"; +import { BankRecRainbowContent } from "./rainbowman_content"; +import { BankRecFinishButtons } from "./finish_buttons"; +import { BankRecGlobalInfo } from "./global_info"; +import { BankRecQuickCreate } from "./bank_rec_quick_create"; + +import { onPatched, useState, useEffect, useRef, useChildSubEnv, markRaw } from "@odoo/owl"; + +export class BankRecKanbanRecord extends KanbanRecord { + static template = "account.BankRecKanbanRecord"; + + setup(){ + super.setup(); + this.state = useState(this.env.methods.getState()); + } + + /** @override **/ + getRecordClasses() { + let classes = `${super.getRecordClasses()} w-100 o_bank_rec_st_line`; + if (this.props.record.resId === this.state.bankRecStLineId) { + classes = `${classes} o_bank_rec_selected_st_line table-info`; + } + return classes; + } +} + + +export class BankRecKanbanController extends KanbanController { + static template = "account.BankRecoKanbanController"; + static props = { + ...KanbanController.props, + skipRestore: { optional: true }, + }; + static components = { + ...KanbanController.components, + Dropdown, + DropdownItem, + Many2OneField, + Many2ManyTagsField, + DateTimeField, + CharField, + AnalyticDistribution, + Chatter, + TagsList, + HtmlField, + BankRecMonetaryField, + Notebook, + BankRecViewEmbedder, + }; + + async setup() { + super.setup(); + + // ==================== INITIAL SETUP ==================== + + // Actions. + this.action = useService("action"); + this.orm = useService("orm"); + this.ui = useService("ui"); + + // RelationalModel services. + this.relationalModelServices = Object.fromEntries( + RelationalModel.services.map((servName) => { + return [servName, useService(servName)]; + }) + ); + this.relationalModelServices.orm = useService("orm"); + + useChildSubEnv(this.getChildSubEnv()); + + // Mount the correct statement line when the search panel changed + this.env.searchModel.addEventListener( + "update", + () => { + this.model.bus.addEventListener( + "update", + this.onKanbanSearchModelChanged.bind(this), + { once: true }, + ); + }, + ); + + // ==================== STATE ==================== + + this.bankRecModel = null; + + this.state = useState({ + // BankRec. + bankRecStLineId: null, + bankRecRecordData: null, + bankRecEmbeddedViewsData: null, + bankRecNotebookPage: null, + bankRecClickedColumn: null, + + // Global info. + journalId: null, + journalBalanceAmount: "", + + // Asynchronous validation stuff. + lockedStLineIds: new Set(), + lockedAmlIds: new Set(), + + quickCreateState : { + isVisible: false, + view: this.props.archInfo.quickCreateView, + context: this.props.context, + }, + }); + + this.counter = { + // Counter state is separated as it should not be impacted by asynchronous changes, the last update is final. + startTime: null, + timeDiff: null, + count: null, + }; + + // When focusing the manual operations tab, mount the last line in edition automatically. + useEffect( + () => { + if( + this.state.bankRecNotebookPage === "manual_operations_tab" + && this.state.bankRecRecordData + && !this.state.bankRecRecordData.form_index + ){ + this.actionMountLastLineInEdit(); + } + }, + () => [this.state.bankRecNotebookPage], + ); + + // ==================== EXPORT STATE ==================== + + this.viewRef = useRef("root"); + + useSetupAction({ + rootRef: this.viewRef, + getLocalState: () => { + const exportState = {}; + if(this.bankRecModel.root.data.st_line_id){ + exportState.backupValues = Object.assign( + {}, + this.state.bankRecEmbeddedViewsData, + { + bankRecStLineId: this.state.bankRecStLineId, + initial_values: this.bankRecModel.getInitialValues(), + }, + ); + } + return exportState; + } + }); + + onPatched(() => { + if( + this.state.bankRecClickedColumn + && this.focusManualOperationField(this.state.bankRecClickedColumn) + ){ + this.state.bankRecClickedColumn = null; + } + }); + + // ==================== LOCK SCREEN ==================== + + this.kanbanLock = false; + this.bankRecLock = false; + this.bankRecPromise = null; + } + + // ----------------------------------------------------------------------------- + // HELPERS CONCURRENCY + // ----------------------------------------------------------------------------- + + /** + * Execute the function passed as parameter initiated by the kanban. + * If some action is already processing by bankRecForm, it will wait until its completion. + * @param {Function} func: The action to execute. + */ + async execProtectedAction(func){ + if(this.kanbanLock){ + return; + } + this.kanbanLock = true; + if(this.bankRecPromise){ + await this.bankRecPromise; + } + await func(); + this.kanbanLock = false; + } + + /** + * Execute the function passed as parameter initiated by bankRecForm. + * If some concurrent actions are triggered by bankRecForm, the second one is ignored. + * @param {Function} func: The action to execute. + */ + async execProtectedBankRecAction(func){ + if(this.bankRecLock){ + return; + } + this.bankRecLock = true; + try { + this.bankRecPromise = func(); + await this.bankRecPromise; + } finally { + this.bankRecPromise = null; + this.bankRecLock = false; + } + } + + // ----------------------------------------------------------------------------- + // HELPERS STATE + // ----------------------------------------------------------------------------- + + getState(){ + return this.state; + } + + /** + * Since the kanban is driven by a reactive state for the additional stuff but by deep render + * in its base implementation, the code is turning crazy with rendering everytime a deep render + * is triggered. Indeed, notifying the model then changing things on the state could trigger a lot of + * mount/unmount of components implying useless rpc requests (when mounting multiple times an + * embedded list view for example). + * To avoid that, this method must be used everywhere to update only once the state and to delay + * the notify (itself triggering the deep render) after the update of the state. + * @param {Function} func: The action to execute taking the newState as parameter. + */ + async withNewState(func){ + const newState = {...this.state}; + await func(newState); + if (newState.__commitChanges) { + newState.__commitChanges(); + delete newState.__commitChanges; + } + Object.assign(this.state, newState); + } + + // ----------------------------------------------------------------------------- + // KANBAN OVERRIDES + // ----------------------------------------------------------------------------- + + /** override **/ + get modelOptions() { + return { + ...super.modelOptions, + onWillStartAfterLoad: this.onWillStartAfterLoad.bind(this), + } + } + + /** + * Define the sub environment allowing the sub-components to access some methods from + * the kanban. + */ + getChildSubEnv(){ + return { + // We don't care about subview states but we want to avoid them to record + // some callbacks in the BankRecKanbanController callback recorders passed + // by the action service. + __beforeLeave__: new CallbackRecorder(), + __getLocalState__: new CallbackRecorder(), + __getGlobalState__: new CallbackRecorder(), + __getContext__: new CallbackRecorder(), + + // Accessible methods from sub-components. + methods: { + withNewState: this.withNewState.bind(this), + actionOpenBankGL: this.actionOpenBankGL.bind(this), + focusManualOperationField: this.focusManualOperationField.bind(this), + getState: this.getState.bind(this), + actionAddNewAml: this.actionAddNewAml.bind(this), + actionRemoveNewAml: this.actionRemoveNewAml.bind(this), + showRainbowMan: this.showRainbowMan.bind(this), + initReconCounter: this.initReconCounter.bind(this), + getCounterSummary: this.getCounterSummary.bind(this), + getRainbowManContentProps: this.getRainbowManContentProps.bind(this), + updateJournalState: this.updateJournalState.bind(this), + }, + }; + } + + /** Called when the kanban is initialized. **/ + async onWillStartAfterLoad(){ + // Fetch groups. + this.hasGroupAnalyticAccounting = await user.hasGroup("analytic.group_analytic_accounting"); + this.hasGroupReadOnly = await user.hasGroup("account.group_account_readonly"); + + + // Prepare bankRecoModel. + await this.initBankRecModel(); + + let stLineId = null; + let backupValues = null; + + // Try to restore. + if(this.props.state && this.props.state.backupValues && !this.props.skipRestore){ + const backupStLineId = this.props.state.backupValues.bankRecStLineId; + if(this.model.root.records.find(x => x.resId === backupStLineId)){ + stLineId = backupStLineId; + backupValues = this.props.state.backupValues; + } + } + + // Find the next transaction to mount. + if(!stLineId){ + stLineId = this.getNextAvailableStLineId(); + } + + await this.withNewState(async (newState) => { + + // Mount the transaction if any. + if(stLineId){ + await this._mountStLineInEdit(newState, stLineId, backupValues); + }else{ + await this.updateJournalState(newState); + } + + this.initReconCounter(); + }); + } + + /** Called when the something changed in the kanban search model. **/ + async onKanbanSearchModelChanged(){ + await this.execProtectedAction(async () => { + await this.withNewState(async (newState) => { + if(this.model.root.records.find(x => x.resId === newState.bankRecStLineId)){ + return; + } + + const nextStLineId = this.getNextAvailableStLineId(); + await this._mountStLineInEdit(newState, nextStLineId); + }); + }); + } + + /** + Method called when the user clicks on a card. + **/ + async openRecord(record, mode) { + const currentStLineId = this.bankRecModel ? this.bankRecModel.root.data.st_line_id[0] : null; + const isSameStLineId = currentStLineId && currentStLineId === record.resId; + if (isSameStLineId) { + return; + } + await this.execProtectedAction(async () => { + await this.withNewState(async (newState) => { + await this._mountStLineInEdit(newState, record.resId); + }); + }); + } + + /** + Method called when the user changes the search pager. + **/ + async onUpdatedPager() { + await this.execProtectedAction(async () => { + await this.withNewState(async (newState) => { + const nextStLineId = this.getNextAvailableStLineId(); + await this._mountStLineInEdit(newState, nextStLineId); + }); + }); + } + + onPageUpdate(page) { + if (this.state.bankRecNotebookPage !== page) { + this.state.bankRecNotebookPage = page; + } + } + + /** + Overriden. + **/ + get canQuickCreate() { + return true; + } + + /** + Overriden. + **/ + createRecord() { + const { onCreate } = this.props.archInfo; + const searchModel = this.env.searchModel; + const journalFilter = Object.values(searchModel.searchItems).filter(i => i.type == "field" && i.fieldName == "journal_id")[0]; + + // If there are no records, deactivate all filters except the journal one. + if (!this.model.root.records.length) { + searchModel.facets.forEach(facet => { + if(facet.groupId !== journalFilter.groupId) + searchModel.deactivateGroup(facet.groupId) + }); + } + + if (onCreate === "quick_create" && this.canQuickCreate) { + this.state.quickCreateState = { + ...this.state.quickCreateState, + isVisible: true, + resModel: this.props.resModel, + model: this.model, + }; + } + } + + // ----------------------------------------------------------------------------- + // NEXT STATEMENT LINE + // ----------------------------------------------------------------------------- + + /** + Get the next eligible statement line for reconciliation. + @param afterStLineId: An optional id of a statement line indicating we want the + next available line after this one. + @param records: An optional list of records. + **/ + getNextAvailableStLineId(afterStLineId=null, records=null) { + const stLines = this.model.root.records; + + // Find all available records that need to be validated. + const isRecordReady = (x) => (!x.data.is_reconciled || !x.data.checked); + let waitBeforeReturn = Boolean(afterStLineId); + let availableRecordIds = []; + for (const stLine of (records || stLines)) { + if (waitBeforeReturn) { + if (stLine.resId === afterStLineId) { + waitBeforeReturn = false; + } + } else if (isRecordReady(stLine)) { + availableRecordIds.push(stLine.resId); + } + } + + // No records left, focus the first record instead. This behavior is mainly there when clicking on "View" from + // the list view to show an already reconciled line. + if (!availableRecordIds.length && stLines.length === 1) { + availableRecordIds = [stLines[0].resId]; + } + + if (availableRecordIds.length){ + return availableRecordIds[0]; + } else if(stLines.length) { + return stLines[0].resId; + } else { + return null; + } + } + + /** + Mount the statement line passed as parameter into the edition widget. + @param stLineId: The id of the statement line to mount. + **/ + async _mountStLineInEdit(newState, stLineId, initialData = null) { + newState.bankRecStLineId = stLineId; + let data = {}; + if (initialData) { + // Restore an existing transaction. + data = await this.onchange(newState, "restore_st_line_data", [initialData]); + const bankRecEmbeddedViewsData = data.return_todo_command; + for (const [key, value] of Object.entries(bankRecEmbeddedViewsData)) { + if (value instanceof Object) { + bankRecEmbeddedViewsData[key] = Object.assign( + {}, + initialData[key] || {}, + value + ); + } else { + bankRecEmbeddedViewsData[key] = value; + } + } + newState.bankRecEmbeddedViewsData = markRaw(bankRecEmbeddedViewsData); + newState.bankRecNotebookPage = null; + } else if (stLineId) { + // Mount a new transaction. + data = await this.onchange(newState, "mount_st_line", [stLineId]); + const bankRecEmbeddedViewsData = data.return_todo_command + newState.bankRecEmbeddedViewsData = bankRecEmbeddedViewsData; + newState.bankRecNotebookPage = null; + } else { + // No transaction mounted. + newState.bankRecNotebookPage = null; + newState.bankRecRecordData = null; + } + + // Refresh balance. + await this.updateJournalState(newState, data); + + // Scroll to the next kanban card iff the view is mounted, a line is selected and the kanban + // card is in the view (cannot use .o_bank_rec_selected_st_line as the dom may not be patched yet) + if (stLineId && this.viewRef.el) { + const selectedKanbanCardEl = this.viewRef.el.querySelector( + `[st-line-id="${stLineId}"]` + ); + if (selectedKanbanCardEl) { + scrollTo(selectedKanbanCardEl, {}); + } + } + } + + /** + Mount the statement line passed as parameter into the edition widget. + @param stLineId: The id of the statement line to mount. + **/ + async mountStLineInEdit(stLineId, initialData=null){ + await this.withNewState(async (newState) => { + await this._mountStLineInEdit(newState, stLineId, initialData); + }); + } + + // ----------------------------------------------------------------------------- + // BANK_REC_RECORD + // ----------------------------------------------------------------------------- + + async initBankRecModel(){ + const initialData = await this.orm.call( + "bank.rec.widget", + "fetch_initial_data", + ); + + // Services. + function makeActiveFields(fields) { + const activeFields = {}; + for (const fieldName in fields) { + const field = fields[fieldName]; + activeFields[fieldName] = makeActiveField({ onChange: field.onChange}); + if (field.relatedFields) { + activeFields[fieldName].related = { + fields: field.relatedFields, + activeFields: makeActiveFields(field.relatedFields), + } + } + } + return activeFields; + } + const activeFields = makeActiveFields(initialData.fields); + this.bankRecModel = new BankRecRelationalModel( + this.env, + { + config: { + resModel: "bank.rec.widget", + fields: initialData.fields, + activeFields, + mode: "edit", + isMonoRecord: true, + } + }, + this.relationalModelServices, + ); + + // Initial loading. + await this.bankRecModel.load({ + values: initialData.initial_values, + }); + + const record = this.bankRecModel.root; + record.bindActionOnLineChanged(async (changedField) => { + await this.actionLineChanged(changedField); + }); + } + + getBankRecRecordLineInEdit(){ + const data = this.state.bankRecRecordData; + const lineIndex = data.form_index; + return data.line_ids.records.find((x) => x.data.index === lineIndex); + } + + // ----------------------------------------------------------------------------- + // GLOBAL INFO + // ----------------------------------------------------------------------------- + + async updateJournalState(newState, data = {}) { + // Find the journal. + let journalId = null; + const stLineJournalId = data.st_line_journal_id; + if(stLineJournalId){ + journalId = stLineJournalId[0]; + }else if(this.model.root.records.length){ + journalId = this.model.root.records[0].data.journal_id[0]; + }else{ + journalId = this.props.context.default_journal_id; + } + newState.journalId = journalId; + const values = await this.orm.call( + "bank.rec.widget", + "collect_global_info_data", + [journalId], + ); + newState.journalBalanceAmount = values.balance_amount; + } + + // ----------------------------------------------------------------------------- + // COUNTER / RAINBOWMAN + // ----------------------------------------------------------------------------- + + /** Reset the timing and reconciliation counter */ + initReconCounter() { + this.counter.startTime = luxon.DateTime.now(); + this.counter.timeDiff = null; + this.counter.count = 0; + } + + /** Increment the timing and reconciliation counter */ + incrementReconCounter() { + const start = this.counter.startTime.set({millisecond: 0}); + const end = luxon.DateTime.now().set({millisecond: 0}); + this.counter.timeDiff = end.diff(start, "seconds"); + this.counter.count += 1; + } + + showRainbowMan(){ + return this.counter.count > 0; + } + + getCounterSummary() { + const diff = this.counter.timeDiff; + const total = this.counter.count; + const diffInSeconds = diff.seconds; + let units = ["seconds"]; + if (diffInSeconds > 60) { + units.unshift("minutes"); + } + if (diffInSeconds > 3600) { + units.unshift("hours"); + } + return { + counter: total, + secondsPerTransaction: Math.round(diffInSeconds / total), + formattedDuration: diff.toFormat(localization.timeFormat.replace(/HH/, "hh")), + humanDuration: diff.shiftTo(...units).toHuman(), + } + } + + getRainbowManContentProps(){ + return { + fadeout: "no", + message: "", + imgUrl: "/web/static/img/smile.svg", + Component: BankRecRainbowContent, + close: () => {}, + } + } + + // ----------------------------------------------------------------------------- + // HELPERS BANK_REC_RECORD + // ----------------------------------------------------------------------------- + + async moveToNextLine(newState){ + const records = this.model.root.records; + const counter = newState.counter; + await this.model.root.load(); + + const nextStLineId = this.getNextAvailableStLineId(newState.bankRecStLineId, records); + if(nextStLineId != newState.bankRecStLineId){ + await this._mountStLineInEdit(newState, nextStLineId); + } + newState.counter = counter; + newState.__kanbanNotify = true; + } + + formatMonetaryField(amount, currencyId){ + const currencyDigits = getCurrency(currencyId)?.digits; + return formatMonetary(amount, { + digits: currencyDigits, + currencyId: currencyId, + }); + } + + isMonetaryZero(amount, currencyId){ + const currencyDigits = getCurrency(currencyId)?.digits; + return Number(amount.toFixed(currencyDigits ? currencyDigits[1] : 2)) === 0; + } + + formatDateField(date){ + return formatDate(date); + } + + async onchange(newState, methodName, args, kwargs){ + const record = this.bankRecModel.root; + const { data, applyChanges } = await record.updateToDoCommand(methodName, args, kwargs); + + newState.__commitChanges = () => { + applyChanges(); + newState.bankRecRecordData = record.data; + newState.__bankRecRecordNotify = true; + }; + return data; + } + + getOne2ManyColumns() { + const data = this.state.bankRecRecordData; + let lineIdsRecords = data.line_ids.records; + + // Prepare columns. + let columns = [ + ["date", _t("Date")], + ["partner", _t("Partner")], + ]; + if(lineIdsRecords.some((x) => Boolean(Object.keys(x.data.analytic_distribution).length))){ + columns.push(["analytic_distribution", _t("Analytic")]); + } + if(lineIdsRecords.some((x) => x.data.tax_ids.records.length)){ + columns.push(["taxes", _t("Taxes")]); + } + if(lineIdsRecords.some((x) => x.data.currency_id[0] !== data.company_currency_id[0])){ + columns.push(["amount_currency", _t("Amount in Currency")], ["currency", _t("Currency")]); + } + if (this.hasGroupReadOnly) { + columns.unshift(["account", _t("Account")]); + columns.push( + ["debit", _t("Debit")], + ["credit", _t("Credit")], + ["__trash", ""], + ); + } else { + columns.push( + ["balance", _t("Amount")], + ["__trash", ""], + ); + } + + return columns; + } + + getKey(lineData) { + return `${lineData.index} ${JSON.stringify(lineData.analytic_distribution)}`; + } + + checkBankRecLineRequiredField(line, invalidFields, fieldName, condition){ + if(!line.data[fieldName] && (!condition || condition())){ + invalidFields.push(fieldName); + } + } + + getBankRecLineInvalidFields(line){ + const invalidFields = []; + this.checkBankRecLineRequiredField(line, invalidFields, "account_id"); + this.checkBankRecLineRequiredField(line, invalidFields, "date", () => line.data.flag === "liquidity"); + return invalidFields; + } + + checkBankRecLinesInvalidFields(data){ + return data.line_ids.records.filter((l) => this.getBankRecLineInvalidFields(l).length > 0).length === 0; + } + + notebookAmlsListViewProps(){ + const initParams = this.state.bankRecEmbeddedViewsData.amls; + const ctx = initParams.context; + const suspenseLine = this.state.bankRecRecordData.line_ids.records.filter((l) => l.data.flag == "auto_balance"); + if (suspenseLine.length) { + // Change the sort order of the AML's in the list view based on the amount of the suspense line + // This is done from JS instead of python because the embedded_views_data is only prepared when selecting + // a statement line, and not after mounting an AML that would change the auto_balance value (suspense line) + ctx['preferred_aml_value'] = suspenseLine[0].data.amount_currency * -1; + ctx['preferred_aml_currency_id'] = suspenseLine[0].data.currency_id[0]; + } + return { + type: "list", + noBreadcrumbs: true, + resModel: "account.move.line", + searchMenuTypes: ["filter", "favorite"], + domain: initParams.domain, + dynamicFilters: initParams.dynamic_filters, + context: ctx, + allowSelectors: false, + searchViewId: false, // little hack: force to load the search view info + globalState: initParams.exportState, + loadIrFilters: true, + } + } + + /** + Focus the field corresponding to the column name passed as parameter inside the + manual operation page. + **/ + focusManualOperationField(clickedColumn){ + // Focus the field corresponding to the clicked column. + if (['debit', 'credit'].includes(clickedColumn)) { + if (this.focusElement("div[name='balance'] input")) { + return true; + } + if (this.focusElement("div[name='amount_currency'] input")) { + return true; + } + } + + if (this.focusElement(`div[name='${clickedColumn}'] input`)) { + return true; + } + if (this.focusElement(`input[name='${clickedColumn}']`)) { + return true; + } + return false; + } + + /** Helper to find the corresponding field to focus inside the DOM. **/ + focusElement(selector) { + const inputEl = this.viewRef.el.querySelector(selector); + if (!inputEl) { + return false; + } + + if (inputEl.tagName === "INPUT") { + inputEl.focus(); + inputEl.select(); + } else { + inputEl.focus(); + } + return true; + } + + // ----------------------------------------------------------------------------- + // RPC + // ----------------------------------------------------------------------------- + + async actionOpenBankGL(journalId) { + const actionData = await this.orm.call( + "account.journal", + "action_open_bank_balance_in_gl", + [journalId], + ); + this.action.doAction(actionData); + } + + async actionRemoveLine(line){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + await this.onchange(newState, "remove_line", [line.data.index]); + + if(newState.bankRecNotebookPage === "manual_operations_tab"){ + newState.bankRecNotebookPage = "amls_tab"; + } + }); + }); + } + + async actionSelectRecoModel(recoModel){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const data = newState.bankRecRecordData; + if(recoModel.resId == data.selected_reco_model_id.id){ + return; + } + const { return_todo_command: actionData } = await this.onchange(newState, "select_reconcile_model", [recoModel.resId]) + if(actionData){ + this.action.doAction(actionData); + } + }); + }); + } + + actionCreateRecoModel(){ + this.execProtectedBankRecAction(async () => { + const journalId = this.state.bankRecRecordData.st_line_journal_id[0]; + const lines = this.state.bankRecRecordData.line_ids.records; + + const defaultLineIds = []; + let balance = lines.filter(line => line.data.flag === "liquidity")[0].data.balance + if(!this.isMonetaryZero(balance, this.state.bankRecRecordData.company_currency_id[0])){ + for (const line of lines) { + const data = line.data; + if (!["manual", "aml"].includes(data.flag)){ + continue; + } + + defaultLineIds.push([0, 0, { + label: data.name, + account_id: data.account_id[0], + tax_ids: [[6, 0, data.tax_ids.currentIds]], + amount_type: "percentage", + amount_string: ((-data.balance / balance) * 100).toFixed(5), + }]); + balance += data.balance; + } + } + + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "account.reconcile.model", + views: [[false, "form"]], + target: "current", + context: { + default_match_journal_ids: [journalId], + default_line_ids: defaultLineIds, + default_to_check: !this.state.bankRecRecordData.checked, + }, + }); + }); + } + + actionViewRecoModels(){ + this.execProtectedBankRecAction(async () => { + this.action.doAction("account.action_account_reconcile_model"); + }); + } + + async _actionValidate(newState){ + const { return_todo_command: result } = await this.onchange(newState, "validate"); + if(result.done){ + this.incrementReconCounter(); + await this.moveToNextLine(newState); + } + return result; + } + + async actionValidate(){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + await this._actionValidate(newState); + }); + }); + } + + async actionReset(){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const { return_todo_command: result } = await this.onchange(newState, "reset"); + + if(result.done){ + await this.model.root.load(); + + const stLineId = newState.bankRecStLineId; + if(!stLineId){ + return; + } + + const records = this.model.root.records; + if(!records.length){ + // The transaction is not longer available on the kanban. + newState.bankRecStLineId = null; + newState.bankRecNotebookPage = null; + newState.bankRecRecordData = null; + }else if(!records.find((x) => x.resId === stLineId)){ + // Move to the next available transaction. + const nextStLineId = this.getNextAvailableStLineId(stLineId); + await this._mountStLineInEdit(newState, nextStLineId); + } + + if(newState.bankRecNotebookPage != "amls_tab"){ + newState.bankRecNotebookPage = "amls_tab"; + } + + newState.__kanbanNotify = true; + } + }); + }); + } + + async actionToCheck(){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const { return_todo_command: result } = await this.onchange(newState, "to_check"); + if(result.done){ + await this.moveToNextLine(newState); + } + }); + }); + } + + async actionSetAsChecked(){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const data = await this.onchange(newState, "set_as_checked"); + const result = data.return_todo_command; + if(result.done && data.state === "reconciled"){ + await this.moveToNextLine(newState); + }else{ + await this.model.root.load(); + newState.__kanbanNotify = true; + } + }); + }); + } + + async actionAddNewAml(amlId){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + await this.onchange(newState, "add_new_aml", [amlId]); + }); + }); + } + + async actionRemoveNewAml(amlId){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + await this.onchange(newState, "remove_new_aml", [amlId]); + }); + }); + } + + async _actionMountLineInEdit(newState, line){ + const data = newState.bankRecRecordData; + const currentLineIndex = data.form_index; + if(line.data.index != currentLineIndex){ + // Mount the line in edition on the form. + await this.onchange(newState, "mount_line_in_edit", [line.data.index]); + } + + if(newState.bankRecNotebookPage !== "manual_operations_tab"){ + newState.bankRecNotebookPage = "manual_operations_tab"; + } + } + + async actionMountLineInEdit(line){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + await this._actionMountLineInEdit(newState, line); + }); + }); + } + + async actionMountLastLineInEdit(){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const data = newState.bankRecRecordData; + const line = data.line_ids.records.at(-1); + await this._actionMountLineInEdit(newState, line); + }); + }); + } + + async postprocessLineChangedReturnTodoCommand(newState, data) { + const todo = data.return_todo_command; + if(!todo){ + return; + } + if(todo.reset_record){ + await this.model.root.load(); + newState.__kanbanNotify = true; + } + if(todo.reset_global_info){ + await this.updateJournalState(newState, data); + } + } + + async actionLineChanged(fieldName){ + await this.execProtectedBankRecAction(async () => { + const line = this.getBankRecRecordLineInEdit(); + await this.withNewState(async (newState) => { + if(line){ + const data = await this.onchange(newState, "line_changed", [line.data.index, fieldName]); + await this.postprocessLineChangedReturnTodoCommand(newState, data); + } + }); + }); + } + + async actionSetPartnerReceivableAccount(){ + await this.execProtectedBankRecAction(async () => { + const line = this.getBankRecRecordLineInEdit(); + await this.withNewState(async (newState) => { + if(line){ + const data = await this.onchange(newState, "line_set_partner_receivable_account", [line.data.index]) + await this.postprocessLineChangedReturnTodoCommand(newState, data); + } + }); + }); + } + + async actionSetPartnerPayableAccount(){ + await this.execProtectedBankRecAction(async () => { + const line = this.getBankRecRecordLineInEdit(); + await this.withNewState(async (newState) => { + if(line){ + const data = await this.onchange(newState, "line_set_partner_payable_account", [line.data.index]) + await this.postprocessLineChangedReturnTodoCommand(newState, data); + } + }); + }); + } + + async actionRedirectToSourceMove(line){ + await this.execProtectedBankRecAction(async () => { + await this.withNewState(async (newState) => { + const { return_todo_command: actionData } = await this.onchange(newState, "redirect_to_move", [line.data.index]) + if(actionData){ + this.action.doAction(actionData); + } + }); + }); + } + + async actionApplyLineSuggestion(){ + await this.execProtectedBankRecAction(async () => { + const line = this.getBankRecRecordLineInEdit(); + await this.withNewState(async (newState) => { + if(line){ + await this.onchange(newState, "apply_line_suggestion", [line.data.index]) + } + }); + }); + } + + async handleLineClicked(ev, line){ + const lineIndexBeforeClick = this.state.bankRecRecordData.form_index; + await this.actionMountLineInEdit(line); + + let clickedColumn = null; + const target = ev.target.tagName === "TD" ? ev.target : ev.target.closest("td"); + if (target?.attributes && target.attributes.field) { + clickedColumn = target.attributes.field.value; + } + + // Track the clicked column to focus automatically the corresponding field on the manual operations page. + // In case we did not change the selected line we directly focus the corresponding field. + if(clickedColumn){ + if(lineIndexBeforeClick === line.data.index) { + this.focusManualOperationField(clickedColumn); + this.state.bankRecClickedColumn = null; + } else { + this.state.bankRecClickedColumn = clickedColumn; + } + } + } + + async handleSuggestionHtmlClicked(ev){ + if (ev.target.tagName === "BUTTON"){ + const buttonName = ev.target.attributes && ev.target.attributes.name ? ev.target.attributes.name.value : null; + if (!buttonName) { + return; + } + + if (buttonName === "action_redirect_to_move"){ + const line = this.getBankRecRecordLineInEdit(); + await this.actionRedirectToSourceMove(line); + } else if (buttonName === "action_apply_line_suggestion"){ + await this.actionApplyLineSuggestion(); + } + } + } + +} + +export class BankRecKanbanRenderer extends KanbanRenderer { + static template = "account.BankRecKanbanRenderer"; + static components = { + ...KanbanRenderer.components, + KanbanRecord: BankRecKanbanRecord, + RainbowMan, + BankRecFinishButtons, + BankRecGlobalInfo, + BankRecQuickCreate, + }; + setup() { + super.setup(); + this.globalState = useState(this.env.methods.getState()); + this.action = useService("action"); + } + + /** + Prepares a list of statements based on the statement_id of the bank statement line records. + Statements are only displayed above the first line of the statement (all lines might not be visible in the kanban) + **/ + groups() { + const { list } = this.props; + let statementGroups = []; + for (const record of list.records) { + let lastItem = statementGroups.slice(-1); + let statementId = record.data.statement_id && record.data.statement_id[0]; + if (statementId && (!lastItem.length || lastItem[0].id != statementId)) { + statementGroups.push({ + id: statementId, + name: record.data.statement_name, + balance: formatMonetary(record.data.statement_balance_end_real, {currencyId: record.data.currency_id[0]}), + }); + } + } + return statementGroups; + } + + openStatementDialog(statementId) { + const action = { + type: "ir.actions.act_window", + res_model: "account.bank.statement", + res_id: statementId, + views: [[false, "form"]], + target: "current", + context: { + form_view_ref: "odex30_account_accountant.view_bank_statement_form_bank_rec_widget", + }, + }; + + this.action.doAction(action); + } + + // ----------------------------------------------------------------------------- + // QUICK CREATE CALLBACKS + // ----------------------------------------------------------------------------- + + /** + Overriden. + **/ + cancelQuickCreate() { + this.globalState.quickCreateState.isVisible = false; + } + + /** + Overriden. + **/ + validateQuickCreate(_recordId, mode) { + this.globalState.quickCreateState.model.load() + if (mode === "add_close") { + this.globalState.quickCreateState.isVisible = false; + } + } +} + +export const BankRecKanbanView = { + ...kanbanView, + Controller: BankRecKanbanController, + Renderer: BankRecKanbanRenderer, + searchMenuTypes: ["filter", "favorite"], +}; + +registry.category("views").add('bank_rec_widget_kanban', BankRecKanbanView); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.scss b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.scss new file mode 100644 index 0000000..9942d7e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.scss @@ -0,0 +1,286 @@ +.o_bank_rec_main_div { + display: flex; + background-color: $o-view-background-color; + width: 100%; + height: 100%; + + .o_form_view.o_xxl_form_view.o_view_controller .o_form_view_container { + width: 100%; + } + + .o_kanban_renderer { + border-top: 0; + } + + .o_bank_rec_kanban_div{ + width: 30%; + height: 100%; + position: relative; + overflow: auto; + padding: 0; + margin: 0; + --KanbanRecord-padding-h: #{$o-kanban-inside-hgutter * 1.5}; + --KanbanRecord-padding-v: #{$o-kanban-inside-vgutter}; + --KanbanRecord-margin-h: #{$o-kanban-record-margin}; + + .o_reward { + .o_reward_rainbow_man { + top: -25% !important; + } + } + + .journal-balance { + margin: 0px var(--KanbanRecord-margin-h) -1px; + padding: $o-kanban-inside-vgutter calc(var(--KanbanRecord-padding-h)); + min-height: $o-statbutton-height; + } + + .kanban-statement { + margin: 0px var(--KanbanRecord-margin-h) -1px; + padding: $o-kanban-inside-vgutter calc(var(--KanbanRecord-padding-h)); + .kanban-statement-subline { + border: none; + font-size: 16px; + padding: 16px 0 0 0; + } + } + + .o_bank_rec_st_line{ + margin: 0px var(--KanbanRecord-margin-h) (-$border-width) !important; + align-items: center; + + &:hover { + .statement_separator .btn-sm { + opacity: 100; + } + } + + &.o_bank_rec_selected_st_line { + background-color: var(--table-bg); + } + + .statement_separator { + position: absolute; + height: 4px; + margin: 0; + padding: 2px 0 0 0; + border: 0; + z-index: 2; + background-color: transparent; + + .btn-sm { + position: relative; + padding-top: 0; + padding-bottom: 0; + top: -21px; + opacity: 0; // display: none doesn't work with tour + } + } + } + } + + .o_bank_rec_right_div { + flex: 1 1 70%; + width: 70%; + border-left: 1px solid $border-color; + height: 100%; + position: relative; + overflow: auto; + + .o_content{ + .o_form_nosheet{ + padding-left: 0 !important; + padding-right: 0 !important; + + .row { + margin-left: 0 !important; + margin-right: 0 !important; + } + + table tr td:first-child, table th:first-child { + padding-left: 10px; + } + + .o_list_table { + --ListRenderer-thead-padding-y: #{$table-cell-padding-y-sm}; + th { + border-left: none !important; + } + } + + .o_bank_rec_stats_buttons{ + display: flex; + flex-flow: row nowrap; + margin-top: -$o-sheet-vpadding; + white-space: nowrap; + border-bottom: 1px solid $border-color; + height: fit-content; + min-height: $o-statbutton-height; + + .o_bank_rec_stats_buttons_aside_left{ + flex-grow: 0; + display: flex !important; + flex-flow: row nowrap; + border-right: 1px solid $border-color; + + .btn { + margin-right: $o-statbutton-spacing; + } + } + + .o_bank_rec_stats_buttons_aside_right{ + width: 100%; + overflow: auto; + overflow-y: hidden; + justify-content: right; + flex-grow: 1; + margin: 0px !important; + + .available_reco_model_ids { + margin-bottom: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: flex-end; + + .o_bank_rec_reco_models_widget_div{ + display: flex; + flex-wrap: wrap; + height: 100%; + width: 100%; + flex: 1 0; + justify-content: flex-end; + + .recon_model_button { + flex: 0 1 auto; + } + } + + .bank_rec_reco_model_dropdown { + display: flex; + flex-shrink: 0; + } + } + } + } + + .bank_rec_widget_form_discuss_anchor, .bank_rec_widget_form_transaction_details_anchor{ + padding: 0 20px; + } + + .o_form_label { + text-wrap: nowrap; + } + } + + .o_notebook { + --notebook-margin-x: 0 !important; + } + } + } +} +@include media-breakpoint-down(md) { + .o_bank_rec_widget_kanban_view { + --ControlPanel-border-bottom: initial; + } + + .o_bank_rec_main_div { + .o_bank_rec_kanban_div { + --KanbanRecord-margin-h: #{-$border-width}; + } + + .o_bank_rec_kanban_div, .o_bank_rec_right_div { + flex: 1 0 95vw; + } + } +} + +@include media-only(print) { + .o_bank_rec_widget_kanban_view .o_control_panel { + margin-bottom: $border-width; + } +} + +.o_bank_rec_liquidity_line{ + font-weight: bold; +} + +.o_bank_rec_auto_balance_line{ + color: $text-muted; +} + +.o_bank_rec_lines_widget_table{ + th{ + border-top: none; + } +} + +.o_bank_rec_model_button{ + margin: 3px; +} + +.o_bank_rec_selected_line > td { + background-color: var(--table-bg) !important; +} + +.o_bank_rec_expanded_line{ + border-bottom: transparent; + td { + padding-bottom: 0; + } +} + +.o_bank_rec_second_line { + td { + padding-top: 0; + padding-bottom: 1rem; + } + .o_form_uri { + cursor: pointer; + } +} + +.o_bank_rec_quick_create { + .o_form_renderer.o_form_nosheet.o_form_editable.d-block { + padding: 0; + } + + &.o_form_view { + height: auto; + } + + &.o_kanban_record > div:not(.o_dropdown_kanban) { + height: auto; + } + + .o_inner_group { + display: flex; + + .o_cell:first-child { + input,span { + font-weight: bold; + } + } + + .o_cell:nth-child(2) { + input { + font-style: italic; + } + } + } + + .o_wrap_field > :first-child { + flex: 0 0 27%; + } + + .o_inner_group > .o_wrap_field:first-child > :first-child { + flex: 0 0 17%; + } +} + +@media (min-width: 768px) { + .o_bank_rec_quick_create.o_form_view { + height: auto; + min-height: auto; + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.xml new file mode 100644 index 0000000..757adb4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/kanban.xml @@ -0,0 +1,75 @@ + + + + + + + 'o_bank_rec_main_div overflow-auto' + + +
    + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list.js new file mode 100644 index 0000000..643aea9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list.js @@ -0,0 +1,37 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { ListController } from "@web/views/list/list_controller"; + +import { useChildSubEnv } from "@odoo/owl"; + +export class BankRecListController extends ListController { + + setup() { + super.setup(...arguments); + + this.skipKanbanRestore = {}; + + useChildSubEnv({ + skipKanbanRestoreNeeded: (stLineId) => this.skipKanbanRestore[stLineId], + }); + } + + /** + * Override + * Don't allow bank_rec_form to be restored with previous values since the statement line has changed. + */ + async onRecordSaved(record) { + this.skipKanbanRestore[record.resId] = true; + return super.onRecordSaved(...arguments); + } + +} + +export const bankRecListView = { + ...listView, + Controller: BankRecListController, +} + +registry.category("views").add("bank_rec_list", bankRecListView); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js new file mode 100644 index 0000000..0e08a60 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { Component } from "@odoo/owl"; + +export class ListViewSwitcher extends Component { + static template = "odex30_account_accountant.ListViewSwitcher"; + static props = standardFieldProps; + + setup() { + this.action = useService("action"); + } + + /** Called when the Match/View button is clicked. **/ + switchView() { + // Add a new search facet to restrict the results to the selected statement line. + const searchItem = Object.values(this.env.searchModel.searchItems).find((i) => i.fieldName === "statement_line_id"); + const stLineId = this.props.record.resId; + const autocompleteValue = { + label: this.props.record.data.move_id[1], + operator: "=", + value: stLineId, + } + this.env.searchModel.addAutoCompletionValues(searchItem.id, autocompleteValue); + + // Switch to the kanban. + this.action.switchView("kanban", { skipRestore: this.env.skipKanbanRestoreNeeded(stLineId) }); + } + + /** Give the button's label for the current record. **/ + get buttonLabel() { + return this.props.record.data.is_reconciled ? _t("View") : _t("Match"); + } +} + +registry.category("fields").add('bank_rec_list_view_switcher', {component: ListViewSwitcher}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.xml new file mode 100644 index 0000000..8a4ae88 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/list_view_switcher.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/many2one_field_multi_edit.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/many2one_field_multi_edit.js new file mode 100644 index 0000000..9c5011b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/many2one_field_multi_edit.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; +import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field"; + +export class BankRecMany2OneMultiID extends Many2OneField { + + get Many2XAutocompleteProps() { + const props = super.Many2XAutocompleteProps; + if (this.props.record.selected && this.props.record.model.multiEdit) { + props.context.active_ids = this.env.model.root.selection.map((r) => r.resId); + } + return props; + } +} + +export const bankRecMany2OneMultiID = { + ...many2OneField, + component: BankRecMany2OneMultiID, +}; + +registry.category("fields").add("bank_rec_list_many2one_multi_id", bankRecMany2OneMultiID); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.js new file mode 100644 index 0000000..37c01fe --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.js @@ -0,0 +1,38 @@ +/** @odoo-module **/ +import { MonetaryField, monetaryField } from "@web/views/fields/monetary/monetary_field"; + +export class BankRecMonetaryField extends MonetaryField{ + static template = "odex30_account_accountant.BankRecMonetaryField"; + static props = { + ...MonetaryField.props, + hasForcedNegativeValue: { type: Boolean }, + }; + + /** Override **/ + get inputOptions(){ + const options = super.inputOptions; + const parse = options.parse; + options.parse = (value) => { + let parsedValue = parse(value); + if (this.props.hasForcedNegativeValue) { + parsedValue = -Math.abs(parsedValue); + } + return parsedValue; + }; + return options; + } + + /** Override **/ + get value() { + let value = super.value; + if(this.props.hasForcedNegativeValue){ + value = Math.abs(value); + } + return value; + } +} + +export const bankRecMonetaryField = { + ...monetaryField, + component: BankRecMonetaryField, +}; diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.xml new file mode 100644 index 0000000..651e62d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/monetary_field_auto_signed_amount.xml @@ -0,0 +1,17 @@ + + + + + + - + + + + - + + + - + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.js new file mode 100644 index 0000000..3eed2dc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.js @@ -0,0 +1,19 @@ +/** @odoo-module **/ +import { BankRecFinishButtons } from "./finish_buttons"; +import { Component, onMounted, onWillUnmount } from "@odoo/owl"; + +export class BankRecRainbowContent extends Component { + static template = "odex30_account_accountant.BankRecRainbowContent"; + static components = { BankRecFinishButtons }; + static props = {}; + + setup() { + onWillUnmount(() => { + this.env.methods.initReconCounter(); + }); + onMounted(() => { + document.querySelector(".o_reward").style.pointerEvents = "none"; + document.querySelector(".o_reward_msg").style.pointerEvents = "auto"; + }); + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml new file mode 100644 index 0000000..69d619f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/rainbowman_content.xml @@ -0,0 +1,13 @@ + + + + +

    Congrats, you're all done!

    +

    You reconciled transactions in transaction in . + +
    That's on average seconds per transaction. +
    +

    + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.js new file mode 100644 index 0000000..3cfaf1d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.js @@ -0,0 +1,18 @@ +/** @odoo-module */ +import { View } from "@web/views/view"; +import { Component, useSubEnv } from "@odoo/owl"; + +export class BankRecViewEmbedder extends Component { + static props = ["viewProps"]; + static template = "odex30_account_accountant.BankRecViewEmbedder"; + static components = { View }; + + setup() { + // Little hack while better solution from framework js. + // Reset the config, especially the ControlPanel which was coming from a parent form view. + // It also reset the view switchers which was necessary to make them disappear. + useSubEnv({ + config: {...this.env.methods}, + }); + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.xml new file mode 100644 index 0000000..6a782e3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/bank_reconciliation/view_embedder.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/export_data_dialog/export_data_dialog.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/export_data_dialog/export_data_dialog.js new file mode 100644 index 0000000..fa72931 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/export_data_dialog/export_data_dialog.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { rpc } from "@web/core/network/rpc"; +import { ExportDataDialog } from "@web/views/view_dialogs/export_data_dialog"; + +patch(ExportDataDialog.prototype, { + async fetchFields(value) { + await super.fetchFields(value); + + const analyticLineIdsField = this.knownFields['analytic_line_ids']; + if (analyticLineIdsField) { + // If analytic_distribution field is here, we remove it to replace it by the new fields + this.state.exportList = this.state.exportList.filter( + (field) => field.id !== 'analytic_distribution' + ); + const analyticLineFields = await rpc("/web/export/get_fields", { + model: analyticLineIdsField.params.model, + prefix: analyticLineIdsField.params.prefix, + parent_name: analyticLineIdsField.params.parent_field.string, + import_compat: analyticLineIdsField.default_export, + parent_field_type: analyticLineIdsField.params.parent_field.type, + domain:[], + }); + // We exclude auto_account_id as it's a magic field who doesn't need to be exported + const filteredAnalyticLineFields = analyticLineFields.filter( + (field) => field.params?.model === 'account.analytic.account' + && !field.id.includes('auto_account_id') + || field.id === 'analytic_line_ids/amount' + ); + + this.state.exportList.push(...filteredAnalyticLineFields); + } + }, +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.js new file mode 100644 index 0000000..d45a9f7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { Component } from "@odoo/owl"; + +class MatchingLink extends Component { + static props = { ...standardFieldProps }; + static template = "odex30_account_accountant.MatchingLink"; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + } + + async reconcile() { + this.action.doAction("odex30_account_accountant.action_move_line_posted_unreconciled", { + additionalContext: { + search_default_partner_id: this.props.record.data.partner_id[0], + search_default_account_id: this.props.record.data.account_id[0], + }, + }); + } + + async viewMatch() { + const action = await this.orm.call("account.move.line", "open_reconcile_view", [this.props.record.resId], {}); + this.action.doAction(action, { additionalContext: { is_matched_view: true } }); + } + + get colorCode() { + const matchValue = this.props.record.data[this.props.name]; + const matchColorValue = matchValue.replace('P', ''); + if (matchColorValue === '*') { + // reserve color code 0 for multi partial matches + return 0; + } else { + // there is 12 available color palette for 'o_tag_color_*' + // since the color code 0 has been reserved by 'P*', we can only use color codes between 1 and 11 + return parseInt(matchColorValue) % 11 + 1; + } + } +} + +registry.category("fields").add("matching_link_widget", { + component: MatchingLink, +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.xml new file mode 100644 index 0000000..07f52d7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/matching_link_widget/matching_link_widget.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/attachment_view_move_line.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/attachment_view_move_line.js new file mode 100644 index 0000000..9e0db1b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/attachment_view_move_line.js @@ -0,0 +1,17 @@ +import { AttachmentView } from "@mail/core/common/attachment_view"; +import { onMounted } from "@odoo/owl"; +import { useBus } from "@web/core/utils/hooks"; + +export class AttachmentViewMoveLine extends AttachmentView { + static props = [...AttachmentView.props, "openInPopout"]; + static components = { AttachmentView }; + + setup() { + super.setup(); + if (this.props.openInPopout) { + onMounted(this.onClickPopout); + } + + useBus(this.uiService.bus, "resize", () => this.env.setPopout(false)); + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.js new file mode 100644 index 0000000..d5f9bf9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.js @@ -0,0 +1,125 @@ +import { AttachmentViewMoveLine } from "./attachment_view_move_line"; + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { listView } from "@web/views/list/list_view"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { ListController } from "@web/views/list/list_controller"; +import { SIZES } from "@web/core/ui/ui_service"; +import { useChildSubEnv, useState } from "@odoo/owl"; +import { makeActiveField } from "@web/model/relational_model/utils"; + +export class AccountMoveLineListController extends ListController { + static template = "odex30_account_accountant.MoveLineListView"; + static components = { + ...ListController.components, + AttachmentViewMoveLine, + }; + setup() { + super.setup(); + /** @type {import("@mail/core/common/store_service").Store} */ + this.store = useState(useService("mail.store")); + this.ui = useState(useService("ui")); + this.mailPopoutService = useState(useService("mail.popout")); + this.attachmentPreviewState = useState({ + displayAttachment: + localStorage.getItem("account.move_line_pdf_previewer_hidden") !== "false", + selectedRecord: false, + thread: null, + }); + this.popout = useState({ active: false }); + + useChildSubEnv({ + setPopout: this.setPopout.bind(this), + }); + + // We need to override the openRecord from ListController since it does things we do not want like opening the default form view + this.openRecord = () => {} + } + + get previewEnabled() { + return ( + !this.env.searchModel.context.disable_preview && + (this.ui.size >= SIZES.XXL || this.mailPopoutService.externalWindow) + ); + } + + get modelParams() { + const params = super.modelParams; + params.config.activeFields.move_attachment_ids = makeActiveField(); + params.config.activeFields.move_attachment_ids.related = { + fields: { + mimetype: { name: "mimetype", type: "char" }, + }, + activeFields: { + mimetype: makeActiveField(), + }, + }; + return params; + } + + togglePreview() { + this.attachmentPreviewState.displayAttachment = + !this.attachmentPreviewState.displayAttachment; + localStorage.setItem( + "account.move_line_pdf_previewer_hidden", + this.attachmentPreviewState.displayAttachment + ); + } + + setPopout(value) { + + if (this.attachmentPreviewState.thread?.attachmentsInWebClientView.length) { + this.popout.active = value; + } + } + + setSelectedRecord(accountMoveLineData) { + this.attachmentPreviewState.selectedRecord = accountMoveLineData; + this.setThread(this.attachmentPreviewState.selectedRecord); + } + + async setThread(accountMoveLineData) { + if (!accountMoveLineData || !accountMoveLineData.data.move_attachment_ids.records.length) { + this.attachmentPreviewState.thread = null; + return; + } + const thread = this.store.Thread.insert({ + attachments: accountMoveLineData.data.move_attachment_ids.records.map((attachment) => ({ + id: attachment.resId, + mimetype: attachment.data.mimetype, + })), + id: accountMoveLineData.data.move_id[0], + model: accountMoveLineData.fields["move_id"].relation, + }); + if (!thread.mainAttachment && thread.attachmentsInWebClientView.length > 0) { + thread.update({ mainAttachment: thread.attachmentsInWebClientView[0] }); + } + this.attachmentPreviewState.thread = thread; + } +} + +export class AccountMoveLineListRenderer extends ListRenderer { + static props = [...ListRenderer.props, "setSelectedRecord?"]; + onCellClicked(record, column, ev) { + this.props.setSelectedRecord(record); + super.onCellClicked(record, column, ev); + } + + findFocusFutureCell(cell, cellIsInGroupRow, direction) { + const futureCell = super.findFocusFutureCell(cell, cellIsInGroupRow, direction); + if (futureCell) { + const dataPointId = futureCell.closest("tr").dataset.id; + const record = this.props.list.records.filter((x) => x.id === dataPointId)[0]; + this.props.setSelectedRecord(record); + } + return futureCell; + } +} +export const AccountMoveLineListView = { + ...listView, + Renderer: AccountMoveLineListRenderer, + Controller: AccountMoveLineListController, +}; + +registry.category("views").add("account_move_line_list", AccountMoveLineListView); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.scss b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.scss new file mode 100644 index 0000000..0b845c8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.scss @@ -0,0 +1,68 @@ +.o_move_line_list_view { + &.o_view_controller > .o_content { + overflow: hidden; + display: flex; + .o_list_renderer { + @include media-only(screen) { + overflow: auto; + height: 100%; + flex: 1 1 100%; + } + } + } + + .o_attachment_preview { + height: 100%; + width: 40%; + border-left: $border-width solid $border-color; + + .o_attachment_control.toggle { + border-radius: 0 30px 30px 0; + padding: 15px 15px 15px 5px; + &::after { + color: white; + content: '>>'; + } + } + + &.hidden { + width: 0; + .o_attachment_control.toggle { + right: 0; + border-radius: 30px 0 0 30px; + padding: 15px 0 15px 15px; + &::after { + content: '<'; + } + &:hover { + padding-right: 5px; + ::after { + content: '<<'; + } + } + } + } + } + + .o_popout_icon { + position: absolute; + top: 8%; + background-color: black; + opacity: 0.3; + margin-top: -15px; + transition: all 0.3s; + z-index: $zindex-dropdown; + right: 0; + border-radius: 30px 0 0 30px; + padding: 15px 0 15px 15px; + &:hover { + padding-right: 15px; + } + } + + @include media-only(print) { + .o_search_panel { + display: none; + } + } +} diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml new file mode 100644 index 0000000..0505f08 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list/move_line_list.xml @@ -0,0 +1,52 @@ + + + + + + + + + this.setSelectedRecord + + + + + + + +
    + + + + + +

    Choose a line to preview its attachments.

    +
    + +

    No attachments linked.

    +
    +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.js b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.js new file mode 100644 index 0000000..ceadab6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useSubEnv } from "@odoo/owl"; +import { AccountMoveLineListController, AccountMoveLineListRenderer, AccountMoveLineListView } from "../move_line_list/move_line_list"; + + +export class AccountMoveLineReconcileListController extends AccountMoveLineListController { + + setup() { + super.setup(); + useSubEnv({ + callAutoReconcileAction: this.openAutoReconcileWizard.bind(this), + }); + } + + openAutoReconcileWizard(group=null) { + if (group) { + return this.actionService.doAction("odex30_account_accountant.action_open_auto_reconcile_wizard", { + additionalContext: { + domain: group.list.domain, + } + }); + } else { + return this.actionService.doAction("odex30_account_accountant.action_open_auto_reconcile_wizard"); + } + } +} + +export class AccountMoveLineReconcileListRenderer extends AccountMoveLineListRenderer { + + static groupRowTemplate = "odex30_account_accountant.AccountMoveLineReconcileGroupRow"; + + setup() { + super.setup(); + this.props.list.groups?.map(group => this.toggleGroup(group)); // unfold the first groups (account_id) + } + +} + +export const AccountMoveLineReconcileLineListView = { + ...AccountMoveLineListView, + Controller: AccountMoveLineReconcileListController, + Renderer: AccountMoveLineReconcileListRenderer, + buttonTemplate: "odex30_account_accountant.ListViewReconcile.Buttons", +} + +registry.category("views").add("account_move_line_reconcile_list", AccountMoveLineReconcileLineListView); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.xml b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.xml new file mode 100644 index 0000000..770a72a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/components/move_line_list_reconcile/move_line_list_reconcile.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/img/accounting-bulk.gif.gif b/dev_odex30_accounting/odex30_account_accountant/static/src/img/accounting-bulk.gif.gif new file mode 100644 index 0000000..55f247e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/static/src/img/accounting-bulk.gif.gif differ diff --git a/dev_odex30_accounting/odex30_account_accountant/static/src/js/tours/account_accountant.js b/dev_odex30_accounting/odex30_account_accountant/static/src/js/tours/account_accountant.js new file mode 100644 index 0000000..48b1638 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/src/js/tours/account_accountant.js @@ -0,0 +1,78 @@ +/** @odoo-module **/ + + import { _t } from "@web/core/l10n/translation"; + import { registry } from "@web/core/registry"; + import { patch } from "@web/core/utils/patch"; + import { markup } from "@odoo/owl"; + import { accountTourSteps } from "@account/js/tours/account"; + + patch(accountTourSteps, { + newInvoice() { + return [ + { + trigger: "button[name=action_create_new]", + content: _t("Now, we'll create your first invoice (accountant)"), + run: "click", + } + ] + }, + }); + + + registry.category("web_tour.tours").add('odex30_account_accountant_tour', { + url: "/odoo", + steps: () => [ + ...accountTourSteps.goToAccountMenu('Let’s automate your bills, bank transactions and accounting processes.'), + // The tour will stop here if there is at least 1 vendor bill in the database. + // While not ideal, it is ok, since that means the user obviously knows how to create a vendor bill... + { + trigger: 'a[name="action_create_vendor_bill"]', + content: markup(_t('Create your first vendor bill.

    Tip: If you don’t have one on hand, use our sample bill.')), + tooltipPosition: 'bottom', + run: "click", + }, { + trigger: 'button.btn-primary[name="action_post"]', + content: _t('After the data extraction, check and validate the bill. If no vendor has been found, add one before validating.'), + tooltipPosition: 'bottom', + run: "click", + }, { + trigger: '.dropdown-item[data-menu-xmlid="account.menu_board_journal_1"]', + content: _t('Let’s go back to the dashboard.'), + tooltipPosition: 'bottom', + run: "click", + }, { + trigger: 'a[name="open_action"] span:contains(bank)', + content: _t('Connect your bank and get your latest transactions.'), + tooltipPosition: 'bottom', + run: "click", + }, { + trigger: 'button.o-kanban-button-new', + content: _t('Create a new transaction.'), + run: "click", + }, { + trigger: "div[name=amount] div input[id=amount_0]", + content: _t("Set an amount."), + tooltipPosition: "bottom", + run: "edit -19250.00", + }, { + trigger: "div[name=payment_ref] input[id=payment_ref_0]", + content: _t("Set the payment reference."), + tooltipPosition: "bottom", + run: "edit Payment Deco Adict", + }, { + trigger: "button.o_kanban_edit", + content: _t("Confirm the transaction."), + tooltipPosition: "bottom", + run: "click", + }, { + trigger: '.o_kanban_renderer:not(:has(.o_bank_rec_quick_create)) .o_bank_rec_st_line:not(.o_bank_rec_selected_st_line)', + content: _t('Click on a fetched bank transaction to start the reconciliation process.'), + run: "click", + }, { + isActive: ['auto'], + trigger: '.dropdown-item[data-menu-xmlid="account.menu_board_journal_1"]', + content: _t('Let’s go back to the dashboard.'), + run: "click", + }, + ] + }); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/move_line.test.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/move_line.test.js new file mode 100644 index 0000000..643bbf6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/move_line.test.js @@ -0,0 +1,340 @@ +import { beforeEach, test } from "@odoo/hoot"; +import { defineAccountModels } from "@account/../tests/account_test_helpers"; +import { + assertSteps, + click, + contains, + onRpcBefore, + openListView, + patchUiSize, + SIZES, + start, + startServer, + step, +} from "@mail/../tests/mail_test_helpers"; +import { onRpc, serverState } from "@web/../tests/web_test_helpers"; +import { getOrigin } from "@web/core/utils/urls"; + +const ROUTES_TO_IGNORE = [ + "/bus/im_status", + "/web/dataset/call_kw/account.move.line/get_views", + "/web/webclient/load_menus", + "/web/dataset/call_kw/res.users/load_views", + "/hr_attendance/attendance_user_data", + "/web/dataset/call_kw/res.users/has_group", +]; +const openPreparedView = async (size) => { + patchUiSize({ size: size }); + onRpcBefore((route, args) => { + if ( + ROUTES_TO_IGNORE.includes(route) || + route.includes("/web/static/lib/pdfjs/web/viewer.html") + ) { + return; + } + step(`${route} - ${JSON.stringify(args)}`); + }); + onRpc(({ method, model, args, kwargs }) => { + const route = `/web/dataset/call_kw/${model}/${method}`; + if (ROUTES_TO_IGNORE.includes(route)) { + return; + } + step(`${route} - {"kwargs":${JSON.stringify(kwargs)}}`); + }); + await start(); + await assertSteps([ + `/mail/data - ${JSON.stringify({ + init_messaging: {}, + failures: true, + systray_get_activities: true, + context: { lang: "en", tz: "taht", uid: serverState.userId, allowed_company_ids: [1] }, + })}`, + ]); + await openListView("account.move.line", { + context: { group_by: ["move_id"] }, + arch: ` + + + + + `, + }); +}; + +defineAccountModels(); + +beforeEach(async () => { + const pyEnv = await startServer(); + const accountMoveLineIds = pyEnv["account.move.line"].create([ + { name: "line0" }, + { name: "line1" }, + { name: "line2" }, + { name: "line3" }, + { name: "line4" }, + { name: "line5" }, + ]); + const accountMove = pyEnv["account.move"].create([ + { name: "move0", invoice_line_ids: [accountMoveLineIds[0], accountMoveLineIds[1]] }, + { name: "move1", invoice_line_ids: [accountMoveLineIds[2], accountMoveLineIds[3]] }, + { name: "move2", invoice_line_ids: [accountMoveLineIds[4], accountMoveLineIds[5]] }, + ]); + const attachmentIds = pyEnv["ir.attachment"].create([ + { res_id: accountMove[1], res_model: "account.move", mimetype: "application/pdf" }, + { res_id: accountMove[2], res_model: "account.move", mimetype: "application/pdf" }, + ]); + pyEnv["account.move"].write([accountMove[1]], { attachment_ids: [attachmentIds[0]] }); + pyEnv["account.move.line"].write([accountMoveLineIds[0]], { move_id: accountMove[0] }); + pyEnv["account.move.line"].write([accountMoveLineIds[1]], { move_id: accountMove[0] }); + pyEnv["account.move.line"].write([accountMoveLineIds[2]], { + move_id: accountMove[1], + move_attachment_ids: [attachmentIds[0]], + }); + pyEnv["account.move.line"].write([accountMoveLineIds[3]], { + move_id: accountMove[1], + move_attachment_ids: [attachmentIds[0]], + }); + pyEnv["account.move.line"].write([accountMoveLineIds[4]], { + move_id: accountMove[2], + move_attachment_ids: [attachmentIds[1]], + }); + pyEnv["account.move.line"].write([accountMoveLineIds[5]], { + move_id: accountMove[2], + move_attachment_ids: [attachmentIds[1]], + }); +}); + +test("No preview on small devices", async () => { + await openPreparedView(SIZES.XL); + await contains(".o_move_line_list_view"); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_read_group - ${JSON.stringify({ + kwargs: { + orderby: "", + lazy: true, + offset: 0, + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + group_by: ["move_id"], + }, + groupby: ["move_id"], + domain: [], + fields: ["id:sum"], + }, + })}`, + ]); + // weak test, no guarantee to wait long enough for the potential attachment preview to show + await contains(".o_attachment_preview", { count: 0 }); // The preview component shouldn't be mounted for small screens + await click(":nth-child(1 of .o_group_header)"); + await contains(".o_data_row", { count: 2 }); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_search_read - ${JSON.stringify({ + kwargs: { + specification: { + id: {}, + name: {}, + move_id: { fields: { display_name: {} } }, + move_attachment_ids: { fields: { mimetype: {} } }, + }, + offset: 0, + order: "", + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + bin_size: true, + group_by: ["move_id"], + default_move_id: 1, + }, + count_limit: 10001, + domain: [["move_id", "=", 1]], + }, + })}`, + ]); + await click(":nth-child(1 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(":nth-child(1 of .o_data_row) :nth-child(2 of .o_data_cell) input"); + // weak test, no guarantee to wait long enough for the potential attachment preview to show + await contains(".o_attachment_preview", { count: 0 }); // The preview component shouldn't be mounted for small screens even when clicking on a line without attachment + await click(":nth-child(2 of .o_group_header)"); + await contains(".o_data_row", { count: 4 }); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_search_read - ${JSON.stringify({ + kwargs: { + specification: { + id: {}, + name: {}, + move_id: { fields: { display_name: {} } }, + move_attachment_ids: { fields: { mimetype: {} } }, + }, + offset: 0, + order: "", + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + bin_size: true, + group_by: ["move_id"], + default_move_id: 2, + }, + count_limit: 10001, + domain: [["move_id", "=", 2]], + }, + })}`, + ]); + await click(":nth-child(4 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(":nth-child(4 of .o_data_row) :nth-child(2 of .o_data_cell) input"); + // weak test, no guarantee to wait long enough for the potential attachment preview to show + await contains(".o_attachment_preview", { count: 0 }); // The preview component shouldn't be mounted for small screens even when clicking on a line with attachment + await assertSteps([], { message: "no extra rpc should be done" }); +}); + +test("Fetch and preview of attachments on big devices", async () => { + await openPreparedView(SIZES.XXL); + await contains(".o_move_line_list_view"); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_read_group - ${JSON.stringify({ + kwargs: { + orderby: "", + lazy: true, + offset: 0, + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + group_by: ["move_id"], + }, + groupby: ["move_id"], + domain: [], + fields: ["id:sum"], + }, + })}`, + ]); + await contains(".o_attachment_preview"); + await contains(".o_attachment_preview p", { + text: "Choose a line to preview its attachments.", + }); + await contains(".o_attachment_preview iframe", { count: 0 }); + await click(":nth-child(1 of .o_group_header)"); + await contains(".o_data_row", { count: 2 }); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_search_read - ${JSON.stringify({ + kwargs: { + specification: { + id: {}, + name: {}, + move_id: { fields: { display_name: {} } }, + move_attachment_ids: { fields: { mimetype: {} } }, + }, + offset: 0, + order: "", + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + bin_size: true, + group_by: ["move_id"], + default_move_id: 1, + }, + count_limit: 10001, + domain: [["move_id", "=", 1]], + }, + })}`, + ]); + await click(":nth-child(1 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(".o_attachment_preview iframe", { count: 0 }); + await contains(".o_attachment_preview p", { text: "No attachments linked." }); + await click(":nth-child(2 of .o_group_header)"); + await contains(".o_data_row", { count: 4 }); + await contains(".o_attachment_preview p", { text: "No attachments linked." }); + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_search_read - ${JSON.stringify({ + kwargs: { + specification: { + id: {}, + name: {}, + move_id: { fields: { display_name: {} } }, + move_attachment_ids: { fields: { mimetype: {} } }, + }, + offset: 0, + order: "", + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + bin_size: true, + group_by: ["move_id"], + default_move_id: 2, + }, + count_limit: 10001, + domain: [["move_id", "=", 2]], + }, + })}`, + ]); + await click(":nth-child(4 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(".o_attachment_preview p", { count: 0 }); + await contains( + `.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent( + getOrigin() + "/web/content/1" + )}#pagemode=none']` + ); + await assertSteps([], { message: "no extra rpc should be done" }); + await click(":nth-child(3 of .o_group_header)"); + await contains(".o_data_row", { count: 6 }); + // weak test, no guarantee to wait long enough for the potential attachment to change + await contains( + `.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent( + getOrigin() + "/web/content/1" + )}#pagemode=none']` + ); // The previewer content shouldn't change without clicking on another line from another account.move + await assertSteps([ + `/web/dataset/call_kw/account.move.line/web_search_read - ${JSON.stringify({ + kwargs: { + specification: { + id: {}, + name: {}, + move_id: { fields: { display_name: {} } }, + move_attachment_ids: { fields: { mimetype: {} } }, + }, + offset: 0, + order: "", + limit: 80, + context: { + lang: "en", + tz: "taht", + uid: serverState.userId, + allowed_company_ids: [1], + bin_size: true, + group_by: ["move_id"], + default_move_id: 3, + }, + count_limit: 10001, + domain: [["move_id", "=", 3]], + }, + })}`, + ]); + await click(":nth-child(5 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(":nth-child(5 of .o_data_row) :nth-child(2 of .o_data_cell) input"); + await contains( + `.o_attachment_preview iframe[data-src='/web/static/lib/pdfjs/web/viewer.html?file=${encodeURIComponent( + getOrigin() + "/web/content/2" + )}#pagemode=none']` + ); + await assertSteps([]); + await click(":nth-child(1 of .o_data_row) :nth-child(2 of .o_data_cell)"); + await contains(".o_attachment_preview iframe", { count: 0 }); + await contains(".o_attachment_preview p"); + await assertSteps([]); +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_rainbowman_reset.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_rainbowman_reset.js new file mode 100644 index 0000000..ed47772 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_rainbowman_reset.js @@ -0,0 +1,91 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { accountTourSteps } from "@account/js/tours/account"; + +registry.category("web_tour.tours").add("account_accountant_bank_rec_widget_rainbowman_reset", { + url: "/odoo", + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + + // Open the widget. The first line should be selected by default. + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + }, + { + content: "'line1' should be selected and form mounted", + trigger: ".o_bank_rec_selected_st_line:contains('line1')", + }, + // Rainbowman gets reset + { + content: "Mount invoice 2 for line 1", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00002')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00002')", + }, + { + content: "Validate line1", + trigger: "button:contains('Validate')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + }, + { + content: "No records brings rainbows", + trigger: "div.o_kanban_view .o_searchview_input", + run: "fill thisShouldNotReturnAnyRecords", + }, + { + content: "Select the Journal Entry search option from the dropdown", + trigger: ".o_searchview_autocomplete li:contains(Journal Entry)", + run: "click", + }, + { + trigger: ".o_reward_rainbow_man:contains('You reconciled 1 transaction in')", + }, + { + content: "Remove the filter while rainbow man is on screen", + trigger: ".o_kanban_view .o_searchview_facet:nth-child(3) .o_facet_remove", + run: "click", + }, + { + trigger: ".o_bank_rec_st_line:contains('line2')", + }, + { + content: "Search for no results again", + trigger: "div.o_kanban_view .o_searchview_input", + run: "fill thisShouldNotReturnAnyRecords", + }, + { + content: "Select the Journal Entry search option from the dropdown", + trigger: ".o_searchview_autocomplete li:contains(Journal Entry)", + run: "click", + }, + { + content: "No content helper is displayed instead of rainbowman", + trigger: ".o_view_nocontent_smiling_face", + }, + // End + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ], +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_save_analytic_distribution.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_save_analytic_distribution.js new file mode 100644 index 0000000..7113a15 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_save_analytic_distribution.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { accountTourSteps } from "@account/js/tours/account"; + + +registry.category("web_tour.tours").add('account_accountant_bank_rec_widget_save_analytic_distribution', { + url: '/odoo', + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + trigger: "div[name='line_ids']", + }, + { + content: "The 'line1' should be selected by default", + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + run: function() {}, + }, + { + content: "Click on first line", + trigger: "div[name='line_ids'] td[field='debit']:first", + run: "click", + }, + { + content: "The 'manual_operations_tab' should be active now and the auto_balance line mounted in edit", + trigger: "a.active[name='manual_operations_tab']", + run: function() {}, + }, + { + content: "Enter an analytic distribution", + trigger: "div[name='analytic_distribution'] .o_input_dropdown", + run: "click", + }, + { + content: "Select analytic distribution", + trigger: "tr[name='line_0'] input", + run: "edit analytic_account", + }, + { + trigger: ".ui-autocomplete", + }, + { + content: "Select analytic distribution", + trigger: ".ui-autocomplete:visible li:contains('analytic_account')", + run: "click", + }, + { + content: "Close the analytic distribution", + trigger: ".o_button", + run: "click", + }, + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ] +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_statements.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_statements.js new file mode 100644 index 0000000..31d37e8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_statements.js @@ -0,0 +1,95 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { accountTourSteps } from "@account/js/tours/account"; + +registry.category("web_tour.tours").add('account_accountant_bank_rec_widget_statements', + { + url: '/odoo', + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + content: "Statement button", + trigger: + ".o_bank_rec_st_line:eq(2) a.oe_kanban_action:contains('Statement'):not(:visible)", + run: "click", + }, + { + trigger: ".modal-dialog:contains('Create Statement')", + }, + { + content: "Save the statement with proposed values", + trigger: ".o_form_button_save", + run: "click", + }, + { + content: "Click the Valid Statement with $ 1,000.00 that is visible in Kanban", + trigger: "span[name='kanban-subline-clickable-amount']:contains('$ 1,000.00')", + run: "click", + }, + { + content: "Modify the end balance", + trigger: "input[id='balance_end_real_0']", + run: "edit 100 && click body", + }, + { + trigger: ".alert-warning:contains('The running balance')", + }, + { + content: "Dialog displays warning, save anyway", + trigger: ".breadcrumb-item.o_back_button:nth-of-type(2)", + run: "click", + }, + { + trigger: ".btn-link:contains('$ 2,100.00')", + }, + { + content: "Click the red statement, after checking the balance", + trigger: "span[name='kanban-subline-clickable-amount']:contains('$ 100.00')", + run: "click", + }, + { + content: "Back in the form view", + trigger: ".alert-warning:contains('The running balance')", + }, + { + content: "Click on Action", + trigger: ".o_cp_action_menus button", + run: "click", + }, + { + content: "Click on Delete", + trigger: ".o-dropdown--menu span:contains('Delete')", + run: "click", + }, + { + content: "Confirm Deletion", + trigger: ".btn-primary:contains('Delete')", + run: "click", + }, + { + trigger: ".o_kanban_renderer:not(:has(.kanban-statement))", + }, + { + content: "balance displays $3000.00 and no statement", + trigger: ".btn-link:contains('$ 3,000')", + }, + // End + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + } + ] +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_ui.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_ui.js new file mode 100644 index 0000000..00b9173 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_ui.js @@ -0,0 +1,960 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { patch } from "@web/core/utils/patch"; +import { accountTourSteps } from "@account/js/tours/account"; + +patch(accountTourSteps, { + bankRecUiReportSteps() { + return [ + { + trigger: ".o_bank_rec_selected_st_line:contains('line1')", + }, + { + content: "balance is 2100", + trigger: ".btn-link:contains('$ 2,100.00')", + }, + ]; + }, +}); + +registry.category("web_tour.tours").add("account_accountant_bank_rec_widget_ui", { + url: "/odoo", + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + + // Open the widget. The first line should be selected by default. + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + }, + { + content: "'line1' should be selected and form mounted", + trigger: ".o_bank_rec_selected_st_line:contains('line1')", + }, + // Select line2. It should remain selected when returning using the breadcrumbs. + { + trigger: ".o_bank_rec_st_line:contains('line3')", + }, + { + content: "select 'line2'", + trigger: ".o_bank_rec_st_line:contains('line2')", + run: "click", + }, + { + content: "'line2' should be selected", + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + }, + { + content: "View an invoice", + trigger: "button.btn-secondary[name='action_open_business_doc']:eq(1)", + run: "click", + }, + { + trigger: ".o_breadcrumb .active:contains('INV/2019/00001')", + }, + { + content: "Breadcrumb back to Bank Reconciliation from INV/2019/00001", + trigger: ".breadcrumb-item:contains('Bank Reconciliation')", + run: "click", + }, + { + trigger: ".o_bank_rec_st_line:contains('line1')", + }, + { + content: "'line2' should be selected after returning", + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "'line2' form mounted", + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + run: "click", + }, + // Keep AML search, and prepared entry (line_ids) when changing tabs, using breadcrumbs, and view switcher + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr:nth-child(2) td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "AMLs list has both invoices", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr:nth-child(1) td[name='move_id']:contains('INV/2019/00002')", + }, + { + trigger: "a.active[name='amls_tab']", + }, + { + content: "Search for INV/2019/00001", + trigger: "div.bank_rec_widget_form_amls_list_anchor .o_searchview_input", + run: "edit INV/2019/00001", + }, + { + content: "Select the Journal Entry search option from the dropdown", + trigger: ".o_searchview_autocomplete li:contains(Journal Entry)", + run: "click", + }, + { + content: "AMLs list only displays one invoice", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr:nth-child(1) td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "Liquidity line displays debit '$ 1,000.00'", + trigger: + "div[name='line_ids'] table.o_list_table tr.o_bank_rec_liquidity_line td[field='debit']:contains('$ 1,000.00')", + }, + { + content: "Select the liquidity line", + trigger: "tr.o_bank_rec_liquidity_line td[field='debit']", + run: "click", + }, + { + content: "Modify the liquidity line amount", + trigger: "div[name='amount_currency'] input", + run: "edit 100.00 && click body", + }, + { + content: "Liquidity line displays debit '$ 100.00'", + trigger: + "div[name='line_ids'] table.o_list_table tr.o_bank_rec_liquidity_line td[field='debit']:contains('$ 100.00')", + }, + { + trigger: "div[name='partner_id'] input", + }, + { + content: "Select 'amls_tab'", + trigger: "a[name='amls_tab']", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor .o_searchview_facet:nth-child(1) .o_facet_value:contains('INV/2019/00001')", + }, + { + content: "AMLs list contains the search facet, and one invoice - select it", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr:nth-child(1) td[name='move_id']:contains('INV/2019/00001')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "Check INV/2019/00001 is well marked as selected", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "View an invoice", + trigger: "button.btn-secondary[name='action_open_business_doc']:nth-child(1)", + run: "click", + }, + { + trigger: ".o_breadcrumb .active:contains('INV/2019/00001')", + }, + { + content: "Breadcrumb back to Bank Reconciliation from INV/2019/00001", + trigger: ".breadcrumb-item:contains('Bank Reconciliation')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor .o_searchview_facet:nth-child(1) .o_facet_value:contains('INV/2019/00001')", + }, + { + content: "Check INV/2019/00001 is selected and still contains the search facet", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + // Search should remove some lines, select the first unmatched record, and persist when returning with breadcrumbs + { + trigger: "a.active[name='amls_tab']", + }, + { + content: "Search for line2", + trigger: "div.o_kanban_view .o_searchview_input", + run: "fill line2", + }, + { + content: "Select the Transaction search option from the dropdown", + trigger: ".o_searchview_autocomplete li:contains(Transaction)", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "'line2' should be selected", + trigger: ".o_bank_rec_st_line:last():contains('line2')", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor .o_searchview_facet:nth-child(1) .o_facet_value:contains('INV/2019/00001')", + }, + { + content: + "Nothing has changed: INV/2019/00001 is selected and still contains the search facet", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + { + trigger: ".o_switch_view.o_kanban.active", + }, + { + content: "Switch to list view", + trigger: ".o_switch_view.o_list", + run: "click", + }, + { + trigger: ".o_switch_view.o_list.active", + }, + { + content: "Switch back to kanban", + trigger: ".o_switch_view.o_kanban", + run: "click", + }, + { + content: "Remove the kanban filter for line2", + trigger: ".o_kanban_view .o_searchview_facet:nth-child(3) .o_facet_remove", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor .o_searchview_facet:nth-child(1) .o_facet_value:contains('INV/2019/00001')", + }, + { + content: + "Nothing has changed: INV/2019/00001 is still selected and contains the search facet", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + // AML Search Facet is removed, and line_ids reset when changing line + { + trigger: ".o_bank_rec_st_line:contains('line3')", + }, + { + content: "selecting 'line1' should reset the AML search filter ", + trigger: ".o_bank_rec_st_line:contains('line1')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + }, + { + content: "select 'line2' again", + trigger: ".o_bank_rec_st_line:contains('line2')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "Bank Suspense Account is back", + trigger: "div[name='line_ids'] .o_bank_rec_auto_balance_line", + }, + { + content: "AML Search Filter has been reset", + trigger: ".o_list_view .o_searchview_input_container:not(:has(.o_searchview_facet))", + }, + // Test statement line selection when using the pager + { + content: "Click Pager", + trigger: ".o_pager_value:first()", + run: "click", + }, + { + content: "Change pager to display lines 1-2", + trigger: "input.o_pager_value", + run: "edit 1-2 && click body", + }, + { + trigger: ".o_pager_value:contains('1-2')", + }, + { + content: "Last St Line is line2", + trigger: ".o_bank_rec_st_line:last():contains('line2')", + }, + { + content: "Page Next", + trigger: ".o_pager_next:first():not(:disabled)", + run: "click", + }, + { + trigger: ".o_pager_value:contains('3-3')", + }, + { + content: "Statement line3 is selected", + trigger: ".o_bank_rec_selected_st_line:contains('line3')", + }, + { + content: "Page to beginning", + trigger: ".o_pager_next:first()", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + }, + { + content: "Statement line1 is selected", + trigger: ".o_bank_rec_selected_st_line:contains('line1')", + }, + // HTML buttons + { + content: "Mount an invoice", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00003')", + run: "click", + }, + { + trigger: + "div[name='line_ids']:has(.text-decoration-line-through:contains('$ 2,000.00'))", + }, + { + content: "Select the mounted invoice line and check the strikethrough value", + trigger: + "div[name='line_ids'] tr.o_data_row:last() td[field='name']:contains('INV/2019/00003')", + run: "click", + }, + { + trigger: "a.active[name='manual_operations_tab']", + }, + { + content: "Fully Paid button", + trigger: "button[name='action_apply_line_suggestion']:contains('fully paid')", + run: "click", + }, + { + content: "Check the remainder", + trigger: + "div[name='line_ids'] tr.o_data_row:contains('Suspense') td[field='debit']:contains('$ 1,000.00')", + }, + { + content: "Partial Payment", + trigger: "button[name='action_apply_line_suggestion']:contains('partial payment')", + run: "click", + }, + { + trigger: "button[name='action_apply_line_suggestion']:contains('fully paid')", + }, + { + content: "View Invoice 0003", + trigger: "button[name='action_redirect_to_move']", + run: "click", + }, + { + trigger: ".o_breadcrumb .active:contains('INV/2019/00003')", + }, + { + content: "Breadcrumb back to Bank Reconciliation from INV/2019/00003", + trigger: ".breadcrumb-item:contains('Bank Reconciliation')", + run: "click", + }, + { + content: "Select the mounted invoice line INV/2019/00003", + trigger: + "div[name='line_ids'] tr.o_data_row:last() td[field='name']:contains('INV/2019/00003')", + run: "click", + }, + // Match Existing entries tab is activated when line is removed + { + trigger: "a.active[name='manual_operations_tab']", + }, + { + content: "Remove the invoice", + trigger: ".o_list_record_remove .fa-trash-o", + run: "click", + }, + { + content: "amls_tab is activated", + trigger: "a.active[name='amls_tab']", + }, + { + content: "Activate Manual Operations to add manual entries", + trigger: "a[name='manual_operations_tab']", + run: "click", + }, + { + content: "add manual entry 1", + trigger: "div[name='amount_currency'] input", + run: "edit -600.0 && click body", + }, + { + content: "mount the remaining opening balance line", + trigger: + "div[name='line_ids'] tr.o_data_row:contains('Suspense') td[field='credit']:contains('$ 400.00')", + run: "click", + }, + { + trigger: "div[name='amount_currency'] input:value('-400.00'):focus-within", + }, + { + content: "Remove the manual entry", + trigger: ".o_list_record_remove .fa-trash-o", + run: "click", + }, + { + trigger: + "div[name='line_ids'] tr.o_data_row:contains('Suspense') td[field='credit']:contains('$ 1,000.00')", + }, + { + content: "amls_tab is activated and auto balancing line is 1000", + trigger: "a.active[name='amls_tab']", + }, + { + content: "Mount another invoice", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00001')", + run: "click", + }, + // After validating, line1 should disappear & line2 should be selected (due to filters) + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "Validate line1", + trigger: "button:contains('Validate')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "The 'line2' is the first kanban record and is selected", + trigger: ".o_bank_rec_st_line:first():contains('line2')", + }, + // Test Reset, "Matched" badge and double-click + { + content: "Remove the kanban filter for 'Not Matched'", + trigger: ".o_kanban_view .o_searchview_facet:nth-child(2) .o_facet_remove", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "The 'line1' is the first kanban record with line2 selected", + trigger: ".o_bank_rec_st_line:first():contains('line1')", + }, + { + content: "Mount invoice 2 for line 2", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00002')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00002')", + }, + { + content: "Validate line2 with double click", + trigger: "button:contains('Validate')", + run: "dblclick", + }, + { + trigger: ".o_bank_rec_st_line:contains('line2') .badge.text-bg-success", + }, + { + content: "Click Pager again after line2 is matched", + trigger: ".o_pager_value:first()", + run: "click", + }, + { + content: "Change pager to display lines 1-3", + trigger: "input.o_pager_value", + run: "edit 1-3 && click body", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line3')", + }, + { + content: "manually select line2 again by clicking it's matched icon", + trigger: ".badge.text-bg-success:last()", + run: "click", + }, + { + trigger: + "div[name='line_ids']:not(:has(.fa-trash-o)) td[field='name']:contains('line2')", + }, + { + content: "Reset line2", + trigger: "button:contains('Reset')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2'):not(:has(div.badge))", + }, + { + content: "amls_tab is activated while still on line2 which doesn't contain a badge", + trigger: ".o_notebook a.active[name='amls_tab']", + }, + // Test view_switcher + { + trigger: ".o_switch_view.o_kanban.active", + }, + { + content: "Switch to list view", + trigger: ".o_switch_view.o_list", + run: "click", + }, + { + trigger: ".btn-secondary:contains('View')", + }, + { + content: "Select the first Match Button (line2)", + trigger: ".btn-secondary:contains('Match')", + run: "click", + }, + { + trigger: ".o_bank_rec_st_line:last():contains('line2')", + }, + { + content: "Last St Line is line2", + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + run: "click", + }, + { + content: "Button To Check will reconcile since partner is saved on line2", + trigger: ".btn-secondary:contains('To Check')", + run: "click", + }, + { + trigger: + ".o_bank_rec_selected_st_line:contains('line2'):has(div.badge[title='Matched'] i):has(span.badge:contains('To check'))", + }, + { + content: "both badges are visible, trash icon is not, manual operation tab is active", + trigger: + "div[name='line_ids']:not(:has(.fa-trash-o))+.o_notebook a.active[name='manual_operations_tab']", + }, + { + trigger: ".o_switch_view.o_kanban.active", + }, + { + content: "Switch to list view", + trigger: ".o_switch_view.o_list", + run: "click", + }, + { + trigger: ".o_switch_view.o_list.active", + }, + { + content: "Remove the line filter", + trigger: ".o_searchview_facet:contains('0002') .o_facet_remove", + run: "click", + }, + { + trigger: ".o_data_row:contains('line2'):has(.btn-secondary:contains('View'))", + }, + { + content: "Select the first Match Button (line3)", + trigger: ".btn-secondary:contains('Match')", + run: "click", + }, + { + trigger: ".o_bank_rec_stats_buttons", + }, + { + content: "Open search bar menu", + trigger: ".o_searchview_dropdown_toggler:eq(0)", + run: "click", + }, + // Test Reco Model + { + trigger: ".o-dropdown--menu.o_search_bar_menu", + }, + { + content: "Choose a filter", + trigger: ".o_search_bar_menu .dropdown-item:first()", + run: "click", + }, + { + trigger: ".o-dropdown--menu", + }, + { + content: "Not Matched Filter", + trigger: ".dropdown-item:contains('Not Matched')", + run: "click", + }, + { + trigger: ".o_switch_view.o_kanban.active", + }, + { + content: "reco model dropdown", + trigger: ".bank_rec_reco_model_dropdown i", + run: "click", + }, + { + trigger: ".o-dropdown--menu", + }, + { + content: "create model", + trigger: ".dropdown-item:contains('Create model')", + run: "click", + }, + { + content: "model name", + trigger: "input#name_0", + run: "edit Bank Fees", + }, + { + content: "add an account", + trigger: "a:contains('Add a line')", + run: "click", + }, + { + content: "search for bank fees account", + trigger: "[name='account_id'] input", + run: "edit Bank Fees", + }, + { + trigger: ".o-autocomplete--dropdown-menu", + }, + { + content: "select the bank fees account", + trigger: ".o-autocomplete--dropdown-item:contains('Bank Fees')", + run: "click", + }, + { + trigger: ".o_breadcrumb .active > span:contains('New')", + }, + { + content: "Breadcrumb back to Bank Reconciliation from the model", + trigger: ".breadcrumb-item:contains('Bank Reconciliation')", + run: "click", + }, + { + content: "Choose Bank Fees Model", + trigger: ".recon_model_button:contains('Bank Fees')", + run: "click", + }, + { + content: "Validate line3", + trigger: "button:contains('Validate').btn-primary", + run: "dblclick", + }, + { + trigger: ".o_reward_rainbow_man", + }, + { + content: + "Remove the kanbans 'not matched' filter to reset all lines - use the rainbow man button", + trigger: "p.btn-primary:contains('All Transactions')", + run: "click", + }, + { + trigger: + ".o_kanban_view .o_searchview:first() .o_searchview_facet:last():contains('Bank')", + }, + { + content: "Wait for search model change and line3 to appear", + trigger: ".o_bank_rec_st_line:last():contains('line3')", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + }, + { + content: "'line2' should be selected, reset it", + trigger: "button:contains('Reset')", + run: "click", + }, + { + trigger: ".o_bank_rec_st_line:contains('line2'):not(:has(div.badge))", + }, + { + content: "select matched 'line3'", + trigger: ".o_bank_rec_st_line:contains('line3')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line3')", + }, + { + content: "'line3' should be selected, reset it", + trigger: "button:contains('Reset')", + run: "click", + }, + { + trigger: ".o_bank_rec_st_line:contains('line3'):not(:has(div.badge))", + }, + { + content: "select matched 'line1'", + trigger: ".o_bank_rec_st_line:contains('line1')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line1')", + }, + { + content: "'line1' should be selected, reset it", + trigger: "button:contains('Reset')", + run: "click", + }, + { + trigger: ".o_bank_rec_stats_buttons", + }, + { + content: "Open search bar menu", + trigger: ".o_searchview_dropdown_toggler:eq(0)", + run: "click", + }, + { + trigger: "button:contains('Validate')", + }, + { + content: "Filter Menu", + trigger: ".o_search_bar_menu .dropdown-item:first()", + run: "click", + }, + { + trigger: ".o-dropdown--menu", + }, + { + content: "Activate the Not Matched filter", + trigger: ".dropdown-item:contains('Not Matched')", + run: "click", + }, + { + trigger: ".o_searchview_facet:contains('Not Matched')", + }, + { + content: "Close the Filter Menu", + trigger: ".o_searchview_dropdown_toggler:eq(0)", + run: "click", + }, + { + trigger: ".o_searchview_facet:contains('Not Matched')", + }, + { + content: "select 'line2'", + trigger: ".o_bank_rec_st_line:contains('line2')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + }, + { + content: "Validate 'line2' again", + trigger: "button:contains('Validate')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line3')", + }, + { + content: "'line3' should be selected now", + trigger: ".o_bank_rec_selected_st_line:contains('line3')", + }, + // Test the Balance when changing journal and liquidity line + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget for Bank2", + trigger: "button.btn-secondary[name='action_open_reconcile']:last()", + run: "click", + }, + { + content: "Remove the kanbans 'not matched' filter", + trigger: ".o_kanban_view .o_searchview_facet:nth-child(2) .o_facet_remove", + run: "click", + }, + { + content: "Remove the kanban 'journal' filter", + trigger: ".o_kanban_view .o_searchview_facet:nth-child(1) .o_facet_remove", + run: "click", + }, + { + content: "select 'line1' from another journal", + trigger: ".o_bank_rec_st_line:contains('line1')", + run: "click", + }, + ...accountTourSteps.bankRecUiReportSteps(), + { + content: "select 'line4' from this journal", + trigger: ".o_bank_rec_st_line:contains('line4')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line4')", + }, + { + content: "balance is $222.22", + trigger: ".btn-link:contains('$ 222.22')", + }, + { + content: "Select the liquidity line", + trigger: "tr.o_bank_rec_liquidity_line td[field='debit']", + run: "click", + }, + { + trigger: "div[name='amount_currency'] input:focus-within", + }, + { + content: "Modify the liquidity line amount", + trigger: "div[name='amount_currency'] input", + run: "edit -333.33 && click body", + }, + { + trigger: ".btn-link:contains('$ -333.33')", + }, + { + content: "balance displays $-333.33", + trigger: ".btn-link:contains('$ -333.33')", + }, + { + content: "Modify the label", + trigger: "div[name='name'] input", + run: "edit Spontaneous Combustion && click body", + }, + { + content: "statement line displays combustion and $-333.33", + trigger: ".o_bank_rec_selected_st_line:contains('Combustion'):contains('$ -333.33')", + }, + // Test that changing the balance in the list view updates the right side of the kanban view + // (including reapplying matching rules) + { + content: "select matched 'line2'", + trigger: ".o_bank_rec_st_line:contains('line2')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2')", + }, + { + content: "'line2' should be selected, reset it", + trigger: "button:contains('Reset')", + run: "click", + }, + { + trigger: ".o_bank_rec_selected_st_line:contains('line2'):not(:has(div.badge))", + }, + { + content: "Liquidity line displays debit '$ 100.00'", + trigger: + "div[name='line_ids'] table.o_list_table tr.o_bank_rec_liquidity_line td[field='debit']:contains('$ 100.00')", + }, + { + trigger: ".o_switch_view.o_kanban.active", + }, + { + content: "Switch to list view", + trigger: ".o_switch_view.o_list", + run: "click", + }, + { + content: "Click amount field of 'line2'; Selects the row", + trigger: "table.o_list_table tr.o_data_row:contains('line2') td[name='amount']", + run: "click", + }, + { + content: "Set balance of 'line2' (selected row) to 500.00", + trigger: "table.o_list_table tr.o_data_row.o_selected_row td[name='amount'] input", + run: "edit 500.00 && click body", + }, + { + trigger: ".o_switch_view.o_list.active", + }, + { + content: "Switch back to kanban", + trigger: ".o_switch_view.o_kanban", + run: "click", + }, + { + content: "'line2' is still selected", + trigger: ".o_bank_rec_st_line:contains('line2')", + }, + { + content: "Liquidity line displays debit '$ 500.00'", + trigger: + "div[name='line_ids'] table.o_list_table tr.o_bank_rec_liquidity_line td[field='debit']:contains('$ 500.00')", + }, + { + content: + "'INV/2019/00001' has been selected as matching existing entry by matching rules", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='name']:contains('INV/2019/00001')", + }, + // End + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ], +}); + +registry.category("web_tour.tours").add('account_accountant_bank_rec_widget_reconciliation_button', + { + url: '/odoo', + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + content: "Remove suggested line, if present", + trigger: ".o_list_record_remove", + run() { + const button = document.querySelector('.fa-trash-o'); + if(button) { + button.click(); + } + } + }, + { + content: "Wait for deletion", + trigger: ".o_data_row:contains('Open balance')", + }, + { + content: "Select reconciliation model creating a new move", + trigger: ".recon_model_button:contains('test reconcile')", + run: "click", + }, + { + content: "Confirm move created through reconciliation model writeoff button", + trigger: "button[name=action_post]", + run: "click", + }, + { + trigger: ".o_breadcrumb", + }, + { + content: "Breadcrumb back to Bank Reconciliation from created move", + trigger: ".breadcrumb-item:contains('Bank Reconciliation')", + run: "click", + }, + { + content: "Validate created move added as a line in reco widget", + trigger: "button:contains('Validate')", + run: "click", + }, + // End + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ], +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_widget.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_widget.js new file mode 100644 index 0000000..38296b8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_bank_rec_widget.js @@ -0,0 +1,236 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { accountTourSteps } from "@account/js/tours/account"; + +registry.category("web_tour.tours").add("account_accountant_bank_rec_widget", { + url: "/odoo", + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + + // Open the widget. The first line should be selected by default. + { + trigger: ".o_breadcrumb", + }, + { + content: "Open the bank reconciliation widget", + trigger: "button.btn-secondary[name='action_open_reconcile']", + run: "click", + }, + { + trigger: "div[name='line_ids']", + }, + { + content: "The 'line1' should be selected by default", + trigger: "div[name='line_ids'] td[field='name']:contains('line1')", + }, + + // Test 1: Check the loading of lazy notebook tabs. + // Check 'amls_tab' (active by default). + { + trigger: "div.bank_rec_widget_form_amls_list_anchor table.o_list_table", + }, + { + content: "The 'amls_tab' should be active and the inner list view loaded", + trigger: "a.active[name='amls_tab']", + }, + // Check 'discuss_tab'. + { + trigger: "a.active[name='amls_tab']", + }, + { + content: "Click on the 'discuss_tab'", + trigger: "a[name='discuss_tab']", + run: "click", + }, + { + trigger: "a.active[name='discuss_tab']", + }, + { + content: "The 'discuss_tab' should be active and the chatter loaded", + trigger: "div.bank_rec_widget_form_discuss_anchor div.o-mail-Chatter", + }, + // Check 'manual_operations_tab'. + { + trigger: "tr.o_bank_rec_auto_balance_line", + }, + { + content: "Click on the 'auto_balance' to make the 'manual_operations_tab' visible", + trigger: "tr.o_bank_rec_auto_balance_line td[field='name']", + run: "click", + }, + { + content: "The 'manual_operations_tab' should be active", + trigger: "a.active[name='manual_operations_tab']", + }, + { + content: "The 'name' field should be focus automatically", + trigger: "div.o_notebook div[name='name'] input:focus", + }, + { + trigger: "tr.o_bank_rec_auto_balance_line", + }, + { + content: "Click on the 'credit' field to change the focus from 'name' to 'amount_currency'", + trigger: "tr.o_bank_rec_auto_balance_line td[field='credit']", + run: "click", + }, + { + content: "Wait to avoid non-deterministic errors on the next step", + trigger: "tr.o_bank_rec_auto_balance_line td[field='credit']", + }, + { + content: "The 'balance' field should be focus now", + trigger: "div.o_notebook div[name='amount_currency'] input:focus", + }, + + // Test 2: Test validation + auto select the next line. + { + trigger: "a.active[name='manual_operations_tab']", + }, + { + content: "Click on the 'amls_tab'", + trigger: "a[name='amls_tab']", + run: "click", + }, + { + trigger: "a.active[name='amls_tab']", + }, + { + content: "Mount INV/2019/00002", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00002')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00002')", + }, + { + content: "Check INV/2019/00002 is well marked as selected", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00002')", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00002')", + }, + { + content: "Remove INV/2019/00002", + trigger: "tr td.o_list_record_remove button", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr:not(.o_rec_widget_list_selected_item) td[name='move_id']:contains('INV/2019/00002')", + }, + { + content: "Mount INV/2019/00001", + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table td[name='move_id']:contains('INV/2019/00001')", + run: "click", + }, + { + trigger: + "div.bank_rec_widget_form_amls_list_anchor table.o_list_table tr.o_rec_widget_list_selected_item td[name='move_id']:contains('INV/2019/00001')", + }, + { + content: "Validate", + trigger: "button:contains('Validate')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + { + content: "The 'line2' is the next not already reconciled line", + trigger: "div[name='line_ids'] td[field='name']:contains('line2')", + }, + + // Test 3: Test manual operations tab. + { + content: "Click on 'credit'", + trigger: "div[name='line_ids'] td[field='credit']:last", + run: "click", + }, + { + content: + "The 'manual_operations_tab' should be active now and the auto_balance line mounted in edit", + trigger: "a.active[name='manual_operations_tab']", + }, + { + content: "The last line should be selected", + trigger: "div[name='line_ids'] tr.o_bank_rec_selected_line", + }, + { + content: "Search for 'partner_a'", + trigger: "div[name='partner_id'] input", + run: "edit partner_a", + }, + { + trigger: ".ui-autocomplete .o_m2o_dropdown_option a:contains('Create')", + }, + { + content: "Select 'partner_a'", + trigger: ".ui-autocomplete:visible li:contains('partner_a')", + run: "click", + }, + { + trigger: + "tr:not(.o_bank_rec_auto_balance_line) td[field='partner_id']:contains('partner_a')", + }, + { + content: "Select the payable account", + trigger: "button:contains('Payable')", + run: "click", + }, + { + trigger: + "tr:not(.o_bank_rec_auto_balance_line) td[field='account_id']:contains('Payable')", + }, + { + content: "Enter a tax", + trigger: "div[name='tax_ids'] input", + run: "edit 15", + }, + { + trigger: ".ui-autocomplete", + }, + { + content: "Select 'Tax 15% (Sales)'", + trigger: ".ui-autocomplete:visible li:contains('Sales')", + run: "click", + }, + { + content: "Tax column appears in list of lines", + trigger: "div[name='line_ids'] td[field='tax_ids']", + }, + { + content: "Wait to avoid non-deterministic errors on the next step", + trigger: "div[name='line_ids'] td:contains('Tax Received')", + }, + { + trigger: "button.btn-primary:contains('Validate')", + }, + { + content: "Validate", + trigger: "button:contains('Validate')", + run: "click", + }, + { + trigger: "div[name='line_ids'] td[field='name']:contains('line3')", + }, + { + content: "The 'line3' is the next not already reconciled line", + trigger: "div[name='line_ids'] td[field='name']:contains('line3')", + }, + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ], +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_journal_items_export.js b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_journal_items_export.js new file mode 100644 index 0000000..c656afc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/static/tests/tours/test_tour_journal_items_export.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { stepUtils } from "@web_tour/tour_service/tour_utils"; +import { accountTourSteps } from "@account/js/tours/account"; + +registry.category("web_tour.tours").add("account_accountant_journal_items_export", { + url: "/web", + steps: () => [ + stepUtils.showAppsMenuItem(), + ...accountTourSteps.goToAccountMenu("Open the accounting module"), + + { + content: "Open journal items list view", + trigger: 'button[data-menu-xmlid="account.menu_finance_entries"]', + run: "click", + }, + { + content: "Open journal items list view", + trigger: 'a[data-menu-xmlid="account.menu_action_account_moves_all"]', + run: "click", + }, + { + content: "Select all items", + trigger: 'thead tr th.o_list_record_selector', + run: "click", + }, + { + content: "Open export dialog", + trigger: 'i.fa-cog', + run: "click", + }, + { + content: "Open export dialog", + trigger: 'i.fa-upload', + run: "click", + }, + { + content: "Click on the cancel button", + trigger: 'button.o_form_button_cancel', + run: "click", + }, + // End + ...stepUtils.toggleHomeMenu(), + ...accountTourSteps.goToAccountMenu("Reset back to accounting module"), + { + content: "check that we're back on the dashboard", + trigger: 'a:contains("Customer Invoices")', + }, + ], +}); diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/__init__.py b/dev_odex30_accounting/odex30_account_accountant/tests/__init__.py new file mode 100644 index 0000000..771a109 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/__init__.py @@ -0,0 +1,12 @@ +from . import test_account_fiscal_year +from . import test_account_payment +from . import test_bank_rec_widget +from . import test_bank_rec_widget_tour +from . import test_prediction +from . import test_reconciliation_matching_rules +from . import test_account_auto_reconcile_wizard +from . import test_account_reconcile_wizard +from . import test_deferred_management +from . import test_ui +from . import test_signature +from . import test_change_lock_date_wizard diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_account_auto_reconcile_wizard.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_auto_reconcile_wizard.py new file mode 100644 index 0000000..bca7fe7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_auto_reconcile_wizard.py @@ -0,0 +1,244 @@ +from odoo import fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.exceptions import UserError +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountAutoReconcileWizard(AccountTestInvoicingCommon): + """ Tests the account automatic reconciliation and its wizard. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.comp_curr = cls.company_data['currency'] + cls.foreign_curr = cls.setup_other_currency('EUR') + + cls.misc_journal = cls.company_data['default_journal_misc'] + cls.partners = cls.partner_a + cls.partner_b + cls.receivable_account = cls.company_data['default_account_receivable'] + cls.payable_account = cls.company_data['default_account_payable'] + cls.revenue_account = cls.company_data['default_account_revenue'] + cls.test_date = fields.Date.from_string('2016-01-01') + + def _create_many_lines(self): + self.line_1_group_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.line_2_group_1 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + self.line_3_group_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-03', partner=self.partner_a) + self.line_4_group_1 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-04', partner=self.partner_a) + self.line_5_group_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-05', partner=self.partner_a) + self.group_1 = self.line_1_group_1 + self.line_2_group_1 + self.line_3_group_1 + self.line_4_group_1 + self.line_5_group_1 + + self.line_1_group_2 = self.create_line_for_reconciliation(500.0, 500.0, self.comp_curr, '2016-01-01', partner=self.partner_b) + self.line_2_group_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.comp_curr, '2016-01-01', partner=self.partner_b) + self.line_3_group_2 = self.create_line_for_reconciliation(500.0, 500.0, self.comp_curr, '2017-01-02', partner=self.partner_b) + self.line_4_group_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.comp_curr, '2017-01-02', partner=self.partner_b) + self.group_2 = self.line_1_group_2 + self.line_2_group_2 + self.line_3_group_2 + self.line_4_group_2 + + self.line_1_group_3 = self.create_line_for_reconciliation(1500.0, 3000.0, self.foreign_curr, '2016-01-01', partner=self.partner_b) + self.line_2_group_3 = self.create_line_for_reconciliation(-1000.0, -3000.0, self.foreign_curr, '2017-01-01', partner=self.partner_b) + self.line_3_group_3 = self.create_line_for_reconciliation(3000.0, 3000.0, self.comp_curr, '2016-01-01', partner=self.partner_b) + self.line_4_group_3 = self.create_line_for_reconciliation(-3000.0, -3000.0, self.comp_curr, '2016-01-01', partner=self.partner_b) + self.group_3 = self.line_1_group_3 + self.line_2_group_3 + self.line_3_group_3 + self.line_4_group_3 + + self.line_1_group_4 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', account_1=self.payable_account, partner=self.partner_a) + self.line_2_group_4 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', account_1=self.payable_account, partner=self.partner_a) + self.group_4 = self.line_1_group_4 + self.line_2_group_4 + + def test_auto_reconcile_one_to_one(self): + self._create_many_lines() + should_be_reconciled = self.line_1_group_1 + self.line_2_group_1 + self.line_3_group_1 + self.line_4_group_1 \ + + self.line_1_group_2 + self.line_2_group_2 \ + + self.line_1_group_3 + self.line_2_group_3 + self.line_3_group_3 + self.line_4_group_3 + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + 'partner_ids': self.partners.ids, + 'search_mode': 'one_to_one', + }) + wizard.auto_reconcile() + + self.assertTrue(should_be_reconciled.full_reconcile_id) + self.assertEqual(self.line_1_group_1.full_reconcile_id, self.line_2_group_1.full_reconcile_id, + "Entries should be reconciled together since they are in the same group and have closer dates.") + self.assertEqual(self.line_3_group_1.full_reconcile_id, self.line_4_group_1.full_reconcile_id, + "Entries should be reconciled together since they are in the same group and have closer dates.") + self.assertEqual(self.line_1_group_2.full_reconcile_id, self.line_1_group_2.full_reconcile_id, + "Entries should be reconciled together since they are in the same group and have closer dates.") + self.assertEqual(self.line_1_group_3.full_reconcile_id, self.line_2_group_3.full_reconcile_id, + "Entries should be reconciled together since they are in the same group and have closer dates.") + self.assertEqual(self.line_3_group_3.full_reconcile_id, self.line_4_group_3.full_reconcile_id, + "Entries should be reconciled together since they are in the same group and have closer dates.") + self.assertNotEqual(self.line_2_group_3.full_reconcile_id, self.line_3_group_3.full_reconcile_id, + "Entries should NOT be reconciled together as they are of different currencies.") + self.assertFalse(self.line_5_group_1.reconciled, + "This entry shouldn't be reconciled since group 1 has an odd number of lines, they can't all be reconciled, and it's the most recent one.") + self.assertFalse((self.line_3_group_2 + self.line_4_group_2).full_reconcile_id, + "Entries shouldn't be reconciled since it's outside of accepted date range of the wizard.") + self.assertFalse((self.line_1_group_4 + self.line_2_group_4).full_reconcile_id, + "Entries shouldn't be reconciled since their account is out of the wizard's scope.") + + def test_auto_reconcile_zero_balance(self): + self._create_many_lines() + should_be_reconciled = self.line_1_group_2 + self.line_2_group_2 + self.group_3 + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + 'partner_ids': self.partners.ids, + 'search_mode': 'zero_balance', + }) + wizard.auto_reconcile() + + self.assertTrue(should_be_reconciled.full_reconcile_id) + self.assertFalse(self.group_1.full_reconcile_id, + "Entries shouldn't be reconciled since their total balance is not zero.") + self.assertEqual((self.line_1_group_2 + self.line_2_group_2).mapped('matching_number'), [self.line_1_group_2.matching_number] * 2, + "Entries should be reconciled together as their total balance is zero.") + self.assertEqual((self.line_1_group_3 + self.line_2_group_3).mapped('matching_number'), [self.line_1_group_3.matching_number] * 2, + "Entries should be reconciled together as their total balance is zero with the same currency.") + self.assertEqual((self.line_3_group_3 + self.line_4_group_3).mapped('matching_number'), [self.line_3_group_3.matching_number] * 2, + "Lines 3 and 4 are reconciled but not with two first lines since their currency is different.") + self.assertFalse(self.group_4.full_reconcile_id, + "Entries shouldn't be reonciled since their account is out of the wizard's scope.") + + def test_nothing_to_auto_reconcile(self): + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + 'partner_ids': self.partners.ids, + 'search_mode': 'zero_balance', + }) + with self.assertRaises(UserError): + wizard.auto_reconcile() + + def test_auto_reconcile_no_account_nor_partner_one_to_one(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + }) + reconciled_amls = wizard._auto_reconcile_one_to_one() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_no_account_nor_partner_zero_balance(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + }) + reconciled_amls = wizard._auto_reconcile_zero_balance() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_no_account_one_to_one(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'partner_ids': self.partners.ids, + }) + reconciled_amls = wizard._auto_reconcile_one_to_one() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_no_account_zero_balance(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'partner_ids': self.partners.ids, + }) + reconciled_amls = wizard._auto_reconcile_zero_balance() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_no_partner_one_to_one(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + }) + reconciled_amls = wizard._auto_reconcile_one_to_one() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_no_partner_zero_balance(self): + self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + }) + reconciled_amls = wizard._auto_reconcile_zero_balance() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_rounding_one_to_one(self): + """ Checks that two lines with different values, currency rounding aside, are reconciled in one-to-one mode. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + # Need to manually update the values to bypass ORM + self.env.cr.execute( + """ + UPDATE account_move_line SET amount_residual_currency = 1000.0000001 WHERE id = %(line_1_id)s; + UPDATE account_move_line SET amount_residual_currency = -999.999999 WHERE id = %(line_2_id)s; + """, + {'line_1_id': line_1.id, 'line_2_id': line_2.id} + ) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + }) + reconciled_amls = wizard._auto_reconcile_one_to_one() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_auto_reconcile_rounding_zero_balance(self): + """ Checks that two lines with different values, currency rounding aside, are reconciled in zero balance mode. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-01', partner=self.partner_a) + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-02', partner=self.partner_a) + # Need to manually update the values to bypass ORM + self.env.cr.execute( + """ + UPDATE account_move_line SET amount_residual_currency = 1000.0000001 WHERE id = %(line_1_id)s; + UPDATE account_move_line SET amount_residual_currency = -999.999999 WHERE id = %(line_2_id)s; + """, + {'line_1_id': line_1.id, 'line_2_id': line_2.id} + ) + wizard = self.env['account.auto.reconcile.wizard'].new({ + 'from_date': '2016-01-01', + 'to_date': '2017-01-01', + 'account_ids': self.receivable_account.ids, + }) + reconciled_amls = wizard._auto_reconcile_zero_balance() + self.assertTrue(reconciled_amls.full_reconcile_id) + + def test_preset_wizard(self): + """ Tests that giving lines_ids to wizard presets correctly values. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-30', partner=self.partner_a) + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.comp_curr, '2016-01-31', partner=self.partner_a) + wizard = self.env['account.auto.reconcile.wizard'].with_context(domain=[('id', 'in', (line_1 + line_2).ids)]).create({}) + self.assertRecordValues(wizard, [{ + 'account_ids': self.receivable_account.ids, + 'partner_ids': self.partner_a.ids, + 'from_date': fields.Date.from_string('2016-01-30'), + 'to_date': fields.Date.from_string('2016-01-31'), + 'search_mode': 'zero_balance', + }]) + + line_3 = self.create_line_for_reconciliation(1000.0, 1000.0, self.comp_curr, '2016-01-31', partner=self.partner_a) + line_4 = self.create_line_for_reconciliation(-500.0, -500.0, self.comp_curr, '2016-02-28', partner=None) + wizard = self.env['account.auto.reconcile.wizard'].with_context(domain=[('id', 'in', (line_3 + line_4).ids)]).create({}) + self.assertRecordValues(wizard, [{ + 'account_ids': self.receivable_account.ids, + 'partner_ids': [], + 'from_date': fields.Date.from_string('2016-01-31'), + 'to_date': fields.Date.from_string('2016-02-28'), + 'search_mode': 'one_to_one', + }]) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_account_fiscal_year.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_fiscal_year.py new file mode 100644 index 0000000..1fad047 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_fiscal_year.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo import fields + + +@tagged('post_install', '-at_install') +class TestFiscalPosition(AccountTestInvoicingCommon): + + def check_compute_fiscal_year(self, company, date, expected_date_from, expected_date_to): + '''Compute the fiscal year at a certain date for the company passed as parameter. + Then, check if the result matches the 'expected_date_from'/'expected_date_to' dates. + + :param company: The company. + :param date: The date belonging to the fiscal year. + :param expected_date_from: The expected date_from after computation. + :param expected_date_to: The expected date_to after computation. + ''' + current_date = fields.Date.from_string(date) + res = company.compute_fiscalyear_dates(current_date) + self.assertEqual(res['date_from'], fields.Date.from_string(expected_date_from)) + self.assertEqual(res['date_to'], fields.Date.from_string(expected_date_to)) + + def test_default_fiscal_year(self): + '''Basic case with a fiscal year xxxx-01-01 - xxxx-12-31.''' + company = self.env.company + company.fiscalyear_last_day = 31 + company.fiscalyear_last_month = '12' + + self.check_compute_fiscal_year( + company, + '2017-12-31', + '2017-01-01', + '2017-12-31', + ) + + self.check_compute_fiscal_year( + company, + '2017-01-01', + '2017-01-01', + '2017-12-31', + ) + + def test_leap_fiscal_year_1(self): + '''Case with a leap year ending the 29 February.''' + company = self.env.company + company.fiscalyear_last_day = 29 + company.fiscalyear_last_month = '2' + + self.check_compute_fiscal_year( + company, + '2016-02-29', + '2015-03-01', + '2016-02-29', + ) + + self.check_compute_fiscal_year( + company, + '2015-03-01', + '2015-03-01', + '2016-02-29', + ) + + def test_leap_fiscal_year_2(self): + '''Case with a leap year ending the 28 February.''' + company = self.env.company + company.fiscalyear_last_day = 28 + company.fiscalyear_last_month = '2' + + self.check_compute_fiscal_year( + company, + '2016-02-29', + '2015-03-01', + '2016-02-29', + ) + + self.check_compute_fiscal_year( + company, + '2016-03-01', + '2016-03-01', + '2017-02-28', + ) + + def test_custom_fiscal_year(self): + '''Case with custom fiscal years.''' + company = self.env.company + company.fiscalyear_last_day = 31 + company.fiscalyear_last_month = '12' + + # Create custom fiscal year covering the 6 first months of 2017. + self.env['account.fiscal.year'].create({ + 'name': '6 month 2017', + 'date_from': '2017-01-01', + 'date_to': '2017-05-31', + 'company_id': company.id, + }) + + # Check before the custom fiscal year). + self.check_compute_fiscal_year( + company, + '2017-02-01', + '2017-01-01', + '2017-05-31', + ) + + # Check after the custom fiscal year. + self.check_compute_fiscal_year( + company, + '2017-11-01', + '2017-06-01', + '2017-12-31', + ) + + # Create custom fiscal year covering the 3 last months of 2017. + self.env['account.fiscal.year'].create({ + 'name': 'last 3 month 2017', + 'date_from': '2017-10-01', + 'date_to': '2017-12-31', + 'company_id': company.id, + }) + + # Check inside the custom fiscal years. + self.check_compute_fiscal_year( + company, + '2017-07-01', + '2017-06-01', + '2017-09-30', + ) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_account_payment.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_payment.py new file mode 100644 index 0000000..f3838e1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_payment.py @@ -0,0 +1,34 @@ +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountBillPayment(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.bank_journal_1 = cls.company_data['default_journal_bank'] + + def test_bill_state_change_on_payment_state(self): + """Test that bill payment state changes correctly when payment state transitions occur. + • Draft payment case: Bill state reverts to 'not_paid' when payment is drafted + • Payment unlink case: Bill state reverts to 'not_paid' when payment is deleted + """ + bill = self.init_invoice('in_invoice', post=True, partner=self.partner_a, products=self.product_a) + + # We have to test it without any Outstanding Payment account set in Journal + self.bank_journal_1.outbound_payment_method_line_ids.payment_account_id = False + + payment = self.env['account.payment.register']\ + .with_context(active_model='account.move', active_ids=bill.ids)\ + .create({})\ + ._create_payments() + self.assertEqual(bill.payment_state, self.env['account.move']._get_invoice_in_payment_state()) + + payment.action_draft() + self.assertEqual(payment.state, 'draft') + self.assertEqual(payment.invoice_ids.payment_state, 'not_paid') + + payment.unlink() + self.assertEqual(bill.payment_state, 'not_paid') diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_account_reconcile_wizard.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_reconcile_wizard.py new file mode 100644 index 0000000..d59de5a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_account_reconcile_wizard.py @@ -0,0 +1,856 @@ +import re + +from odoo import Command, fields +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged('post_install', '-at_install') +class TestAccountReconcileWizard(AccountTestInvoicingCommon): + """ Tests the account reconciliation and its wizard. """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.receivable_account = cls.company_data['default_account_receivable'] + cls.payable_account = cls.company_data['default_account_payable'] + cls.revenue_account = cls.company_data['default_account_revenue'] + cls.payable_account_2 = cls.env['account.account'].create({ + 'name': 'Payable Account 2', + 'account_type': 'liability_current', + 'code': 'PAY2.TEST', + 'reconcile': True + }) + cls.write_off_account = cls.env['account.account'].create({ + 'name': 'Write-Off Account', + 'account_type': 'liability_current', + 'code': 'WO.TEST', + 'reconcile': False + }) + + cls.misc_journal = cls.company_data['default_journal_misc'] + cls.test_date = fields.Date.from_string('2016-01-01') + cls.company_currency = cls.company_data['currency'] + cls.foreign_currency = cls.setup_other_currency('EUR') + cls.foreign_currency_2 = cls.setup_other_currency('XAF', rates=[('2016-01-01', 6.0), ('2017-01-01', 4.0)]) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + def assertWizardReconcileValues(self, selected_lines, input_values, wo_expected_values, expected_transfer_values=None): + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=selected_lines.ids, + ).new(input_values) + if expected_transfer_values: + transfer_move = wizard.create_transfer() + # transfer move values + self.assertRecordValues(transfer_move.line_ids.sorted('balance'), expected_transfer_values) + # transfer warning message + self.assertTrue(wizard.transfer_warning_message) + regex_match = re.findall(r'([+-]*\d*,*\d+\.*\d+)', wizard.transfer_warning_message) + # match transferred amount + self.assertEqual( + float(regex_match[0].replace(',', '')), + transfer_move.amount_total_in_currency_signed or transfer_move.amount_total_signed + ) + transfer_from_account = transfer_move.line_ids.filtered(lambda aml: 'Transfer from' in aml.name).account_id + transfer_to_account = transfer_move.line_ids.account_id - transfer_from_account + transfer_from_amls = transfer_move.line_ids.filtered(lambda aml: aml.account_id == transfer_from_account) + transfer_amount = sum(aml.balance for aml in transfer_from_amls) + # match account codes + if transfer_amount > 0: + self.assertEqual(regex_match[1:], [transfer_from_account.code, transfer_to_account.code]) + else: + self.assertEqual(regex_match[1:], [transfer_to_account.code, transfer_from_account.code]) + write_off_move = wizard.create_write_off() + self.assertRecordValues(write_off_move.line_ids.sorted('balance'), wo_expected_values) + wizard.reconcile() + if wizard.allow_partials or ( + wizard.edit_mode + and wizard.reco_currency_id.compare_amounts(wizard.edit_mode_amount_currency, wizard.amount_currency) + ): + # partial reconcile + self.assertTrue(len(selected_lines.matched_debit_ids) > 0 or len(selected_lines.matched_credit_ids) > 0) + else: + # full reconcile + self.assertTrue(selected_lines.full_reconcile_id) + self.assertRecordValues( + selected_lines, + [{'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}] * len(selected_lines), + ) + + # ------------------------------------------------------------------------- + # TESTS + # ------------------------------------------------------------------------- + def test_wizard_should_not_open(self): + """ Test that when we reconcile two lines that belong to the same account and have a 0 balance should + reconcile silently and not open the write-off wizard. + """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01') + (line_1 + line_2).action_reconcile() + self.assertRecordValues( + line_1 + line_2, + [{'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}] * 2 + ) + + def test_wizard_should_open(self): + """ Test that when a write-off is required (because of transfer or non-zero balance) the wizard opens. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01') + line_3 = self.create_line_for_reconciliation(-500.0, -1500.0, self.foreign_currency, '2016-01-01') + line_4 = self.create_line_for_reconciliation(-900.0, -900.0, self.company_currency, '2016-01-01', account_1=self.payable_account) + for batch, sub_test_name in ( + (line_1 + line_2, 'Batch with non-zero balance in company currency'), + (line_1 + line_3, 'Batch with non-zero balance in foreign currency'), + (line_1 + line_4, 'Batch with different accounts'), + ): + with self.subTest(sub_test_name=sub_test_name): + returned_action = batch.action_reconcile() + self.assertEqual(returned_action.get('res_model'), 'account.reconcile.wizard') + + def test_reconcile_silently_same_account(self): + """ When balance is 0 we can silently reconcile items. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01') + lines = (line_1 + line_2) + lines.action_reconcile() + self.assertTrue(lines.full_reconcile_id) + self.assertRecordValues( + lines, + [{'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}] * len(lines), + ) + + def test_reconcile_silently_transfer(self): + """ When balance is 0, and we need a transfer, we do the transfer+reconcile silently. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01', account_1=self.payable_account) + lines = (line_1 + line_2) + lines.action_reconcile() + self.assertTrue(lines.full_reconcile_id) + self.assertRecordValues( + lines, + [{'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}] * len(lines), + ) + + def test_write_off_same_currency(self): + """ Reconciliation of two lines with no transfer/foreign currencies/taxes/reco models.""" + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + write_off_expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': -500.0}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', 'balance': 500.0}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, write_off_expected_values) + + def test_write_off_one_foreign_currency(self): + """ Reconciliation of two lines with one of the two using foreign currency should reconcile in foreign currency.""" + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -1500.0, self.foreign_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -500.0, 'amount_currency': -1500.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 500.0, 'amount_currency': 1500.0, 'currency_id': self.foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values) + + def test_write_off_one_foreign_currency_rounding(self): + """ Reconciliation of two lines with one of the two using foreign currency should reconcile in foreign currency.""" + foreign_currency = self.setup_other_currency('CAD', rounding=0.01, rates=[('2016-01-01', 0.052972554919), ('2017-01-01', 4.0)]) + + # Check that the reconciliation works independently of + # - whether the foreign amount is debit or credit + # - the account type (payable / receivable) + self.assertFalse(self.payable_account_2.account_type in ('asset_receivable', 'liability_payable')) + self.assertTrue(self.receivable_account.account_type in ('asset_receivable', 'liability_payable')) + for foreign_amount_sign, account in [ + (-1, self.payable_account_2), + (1, self.payable_account_2), + (-1, self.receivable_account), + (1, self.receivable_account), + ]: + with self.subTest(sub_test_name=f'sign: {foreign_amount_sign}, account: {account.name}'): + line_1 = self.create_line_for_reconciliation( + -foreign_amount_sign * 372239.38, -foreign_amount_sign * 372239.38, self.company_currency, + '2016-01-01', account_1=account, + ) + line_2 = self.create_line_for_reconciliation( + foreign_amount_sign * 377554.0, foreign_amount_sign * 20000.0, foreign_currency, + '2016-01-01', account_1=account, + ) + lines = line_1 + line_2 + + # Test the opening of the wizard without input values + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=lines.ids, + ).new() + self.assertRecordValues(wizard, [{ + 'is_write_off_required': True, + 'amount': foreign_amount_sign * 5314.62, + 'amount_currency': foreign_amount_sign * 281.53, + 'reco_currency_id': foreign_currency.id, + }]) + + # Check the created write-off move and that there is no residual + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + # We sort the expected values the same way as `assertWizardReconcileValues` sorts the lines + expected_values = sorted([ + {'account_id': account.id, 'name': 'Write-Off Test Label', + 'balance': -foreign_amount_sign * 5314.62, + 'amount_currency': -foreign_amount_sign * 281.53, 'currency_id': foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': foreign_amount_sign * 5314.62, + 'amount_currency': foreign_amount_sign * 281.53, 'currency_id': foreign_currency.id}, + ], key=lambda vals: vals['balance']) + self.assertWizardReconcileValues(lines, wizard_input_values, expected_values) + + full_reconcile = lines.full_reconcile_id + self.assertTrue(full_reconcile) + self.assertFalse(full_reconcile.exchange_move_id) + + def test_write_off_mixed_foreign_currencies(self): + """ Write off with multiple currencies should reconcile in company currency.""" + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -1500.0, self.foreign_currency, '2016-01-01') + line_3 = self.create_line_for_reconciliation(-400.0, -2400.0, self.foreign_currency_2, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -100.0, 'amount_currency': -100.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 100.0, 'amount_currency': 100.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2 + line_3, wizard_input_values, expected_values) + + def test_write_off_one_foreign_currency_change_rate(self): + """ Tests that write-off use the correct rate from/at wizard's date. """ + foreign_currency = self.setup_other_currency('CAD', rounding=0.001, rates=[('2016-01-01', 0.5), ('2017-01-01', 1 / 3)]) + new_date = fields.Date.from_string('2017-02-01') + line_1 = self.create_line_for_reconciliation(-2000.0, -2000.0, self.company_currency, '2017-01-01') # conversion in 2017 => -666.67🍫 + line_2 = self.create_line_for_reconciliation(2000.0, 1000.0, foreign_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': new_date, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -1000.0, 'amount_currency': -333.333, 'currency_id': foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 1000.0, 'amount_currency': 333.333, 'currency_id': foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values) + + def test_write_off_mixed_foreign_currencies_change_rate(self): + """ Tests that write-off use the correct rate from/at wizard's date. """ + new_date = fields.Date.from_string('2017-02-01') + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -1500.0, self.foreign_currency, '2016-01-01') + line_3 = self.create_line_for_reconciliation(-400.0, -2400.0, self.foreign_currency_2, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': new_date, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -100.0, 'amount_currency': -100.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 100.0, 'amount_currency': 100.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2 + line_3, wizard_input_values, expected_values) + + def test_write_off_both_same_foreign_currency_ensure_no_exchange_diff(self): + """ Test that if both AMLs have the same foreign currency and rate, the amount in company currency + is computed on the write-off in such a way that no exchange diff is created. + """ + foreign_currency = self.setup_other_currency('CAD', rounding=0.01, rates=[('2016-01-01', 1 / 0.225)]) + new_date = fields.Date.from_string('2017-02-01') + line_1 = self.create_line_for_reconciliation(21.38, 95.0, foreign_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(1.13, 5.0, foreign_currency, '2016-01-01') + line_3 = self.create_line_for_reconciliation(1.13, 5.0, foreign_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': new_date, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -23.64, 'amount_currency': -105.0, 'currency_id': foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 23.64, 'amount_currency': 105.0, 'currency_id': foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2 + line_3, wizard_input_values, expected_values) + + def test_write_off_with_transfer_account_same_currency(self): + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(100.0, 100.0, self.company_currency, '2016-01-01', account_1=self.payable_account) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_transfer_values = [ + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': -100.0, 'amount_currency': -100.0, 'currency_id': self.company_currency.id}, + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': 100.0, 'amount_currency': 100.0, 'currency_id': self.company_currency.id}, + ] + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -1100.0, 'amount_currency': -1100.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 1100.0, 'amount_currency': 1100.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values, expected_transfer_values=expected_transfer_values) + + def test_write_off_with_transfer_account_one_foreign_currency(self): + line_1 = self.create_line_for_reconciliation(1100.0, 1100.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(100.0, 300.0, self.foreign_currency, '2016-01-01', account_1=self.payable_account) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_transfer_values = [ + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': -100.0, 'amount_currency': -300.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': 100.0, 'amount_currency': 300.0, 'currency_id': self.foreign_currency.id}, + ] + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -1200.0, 'amount_currency': -3600.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 1200.0, 'amount_currency': 3600.0, 'currency_id': self.foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values, expected_transfer_values=expected_transfer_values) + + def test_write_off_with_complex_transfer(self): + partner_1 = self.env['res.partner'].create({'name': 'Test Partner 1'}) + partner_2 = self.env['res.partner'].create({'name': 'Test Partner 2'}) + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01', partner=partner_2) + line_2 = self.create_line_for_reconciliation(-100.0, -300.0, self.foreign_currency, '2016-01-01', account_1=self.payable_account, partner=partner_1) + line_3 = self.create_line_for_reconciliation(-200.0, -200.0, self.company_currency, '2016-01-01', account_1=self.payable_account, partner=partner_2) + line_4 = self.create_line_for_reconciliation(-200.0, -600.0, self.foreign_currency, '2016-01-01', account_1=self.payable_account, partner=partner_2) + line_5 = self.create_line_for_reconciliation(-200.0, -600.0, self.foreign_currency, '2016-01-01', account_1=self.payable_account, partner=partner_2) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_transfer_values = [ + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': -400.0, 'amount_currency': -1200.0, 'currency_id': self.foreign_currency.id, 'partner_id': partner_2.id}, + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': -200.0, 'amount_currency': -200.0, 'currency_id': self.company_currency.id, 'partner_id': partner_2.id}, + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': -100.0, 'amount_currency': -300.0, 'currency_id': self.foreign_currency.id, 'partner_id': partner_1.id}, + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': 100.0, 'amount_currency': 300.0, 'currency_id': self.foreign_currency.id, 'partner_id': partner_1.id}, + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': 200.0, 'amount_currency': 200.0, 'currency_id': self.company_currency.id, 'partner_id': partner_2.id}, + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': 400.0, 'amount_currency': 1200.0, 'currency_id': self.foreign_currency.id, 'partner_id': partner_2.id}, + ] + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -300.0, 'amount_currency': -900.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 300.0, 'amount_currency': 900.0, 'currency_id': self.foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2 + line_3 + line_4 + line_5, wizard_input_values, expected_values, expected_transfer_values=expected_transfer_values) + + def test_write_off_with_tax(self): + """ Tests write-off with a tax set on the wizard. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01') + tax_recover_account_id = self.env['account.account'].create({ + 'name': 'Tax Account Test', + 'account_type': 'liability_current', + 'code': 'TAX.TEST', + 'reconcile': False + }) + base_tag = self.env['account.account.tag'].create({ + 'applicability': 'taxes', + 'name': 'base_tax_tag', + 'country_id': self.company_data['company'].country_id.id, + }) + tax_tag = self.env['account.account.tag'].create({ + 'applicability': 'taxes', + 'name': 'tax_tax_tag', + 'country_id': self.company_data['company'].country_id.id, + }) + tax_id = self.env['account.tax'].create({ + 'name': 'tax_test', + 'amount_type': 'percent', + 'amount': 25.0, + 'type_tax_use': 'sale', + 'company_id': self.company_data['company'].id, + 'invoice_repartition_line_ids': [ + Command.create({'factor_percent': 100, 'repartition_type': 'base', 'tag_ids': [Command.set(base_tag.ids)]}), + Command.create({'factor_percent': 100, 'account_id': tax_recover_account_id.id, 'tag_ids': [Command.set(tax_tag.ids)]}), + ], + 'refund_repartition_line_ids': [ + Command.create({'factor_percent': 100, 'repartition_type': 'base', 'tag_ids': [Command.set(base_tag.ids)]}), + Command.create({'factor_percent': 100, 'account_id': tax_recover_account_id.id, 'tag_ids': [Command.set(tax_tag.ids)]}), + ], + }) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'tax_id': tax_id.id, + 'allow_partials': False, + 'date': self.test_date, + } + write_off_expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': -500.0}, + {'account_id': tax_recover_account_id.id, 'name': f'{tax_id.name}', 'balance': 100.0}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', 'balance': 400.0}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, write_off_expected_values) + + def test_reconcile_partials_allowed(self): + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01') + lines = line_1 + line_2 + wizard_input_values = { + 'allow_partials': True, + } + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=lines.ids, + ).new(wizard_input_values) + wizard.reconcile() + self.assertTrue(len(lines.matched_debit_ids) > 0 or len(lines.matched_credit_ids) > 0) + + def test_raise_lock_date_violation(self): + """ If a write-off violates the lock date we display a banner and change the date afterwards. """ + company_id = self.company_data['company'] + company_id.fiscalyear_lock_date = fields.Date.from_string('2016-12-01') + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-06-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-06-01') + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=(line_1 + line_2).ids, + ).new({'date': self.test_date}) + self.assertTrue(bool(wizard.lock_date_violated_warning_message)) + + def test_raise_reconcile_too_many_accounts(self): + """ If you try to reconcile lines from more than 2 accounts, it should raise an error. """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01', account_1=self.payable_account) + line_3 = self.create_line_for_reconciliation(-500.0, -500.0, self.company_currency, '2016-01-01', account_1=self.payable_account_2) + with self.assertRaises(UserError): + (line_1 + line_2 + line_3).action_reconcile() + + def test_reconcile_no_receivable_no_payable_account(self): + """ If you try to reconcile lines in an account that is neither from payable nor receivable + it should reconcile in company currency. + """ + account = self.company_data['default_account_expense'] + account.reconcile = True + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01', account_1=account) + line_2 = self.create_line_for_reconciliation(-500.0, -1500.0, self.foreign_currency, '2016-01-01', account_1=account) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_values = [ + {'account_id': account.id, 'name': 'Write-Off Test Label', + 'balance': -500.0, 'amount_currency': -500.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 500.0, 'amount_currency': 500.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values) + + def test_reconcile_exchange_diff_foreign_currency(self): + """ When reconciling exchange_diff with amount_residual_currency = 0 we need to reconcile in company_currency. + """ + exchange_gain_account = self.company_data['company'].income_currency_exchange_account_id + exchange_gain_account.reconcile = True + line_1 = self.create_line_for_reconciliation(150.0, 0.0, self.foreign_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-100.0, 0.0, self.foreign_currency, '2016-01-01', account_1=exchange_gain_account) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + # Note the transfer will always be in the currency of the line transferred + expected_transfer_values = [ + {'account_id': self.receivable_account.id, 'name': f'Transfer from {exchange_gain_account.display_name}', + 'balance': -100.0, 'amount_currency': 0.0, 'currency_id': self.foreign_currency.id}, + {'account_id': exchange_gain_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': 100.0, 'amount_currency': 0.0, 'currency_id': self.foreign_currency.id}, + ] + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -50.0, 'amount_currency': -50.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 50.0, 'amount_currency': 50.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values, expected_transfer_values=expected_transfer_values) + + def test_write_off_on_same_account(self): + """ When creating a write-off in the same account than the one used by the lines to reconcile, + the lines and the write-off should be fully reconciled. + """ + line_1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(2000.0, 2000.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.receivable_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + write_off_expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': -3000.0}, + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': 3000.0}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, write_off_expected_values) + + def test_reconcile_exchange_diff_foreign_currency_full(self): + """ When reconciling exchange_diff with amount_residual_currency = 0 we need to reconcile in company_currency. + """ + exchange_gain_account = self.company_data['company'].income_currency_exchange_account_id + exchange_gain_account.reconcile = True + line_1 = self.create_line_for_reconciliation(100.0, 0.0, self.foreign_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-100.0, 0.0, self.foreign_currency, '2016-01-01', account_1=exchange_gain_account) + lines = line_1 + line_2 + lines.action_reconcile() + self.assertTrue(lines.full_reconcile_id) + self.assertRecordValues( + lines, + [{'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}] * len(lines), + ) + + def test_write_off_kpmg_case(self): + """ Test that write-off does a full reconcile with 2 foreign currencies using a custom exchange rate. """ + new_date = fields.Date.from_string('2017-02-01') + line_1 = self.create_line_for_reconciliation(1000.0, 1500.0, self.foreign_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(-900.0, -5400.0, self.foreign_currency_2, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': new_date, + } + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, [ + { + 'account_id': self.receivable_account.id, + 'balance': -100.0, + 'amount_currency': -150.0, + 'currency_id': self.foreign_currency.id, + }, + { + 'account_id': self.write_off_account.id, + 'balance': 100.0, + 'amount_currency': 150.0, + 'currency_id': self.foreign_currency.id, + }, + ]) + + def test_write_off_multi_curr_multi_residuals_force_partials(self): + """ Test that we raise an error when trying to reconcile lines with multiple residuals. + Here debit1 will be reconciled with credit1 first as they have the same currency. + Then residual of debit1 will try to reconcile with debit2 which is impossible + => 2 residuals both in foreign currency, we don't know in which currency we should make the write-off + => We should only allow partial reconciliation. """ + debit_1 = self.create_line_for_reconciliation(2000.0, 12000.0, self.foreign_currency_2, '2016-01-01') + credit_1 = self.create_line_for_reconciliation(-1000.0, -6000.0, self.foreign_currency_2, '2016-01-01') + debit_2 = self.create_line_for_reconciliation(2000.0, 3000.0, self.foreign_currency, '2016-01-01') + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=(debit_1 + debit_2 + credit_1).ids, + ).new() + self.assertRecordValues(wizard, [{'force_partials': True, 'allow_partials': True}]) + + def test_write_off_multi_curr_multi_residuals_exch_diff_force_partials(self): + debit_1 = self.create_line_for_reconciliation(2000.0, 0.0, self.foreign_currency_2, '2016-01-01') + credit_1 = self.create_line_for_reconciliation(-1000.0, 0.0, self.foreign_currency_2, '2016-01-01') + debit_2 = self.create_line_for_reconciliation(2000.0, 0.0, self.foreign_currency, '2016-01-01') + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=(debit_1 + debit_2 + credit_1).ids, + ).new() + self.assertRecordValues(wizard, [{'force_partials': True, 'allow_partials': True}]) + + def test_reconcile_with_partner_change(self): + partner_1 = self.env['res.partner'].create({'name': 'Test Partner 1'}) + partner_2 = self.env['res.partner'].create({'name': 'Test Partner 2'}) + line_1 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01', partner=partner_1) + line_2 = self.create_line_for_reconciliation(2000.0, 2000.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.receivable_account.id, + 'to_partner_id': partner_2.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + 'tax_id': self.tax_sale_a.id, + } + write_off_expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': -1000.0, 'partner_id': partner_1.id}, + {'account_id': self.company_data['default_account_tax_sale'].id, 'name': '15%', 'balance': 130.43, 'partner_id': partner_2.id}, + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': 869.57, 'partner_id': partner_2.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, write_off_expected_values) + + def test_reconcile_with_partner_change_and_transfer(self): + partner_1 = self.env['res.partner'].create({'name': 'Test Partner 1'}) + partner_2 = self.env['res.partner'].create({'name': 'Test Partner 2'}) + line_1 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01', account_1=self.payable_account) + line_2 = self.create_line_for_reconciliation(2000.0, 2000.0, self.company_currency, '2016-01-01', partner=partner_1) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.receivable_account.id, + 'to_partner_id': partner_2.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_transfer_values = [ + {'account_id': self.receivable_account.id, 'name': f'Transfer from {self.payable_account.display_name}', + 'balance': -1000.0, 'amount_currency': -1000.0, 'currency_id': self.company_currency.id}, + {'account_id': self.payable_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': 1000.0, 'amount_currency': 1000.0, 'currency_id': self.company_currency.id}, + ] + write_off_expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': -1000.0, 'partner_id': partner_1.id}, + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', 'balance': 1000.0, 'partner_id': partner_2.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, write_off_expected_values, expected_transfer_values) + + def test_reconcile_edit_mode_partial_foreign_curr(self): + line_1 = self.create_line_for_reconciliation(100.0, 300.0, self.foreign_currency, '2016-01-01') + wizard_input_values = { + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'date': self.test_date, + 'edit_mode_amount_currency': 30.0, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -10.0, 'amount_currency': -30.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 10.0, 'amount_currency': 30.0, 'currency_id': self.foreign_currency.id}, + ] + self.assertWizardReconcileValues(line_1, wizard_input_values, expected_values) + + def test_reconcile_edit_mode_partial_company_curr(self): + line_1 = self.create_line_for_reconciliation(300.0, 300.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'date': self.test_date, + 'edit_mode_amount_currency': 100.0, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -100.0, 'amount_currency': -100.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 100.0, 'amount_currency': 100.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1, wizard_input_values, expected_values) + + def test_reconcile_edit_mode_partial_wrong_amount_raises(self): + line_1 = self.create_line_for_reconciliation(300.0, 300.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'account_id': self.write_off_account.id, + } + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=line_1.ids, + ).create(wizard_input_values) + with self.assertRaisesRegex(UserError, 'The amount of the write-off'): + wizard.edit_mode_amount_currency = -100.0 + + def test_reconcile_edit_mode_full_reconcile(self): + line_1 = self.create_line_for_reconciliation(300.0, 300.0, self.company_currency, '2016-01-01') + wizard_input_values = { + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'edit_mode_amount_currency': 300.0, + } + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -300.0, 'amount_currency': -300.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 300.0, 'amount_currency': 300.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1, wizard_input_values, expected_values) + + def test_reconcile_same_currency_same_side_not_recpay(self): + """ + Test the reconciliation with two lines on the same side (debit/credit), same currency and not on a receivable/payable account + """ + current_assets_account = self.company_data['default_account_assets'].copy({'name': 'Current Assets', 'account_type': 'asset_current', 'reconcile': True}) + line_1 = self.create_line_for_reconciliation(200, 200, self.company_currency, '2016-01-01', current_assets_account) + line_2 = self.create_line_for_reconciliation(200, 200, self.company_currency, '2016-01-01', current_assets_account) + + # Test the opening of the wizard without input values + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=(line_1 + line_2).ids, + ).new() + + self.assertRecordValues(wizard, [{'is_write_off_required': True, 'amount': 400, 'amount_currency': 400}]) + + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_values = [ + {'account_id': current_assets_account.id, 'name': 'Write-Off Test Label', + 'balance': -400.0, 'amount_currency': -400.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 400.0, 'amount_currency': 400.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values) + + def test_reconcile_foreign_currency_same_side_not_recpay(self): + """ + Test the reconciliation with two lines on the same side (debit/credit), one foreign currency and not on a receivable/payable account + """ + current_assets_account = self.company_data['default_account_assets'].copy({'name': 'Current Assets', 'account_type': 'asset_current', 'reconcile': True}) + line_1 = self.create_line_for_reconciliation(200, 300, self.foreign_currency, '2016-01-01', current_assets_account) + line_2 = self.create_line_for_reconciliation(200, 200, self.company_currency, '2016-01-01', current_assets_account) + + # Test the opening of the wizard without input values + wizard = self.env['account.reconcile.wizard'].with_context( + active_model='account.move.line', + active_ids=(line_1 + line_2).ids, + ).new() + + self.assertRecordValues(wizard, [{'is_write_off_required': True, 'amount': 400, 'amount_currency': 400}]) + + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + expected_values = [ + {'account_id': current_assets_account.id, 'name': 'Write-Off Test Label', + 'balance': -400.0, 'amount_currency': -400.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 400.0, 'amount_currency': 400.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values) + + def test_reconcile_same_side_exch_diff(self): + """ + Test the reconciliation with two lines on the same side (debit/credit), one exchange diff in foreign currency, + one regular aml in company currency + """ + exchange_gain_account = self.company_data['company'].income_currency_exchange_account_id + exchange_gain_account.reconcile = True + line_1 = self.create_line_for_reconciliation(150.0, 150.0, self.company_currency, '2016-01-01') + line_2 = self.create_line_for_reconciliation(100.0, 0.0, self.foreign_currency, '2016-01-01', account_1=exchange_gain_account) + wizard_input_values = { + 'journal_id': self.misc_journal.id, + 'account_id': self.write_off_account.id, + 'label': 'Write-Off Test Label', + 'allow_partials': False, + 'date': self.test_date, + } + # Note the transfer will always be in the currency of the line transferred + expected_transfer_values = [ + {'account_id': exchange_gain_account.id, 'name': f'Transfer to {self.receivable_account.display_name}', + 'balance': -100.0, 'amount_currency': 0.0, 'currency_id': self.foreign_currency.id}, + {'account_id': self.receivable_account.id, 'name': f'Transfer from {exchange_gain_account.display_name}', + 'balance': 100.0, 'amount_currency': 0.0, 'currency_id': self.foreign_currency.id}, + ] + expected_values = [ + {'account_id': self.receivable_account.id, 'name': 'Write-Off Test Label', + 'balance': -250.0, 'amount_currency': -250.0, 'currency_id': self.company_currency.id}, + {'account_id': self.write_off_account.id, 'name': 'Write-Off Test Label', + 'balance': 250.0, 'amount_currency': 250.0, 'currency_id': self.company_currency.id}, + ] + self.assertWizardReconcileValues(line_1 + line_2, wizard_input_values, expected_values, expected_transfer_values=expected_transfer_values) + + def test_reconcile_transfer_with_different_partners(self): + """ When balance is 0, and we need a transfer, we do the transfer+reconcile silently. """ + partner_a = self.env['res.partner'].create({'name': 'Test Partner A'}) + partner_b = self.env['res.partner'].create({'name': 'Test Partner B'}) + partner_c = self.env['res.partner'].create({'name': 'Test Partner C'}) + # when reconciling journal items on 2 different accounts from 2 different partners that balance themselves, + # all journal items should be fully reconciled together + line_a1 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01', account_1=self.receivable_account, partner=partner_a) + line_b1 = self.create_line_for_reconciliation(-1000.0, -1000.0, self.company_currency, '2016-01-01', account_1=self.payable_account, partner=partner_b) + lines = line_a1 + line_b1 + lines.action_reconcile() + transfer_lines = lines.full_reconcile_id.reconciled_line_ids.filtered(lambda line: line.id not in lines.ids) + self.assertRecordValues(transfer_lines, [ + {'balance': -1000.0, 'account_id': self.receivable_account.id, 'partner_id': partner_a.id}, + {'balance': 1000.0, 'account_id': self.payable_account.id, 'partner_id': partner_b.id}, + ]) + # even if the journal items on one of the accounts are for several different partners, + # the journal items should be fully reconciled together + line_a2 = self.create_line_for_reconciliation(1000.0, 1000.0, self.company_currency, '2016-01-01', account_1=self.receivable_account, partner=partner_a) + line_b2 = self.create_line_for_reconciliation(-600.0, -600.0, self.company_currency, '2016-01-01', account_1=self.payable_account, partner=partner_b) + line_c2 = self.create_line_for_reconciliation(-400.0, -400.0, self.company_currency, '2016-01-01', account_1=self.payable_account, partner=partner_c) + lines = line_a2 + line_b2 + line_c2 + lines.action_reconcile() + transfer_lines = lines.full_reconcile_id.reconciled_line_ids.filtered(lambda line: line.id not in lines.ids) + self.assertRecordValues(transfer_lines, [ + {'balance': -1000.0, 'account_id': self.receivable_account.id, 'partner_id': partner_a.id}, + {'balance': 600.0, 'account_id': self.payable_account.id, 'partner_id': partner_b.id}, + {'balance': 400.0, 'account_id': self.payable_account.id, 'partner_id': partner_c.id}, + ]) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget.py new file mode 100644 index 0000000..5bec5d6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget.py @@ -0,0 +1,3726 @@ +# -*- coding: utf-8 -*- +from odoo.addons.odex30_account_accountant.tests.test_bank_rec_widget_common import TestBankRecWidgetCommon +from odoo.tests import tagged +from odoo.tools import html2plaintext +from odoo import fields, Command + +from freezegun import freeze_time +from unittest.mock import patch +import re + + +@tagged('post_install', '-at_install') +class TestBankRecWidget(TestBankRecWidgetCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company_data_2 = cls.setup_other_company() + + cls.early_payment_term = cls.env['account.payment.term'].create({ + 'name': "early_payment_term", + 'company_id': cls.company_data['company'].id, + 'discount_percentage': 10, + 'discount_days': 10, + 'early_discount': True, + 'line_ids': [ + Command.create({ + 'value': 'percent', + 'value_amount': 100, + 'nb_days': 20, + }), + ], + }) + + cls.account_revenue1 = cls.company_data['default_account_revenue'] + cls.account_revenue2 = cls.copy_account(cls.account_revenue1) + + cls.reco_model_bill = cls.env['account.reconcile.model'].create({ + 'name': "test create bill", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [ + Command.create({'amount_string': '50'}), + Command.create({'amount_string': '50'}), + ], + }) + + def assert_form_extra_text_value(self, wizard, regex): + line = wizard.line_ids.filtered(lambda x: x.index == wizard.form_index) + value = line.suggestion_html + if regex: + cleaned_value = html2plaintext(value).replace('\n', '') + if not re.match(regex, cleaned_value): + self.fail(f"The following 'form_extra_text':\n\n'{cleaned_value}'\n\n...doesn't match the provided regex:\n\n'{regex}'") + else: + self.assertFalse(value) + + def test_retrieve_partner_from_account_number(self): + st_line = self._create_st_line(1000.0, partner_id=None, account_number="014 474 8555") + bank_account = self.env['res.partner.bank'].create({ + 'acc_number': '0144748555', + 'partner_id': self.partner_a.id, + }) + self.assertEqual(st_line._retrieve_partner(), bank_account.partner_id) + + # Can't retrieve the partner since the bank account is used by multiple partners. + self.env['res.partner.bank'].create({ + 'acc_number': '0144748555', + 'partner_id': self.partner_b.id, + }) + self.assertEqual(st_line._retrieve_partner(), self.env['res.partner']) + + # Archive partner_a and see if partner_b is then chosen + self.partner_a.active = False + self.assertEqual(st_line._retrieve_partner(), self.partner_b) + + def test_retrieve_partner_from_account_number_in_other_company(self): + st_line = self._create_st_line(1000.0, partner_id=None, account_number="014 474 8555") + self.env['res.partner.bank'].create({ + 'acc_number': '0144748555', + 'partner_id': self.partner_a.id, + }) + + # Bank account is owned by another company. + new_company = self.env['res.company'].create({'name': "test_retrieve_partner_from_account_number_in_other_company"}) + self.partner_a.company_id = new_company + self.assertEqual(st_line._retrieve_partner(), self.env['res.partner']) + + def test_retrieve_partner_from_partner_name(self): + """ Ensure the partner having a name fitting exactly the 'partner_name' is retrieved first. + This test create two partners that will be ordered in the lexicographic order when performing + a search. So: + row1: "Turlututu tsoin tsoin" + row2: "turlututu" + + Since "turlututu" matches exactly (case insensitive) the partner_name of the statement line, + it should be suggested first. + + However if we have two partners called turlututu, we should not suggest any or we risk selecting + the wrong one. + """ + _partner_a, partner_b = self.env['res.partner'].create([ + {'name': "Turlututu tsoin tsoin"}, + {'name': "turlututu"}, + ]) + + st_line = self._create_st_line(1000.0, partner_id=None, partner_name="Turlututu") + self.assertEqual(st_line._retrieve_partner(), partner_b) + + self.env['res.partner'].create({'name': "turlututu"}) + self.assertFalse(st_line._retrieve_partner()) + + def test_retrieve_partner_suggested_account_from_rank(self): + """ Ensure a retrieved partner is proposing his receivable/payable according his customer/supplier rank. """ + partner = self.env['res.partner'].create({'name': "turlututu"}) + rec_account_id = partner.property_account_receivable_id.id + pay_account_id = partner.property_account_payable_id.id + + st_line = self._create_st_line(1000.0, partner_id=None, partner_name="turlututu") + liq_account_id = st_line.journal_id.default_account_id.id + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': liq_account_id, 'balance': 1000.0}, + {'flag': 'auto_balance', 'account_id': rec_account_id, 'balance': -1000.0}, + ]) + + partner._increase_rank('supplier_rank', 1) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': liq_account_id, 'balance': 1000.0}, + {'flag': 'auto_balance', 'account_id': pay_account_id, 'balance': -1000.0}, + ]) + + def test_res_partner_bank_find_create_when_archived(self): + """ Test we don't get the "The combination Account Number/Partner must be unique." error with archived + bank account. + """ + partner = self.env['res.partner'].create({ + 'name': "Zitycard", + 'bank_ids': [Command.create({ + 'acc_number': "123456789", + 'active': False, + })], + }) + + st_line = self._create_st_line( + 100.0, + partner_name="Zeumat Zitycard", + account_number="123456789", + ) + inv_line = self._create_invoice_line( + 'out_invoice', + partner_id=partner.id, + invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': []}], + ) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + wizard._action_validate() + + # Should not trigger the error. + self.env['res.partner.bank'].flush_model() + + def test_res_partner_bank_find_create_multi_company(self): + """ Test we don't get the "The combination Account Number/Partner must be unique." error when the bank account + already exists on another company. + """ + partner = self.env['res.partner'].create({ + 'name': "Zitycard", + 'bank_ids': [Command.create({'acc_number': "123456789"})], + }) + partner.bank_ids.company_id = self.company_data_2['company'] + self.env.user.company_ids = self.env.company + + st_line = self._create_st_line( + 100.0, + partner_name="Zeumat Zitycard", + account_number="123456789", + ) + inv_line = self._create_invoice_line( + 'out_invoice', + partner_id=partner.id, + invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': []}], + ) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + wizard._action_validate() + + # Should not trigger the error. + self.env['res.partner.bank'].flush_model() + + def test_validation_base_case(self): + st_line = self._create_st_line( + 1000.0, + date='2017-01-01', + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + line.account_id = self.account_revenue1 + wizard._line_value_changed_account_id(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0}, + {'flag': 'manual', 'amount_currency': -1000.0, 'currency_id': self.company_data['currency'].id, 'balance': -1000.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0, 'reconciled': False}, + {'account_id': self.account_revenue1.id, 'amount_currency': -1000.0, 'currency_id': self.company_data['currency'].id, 'balance': -1000.0, 'reconciled': False}, + ]) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0}, + {'flag': 'aml', 'account_id': self.account_revenue1.id, 'amount_currency': -1000.0, 'currency_id': self.company_data['currency'].id, 'balance': -1000.0}, + ]) + + def test_validation_exchange_difference(self): + # 240.0 curr2 == 120.0 comp_curr + st_line = self._create_st_line( + 120.0, + date='2017-01-01', + foreign_currency_id=self.other_currency.id, + amount_currency=240.0, + ) + # 240.0 curr2 == 80.0 comp_curr + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 240.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 120.0, 'currency_id': self.company_data['currency'].id, 'balance': 120.0}, + {'flag': 'new_aml', 'amount_currency': -240.0, 'currency_id': self.other_currency.id, 'balance': -80.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -40.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + wizard._action_validate() + + # Check the statement line. + self.assertRecordValues(st_line.line_ids.sorted(), [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 120.0, 'currency_id': self.company_data['currency'].id, 'balance': 120.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -240.0, 'currency_id': self.other_currency.id, 'balance': -120.0, 'reconciled': True}, + ]) + + # Check the partials. + partials = st_line.line_ids.matched_debit_ids + exchange_move = partials.exchange_move_id + _liquidity_line, _suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(partials.sorted(), [ + # pylint: disable=C0326 + { + 'amount': 40.0, + 'debit_amount_currency': 0.0, + 'credit_amount_currency': 0.0, + 'debit_move_id': exchange_move.line_ids.sorted()[0].id, + 'credit_move_id': other_line.id, + 'exchange_move_id': False, + }, + { + 'amount': 80.0, + 'debit_amount_currency': 240.0, + 'credit_amount_currency': 240.0, + 'debit_move_id': inv_line.id, + 'credit_move_id': other_line.id, + 'exchange_move_id': exchange_move.id, + }, + ]) + + # Check the exchange diff journal entry. + self.assertRecordValues(exchange_move.line_ids.sorted(), [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 40.0, 'reconciled': True}, + {'account_id': self.env.company.income_currency_exchange_account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -40.0, 'reconciled': False}, + ]) + + def test_validation_new_aml_same_foreign_currency(self): + income_exchange_account = self.env.company.income_currency_exchange_account_id + + # 6000.0 curr2 == 1200.0 comp_curr (bank rate 5:1 instead of the odoo rate 4:1) + st_line = self._create_st_line( + 1200.0, + date='2017-01-01', + foreign_currency_id=self.other_currency_2.id, + amount_currency=6000.0, + ) + # 6000.0 curr2 == 1000.0 comp_curr (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 6000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -6000.0, 'currency_id': self.other_currency_2.id, 'balance': -1000.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -200.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -6000.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 200.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -200.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + # Reset the wizard. + wizard._js_action_reset() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'auto_balance', 'amount_currency': -6000.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0}, + ]) + + # Create the same invoice with a higher amount to check the partial flow. + # 9000.0 curr2 == 1500.0 comp_curr (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 9000.0}], + ) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'name': "turlututu"}, + {'flag': 'new_aml', 'amount_currency': -6000.0, 'currency_id': self.other_currency_2.id, 'balance': -1000.0, 'name': "INV/2016/00002"}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -200.0, 'name': "Exchange Difference: INV/2016/00002"}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 9,000.000.+ reduced by 6,000.000.+ set the invoice as fully paid .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -9000.0, + 'suggestion_balance': -1500.0, + }]) + + # Switch to a full reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'name': "turlututu"}, + {'flag': 'new_aml', 'amount_currency': -9000.0, 'currency_id': self.other_currency_2.id, 'balance': -1500.0, 'name': "INV/2016/00002"}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -300.0, 'name': "Exchange Difference: INV/2016/00002"}, + {'flag': 'auto_balance', 'amount_currency': 3000.0, 'currency_id': self.other_currency_2.id, 'balance': 600.0, 'name': "Open balance of 6,000.000 $"}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 9,000.000.+ paid .+ record a partial payment .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -6000.0, + 'suggestion_balance': -1000.0, + }]) + + # Switch back to a partial reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Reconcile + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -6000.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{ + 'payment_state': 'partial', + 'amount_residual': 3000.0, + }]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 200.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -200.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + def test_validation_expense_exchange_difference(self): + expense_exchange_account = self.env.company.expense_currency_exchange_account_id + + # 1200.0 comp_curr = 3600.0 foreign_curr in 2016 (rate 1:3) + st_line = self._create_st_line( + 1200.0, + date='2016-01-01', + ) + # 1800.0 comp_curr = 3600.0 foreign_curr in 2017 (rate 1:2) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 3600.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 600.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -600.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': expense_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 600.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + # Checks that the wizard still display the 3 initial lines + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1800.0}, + {'flag': 'aml', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 600.0}, + ]) + + def test_validation_income_exchange_difference(self): + income_exchange_account = self.env.company.income_currency_exchange_account_id + + # 1800.0 comp_curr = 3600.0 foreign_curr in 2017 (rate 1:2) + st_line = self._create_st_line( + 1800.0, + date='2017-01-01', + ) + # 1200.0 comp_curr = 3600.0 foreign_curr in 2016 (rate 1:3) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 3600.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -600.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1800.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 600.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -600.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + # Checks that the wizard still display the 3 initial lines + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0}, + {'flag': 'aml', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -600.0}, + ]) + + def test_validation_income_exchange_difference_with_rounding(self): + # 1000.0 comp_curr = 3000.0 foreign_curr in 2016 (rate 1:3) + # However divided in 3 invoices + rounding we have 333.33333 ≃ 333.33 comp_curr = 1000.0 foreign_curr + # this implies that the full amount has been used in foreign_curr but there is 0.01 in comp_curr + st_line = self._create_st_line( + 1000.0, + date='2016-01-01', + ) + + # 1500 comp_curr = 3000.0 foreign_curr in 2017 (rate 1:2) + inv_line_1 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + inv_line_2 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + inv_line_3 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line_1 + inv_line_2 + inv_line_3) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.67}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.67}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.67}, + {'flag': 'auto_balance', 'amount_currency': -0.01, 'currency_id': self.company_data['currency'].id, 'balance': -0.01}, + ]) + + # Remove 0.01 cent in the balance of first exchange line + first_exchange_line = wizard.line_ids.filtered(lambda x: x.flag == 'exchange_diff')[:1] + wizard._js_action_mount_line_in_edit(first_exchange_line.index) + first_exchange_line.balance = 166.66 + wizard._line_value_changed_balance(first_exchange_line) + + # Every line balance so no 'auto_balance' is generated + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.66}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.67}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 166.67}, + ]) + + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + wizard._action_validate() + + # Check that the first line with exchange has -0.01 compared to others + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1000.0, 'currency_id': self.company_data['currency'].id, 'balance': 1000.0, 'reconciled': False}, + {'account_id': inv_line_1.account_id.id, 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -333.34, 'reconciled': True}, + {'account_id': inv_line_2.account_id.id, 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -333.33, 'reconciled': True}, + {'account_id': inv_line_3.account_id.id, 'amount_currency': -1000.0, 'currency_id': self.other_currency.id, 'balance': -333.33, 'reconciled': True}, + ]) + + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line_1.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line_2.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line_3.move_id, [{'payment_state': 'paid'}]) + + def test_validation_exchange_diff_multiple(self): + income_exchange_account = self.env.company.income_currency_exchange_account_id + foreign_currency = self.setup_other_currency('AED', rates=[('2016-01-01', 6.0), ('2017-01-01', 5.0)]) + + # 6000.0 curr2 == 1200.0 comp_curr (bank rate 5:1 instead of the odoo rate 6:1) + st_line = self._create_st_line( + 1200.0, + date='2016-01-01', + foreign_currency_id=foreign_currency.id, + amount_currency=6000.0, + ) + # 1000.0 foreign_curr == 166.67 comp_curr (rate 6:1) + inv_line_1 = self._create_invoice_line( + 'out_invoice', + currency_id=foreign_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + # 2000.00 foreign_curr == 400.0 comp_curr (rate 5:1) + inv_line_2 = self._create_invoice_line( + 'out_invoice', + currency_id=foreign_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 2000.0}], + ) + # 3000.0 foreign_curr == 500.0 comp_curr (rate 6:1) + inv_line_3 = self._create_invoice_line( + 'out_invoice', + currency_id=foreign_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 3000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line_1 + inv_line_2 + inv_line_3) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'currency_id': foreign_currency.id, 'balance': -166.67}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': -33.33}, + {'flag': 'new_aml', 'amount_currency': -2000.0, 'currency_id': foreign_currency.id, 'balance': -400.0}, + {'flag': 'new_aml', 'amount_currency': -3000.0, 'currency_id': foreign_currency.id, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': -100.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line_1.account_id.id, 'amount_currency': -1000.0, 'currency_id': foreign_currency.id, 'balance': -200.0, 'reconciled': True}, + {'account_id': inv_line_2.account_id.id, 'amount_currency': -2000.0, 'currency_id': foreign_currency.id, 'balance': -400.0, 'reconciled': True}, + {'account_id': inv_line_3.account_id.id, 'amount_currency': -3000.0, 'currency_id': foreign_currency.id, 'balance': -600.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line_1.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line_2.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line_3.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues((inv_line_1 + inv_line_2 + inv_line_3).matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line_1.account_id.id, 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': 33.33, 'reconciled': True}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': -33.33, 'reconciled': False}, + {'account_id': inv_line_3.account_id.id, 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': 100.0, 'reconciled': True}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': foreign_currency.id, 'balance': -100.0, 'reconciled': False}, + ]) + + def test_validation_foreign_curr_st_line_comp_curr_payment_partial_exchange_difference(self): + comp_curr = self.env.company.currency_id + foreign_curr = self.other_currency + + st_line = self._create_st_line( + 650.0, + date='2017-01-01', + foreign_currency_id=foreign_curr.id, + amount_currency=800, + ) + + payment = self.env['account.payment'].create({ + 'partner_id': self.partner_a.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'date': '2017-01-01', + 'amount': 725.0, + }) + payment.action_post() + pay_line, _counterpart_lines, _writeoff_lines = payment._seek_for_lines() + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(pay_line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 650.0, 'currency_id': comp_curr.id, 'balance': 650.0}, + {'flag': 'new_aml', 'amount_currency': -650.0, 'currency_id': comp_curr.id, 'balance': -650.0}, + ]) + + # Switch to a full reconciliation. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + wizard._js_action_apply_line_suggestion(line.index) + + # 725 * 800 / 650 = 892.308 + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 650.0, 'currency_id': comp_curr.id, 'balance': 650.0}, + {'flag': 'new_aml', 'amount_currency': -725.0, 'currency_id': comp_curr.id, 'balance': -725.0}, + {'flag': 'auto_balance', 'amount_currency': 92.308, 'currency_id': foreign_curr.id, 'balance': 75.0}, + ]) + + # Switch to a partial reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 650.0, 'currency_id': comp_curr.id, 'balance': 650.0}, + {'flag': 'new_aml', 'amount_currency': -650.0, 'currency_id': comp_curr.id, 'balance': -650.0}, + ]) + + wizard._action_validate() + self.assertRecordValues(pay_line, [{'amount_residual': 75.0}]) + + def test_validation_remove_exchange_difference(self): + """ Test the case when the foreign currency is missing on the statement line. + In that case, the user can remove the exchange difference in order to fully reconcile both items without additional + write-off/exchange difference. + """ + # 1200.0 comp_curr = 2400.0 foreign_curr in 2017 (rate 1:2) + st_line = self._create_st_line( + 1200.0, + date='2017-01-01', + ) + # 1200.0 comp_curr = 3600.0 foreign_curr in 2016 (rate 1:3) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 3600.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -2400.0, 'currency_id': self.other_currency.id, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -400.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Remove the partial. + line_index = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml').index + wizard._js_action_mount_line_in_edit(line_index) + wizard._js_action_apply_line_suggestion(line_index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': -600.0}, + {'flag': 'auto_balance', 'amount_currency': 600.0, 'currency_id': self.company_data['currency'].id, 'balance': 600.0}, + ]) + + exchange_diff_index = wizard.line_ids.filtered(lambda x: x.flag == 'exchange_diff').index + wizard._js_action_remove_line(exchange_diff_index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0}, + ]) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + + def test_validation_new_aml_one_foreign_currency_on_st_line(self): + income_exchange_account = self.env.company.income_currency_exchange_account_id + + # 4800.0 curr2 == 1200.0 comp_curr (rate 4:1) + st_line = self._create_st_line( + 1200.0, + date='2017-01-01', + ) + # 4800.0 curr2 in 2016 (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + invoice_date='2016-01-01', + currency_id=self.other_currency_2.id, + invoice_line_ids=[{'price_unit': 4800.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 400.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + # Checks that the wizard still display the 3 initial lines + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'aml', 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'aml', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0}, # represents the exchange diff + ]) + + # Reset the wizard. + wizard._js_action_reset() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'auto_balance', 'amount_currency': -1200.0, 'currency_id': self.company_data['currency'].id, 'balance': -1200.0}, + ]) + + # Create the same invoice with a higher amount to check the partial flow. + # 4800.0 curr2 in 2016 (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + invoice_date='2016-01-01', + currency_id=self.other_currency_2.id, + invoice_line_ids=[{'price_unit': 9600.0}], + ) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 9,600.000.+ reduced by 4,800.000.+ set the invoice as fully paid .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -9600.0, + 'suggestion_balance': -1600.0, + }]) + + # Switch to a full reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -9600.0, 'currency_id': self.other_currency_2.id, 'balance': -1600.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'auto_balance', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 9,600.000.+ paid .+ record a partial payment .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -4800.0, + 'suggestion_balance': -800.0, + }]) + + # Switch back to a partial reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Reconcile + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{ + 'payment_state': 'partial', + 'amount_residual': 4800.0, + }]) + self.assertRecordValues(inv_line, [{ + 'amount_residual_currency': 4800.0, + 'amount_residual': 800.0, + 'reconciled': False, + }]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 400.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + def test_validation_new_aml_one_foreign_currency_on_inv_line(self): + income_exchange_account = self.env.company.income_currency_exchange_account_id + + # 1200.0 comp_curr is equals to 4800.0 curr2 in 2017 (rate 4:1) + st_line = self._create_st_line( + 1200.0, + date='2017-01-01', + ) + # 4800.0 curr2 == 800.0 comp_curr (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 4800.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + # Remove the line to see if the exchange difference is well removed. + wizard._action_remove_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'auto_balance', 'amount_currency': -1200.0, 'currency_id': self.company_data['currency'].id, 'balance': -1200.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'invalid'}]) + + # Mount the line again and validate. + wizard._action_add_new_amls(inv_line) + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 400.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + # Reset the wizard. + wizard._js_action_reset() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'auto_balance', 'amount_currency': -1200.0, 'currency_id': self.company_data['currency'].id, 'balance': -1200.0}, + ]) + + # Create the same invoice with a higher amount to check the partial flow. + # 7200.0 curr2 == 1200.0 comp_curr (rate 6:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 7200.0}], + ) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 7,200.000.+ reduced by 4,800.000.+ set the invoice as fully paid .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -7200.0, + 'suggestion_balance': -1200.0, + }]) + + # Switch to a full reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency': -7200.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -600.0}, + {'flag': 'auto_balance', 'amount_currency': 600.0, 'currency_id': self.company_data['currency'].id, 'balance': 600.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 7,200.000.+ paid .+ record a partial payment .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -4800.0, + 'suggestion_balance': -800.0, + }]) + + # Switch back to a partial reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Reconcile + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -4800.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{ + 'payment_state': 'partial', + 'amount_residual': 2400.0, + }]) + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + # pylint: disable=C0326 + {'account_id': inv_line.account_id.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': 400.0, 'reconciled': True, 'date': fields.Date.from_string('2017-01-31')}, + {'account_id': income_exchange_account.id, 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -400.0, 'reconciled': False, 'date': fields.Date.from_string('2017-01-31')}, + ]) + + def test_validation_new_aml_multi_currencies(self): + # 6300.0 curr2 == 1800.0 comp_curr (bank rate 3.5:1 instead of the odoo rate 4:1) + st_line = self._create_st_line( + 1800.0, + date='2017-01-01', + foreign_currency_id=self.other_currency_2.id, + amount_currency=6300.0, + ) + # 21600.0 curr3 == 1800.0 comp_curr (rate 12:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_3.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 21600.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -21600.0, 'currency_id': self.other_currency_3.id, 'balance': -1800.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # The amount is the same, no message under the 'amount' field. + self.assert_form_extra_text_value(wizard, False) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -21600.0, 'currency_id': self.other_currency_3.id, 'balance': -1800.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{'payment_state': 'paid'}]) + + # Reset the wizard. + wizard._js_action_reset() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'auto_balance', 'amount_currency': -6300.0, 'currency_id': self.other_currency_2.id, 'balance': -1800.0}, + ]) + + # Create the same invoice with a higher amount to check the partial flow. + # 32400.0 curr3 == 2700.0 comp_curr (rate 12:1) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_3.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 32400.0}], + ) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -21600.0, 'currency_id': self.other_currency_3.id, 'balance': -1800.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 32,400.000.+ reduced by 21,600.000.+ set the invoice as fully paid .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -32400.0, + 'suggestion_balance': -2700.0, + }]) + + # Switch to a full reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -32400.0, 'currency_id': self.other_currency_3.id, 'balance': -2700.0}, + {'flag': 'auto_balance', 'amount_currency': 3150.0, 'currency_id': self.other_currency_2.id, 'balance': 900.0}, + ]) + + # Check the message under the 'amount' field. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + self.assert_form_extra_text_value( + wizard, + r".+open amount of 32,400.000.+ paid .+ record a partial payment .", + ) + self.assertRecordValues(line, [{ + 'suggestion_amount_currency': -21600.0, + 'suggestion_balance': -1800.0, + }]) + + # Switch back to a partial reconciliation. + wizard._js_action_apply_line_suggestion(line.index) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Reconcile + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'account_id': st_line.journal_id.default_account_id.id, 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0, 'reconciled': False}, + {'account_id': inv_line.account_id.id, 'amount_currency': -21600.0, 'currency_id': self.other_currency_3.id, 'balance': -1800.0, 'reconciled': True}, + ]) + self.assertRecordValues(st_line, [{'is_reconciled': True}]) + self.assertRecordValues(inv_line.move_id, [{ + 'payment_state': 'partial', + 'amount_residual': 10800.0, + }]) + + def test_validation_new_aml_multi_currencies_exchange_diff_custom_rates(self): + self.company_data['default_journal_bank'].currency_id = self.other_currency + + self.env['res.currency.rate'].create([ + { + 'name': '2017-02-01', + 'rate': 1.0683, + 'currency_id': self.other_currency.id, + 'company_id': self.env.company.id, + }, + { + 'name': '2017-03-01', + 'rate': 1.0812, + 'currency_id': self.other_currency.id, + 'company_id': self.env.company.id, + }, + ]) + + # 960.14 curr1 = 888.03 comp_curr + st_line = self._create_st_line( + -960.14, + date='2017-03-01', + ) + # 112.7 curr1 == 105.49 comp_curr + inv_line1 = self._create_invoice_line( + 'in_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-02-01', + invoice_line_ids=[{'price_unit': 112.7}], + ) + # 847.44 curr1 == 793.26 comp_curr + inv_line2 = self._create_invoice_line( + 'in_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-02-01', + invoice_line_ids=[{'price_unit': 847.44}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line1) + wizard._action_add_new_amls(inv_line2) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': -960.14, 'balance': -888.03}, + {'flag': 'new_aml', 'amount_currency': 112.7, 'balance': 105.49}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -1.25}, + {'flag': 'new_aml', 'amount_currency': 847.44, 'balance': 793.26}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -9.47}, + ]) + wizard._action_remove_new_amls(inv_line1 + inv_line2) + wizard._action_add_new_amls(inv_line2) + wizard._action_add_new_amls(inv_line1) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': -960.14, 'balance': -888.03}, + {'flag': 'new_aml', 'amount_currency': 847.44, 'balance': 793.26}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -9.47}, + {'flag': 'new_aml', 'amount_currency': 112.7, 'balance': 105.49}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -1.25}, + ]) + + def test_validation_new_aml_from_partially_reconciled_invoice(self): + """ In this scenario, the invoice we are reconciling with is already partially reconciled. + We need to make sure the new aml's balance is computed using a rate computed from the + invoice's balance / amount_currency rather than the invoice's amount_residual / amount_residual_currency. + """ + self.env['res.currency.rate'].create([ + { + 'name': '2017-02-01', + 'rate': 1 / 19.839, + 'currency_id': self.other_currency.id, + 'company_id': self.env.company.id, + }, + { + 'name': '2017-03-01', + 'rate': 1 / 19.9338, + 'currency_id': self.other_currency.id, + 'company_id': self.env.company.id, + }, + ]) + + # 600000 comp_curr + st_line = self._create_st_line( + 600000, + date='2017-03-01', + ) + liquidity_line, suspense_line, _other_lines = st_line._seek_for_lines() + outstanding_account = suspense_line.account_id + + # 23664 curr1 = 469470.10 comp_curr + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-02-01', + invoice_line_ids=[{'price_unit': 23664}], + ) + + # Register a partial payment on the invoice at the same date as the invoice. + payment = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=inv_line.move_id.ids).create({ + 'amount': 9.0, + 'payment_difference_handling': 'open', + 'currency_id': self.other_currency.id, + 'payment_method_line_id': self.inbound_payment_method_line.id, + })._create_payments() + + # Sanity check that the residuals on the invoice are as expected + self.assertRecordValues(inv_line, [{ + 'balance': 469470.1, + 'amount_currency': 23664.0, + 'amount_residual': 469291.55, + 'amount_residual_currency': 23655.0, + }]) + + # Create the wizard + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 600000, 'balance': 600000.0}, + {'flag': 'new_aml', 'amount_currency': -23655.0, 'balance': -469291.55}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -2242.49}, + {'flag': 'auto_balance', 'amount_currency': -128465.96, 'balance': -128465.96}, + ]) + + # Custom amount_currency. + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + line.amount_currency = -6954.76 + wizard._line_value_changed_amount_currency(line) + + # Check that the new aml is adjusted according to the invoice rate rather than the invoice residuals' rate. + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 600000, 'balance': 600000.0}, + {'flag': 'new_aml', 'amount_currency': -6954.76, 'balance': -137975.48}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -659.31}, + {'flag': 'auto_balance', 'amount_currency': -461365.21, 'balance': -461365.21}, + ]) + + # Check that after reconciling, the new aml has 0 residual. + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + { + 'account_id': st_line.journal_id.default_account_id.id, + 'amount_currency': 600000.0, + 'balance': 600000.0, + 'currency_id': self.company_data['currency'].id, + 'reconciled': False, + }, + { + 'account_id': inv_line.account_id.id, + 'amount_currency': -6954.76, + 'balance': -138634.79, + 'currency_id': self.other_currency.id, + 'reconciled': True, + }, + { + 'account_id': outstanding_account.id, + 'amount_currency': -461365.21, + 'currency_id': self.company_data['currency'].id, + 'balance': -461365.21, + 'reconciled': False, + }, + ]) + self.assertRecordValues(inv_line.move_id, [{ + 'payment_state': 'partial', + 'amount_residual': 16700.24, + }]) + + def test_validation_with_partner(self): + partner = self.partner_a.copy() + + st_line = self._create_st_line(1000.0, partner_id=self.partner_a.id) + + # The wizard can be validated directly thanks to the receivable account set on the partner. + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Validate and check the statement line. + wizard._action_validate() + self.assertRecordValues(st_line, [{'partner_id': self.partner_a.id}]) + liquidity_line, _suspense_line, other_line = st_line._seek_for_lines() + account = self.partner_a.property_account_receivable_id + self.assertRecordValues(liquidity_line + other_line, [ + # pylint: disable=C0326 + {'account_id': liquidity_line.account_id.id, 'balance': 1000.0}, + {'account_id': account.id, 'balance': -1000.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + # Match an invoice with a different partner. + wizard._js_action_reset() + inv_line = self._create_invoice_line( + 'out_invoice', + partner_id=partner.id, + invoice_line_ids=[{'price_unit': 1000.0}], + ) + wizard._action_add_new_amls(inv_line) + wizard._action_validate() + liquidity_line, suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(st_line, [{'partner_id': partner.id}]) + self.assertRecordValues(st_line.move_id, [{'partner_id': partner.id}]) + self.assertRecordValues(liquidity_line + other_line, [ + # pylint: disable=C0326 + {'account_id': liquidity_line.account_id.id, 'partner_id': partner.id, 'balance': 1000.0}, + {'account_id': inv_line.account_id.id, 'partner_id': partner.id, 'balance': -1000.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + # Reset the wizard and match invoices with different partners. + wizard._js_action_reset() + partner1 = self.partner_a.copy() + inv_line1 = self._create_invoice_line( + 'out_invoice', + partner_id=partner1.id, + invoice_line_ids=[{'price_unit': 300.0}], + ) + partner2 = self.partner_a.copy() + inv_line2 = self._create_invoice_line( + 'out_invoice', + partner_id=partner2.id, + invoice_line_ids=[{'price_unit': 300.0}], + ) + wizard._action_add_new_amls(inv_line1 + inv_line2) + wizard._action_validate() + liquidity_line, _suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(st_line, [{'partner_id': False}]) + self.assertRecordValues(st_line.move_id, [{'partner_id': False}]) + self.assertRecordValues(liquidity_line + other_line, [ + # pylint: disable=C0326 + {'account_id': liquidity_line.account_id.id, 'partner_id': False, 'balance': 1000.0}, + {'account_id': inv_line1.account_id.id, 'partner_id': partner1.id, 'balance': -300.0}, + {'account_id': inv_line2.account_id.id, 'partner_id': partner2.id, 'balance': -300.0}, + {'account_id': account.id, 'partner_id': False, 'balance': -400.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + # Clear the accounts set on the partner and reset the widget. + # The wizard should be invalid since we are not able to set an open balance. + partner.property_account_receivable_id = None + wizard._js_action_reset() + liquidity_line, suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': liquidity_line.account_id.id}, + {'flag': 'auto_balance', 'account_id': suspense_line.account_id.id}, + ]) + self.assertRecordValues(wizard, [{'state': 'invalid'}]) + + def test_partner_receivable_payable_account(self): + self.partner_a.write({'customer_rank': 1, 'supplier_rank': 0}) # always receivable + self.partner_b.write({'customer_rank': 0, 'supplier_rank': 1}) # always payable + partner_c = self.partner_b.copy({'customer_rank': 3, 'supplier_rank': 2}) # no preference + + positive_st_line = self._create_st_line(1000) + journal_account = positive_st_line.journal_id.default_account_id + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=positive_st_line.id).new({}) + suspense_line = wizard.line_ids.filtered(lambda l: l.flag != "liquidity") + wizard._js_action_mount_line_in_edit(suspense_line.index) + + suspense_line.partner_id = self.partner_a + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': self.partner_a.id, 'account_id': self.partner_a.property_account_receivable_id.id}, + ]) + + suspense_line.partner_id = self.partner_b + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': self.partner_b.id, 'account_id': self.partner_b.property_account_payable_id.id}, + ]) + + suspense_line.partner_id = partner_c + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': partner_c.id, 'account_id': partner_c.property_account_receivable_id.id}, + ]) + + negative_st_line = self._create_st_line(-1000) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=negative_st_line.id).new({}) + suspense_line = wizard.line_ids.filtered(lambda l: l.flag != "liquidity") + wizard._js_action_mount_line_in_edit(suspense_line.index) + + suspense_line.partner_id = self.partner_a + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': self.partner_a.id, 'account_id': self.partner_a.property_account_receivable_id.id}, + ]) + + suspense_line.partner_id = self.partner_b + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': self.partner_b.id, 'account_id': self.partner_b.property_account_payable_id.id}, + ]) + + suspense_line.partner_id = partner_c + wizard._line_value_changed_partner_id(suspense_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'partner_id': False, 'account_id': journal_account.id}, + {'partner_id': partner_c.id, 'account_id': partner_c.property_account_payable_id.id}, + ]) + + def test_validation_using_custom_account(self): + st_line = self._create_st_line(1000.0) + + # By default, the wizard can't be validated directly due to the suspense account. + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard, [{'state': 'invalid'}]) + + # Mount the auto-balance line in edit mode. + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + liquidity_line, suspense_line, _other_lines = st_line._seek_for_lines() + self.assertRecordValues(line, [{ + 'account_id': suspense_line.account_id.id, + 'balance': -1000.0, + }]) + + # Switch to a custom account. + account = self.env['account.account'].create({ + 'name': "test_validation_using_custom_account", + 'code': "424242", + 'account_type': "asset_current", + }) + line.account_id = account + wizard._line_value_changed_account_id(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': liquidity_line.account_id.id, 'balance': 1000.0}, + {'flag': 'manual', 'account_id': account.id, 'balance': -1000.0}, + ]) + + # The wizard can be validated. + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Validate and check the statement line. + wizard._action_validate() + liquidity_line, _suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(liquidity_line + other_line, [ + # pylint: disable=C0326 + {'account_id': liquidity_line.account_id.id, 'balance': 1000.0}, + {'account_id': account.id, 'balance': -1000.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + def test_validation_with_taxes(self): + st_line = self._create_st_line(1000.0) + + tax_tags = self.env['account.account.tag'].create({ + 'name': f'tax_tag_{i}', + 'applicability': 'taxes', + 'country_id': self.env.company.account_fiscal_country_id.id, + } for i in range(4)) + + tax_21 = self.env['account.tax'].create({ + 'name': "tax_21", + 'amount': 21, + 'invoice_repartition_line_ids': [ + Command.create({ + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [Command.set(tax_tags[0].ids)], + }), + Command.create({ + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [Command.set(tax_tags[1].ids)], + }), + ], + 'refund_repartition_line_ids': [ + Command.create({ + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [Command.set(tax_tags[2].ids)], + }), + Command.create({ + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [Command.set(tax_tags[3].ids)], + }), + ], + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.link(tax_21.id)] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0, 'tax_tag_ids': []}, + {'flag': 'manual', 'balance': -826.45, 'tax_tag_ids': tax_tags[0].ids}, + {'flag': 'tax_line', 'balance': -173.55, 'tax_tag_ids': tax_tags[1].ids}, + ]) + + # Remove the tax directly. + line.tax_ids = [Command.clear()] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0, 'tax_tag_ids': []}, + {'flag': 'manual', 'balance': -1000.0, 'tax_tag_ids': []}, + ]) + + # Edit the base line. The tax tags should be the refund ones. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.link(tax_21.id)] + wizard._line_value_changed_tax_ids(line) + line.balance = 500.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0, 'tax_tag_ids': []}, + {'flag': 'manual', 'balance': 500.0, 'tax_tag_ids': tax_tags[2].ids}, + {'flag': 'tax_line', 'balance': 105.0, 'tax_tag_ids': tax_tags[3].ids}, + {'flag': 'auto_balance', 'balance': -1605.0, 'tax_tag_ids': []}, + ]) + + # Edit the base line. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -500.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0, 'tax_tag_ids': []}, + {'flag': 'manual', 'balance': -500.0, 'tax_tag_ids': tax_tags[0].ids}, + {'flag': 'tax_line', 'balance': -105.0, 'tax_tag_ids': tax_tags[1].ids}, + {'flag': 'auto_balance', 'balance': -395.0, 'tax_tag_ids': []}, + ]) + + # Edit the tax line. + line = wizard.line_ids.filtered(lambda x: x.flag == 'tax_line') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -100.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0, 'tax_tag_ids': []}, + {'flag': 'manual', 'balance': -500.0, 'tax_tag_ids': tax_tags[0].ids}, + {'flag': 'tax_line', 'balance': -100.0, 'tax_tag_ids': tax_tags[1].ids}, + {'flag': 'auto_balance', 'balance': -400.0, 'tax_tag_ids': []}, + ]) + + # Add a new tax. + tax_10 = self.env['account.tax'].create({ + 'name': "tax_10", + 'amount': 10, + }) + + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.link(tax_10.id)] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -500.0}, + {'flag': 'tax_line', 'balance': -105.0}, + {'flag': 'tax_line', 'balance': -50.0}, + {'flag': 'auto_balance', 'balance': -345.0}, + ]) + + # Remove the taxes. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.clear()] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -500.0}, + {'flag': 'auto_balance', 'balance': -500.0}, + ]) + + # Reset the amount. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -1000.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -1000.0}, + ]) + + # Add taxes. We should be back into the "price included taxes" mode. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.link(tax_21.id)] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -826.45}, + {'flag': 'tax_line', 'balance': -173.55}, + ]) + + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.tax_ids = [Command.link(tax_10.id)] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -763.35}, + {'flag': 'tax_line', 'balance': -160.31}, + {'flag': 'tax_line', 'balance': -76.34}, + ]) + + # Changing the account should recompute the taxes but preserve the "price included taxes" mode. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.account_id = self.account_revenue1 + wizard._line_value_changed_account_id(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -763.35}, + {'flag': 'tax_line', 'balance': -160.31}, + {'flag': 'tax_line', 'balance': -76.34}, + ]) + + # The wizard can be validated. + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + # Validate and check the statement line. + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'balance': 1000.0}, + {'balance': -763.35}, + {'balance': -160.31}, + {'balance': -76.34}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + def test_validation_caba_tax_account(self): + """ Cash basis taxes usually put their tax lines on a transition account, and the cash basis entries then move those amounts + to the regular tax accounts. When using a cash basis tax in the bank reconciliation widget, their won't be any cash basis + entry and the lines will directly be exigible, so we want to use the final tax account directly. + """ + tax_account = self.company_data['default_account_tax_sale'] + + caba_tax = self.env['account.tax'].create({ + 'name': "CABA", + 'amount_type': 'percent', + 'amount': 20.0, + 'tax_exigibility': 'on_payment', + 'cash_basis_transition_account_id': self.safe_copy(tax_account).id, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + }), + (0, 0, { + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'repartition_type': 'base', + }), + (0, 0, { + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + }) + + st_line = self._create_st_line(120.0) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + line.account_id = self.account_revenue1 + line.tax_ids = [Command.link(caba_tax.id)] + wizard._line_value_changed_tax_ids(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 120.0, 'account_id': st_line.journal_id.default_account_id.id}, + {'flag': 'manual', 'balance': -100.0, 'account_id': self.account_revenue1.id}, + {'flag': 'tax_line', 'balance': -20.0, 'account_id': tax_account.id}, + ]) + + self.assertRecordValues(wizard, [{'state': 'valid'}]) + + wizard._action_validate() + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'balance': 120.0, 'tax_ids': [], 'tax_line_id': False, 'account_id': st_line.journal_id.default_account_id.id}, + {'balance': -100.0, 'tax_ids': caba_tax.ids, 'tax_line_id': False, 'account_id': self.account_revenue1.id}, + {'balance': -20.0, 'tax_ids': [], 'tax_line_id': caba_tax.id, 'account_id': tax_account.id}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + def test_validation_changed_default_account(self): + st_line = self._create_st_line(100.0, partner_id=self.partner_a.id) + original_journal_account_id = st_line.journal_id.default_account_id + # Change the default account of the journal (exceptional case) + st_line.journal_id.default_account_id = self.company_data['default_journal_cash'].default_account_id + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard, [{'state': 'valid'}]) + # Validate and check the statement line. + wizard._action_validate() + liquidity_line, _suspense_line, _other_line = st_line._seek_for_lines() + self.assertRecordValues(liquidity_line, [ + {'account_id': original_journal_account_id.id, 'balance': 100.0}, + ]) + self.assertRecordValues(wizard, [{'state': 'reconciled'}]) + + def test_apply_taxes_with_reco_model(self): + st_line = self._create_st_line(1000.0) + + tax_21 = self.env['account.tax'].create({ + 'name': "tax_21", + 'amount': 21, + }) + + reco_model = self.env['account.reconcile.model'].create({ + 'name': "test_apply_taxes_with_reco_model", + 'rule_type': 'writeoff_button', + 'line_ids': [Command.create({ + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(tax_21.ids)], + })], + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_select_reconcile_model(reco_model) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1000.0}, + {'flag': 'manual', 'balance': -826.45}, + {'flag': 'tax_line', 'balance': -173.55}, + ]) + + def test_percentage_st_line_with_reco_model(self): + journal_curr = self.other_currency + foreign_curr = self.other_currency_2 + self.company_data['default_journal_bank'].currency_id = journal_curr + + # Setup triple currency. + st_line = self._create_st_line( + 1000.0, + date='2018-01-01', + foreign_currency_id=foreign_curr.id, + amount_currency=4000.0, + ) + + reco_model = self.env['account.reconcile.model'].create({ + 'name': "test_percentage_st_line_with_reco_model", + 'rule_type': 'writeoff_button', + 'line_ids': [ + Command.create({ + 'amount_type': 'percentage_st_line', + 'amount_string': str(percentage), + 'label': str(i), + 'account_id': self.account_revenue1.id, + }) + for i, percentage in enumerate((74.0, 24.0, 12.0, -10.0)) + ], + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_select_reconcile_model(reco_model) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'currency_id': journal_curr.id, 'amount_currency': 1000.0, 'balance': 500.0}, + {'flag': 'manual', 'currency_id': journal_curr.id, 'amount_currency': -740.0, 'balance': -370.0}, + {'flag': 'manual', 'currency_id': journal_curr.id, 'amount_currency': -240.0, 'balance': -120.0}, + {'flag': 'manual', 'currency_id': journal_curr.id, 'amount_currency': -120.0, 'balance': -60.0}, + {'flag': 'manual', 'currency_id': journal_curr.id, 'amount_currency': 100.0, 'balance': 50.0}, + ]) + + def test_manual_edits_not_replaced(self): + """ 2 partial payments should keep the edited balance """ + st_line = self._create_st_line( + 1200.0, + date='2017-02-01', + ) + inv_line_1 = self._create_invoice_line( + 'out_invoice', + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 3000.0}], + ) + inv_line_2 = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 4000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line_1) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1200.0}, + {'flag': 'new_aml', 'balance':-1200.0}, + ]) + + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -600.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1200.0}, + {'flag': 'new_aml', 'balance': -600.0}, + {'flag': 'auto_balance', 'balance': -600.0}, + ]) + + wizard._action_add_new_amls(inv_line_2) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 1200.0}, + {'flag': 'new_aml', 'balance': -600.0}, + {'flag': 'new_aml', 'balance': -600.0}, + ]) + + def test_manual_edits_not_replaced_multicurrency(self): + """ 2 partial payments should keep the edited amount_currency """ + st_line = self._create_st_line( + 1200.0, + date='2018-01-01', + foreign_currency_id=self.other_currency_2.id, + amount_currency=6000.0, # rate 5:1 + ) + + inv_line_1 = self._create_invoice_line( + 'out_invoice', + invoice_date='2016-01-01', + currency_id=self.other_currency_2.id, + invoice_line_ids=[{'price_unit': 6000.0}], # 1000 company curr (rate 6:1) + ) + inv_line_2 = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + currency_id=self.other_currency_2.id, + invoice_line_ids=[{'price_unit': 4000.0}], # 1000 company curr (rate 4:1) + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line_1) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency':-6000.0, 'balance':-1000.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -200.0}, + ]) + + line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(line.index) + line.amount_currency = -3000.0 + wizard._line_value_changed_amount_currency(line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency':-3000.0, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -100.0}, + {'flag': 'auto_balance', 'amount_currency':-3000.0, 'balance': -600.0}, + ]) + + wizard._action_add_new_amls(inv_line_2) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'balance': 1200.0}, + {'flag': 'new_aml', 'amount_currency':-3000.0, 'balance': -500.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -100.0}, + {'flag': 'new_aml', 'amount_currency':-3000.0, 'balance': -750.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': 150.0}, + ]) + + def test_creating_manual_line_multi_currencies(self): + # 6300.0 curr2 == 1800.0 comp_curr (bank rate 3.5:1 instead of the odoo rate 4:1) + st_line = self._create_st_line( + 1800.0, + date='2017-01-01', + foreign_currency_id=self.other_currency_2.id, + amount_currency=6300.0, + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'auto_balance', 'amount_currency': -6300.0, 'currency_id': self.other_currency_2.id, 'balance': -1800.0}, + ]) + + # Custom balance. + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -1500.0 + wizard._line_value_changed_balance(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'manual', 'amount_currency': -6300.0, 'currency_id': self.other_currency_2.id, 'balance': -1500.0}, + {'flag': 'auto_balance', 'amount_currency': 0.0, 'currency_id': self.other_currency_2.id, 'balance': -300.0}, + ]) + + # Custom amount_currency. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.amount_currency = -4200.0 + wizard._line_value_changed_amount_currency(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'manual', 'amount_currency': -4200.0, 'currency_id': self.other_currency_2.id, 'balance': -1200.0}, + {'flag': 'auto_balance', 'amount_currency': -2100.0, 'currency_id': self.other_currency_2.id, 'balance': -600.0}, + ]) + + # Custom currency_id. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.currency_id = self.other_currency + wizard._line_value_changed_currency_id(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'manual', 'amount_currency': -4200.0, 'currency_id': self.other_currency.id, 'balance': -2100.0}, + {'flag': 'auto_balance', 'amount_currency': 1050.0, 'currency_id': self.other_currency_2.id, 'balance': 300.0}, + ]) + + # Custom balance. + line = wizard.line_ids.filtered(lambda x: x.flag == 'manual') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -1800.0 + wizard._line_value_changed_balance(line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'currency_id': self.company_data['currency'].id, 'balance': 1800.0}, + {'flag': 'manual', 'amount_currency': -4200.0, 'currency_id': self.other_currency.id, 'balance': -1800.0}, + ]) + + def test_auto_reconcile_cron(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + cron = self.env.ref('account_accountant.auto_reconcile_bank_statement_line') + self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)]).unlink() + + st_line = self._create_st_line(1234.0, partner_id=self.partner_a.id, date='2017-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 1) + + self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + + rule = self.env['account.reconcile.model'].create({ + 'name': "test_auto_reconcile_cron", + 'rule_type': 'writeoff_suggestion', + 'auto_reconcile': False, + 'line_ids': [Command.create({'account_id': self.account_revenue1.id})], + }) + + # The CRON is not doing anything since the model is not auto reconcile. + with freeze_time('2017-01-01'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines() + self.assertRecordValues(st_line, [{'is_reconciled': False, 'cron_last_check': False}]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 1) + + rule.auto_reconcile = True + + # The CRON don't consider old statement lines. + with freeze_time('2017-06-01'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines() + self.assertRecordValues(st_line, [{'is_reconciled': False, 'cron_last_check': False}]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 1) + + # The CRON will auto-reconcile the line. + with freeze_time('2017-01-02'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines() + self.assertRecordValues(st_line, [{'is_reconciled': True, 'cron_last_check': fields.Datetime.from_string('2017-01-02 00:00:00')}]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 1) + + st_line1 = self._create_st_line(1234.0, partner_id=self.partner_a.id, date='2018-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 2) + self._create_invoice_line( + 'out_invoice', + invoice_date='2018-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + st_line2 = self._create_st_line(1234.0, partner_id=self.partner_a.id, date='2018-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 3) + self._create_invoice_line( + 'out_invoice', + invoice_date='2018-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + + # Simulate the cron already tried to process 'st_line1' before. + with freeze_time('2017-12-31'): + st_line1.cron_last_check = fields.Datetime.now() + + # The statement line with no 'cron_last_check' must be processed before others. + with freeze_time('2018-01-02'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=1) + + self.assertRecordValues(st_line1 + st_line2, [ + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2017-12-31 00:00:00')}, + {'is_reconciled': True, 'cron_last_check': fields.Datetime.from_string('2018-01-02 00:00:00')}, + ]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 4) + + with freeze_time('2018-01-03'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=1) + + self.assertRecordValues(st_line1, [{'is_reconciled': True, 'cron_last_check': fields.Datetime.from_string('2018-01-03 00:00:00')}]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 4) + + st_line3 = self._create_st_line(1234.0, date='2018-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 5) + self._create_invoice_line( + 'out_invoice', + invoice_date='2018-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + st_line4 = self._create_st_line(1234.0, date='2018-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 6) + self._create_invoice_line( + 'out_invoice', + invoice_date='2018-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + + # Make sure the CRON is no longer applicable. + rule.match_partner = True + rule.match_partner_ids = [Command.set(self.partner_a.ids)] + with freeze_time('2018-01-01'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=1) + + self.assertRecordValues(st_line3 + st_line4, [ + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-01 00:00:00')}, + {'is_reconciled': False, 'cron_last_check': False}, + ]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 7) + + # Make sure the statement lines are reconciled by the cron in the right order. + self.assertRecordValues(st_line3 + st_line4, [ + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-01 00:00:00')}, + {'is_reconciled': False, 'cron_last_check': False}, + ]) + + # st_line4 is processed because cron_last_check is null. + with freeze_time('2018-01-02'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=1) + + self.assertRecordValues(st_line3 + st_line4, [ + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-01 00:00:00')}, + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-02 00:00:00')}, + ]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 7) + + # st_line3 is processed because it has the oldest cron_last_check. + with freeze_time('2018-01-03'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(batch_size=1) + + self.assertRecordValues(st_line3 + st_line4, [ + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-03 00:00:00')}, + {'is_reconciled': False, 'cron_last_check': fields.Datetime.from_string('2018-01-02 00:00:00')}, + ]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 7) + + def test_duplicate_amls_constraint(self): + st_line = self._create_st_line(1000.0) + inv_line = self._create_invoice_line( + 'out_invoice', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertTrue(len(wizard.line_ids), 2) + + wizard._action_add_new_amls(inv_line) + self.assertTrue(len(wizard.line_ids), 2) + + @freeze_time('2017-01-01') + def test_reconcile_model_with_payment_tolerance(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + + invoice_line = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + st_line = self._create_st_line(998.0, partner_id=self.partner_a.id, date='2017-01-01', payment_ref=invoice_line.move_id.name) + + rule = self.env['account.reconcile.model'].create({ + 'name': "test_reconcile_model_with_payment_tolerance", + 'rule_type': 'invoice_matching', + 'allow_payment_tolerance': True, + 'payment_tolerance_type': 'percentage', + 'payment_tolerance_param': 2.0, + 'line_ids': [Command.create({'account_id': self.account_revenue1.id})], + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_trigger_matching_rules() + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 998.0, 'reconcile_model_id': False}, + {'flag': 'new_aml', 'balance': -1000.0, 'reconcile_model_id': rule.id}, + {'flag': 'manual', 'balance': 2.0, 'reconcile_model_id': rule.id}, + ]) + + @freeze_time('2017-01-01') + def test_auto_reconcile_model_with_archived_partner(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + self.env['res.partner.bank'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + + # Needed as partner_a does not have a company and we need a company in order to find matching partner from account_number + self.partner_a.company_id = self.company_data['company'].id + self.env['res.partner.bank'].create({ + 'partner_id': self.partner_a.id, + 'acc_number': '12345', + }) + invoice_line = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + st_line = self._create_st_line(1000.0, account_number='12345', date='2017-01-01', payment_ref=invoice_line.move_id.name) + + self.env['account.reconcile.model'].create({ + 'name': "test_reconcile_model_with_archived_partner", + 'rule_type': 'invoice_matching', + 'auto_reconcile': True, + 'match_partner': True, + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_trigger_matching_rules() + self.assertEqual(wizard.partner_id, self.partner_a) + self.assertTrue(wizard.matching_rules_allow_auto_reconcile) + + # archive partner and trigger again, partner should still be set (match based on account_number), because it's the only match + self.partner_a.active = False + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_trigger_matching_rules() + self.assertEqual(wizard.partner_id, self.partner_a) # Partner should still be set + self.assertTrue(wizard.matching_rules_allow_auto_reconcile) # no special process because the partner is unactive + + def test_early_payment_included_multi_currency(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + self.early_payment_term.early_pay_discount_computation = 'included' + income_exchange_account = self.env.company.income_currency_exchange_account_id + expense_exchange_account = self.env.company.expense_currency_exchange_account_id + + inv_line1_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2016-12-01', + invoice_line_ids=[ + { + 'price_unit': 4800.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 9600.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line1_with_epd_rec_lines = inv_line1_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line1_with_epd_rec_lines, + [ + { + 'amount_currency': 16560.0, + 'balance': 2760.0, + 'discount_amount_currency': 14904.0, + 'discount_balance': 2484.0, + 'discount_date': fields.Date.from_string('2016-12-11'), + 'date_maturity': fields.Date.from_string('2016-12-21'), + }, + ], + ) + + inv_line2_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2017-01-20', + invoice_line_ids=[ + { + 'price_unit': 480.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 960.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line2_with_epd_rec_lines = inv_line2_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line2_with_epd_rec_lines, + [ + { + 'amount_currency': 1656.0, + 'balance': 414.0, + 'discount_amount_currency': 1490.4, + 'discount_balance': 372.6, + 'discount_date': fields.Date.from_string('2017-01-30'), + 'date_maturity': fields.Date.from_string('2017-02-09'), + }, + ], + ) + + # inv1: 16560.0 (no epd) + # inv2: 1490.4 (epd) + st_line = self._create_st_line( + 4512.0, # instead of 4512.6 (rate 1:4) + date='2017-01-04', + foreign_currency_id=self.other_currency_2.id, + amount_currency=18050.4, + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + + # Add all lines from the first invoice plus the first one from the second one. + wizard._action_add_new_amls(inv_line1_with_epd_rec_lines + inv_line2_with_epd_rec_lines) + liquidity_acc = st_line.journal_id.default_account_id + receivable_acc = self.company_data['default_account_receivable'] + early_pay_acc = self.env.company.account_journal_early_pay_discount_loss_account_id + tax_acc = self.company_data['default_tax_sale'].invoice_repartition_line_ids.account_id + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 4512.0, 'balance': 4512.0, 'account_id': liquidity_acc.id}, + {'flag': 'new_aml', 'amount_currency': -16560.0, 'balance': -2760.0, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -1379.45, 'account_id': income_exchange_account.id}, + {'flag': 'new_aml', 'amount_currency': -1656.0, 'balance': -414.0, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': 0.06, 'account_id': expense_exchange_account.id}, + {'flag': 'early_payment', 'amount_currency': 144.0, 'balance': 36.0, 'account_id': early_pay_acc.id}, + {'flag': 'early_payment', 'amount_currency': 21.6, 'balance': 5.4, 'account_id': tax_acc.id}, + {'flag': 'early_payment', 'amount_currency': 0.0, 'balance': -0.01, 'account_id': income_exchange_account.id}, + ]) + + def test_early_payment_excluded_multi_currency(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + self.early_payment_term.early_pay_discount_computation = 'excluded' + income_exchange_account = self.env.company.income_currency_exchange_account_id + expense_exchange_account = self.env.company.expense_currency_exchange_account_id + + inv_line1_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2016-12-01', + invoice_line_ids=[ + { + 'price_unit': 4800.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 9600.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line1_with_epd_rec_lines = inv_line1_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line1_with_epd_rec_lines, + [ + { + 'amount_currency': 16560.0, + 'balance': 2760.0, + 'discount_amount_currency': 15120.0, + 'discount_balance': 2520.0, + 'discount_date': fields.Date.from_string('2016-12-11'), + 'date_maturity': fields.Date.from_string('2016-12-21'), + }, + ], + ) + + inv_line2_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2017-01-20', + invoice_line_ids=[ + { + 'price_unit': 480.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 960.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line2_with_epd_rec_lines = inv_line2_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line2_with_epd_rec_lines, + [ + { + 'amount_currency': 1656.0, + 'balance': 414.0, + 'discount_amount_currency': 1512.0, + 'discount_balance': 378.0, + 'discount_date': fields.Date.from_string('2017-01-30'), + 'date_maturity': fields.Date.from_string('2017-02-09'), + }, + ], + ) + + # inv1: 16560.0 (no epd) + # inv2: 1512.0 (epd) + st_line = self._create_st_line( + 4515.0, # instead of 4518.0 (rate 1:4) + date='2017-01-04', + foreign_currency_id=self.other_currency_2.id, + amount_currency=18072.0, + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + + # Add all lines from the first invoice plus the first one from the second one. + wizard._action_add_new_amls(inv_line1_with_epd_rec_lines + inv_line2_with_epd_rec_lines[:2]) + liquidity_acc = st_line.journal_id.default_account_id + receivable_acc = self.company_data['default_account_receivable'] + early_pay_acc = self.env.company.account_journal_early_pay_discount_loss_account_id + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 4515.0, 'balance': 4515.0, 'account_id': liquidity_acc.id}, + {'flag': 'new_aml', 'amount_currency': -16560.0, 'balance': -2760.0, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -1377.25, 'account_id': income_exchange_account.id}, + {'flag': 'new_aml', 'amount_currency': -1656.0, 'balance': -414.0, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': 0.27, 'account_id': expense_exchange_account.id}, + {'flag': 'early_payment', 'amount_currency': 144.0, 'balance': 36.0, 'account_id': early_pay_acc.id}, + {'flag': 'early_payment', 'amount_currency': 0.0, 'balance': -0.02, 'account_id': income_exchange_account.id}, + ]) + + def test_early_payment_mixed_multi_currency(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + self.early_payment_term.early_pay_discount_computation = 'mixed' + income_exchange_account = self.env.company.income_currency_exchange_account_id + expense_exchange_account = self.env.company.expense_currency_exchange_account_id + + inv_line1_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2016-12-01', + invoice_line_ids=[ + { + 'price_unit': 4800.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 9600.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line1_with_epd_rec_lines = inv_line1_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line1_with_epd_rec_lines, + [ + { + 'amount_currency': 16344.0, + 'balance': 2724.0, + 'discount_amount_currency': 14904.0, + 'discount_balance': 2484.0, + 'discount_date': fields.Date.from_string('2016-12-11'), + 'date_maturity': fields.Date.from_string('2016-12-21'), + }, + ], + ) + + inv_line2_with_epd = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2017-01-20', + invoice_line_ids=[ + { + 'price_unit': 480.0, + 'account_id': self.account_revenue1.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + { + 'price_unit': 960.0, + 'account_id': self.account_revenue2.id, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + }, + ], + ) + inv_line2_with_epd_rec_lines = inv_line2_with_epd.move_id.line_ids\ + .filtered(lambda x: x.account_type == 'asset_receivable')\ + .sorted(lambda x: x.discount_date or x.date_maturity) + self.assertRecordValues( + inv_line2_with_epd_rec_lines, + [ + { + 'amount_currency': 1634.4, + 'balance': 408.6, + 'discount_amount_currency': 1490.4, + 'discount_balance': 372.6, + 'discount_date': fields.Date.from_string('2017-01-30'), + 'date_maturity': fields.Date.from_string('2017-02-09'), + }, + ], + ) + + # inv1: 16344.0 (no epd) + # inv2: 1490.4 (epd) + st_line = self._create_st_line( + 4458.0, # instead of 4458.6 (rate 1:4) + date='2017-01-04', + foreign_currency_id=self.other_currency_2.id, + amount_currency=17834.4, + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + + # Add all lines from the first invoice plus the first one from the second one. + wizard._action_add_new_amls(inv_line1_with_epd_rec_lines + inv_line2_with_epd_rec_lines[:2]) + liquidity_acc = st_line.journal_id.default_account_id + receivable_acc = self.company_data['default_account_receivable'] + early_pay_acc = self.env.company.account_journal_early_pay_discount_loss_account_id + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 4458.0, 'balance': 4458.0, 'account_id': liquidity_acc.id}, + {'flag': 'new_aml', 'amount_currency': -16344.0, 'balance': -2724.0, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -1361.45, 'account_id': income_exchange_account.id}, + {'flag': 'new_aml', 'amount_currency': -1634.4, 'balance': -408.6, 'account_id': receivable_acc.id}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': 0.05, 'account_id': expense_exchange_account.id}, + {'flag': 'early_payment', 'amount_currency': 144.0, 'balance': 36.0, 'account_id': early_pay_acc.id}, + ]) + + def test_early_payment_included_intracomm_bill(self): + tax_tags = self.env['account.account.tag'].create({ + 'name': f'tax_tag_{i}', + 'applicability': 'taxes', + 'country_id': self.env.company.account_fiscal_country_id.id, + } for i in range(6)) + + intracomm_tax = self.env['account.tax'].create({ + 'name': 'tax20', + 'amount_type': 'percent', + 'amount': 20, + 'type_tax_use': 'purchase', + 'invoice_repartition_line_ids': [ + # pylint: disable=bad-whitespace + Command.create({'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [Command.set(tax_tags[0].ids)]}), + Command.create({'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [Command.set(tax_tags[1].ids)]}), + Command.create({'repartition_type': 'tax', 'factor_percent': -100.0, 'tag_ids': [Command.set(tax_tags[2].ids)]}), + ], + 'refund_repartition_line_ids': [ + # pylint: disable=bad-whitespace + Command.create({'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [Command.set(tax_tags[3].ids)]}), + Command.create({'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [Command.set(tax_tags[4].ids)]}), + Command.create({'repartition_type': 'tax', 'factor_percent': -100.0, 'tag_ids': [Command.set(tax_tags[5].ids)]}), + ], + }) + + early_payment_term = self.env['account.payment.term'].create({ + 'name': "early_payment_term", + 'company_id': self.company_data['company'].id, + 'early_pay_discount_computation': 'included', + 'early_discount': True, + 'discount_percentage': 2, + 'discount_days': 7, + 'line_ids': [ + Command.create({ + 'value': 'percent', + 'value_amount': 100.0, + 'nb_days': 30, + }), + ], + }) + + bill = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_payment_term_id': early_payment_term.id, + 'invoice_date': '2019-01-01', + 'date': '2019-01-01', + 'invoice_line_ids': [ + Command.create({ + 'name': 'line', + 'price_unit': 1000.0, + 'tax_ids': [Command.set(intracomm_tax.ids)], + }), + ], + }) + bill.action_post() + + st_line = self._create_st_line( + -980.0, + date='2017-01-01', + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(bill.line_ids.filtered(lambda x: x.account_type == 'liability_payable')) + wizard._action_validate() + + self.assertRecordValues(st_line.line_ids.sorted('balance'), [ + # pylint: disable=bad-whitespace + {'amount_currency': -980.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_tag_invert': False}, + {'amount_currency': -20.0, 'tax_ids': intracomm_tax.ids, 'tax_tag_ids': tax_tags[3].ids, 'tax_tag_invert': True}, + {'amount_currency': -4.0, 'tax_ids': [], 'tax_tag_ids': tax_tags[4].ids, 'tax_tag_invert': True}, + {'amount_currency': 4.0, 'tax_ids': [], 'tax_tag_ids': tax_tags[5].ids, 'tax_tag_invert': True}, + {'amount_currency': 1000.0, 'tax_ids': [], 'tax_tag_ids': [], 'tax_tag_invert': False}, + ]) + + def test_early_payment_eligibility_date(self): + inv_line = self._create_invoice_line( + 'out_invoice', + partner_id=self.partner_a.id, + invoice_payment_term_id=self.early_payment_term.id, + invoice_date='2017-01-01', + invoice_line_ids=[ + { + 'price_unit': 1000.0, + 'account_id': self.account_revenue1.id, + }, + ] + ) + st_line = self._create_st_line(900.0, partner_id=self.partner_a.id, date='2017-01-10') + + self.early_payment_term.discount_days = 5 + + liquidity_acc = st_line.journal_id.default_account_id + receivable_acc = self.company_data['default_account_receivable'] + early_pay_acc = self.env.company.account_journal_early_pay_discount_loss_account_id + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=bad-whitespace + {'flag': 'liquidity', 'amount_currency': 900.0, 'balance': 900.0, 'account_id': liquidity_acc.id}, + {'flag': 'new_aml', 'amount_currency': -1000.0, 'balance': -1000.0, 'account_id': receivable_acc.id}, + {'flag': 'early_payment', 'amount_currency': 100.0, 'balance': 100.0, 'account_id': early_pay_acc.id}, + ]) + + def test_multi_currencies_with_custom_rate(self): + self.company_data['default_journal_bank'].currency_id = self.other_currency + st_line = self._create_st_line(1200.0) # rate 1:2 + self.assertRecordValues(st_line.move_id.line_ids, [ + # pylint: disable=C0326 + {'amount_currency': 1200.0, 'balance': 600.0}, + {'amount_currency': -1200.0, 'balance': -600.0}, + ]) + + # invoice with other_currency and rate 1:2 + invoice_line1 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 300.0}], # = 150 USD + ) + + # Remove all rates. + self.other_currency.rate_ids.unlink() + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'balance': 600.0}, + {'flag': 'auto_balance', 'amount_currency': -1200.0, 'balance': -600.0}, + ]) + + # invoice with other_currency_2 and rate 1:6 + invoice_line2 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 600.0}], # = 100 USD + ) + # invoice with other_currency_2 and rate 1:4 + invoice_line3 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency_2.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 400.0}], # = 100 USD + ) + + # Remove all rates. + self.other_currency_2.rate_ids.unlink() + + # Ensure no conversion rate has been made. + wizard._action_add_new_amls(invoice_line1 + invoice_line2 + invoice_line3) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'balance': 600.0}, + {'flag': 'new_aml', 'amount_currency': -300.0, 'balance': -150.0}, + {'flag': 'new_aml', 'amount_currency': -600.0, 'balance': -100.0}, + {'flag': 'new_aml', 'amount_currency': -400.0, 'balance': -100.0}, + {'flag': 'auto_balance', 'amount_currency': -500.0, 'balance': -250.0}, + ]) + + def test_partial_reconciliation_suggestion_with_mixed_invoice_and_refund(self): + """ Test the partial reconciliation suggestion is well recomputed when adding another + line. For example, when adding 2 invoices having an higher amount then a refund. In that + case, the partial on the second invoice should be removed since the difference is filled + by the newly added refund. + """ + st_line = self._create_st_line( + 1800.0, + date='2017-01-01', + foreign_currency_id=self.other_currency.id, + amount_currency=3600.0, + ) + + inv1 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 2400.0}], + ) + inv2 = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 2400.0}], + ) + refund = self._create_invoice_line( + 'out_refund', + currency_id=self.other_currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 1200.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv1 + inv2) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -2400.0, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -400.0}, + {'flag': 'new_aml', 'amount_currency': -1200.0, 'balance': -400.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -200.0}, + ]) + wizard._action_add_new_amls(refund) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1800.0, 'balance': 1800.0}, + {'flag': 'new_aml', 'amount_currency': -2400.0, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -400.0}, + {'flag': 'new_aml', 'amount_currency': -2400.0, 'balance': -800.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': -400.0}, + {'flag': 'new_aml', 'amount_currency': 1200.0, 'balance': 400.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'balance': 200.0}, + ]) + + def test_auto_reconcile_cron_with_time_limit(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + cron = self.env.ref('account_accountant.auto_reconcile_bank_statement_line') + self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)]).unlink() + + st_line1 = self._create_st_line(1234.0, partner_id=self.partner_a.id, date='2017-01-01') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 1) + st_line2 = self._create_st_line(5678.0, partner_id=self.partner_a.id, date='2017-01-02') + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 2) + + self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 5678.0}], + ) + self.env['account.reconcile.model'].create({ + 'name': "test_auto_reconcile_cron_with_time_limit", + 'rule_type': 'writeoff_suggestion', + 'auto_reconcile': True, + 'line_ids': [Command.create({'account_id': self.account_revenue1.id})], + }) + + with freeze_time('2017-01-01 00:00:00') as frozen_time: + def datetime_now_override(): + frozen_time.tick() + return frozen_time() + with patch('odoo.fields.Datetime.now', side_effect=datetime_now_override): + # we simulate that the time limit is reached after first loop + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(limit_time=1) + # after first loop, only one statement should be reconciled + self.assertRecordValues(st_line1, [{'is_reconciled': True, 'cron_last_check': fields.Datetime.from_string('2017-01-01 00:00:01')}]) + # the other one should be in queue for regular cron tigger + self.assertRecordValues(st_line2, [{'is_reconciled': False, 'cron_last_check': False}]) + self.assertEqual(len(self.env['ir.cron.trigger'].search([('cron_id', '=', cron.id)])), 3) + + def test_auto_reconcile_cron_with_provided_statements_lines(self): + self.env['account.reconcile.model'].search([('company_id', '=', self.company_data['company'].id)]).unlink() + + st_line1 = self._create_st_line(1234.0, partner_id=self.partner_a.id, date='2017-01-01') + st_line2 = self._create_st_line(5678.0, partner_id=self.partner_a.id, date='2017-01-02') + self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 1234.0}], + ) + self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 5678.0}], + ) + self.env['account.reconcile.model'].create({ + 'name': "test_auto_reconcile_cron_with_time_limit", + 'rule_type': 'writeoff_suggestion', + 'auto_reconcile': True, + 'line_ids': [Command.create({'account_id': self.account_revenue1.id})], + }) + with freeze_time('2017-01-01 00:00:00'): + # we call auto reconcile on st_lines1 **only** + st_line1._cron_try_auto_reconcile_statement_lines() + self.assertRecordValues(st_line1, [{'is_reconciled': True, 'cron_last_check': fields.Datetime.from_string('2017-01-01 00:00:00')}]) + self.assertRecordValues(st_line2, [{'is_reconciled': False, 'cron_last_check': False}]) + + @freeze_time('2019-01-01') + def test_button_apply_reco_model(self): + inv_line = self._create_invoice_line( + 'in_invoice', + invoice_date='2019-01-01', + invoice_line_ids=[{'price_unit': 980.0}], + ) + st_line = self._create_st_line(-1000.0, partner_id=self.partner_a.id, date=inv_line.date, payment_ref=inv_line.move_name) + + reco_model = self.env['account.reconcile.model'].create({ + 'name': "test_apply_taxes_with_reco_model", + 'rule_type': 'writeoff_button', + 'line_ids': [Command.create({ + 'account_id': self.account_revenue1.copy().id, + 'label': 'Bank Fees' + })], + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_trigger_matching_rules() + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': st_line.journal_id.default_account_id.id, 'balance': -1000.0}, + {'flag': 'new_aml', 'account_id': inv_line.account_id.id, 'balance': 980.0}, + {'flag': 'auto_balance', 'account_id': self.company_data['default_account_payable'].id, 'balance': 20.0}, + ]) + + wizard._action_select_reconcile_model(reco_model) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': st_line.journal_id.default_account_id.id, 'balance': -1000.0}, + {'flag': 'new_aml', 'account_id': inv_line.account_id.id, 'balance': 980.0}, + {'flag': 'manual', 'account_id': reco_model.line_ids[0].account_id.id, 'balance': 20.0}, + ]) + + def test_exchange_diff_on_partial_aml_multi_currency(self): + self.company_data['default_journal_bank'].currency_id = self.other_currency + st_line = self._create_st_line(-36000.0) # rate 1:2 + inv_line = self._create_invoice_line( + 'in_invoice', + invoice_date='2016-01-01', # rate 1:3 + currency_id=self.other_currency.id, + invoice_line_ids=[{'price_unit': 38000.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': -36000.0, 'currency_id': self.other_currency.id, 'balance': -18000.0}, + {'flag': 'new_aml', 'amount_currency': 36000.0, 'currency_id': self.other_currency.id, 'balance': 12000.0}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 6000.0}, + ]) + + def test_exchange_diff_on_partial_aml_multi_currency_close_amount(self): + self.other_currency.rate_ids.rate = 0.9839 + self.company_data['default_journal_bank'].currency_id = self.other_currency + + st_line = self._create_st_line(-37436.50) + self.assertRecordValues(st_line.line_ids, [ + # pylint: disable=C0326 + {'amount_currency': -37436.50, 'balance': -38049.09}, + {'amount_currency': 37436.50, 'balance': 38049.09}, + ]) + + inv_line = self._create_invoice_line( + 'in_invoice', + invoice_date=st_line.date, + currency_id=self.other_currency.id, + invoice_line_ids=[{'price_unit': 37436.52}], + ) + self.assertRecordValues(inv_line, [{ + 'amount_currency': -37436.52, + 'balance': -38049.11, + }]) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': -37436.50, 'currency_id': self.other_currency.id, 'balance': -38049.09}, + {'flag': 'new_aml', 'amount_currency': 37436.50, 'currency_id': self.other_currency.id, 'balance': 38049.09}, + ]) + + def test_matching_zero_amount_misc_entry(self): + """ Check for division by zero with foreign currencies and some 0 making a broken rate. """ + self.company_data['default_journal_bank'].currency_id = self.other_currency + st_line = self._create_st_line(0.0, amount_currency=10.0, foreign_currency_id=self.company_data['currency'].id) + + entry = self.env['account.move'].create({ + 'date': '2019-01-01', + 'line_ids': [ + Command.create({ + 'account_id': self.company_data['default_account_receivable'].id, + 'currency_id': self.other_currency.id, + 'debit': 1.0, + 'credit': 0.0, + }), + Command.create({ + 'account_id': self.company_data['default_account_revenue'].id, + 'currency_id': self.other_currency.id, + 'debit': 0.0, + 'credit': 1.0, + }), + ] + }) + entry.action_post() + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + aml = entry.line_ids.filtered('debit') + wizard._action_add_new_amls(aml) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'balance': 10.0}, + {'flag': 'new_aml', 'balance': -1.0}, + {'flag': 'exchange_diff', 'balance': 1.0}, + {'flag': 'auto_balance', 'balance': -10.0}, + ]) + + def test_amls_order_with_matching_amount(self): + """ AML's with a matching amount_residual should be displayed first when the order is not specified. """ + + foreign_st_line = self._create_st_line( + 500.0, + date='2016-01-01', + foreign_currency_id=self.other_currency.id, + amount_currency=1500.0, + ) + st_line = self._create_st_line( + 66.66, + date='2016-01-01', + ) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=foreign_st_line.id).new({}) + + aml1_id = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-30', + invoice_line_ids=[{'price_unit': 1000.0}], + ).id + aml2_id = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-29', + currency_id=self.other_currency.id, + invoice_line_ids=[{'price_unit': 1500.0}], # = 100 USD + ).id + aml3_id = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-28', + invoice_line_ids=[{'price_unit': 500.0}], + ).id + aml4_id = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-27', + invoice_line_ids=[{'price_unit': 55.55}], # = 55.550000000000004 + ).id + aml5_id = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-26', + invoice_line_ids=[{'price_unit': 66.66}], + ).id + + # Check the lines without the context key. + wizard._js_action_mount_st_line(foreign_st_line.id) + domain = wizard.return_todo_command['amls']['domain'] + amls_list = self.env['account.move.line'].search_fetch(domain=domain, field_names=['id']) + self.assertEqual( + [x['id'] for x in amls_list], + [aml1_id, aml2_id, aml3_id, aml4_id, aml5_id], + ) + + # Check the lines with the context key. + suspense_line = wizard.line_ids.filtered(lambda l: l.flag == 'auto_balance') + amls_list = self.env['account.move.line']\ + .with_context(preferred_aml_value=suspense_line.amount_currency * -1, preferred_aml_currency_id=suspense_line.currency_id.id)\ + .search_fetch(domain=domain, field_names=['id']) + self.assertEqual( + [x['id'] for x in amls_list], + [aml2_id, aml1_id, aml3_id, aml4_id, aml5_id], + ) + + # Check the order with limits and offsets + amls_list = self.env['account.move.line']\ + .with_context(preferred_aml_value=suspense_line.amount_currency * -1, preferred_aml_currency_id=suspense_line.currency_id.id)\ + .search_fetch(domain=domain, field_names=['id'], limit=2) + self.assertEqual( + [x['id'] for x in amls_list], + [aml2_id, aml1_id], + ) + amls_list = self.env['account.move.line']\ + .with_context(preferred_aml_value=suspense_line.amount_currency * -1, preferred_aml_currency_id=suspense_line.currency_id.id)\ + .search_fetch(domain=domain, field_names=['id'], offset=2, limit=3) + self.assertEqual( + [x['id'] for x in amls_list], + [aml3_id, aml4_id, aml5_id], + ) + + # Check rounding and new suspense line + wizard._js_action_mount_st_line(st_line.id) + suspense_line = wizard.line_ids.filtered(lambda l: l.flag == 'auto_balance') + amls_list = self.env['account.move.line']\ + .with_context(preferred_aml_value=suspense_line.amount_currency * -1, preferred_aml_currency_id=suspense_line.currency_id.id)\ + .search_fetch(domain=domain, field_names=['id']) + self.assertEqual( + [x['id'] for x in amls_list], + [aml5_id, aml1_id, aml2_id, aml3_id, aml4_id], + ) + wizard._js_action_mount_line_in_edit(suspense_line.index) + suspense_line.balance = -11.11 + wizard._line_value_changed_balance(suspense_line) + suspense_line = wizard.line_ids.filtered(lambda l: l.flag == 'auto_balance') + self.assertEqual(suspense_line.balance, -55.55) + self.env.cr.execute(f""" + UPDATE account_move_line SET amount_residual_currency = 55.550000001 WHERE id = {aml4_id}; + """) + amls_list = self.env['account.move.line']\ + .with_context(preferred_aml_value=55.550003, preferred_aml_currency_id=suspense_line.currency_id.id)\ + .search_fetch(domain=domain, field_names=['id']) + self.assertEqual( + [x['id'] for x in amls_list], + [aml4_id, aml1_id, aml2_id, aml3_id, aml5_id], + ) + + # Check that context keys are not propagated + action = amls_list[0].action_open_business_doc() + self.assertFalse(action['context'].get('preferred_aml_value')) + + @freeze_time('2023-12-25') + def test_analtyic_distribution_model_exchange_diff_line(self): + """Test that the analytic distribution model is present on the exchange diff line.""" + expense_exchange_account = self.env.company.expense_currency_exchange_account_id + analytic_plan = self.env['account.analytic.plan'].create({ + 'name': 'Plan 1', + 'default_applicability': 'unavailable', + }) + analytic_account_1 = self.env['account.analytic.account'].create({'name': 'Account 1', 'plan_id': analytic_plan.id}) + analytic_account_2 = self.env['account.analytic.account'].create({'name': 'Account 1', 'plan_id': analytic_plan.id}) + distribution_model = self.env['account.analytic.distribution.model'].create({ + 'account_prefix': expense_exchange_account.code, + 'partner_id': self.partner_a.id, + 'analytic_distribution': {analytic_account_1.id: 100}, + }) + + # 1200.0 comp_curr = 3600.0 foreign_curr in 2016 (rate 1:3) + st_line = self._create_st_line( + 1200.0, + date='2016-01-01', + ) + # 1800.0 comp_curr = 3600.0 foreign_curr in 2017 (rate 1:2) + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=self.other_currency.id, + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 3600.0}], + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'amount_currency': 1200.0, 'currency_id': self.company_data['currency'].id, 'balance': 1200.0, 'analytic_distribution': False}, + {'flag': 'new_aml', 'amount_currency': -3600.0, 'currency_id': self.other_currency.id, 'balance': -1800.0, 'analytic_distribution': False}, + {'flag': 'exchange_diff', 'amount_currency': 0.0, 'currency_id': self.other_currency.id, 'balance': 600.0, 'analytic_distribution': distribution_model.analytic_distribution}, + ]) + + # Test that the analytic distribution is kept on the creation of the exchange diff move + new_distribution = {**distribution_model.analytic_distribution, str(analytic_account_2.id): 100} + + line = wizard.line_ids.filtered(lambda x: x.flag == 'exchange_diff') + line.analytic_distribution = new_distribution + wizard._action_validate() + + self.assertRecordValues(inv_line.matched_credit_ids.exchange_move_id.line_ids, [ + {'analytic_distribution': False}, + {'analytic_distribution': new_distribution}, + ]) + + def test_access_child_bank_with_user_set_on_child(self): + """ + Demo user with a Child Company as default company/allowed companies + should be able to access the Bank set on this same Child Company + """ + child_company = self.env['res.company'].create({ + 'name': 'Childest Company', + 'parent_id': self.env.company.id, + }) + child_bank_journal = self.env['account.journal'].create({ + 'name': 'Child Bank', + 'type': 'bank', + 'company_id': child_company.id, + }) + self.user.write({ + 'company_ids': [Command.set(child_company.ids)], + 'company_id': child_company.id, + 'groups_id': [ + Command.set(self.env.ref('account.group_account_user').ids), + ] + }) + res = self.env['bank.rec.widget'].with_user(self.user).collect_global_info_data(child_bank_journal.id) + self.assertTrue(res, "Journal should be accessible") + + def test_collect_global_info_data_other_company_bank_journal_with_user_on_main_company(self): + """ The aim of this test is checking that a user who having + access to 2 companies will have values even when he's + calling collect_global_info_data function if + it's current company it's not the one on the journal + but is still available. + To do that, we add 2 companies to the user, and try to + call collect_global_info_data on the journal of the second + company, even if the main company it's the first one. + """ + self.user.write({ + 'company_ids': [Command.set((self.company_data['company'] + self.company_data_2['company']).ids)], + 'company_id': self.company_data['company'].id, + }) + + result = self.env['bank.rec.widget'].with_user(self.user).collect_global_info_data(self.company_data_2['default_journal_bank'].id) + self.assertTrue(result['balance_amount'], "Balance amount shouldn't be False value") + + def test_collect_global_info_data_non_existing_bank_journal(self): + """ The aim of this test is checking that we receive an empty + string when we call collect_global_info_data function + with a non-existing journal. This use case could happen + when we try to open the bank rec widget on a journal that + is not actually existing. As this function is callable by + rpc, this usecase could happen. + """ + result = self.env['bank.rec.widget'].with_user(self.user).collect_global_info_data(99999999) + self.assertEqual(result['balance_amount'], "", "If no value, the function should return an empty string") + + def test_res_partner_bank_find_create_multi_account(self): + """ Make sure that we can save multiple bank accounts for a partner. """ + partner = self.env['res.partner'].create({'name': "Zitycard"}) + + for acc_number in ("123456789", "123456780"): + st_line = self._create_st_line(100.0, account_number=acc_number) + inv_line = self._create_invoice_line( + 'out_invoice', + partner_id=partner.id, + invoice_line_ids=[{'price_unit': 100.0, 'tax_ids': []}], + ) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + wizard._action_validate() + + bank_accounts = self.env['res.partner.bank'].sudo().with_context(active_test=False).search([ + ('partner_id', '=', partner.id), + ]) + self.assertEqual(len(bank_accounts), 2, "Second bank account was not registered!") + + #################################################### + # RECO MODEL MOVE CREATION + #################################################### + + def create_test_reco_invoice(self, st_line, reco_model): + """ Helper method to create a move given a statement line and reconciliation model """ + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_select_reconcile_model(reco_model) + move = self.env['account.move'].browse(wizard.return_todo_command['res_id']) + return move + + def assert_reco_invoice_values(self, move, st_line, expected_move_type, expected_amount_total=None): + """ Helper method to assert that values in a move match the information in the given statement line """ + if expected_amount_total is None: + expected_amount_total = abs(st_line.amount) + self.assertRecordValues(move, [{ + 'amount_total': expected_amount_total, + 'move_type': expected_move_type, + 'partner_id': st_line.partner_id.id, + 'invoice_date': st_line.date, + }]) + + def test_invoice_creation_from_reco_model(self): + """ Test the created invoice from a sale/purchase reconciliation model. """ + reco_model_invoice = self.env['account.reconcile.model'].create({ + 'name': "test reconcile create invoice", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'sale', + 'line_ids': [ + Command.create({'amount_string': '50'}), + Command.create({'amount_string': '50'}), + ], + }) + + for st_line_amount, move_type, reco_model in ( + (1000.0, 'out_invoice', reco_model_invoice), + (-1000.0, 'out_refund', reco_model_invoice), + (1000.0, 'in_refund', self.reco_model_bill), + (-1000.0, 'in_invoice', self.reco_model_bill), + ): + st_line = self._create_st_line(st_line_amount, partner_id=self.partner_a.id) + with self.subTest(): + move = self.create_test_reco_invoice(st_line, reco_model) + self.assert_reco_invoice_values(move, st_line, move_type) + + def test_invoice_reco_model_round_odd(self): + """ Test if correct move is created when rounding is needed for multiple reco model lines""" + # Odd amount and a reconciliation model with two lines (50% each line) requires rounding to ensure values match. + st_line = self._create_st_line(amount=-111.11, partner_id=self.partner_a.id) + move = self.create_test_reco_invoice(st_line, self.reco_model_bill) + self.assert_reco_invoice_values(move, st_line, 'in_invoice') + + def test_invoice_reco_model_round_single_line(self): + """ Test if correct move is created with single-line reco model when rounding is needed """ + # A st_line of $150 with 15% tax (default) requires rounding, as without it the invoice would total $149.99 + reco_model_single_line = self.env['account.reconcile.model'].create({ + 'name': "single line reco model", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [Command.create({})], + }) + st_line = self._create_st_line(amount=150, partner_id=self.partner_a.id) + move = self.create_test_reco_invoice(st_line, reco_model_single_line) + self.assert_reco_invoice_values(move, st_line, 'in_refund') + + def test_invoice_reco_model_round_large_percentage(self): + """ Test if total move amount is correctly rounded when reco model lines go above 100% of st_line amount """ + # Reco model with two 100% st_line amount lines + reco_model_two_lines = self.env['account.reconcile.model'].create({ + 'name': "two lines reco model", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [Command.create({}), Command.create({})], + }) + # Reco model with one 200% st_line amount line + reco_model_single_line = self.env['account.reconcile.model'].create({ + 'name': "single line reco model", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [Command.create({'amount_string': '200'})], + }) + st_line = self._create_st_line(-150, partner_id=self.partner_a.id) + for reco_model in (reco_model_two_lines, reco_model_single_line): + with self.subTest(): + move = self.create_test_reco_invoice(st_line, reco_model) + # Total move amount should equal 2 * st_line amount = 300 in both cases + self.assert_reco_invoice_values(move, st_line, 'in_invoice', 300.0) + + def test_invoice_reco_model_round_combined(self): + """ Test if total move amount is correctly rounded when reco model lines are a combination of + percentage_st_line and fixed amount types """ + # Reco model with 100% amount + fixed amount, total move amount should equal st_line amount + fixed amount + reco_model_percentage_fixed = self.env['account.reconcile.model'].create({ + 'name': "combined percentage + fixed reco model", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'sale', + 'line_ids': [ + Command.create({}), + Command.create({ + 'amount_type': 'fixed', + 'amount_string': '50', + }), + ], + }) + st_line = self._create_st_line(amount=100, partner_id=self.partner_a.id) + move = self.create_test_reco_invoice(st_line, reco_model_percentage_fixed) + self.assert_reco_invoice_values(move, st_line, 'out_invoice', 150.0) + + def test_invoice_reco_model_multiple_taxes(self): + """ + Test if correct move is created through reco model writeoff button when the + reconciliation model has more than one tax per line. + Note: this only works if taxes are all price_include or all not price_include + """ + tax_21 = self.env['account.tax'].create({ + 'name': "tax_21", + 'amount': 21, + }) + reco_model_bill_mult_taxes = self.env['account.reconcile.model'].create({ + 'name': "test reconcile bill multiple taxes", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [ + Command.create({ + 'tax_ids': [Command.set((tax_21 + self.company_data['default_tax_purchase']).ids)], + }), + ], + }) + st_line = self._create_st_line(amount=-100, partner_id=self.partner_a.id) + move = self.create_test_reco_invoice(st_line, reco_model_bill_mult_taxes) + self.assert_reco_invoice_values(move, st_line, 'in_invoice') + + def test_invoice_creation_reco_model_percentage(self): + """ Test if correct move is created when rounding is needed """ + # Odd amount and a reconciliation model with two lines (50% each line) requires rounding to ensure values match. + st_line = self._create_st_line(amount=150, partner_id=self.partner_a.id) + reco_model_bill_balance = self.env['account.reconcile.model'].create({ + 'name': "test balance", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [ + Command.create({ + 'amount_type': 'percentage', + 'amount_string': '50', + }), + Command.create({ + 'amount_type': 'percentage', + 'amount_string': '50', + }), + ], + }) + move = self.create_test_reco_invoice(st_line, reco_model_bill_balance) + self.assert_reco_invoice_values(move, st_line, 'in_refund', 112.5) + + def test_invoice_creation_reco_model_fixed(self): + """ Test if correct move is created when rounding is needed """ + # Odd amount and a reconciliation model with two lines (50% each line) requires rounding to ensure values match. + st_line = self._create_st_line(amount=-150, partner_id=self.partner_a.id) + reco_model_invoice_fixed = self.env['account.reconcile.model'].create({ + 'name': "test fixed", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'sale', + 'line_ids': [ + Command.create({ + 'amount_type': 'fixed', + 'amount_string': '100', + }), + ], + }) + move = self.create_test_reco_invoice(st_line, reco_model_invoice_fixed) + self.assert_reco_invoice_values(move, st_line, 'out_refund', 100.0) + + def test_invoice_creation_reco_model_regex(self): + """ Test if correct move is created when rounding is needed """ + # Odd amount and a reconciliation model with two lines (50% each line) requires rounding to ensure values match. + st_line = self._create_st_line(amount=150, partner_id=self.partner_a.id, payment_ref="150 invoice", statement_name='150 invoice') + reco_model_invoice_regex = self.env['account.reconcile.model'].create({ + 'name': "test label/regex", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'sale', + 'line_ids': [ + Command.create({ + 'amount_type': 'regex', + 'amount_string': r'([\d,.]+)', + }), + ], + }) + move = self.create_test_reco_invoice(st_line, reco_model_invoice_regex) + self.assert_reco_invoice_values(move, st_line, 'out_invoice', 150.0) + + def test_unreconciled_with_other_lines(self): + """Test that other lines are shown in the widget if they exist.""" + st_line = self._create_st_line( + 1000.0, + date='2017-01-01', + ) + + # Edit the associated move to partially reconcile some of the suspense amount; i.e. we add another line + liquidity_line, suspense_line, other_line = st_line._seek_for_lines() + other_account = st_line.journal_id.company_id.default_cash_difference_income_account_id + self.assertFalse(other_line) + move = st_line.move_id + move.button_draft() + move.write({'line_ids': [ + Command.create({'account_id': other_account.id, 'credit': 100.0}), + Command.update(suspense_line.id, {'credit': 900.0}), + ]}) + move.action_post() + liquidity_line, suspense_line, other_line = st_line._seek_for_lines() + self.assertTrue(other_line) + + # Check that the wizard displays the new line + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + self.assertRecordValues(wizard.line_ids, [ + # pylint: disable=C0326 + {'flag': 'liquidity', 'account_id': liquidity_line.account_id.id, 'amount_currency': 1000.0}, + {'flag': 'aml', 'account_id': other_line.account_id.id, 'amount_currency': -100.0}, + {'flag': 'auto_balance', 'account_id': suspense_line.account_id.id, 'amount_currency': -900.0}, + ]) + + def test_caba_reconcile_account_multi_currency(self): + self.env.company.tax_exigibility = True + cash_basis_transfer_account = self.env['account.account'].create({ + 'code': 'cash.basis.transfer.account', + 'name': 'cash_basis_transfer_account', + 'account_type': 'income', + 'reconcile': True, + }) + tax_account = self.env['account.account'].create({ + 'code': 'cash.basis.tax.account', + 'name': 'cash_basis_tax_account', + 'account_type': 'income', + }) + caba_tax = self.env['account.tax'].create({ + 'name': 'tax_caba', + 'amount': 15.0, + 'cash_basis_transition_account_id': cash_basis_transfer_account.id, + 'tax_exigibility': 'on_payment', + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + }) + + currency = self.other_currency + inv_line = self._create_invoice_line( + 'out_invoice', + currency_id=currency.id, + invoice_date='2016-01-01', + invoice_line_ids=[{'price_unit': 1200.0, 'tax_ids': [Command.set(caba_tax.ids)]}], + ) + inv = inv_line.move_id + st_line = self._create_st_line( + 690.0, + date='2017-01-01', + foreign_currency_id=currency.id, + amount_currency=1380.0, + ) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 690.0, 'amount_currency': 690.0, 'currency_id': self.env.company.currency_id.id}, + {'flag': 'new_aml', 'balance': -460.0, 'amount_currency': -1380.0, 'currency_id': currency.id}, + {'flag': 'exchange_diff', 'balance': -230.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + ]) + + line = wizard.line_ids.filtered(lambda x: x.flag == 'exchange_diff') + wizard._js_action_mount_line_in_edit(line.index) + line.balance = -240.0 + wizard._line_value_changed_balance(line) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 690.0, 'amount_currency': 690.0, 'currency_id': self.env.company.currency_id.id}, + {'flag': 'new_aml', 'balance': -460.0, 'amount_currency': -1380.0, 'currency_id': currency.id}, + {'flag': 'exchange_diff', 'balance': -240.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + {'flag': 'auto_balance', 'balance': 10.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + ]) + + wizard._action_validate() + + self.assertRecordValues(st_line.line_ids, [ + {'balance': 690.0, 'amount_currency': 690.0, 'currency_id': self.env.company.currency_id.id}, + {'balance': -700.0, 'amount_currency': -1380.0, 'currency_id': currency.id}, + {'balance': 10.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + ]) + + caba_move = inv.tax_cash_basis_created_move_ids + product_account = self.company_data['default_account_revenue'] + self.assertRecordValues(caba_move.line_ids, [ + {'balance': 608.7, 'amount_currency': 1200.0, 'currency_id': currency.id, 'account_id': product_account.id}, + {'balance': -608.7, 'amount_currency': -1200.0, 'currency_id': currency.id, 'account_id': product_account.id}, + {'balance': 91.3, 'amount_currency': 180.0, 'currency_id': currency.id, 'account_id': cash_basis_transfer_account.id}, + {'balance': -91.3, 'amount_currency': -180.0, 'currency_id': currency.id, 'account_id': tax_account.id}, + ]) + + tax_line = inv.line_ids.filtered('tax_repartition_line_id') + _liquidity_line, _suspense_line, other_line = st_line._seek_for_lines() + self.assertRecordValues(caba_move.line_ids + inv_line + tax_line + other_line, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0}, + ] * 7) + + exchange_diff_moves = self.env['account.move'].search([('journal_id', '=', self.env.company.currency_exchange_journal_id.id)]) + self.assertRecordValues(exchange_diff_moves.line_ids, [ + # Exchange diff CABA entry with invoice for the tax line ((180 / 2) - (180 / 3) = 90 - 60 = 30): + {'balance': -31.3, 'amount_currency': 0.0, 'currency_id': currency.id}, + {'balance': 31.3, 'amount_currency': 0.0, 'currency_id': currency.id}, + # Exchange diff st_line with invoice ((1380 / 2) - (1380 / 3) = 690 - 430 = 230): + {'balance': 240.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + {'balance': -240.0, 'amount_currency': 0.0, 'currency_id': currency.id}, + ]) + + def test_reconciliation_with_branch(self): + """ + Test the reconciliation flow with different configurations of company with a branch + """ + company = self.company_data['company'] + branch = self.env['res.company'].create({ + 'name': "Branch A", + 'parent_id': company.id, + }) + # Load CoA + self.cr.precommit.run() + + partner_branch = self.env['res.partner'].create({ + 'name': 'Partner Branch', + 'company_id': branch.id, + }) + + aml_branch = self._create_invoice_line( + 'out_invoice', + company_id=branch.id, + partner_id=partner_branch.id, + invoice_date='2019-01-01', + invoice_line_ids=[{'name': 'Test reco', 'quantity': 1, 'price_unit': 1000}], + ) + st_line_main = self._create_st_line( + 1000.0, + company_id=company.id, + date='2019-01-01', + payment_ref='Test reco', + ) + st_line_branch = self._create_st_line( + 1000.0, + company_id=branch.id, + date='2019-01-01', + payment_ref='Test reco2', + partner_id=partner_branch.id, + ) + + # Case 1: reconciliation with st_line on the main company + aml on the branch + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line_main.id).new({}) + wizard._action_add_new_amls(aml_branch) + wizard._action_validate() + + # Assert that the partner is not set to avoid "Incompatible companies on records" error + self.assertFalse(st_line_main.partner_id) + + st_line_main.action_undo_reconciliation() + + # Case 2: reconciliation with both st_line and aml on the branch + wizard_branch = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line_branch.id).new({}) + wizard_branch._action_add_new_amls(aml_branch) + wizard_branch._action_validate() + + # Assert that the partner remains set on the transaction + self.assertEqual(st_line_branch.partner_id, partner_branch, "The partner should remain set on the transaction for the branch company.") + + st_line_branch.action_undo_reconciliation() + + # Case 3: reconciliation with both st_line and aml on the branch, no partner on the st_line + st_line_branch.partner_id = False + wizard_branch = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line_branch.id).new({}) + wizard_branch._action_add_new_amls(aml_branch) + wizard_branch._action_validate() + + # Assert that the partner is set from the aml on the transaction + self.assertEqual(st_line_branch.partner_id, partner_branch, "The partner should be automatically set on the transaction for the branch company if it's set on the aml.") + + def test_analytic_distribution_for_early_payment_statement(self): + """ + Test that the analytic is applied for statement with early discount + """ + early_payment_term = self.env['account.payment.term'].create({ + 'name': "early_payment_term", + 'company_id': self.company_data['company'].id, + 'early_pay_discount_computation': 'included', + 'early_discount': True, + 'discount_percentage': 2, + 'discount_days': 7, + 'line_ids': [ + Command.create({ + 'value': 'percent', + 'value_amount': 100.0, + 'nb_days': 30, + }), + ], + }) + + analytic_plan = self.env['account.analytic.plan'].create({ + 'name': 'existential plan', + }) + analytic_account = self.env['account.analytic.account'].create({ + 'name': 'positive_account', + 'plan_id': analytic_plan.id, + }) + + cash_discount_account = self.company_data['company'].account_journal_early_pay_discount_loss_account_id + self.env['account.analytic.distribution.model'].create({ + 'account_prefix': cash_discount_account.code, + 'analytic_distribution': {analytic_account.id: 100.0}, + }) + + invoice = self.env['account.move'].create([{ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'invoice_payment_term_id': early_payment_term.id, + 'invoice_line_ids': [Command.create({ + 'name': 'line', + 'price_unit': 100.0, + })] + }]) + invoice.action_post() + + statement = self.env['account.bank.statement.line'].create({ + 'date': '2017-01-01', + 'payment_ref': invoice.payment_reference, + 'partner_id': self.partner_b.id, + 'amount': 98.0, + 'journal_id': self.company_data['default_journal_bank'].id, + }) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=statement.id).new({}) + receivable_line = invoice.line_ids.filtered(lambda l: l.account_id.account_type == 'asset_receivable') + wizard._action_add_new_amls(receivable_line) + wizard._action_validate() + + early_payment_line = statement.line_ids.filtered(lambda p: p.balance == 2.0) + self.assertTrue(early_payment_line.analytic_distribution) + + def test_reconcile_no_caba_move_on_bank_journal_entry(self): + """ Test that no cash basis move is created for a bank reconciliation journal entry. """ + self.env.company.tax_exigibility = True + cash_basis_transfer_account = self.env['account.account'].create({ + 'code': 'cash.basis.transfer.account', + 'name': 'cash_basis_transfer_account', + 'account_type': 'income', + 'reconcile': True, + }) + tax_account = self.env['account.account'].create({ + 'code': 'cash.basis.tax.account', + 'name': 'cash_basis_tax_account', + 'account_type': 'income', + }) + caba_tax = self.env['account.tax'].create({ + 'name': 'tax_caba', + 'amount': 15.0, + 'cash_basis_transition_account_id': cash_basis_transfer_account.id, + 'tax_exigibility': 'on_payment', + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + }) + + inv_line = self._create_invoice_line( + 'out_invoice', + invoice_date='2017-01-01', + invoice_line_ids=[{'price_unit': 200.0, 'tax_ids': caba_tax}], + ) + inv = inv_line.move_id + st_line = self._create_st_line(200.0, date='2017-01-01') + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + wizard._action_add_new_amls(inv_line) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 200.0}, + {'flag': 'new_aml', 'balance': -200.0}, + ]) + + # Switch to a full reconciliation. + new_line = wizard.line_ids.filtered(lambda x: x.flag == 'new_aml') + wizard._js_action_mount_line_in_edit(new_line.index) + wizard._js_action_apply_line_suggestion(new_line.index) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 200.0}, + {'flag': 'new_aml', 'balance': -230.0}, + {'flag': 'auto_balance', 'balance': 30.0}, + ]) + + # Add the CABA tax to the auto-balance line + auto_balance_line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(auto_balance_line.index) + auto_balance_line.account_id = self.company_data['default_account_expense'] + wizard._line_value_changed_account_id(auto_balance_line) + auto_balance_line.tax_ids = [Command.link(caba_tax.id)] + wizard._line_value_changed_tax_ids(auto_balance_line) + + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 200.0}, + {'flag': 'new_aml', 'balance': -230.0}, + {'flag': 'manual', 'balance': 26.09}, + {'flag': 'tax_line', 'balance': 3.91}, + ]) + + wizard._action_validate() + # Cash basis move should be created for the invoice + caba_move = inv.tax_cash_basis_created_move_ids + product_account = self.company_data['default_account_revenue'] + + self.assertRecordValues(caba_move.line_ids, [ + {'balance': 200.0, 'account_id': product_account.id}, + {'balance': -200.0, 'account_id': product_account.id}, + {'balance': 30.0, 'account_id': cash_basis_transfer_account.id}, + {'balance': -30.0, 'account_id': tax_account.id}, + ]) + + # No cash basis move should be created for the bank journal entry + self.assertFalse(st_line.move_id.tax_cash_basis_created_move_ids) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_common.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_common.py new file mode 100644 index 0000000..b961a5c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_common.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from odoo import Command +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestBankRecWidgetCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.other_currency = cls.setup_other_currency('EUR') + cls.other_currency_2 = cls.setup_other_currency('CAD', rounding=0.001, rates=[('2016-01-01', 6.0), ('2017-01-01', 4.0)]) + cls.other_currency_3 = cls.setup_other_currency('XAF', rounding=0.001, rates=[('2016-01-01', 12.0), ('2017-01-01', 8.0)]) + + @classmethod + def _create_invoice_line(cls, move_type, **kwargs): + ''' Create an invoice on the fly.''' + kwargs.setdefault('partner_id', cls.partner_a.id) + kwargs.setdefault('invoice_date', '2017-01-01') + kwargs.setdefault('invoice_line_ids', []) + for one2many_values in kwargs['invoice_line_ids']: + one2many_values.setdefault('name', 'xxxx') + one2many_values.setdefault('quantity', 1) + one2many_values.setdefault('tax_ids', []) + + invoice = cls.env['account.move'].create({ + 'move_type': move_type, + **kwargs, + 'invoice_line_ids': [Command.create(x) for x in kwargs['invoice_line_ids']], + }) + invoice.action_post() + return invoice.line_ids\ + .filtered(lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) + + @classmethod + def _create_st_line(cls, amount, date='2019-01-01', payment_ref='turlututu', **kwargs): + st_line = cls.env['account.bank.statement.line'].create({ + 'amount': amount, + 'date': date, + 'payment_ref': payment_ref, + 'journal_id': kwargs.get('journal_id', cls.company_data['default_journal_bank'].id), + **kwargs, + }) + # The automatic reconcile cron checks the create_date when considering st_lines to run on. + # create_date is a protected field so this is the only way to set it correctly + cls.env.cr.execute("UPDATE account_bank_statement_line SET create_date = %s WHERE id=%s", + (st_line.date, st_line.id)) + return st_line + + @classmethod + def _create_reconcile_model(cls, **kwargs): + return cls.env['account.reconcile.model'].create({ + 'name': "test", + 'rule_type': 'invoice_matching', + 'allow_payment_tolerance': True, + 'payment_tolerance_type': 'percentage', + 'payment_tolerance_param': 0.0, + **kwargs, + 'line_ids': [ + Command.create({ + 'account_id': cls.company_data['default_account_revenue'].id, + 'amount_type': 'percentage', + 'label': f"test {i}", + **line_vals, + }) + for i, line_vals in enumerate(kwargs.get('line_ids', [])) + ], + }) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_tour.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_tour.py new file mode 100644 index 0000000..e8e6217 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_bank_rec_widget_tour.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +from odoo.addons.odex30_account_accountant.tests.test_bank_rec_widget_common import TestBankRecWidgetCommon +from odoo.tests import tagged, HttpCase +from odoo import Command + + +@tagged('post_install', '-at_install') +class TestBankRecWidget(TestBankRecWidgetCommon, HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.st_line1 = cls._create_st_line(1000.0, payment_ref="line1", sequence=1) + cls.st_line2 = cls._create_st_line(1000.0, payment_ref="line2", sequence=2) + cls._create_st_line(1000.0, payment_ref="line3", sequence=3) + cls._create_st_line(1000.0, payment_ref="line_credit", sequence=4, journal_id=cls.company_data['default_journal_credit'].id) + + # INV/2019/00001: + cls._create_invoice_line( + 'out_invoice', + partner_id=cls.partner_a.id, + invoice_date='2019-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + # INV/2019/00002: + cls._create_invoice_line( + 'out_invoice', + partner_id=cls.partner_a.id, + invoice_date='2019-01-01', + invoice_line_ids=[{'price_unit': 1000.0}], + ) + + cls.env['account.reconcile.model']\ + .search([('company_id', '=', cls.company_data['company'].id)])\ + .write({'past_months_limit': None}) + + cls.reco_model_invoice = cls.env['account.reconcile.model'].create({ + 'name': "test reconcile create invoice", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'sale', + 'line_ids': [ + Command.create({'amount_string': '50'}), + Command.create({'amount_string': '50'}), + ], + }) + + def test_tour_bank_rec_widget(self): + self.start_tour('/odoo', 'account_accountant_bank_rec_widget', login=self.env.user.login) + + self.assertRecordValues(self.st_line1.line_ids, [ + # pylint: disable=C0326 + {'account_id': self.st_line1.journal_id.default_account_id.id, 'balance': 1000.0, 'reconciled': False}, + {'account_id': self.company_data['default_account_receivable'].id, 'balance': -1000.0, 'reconciled': True}, + ]) + + tax_account = self.company_data['default_tax_sale'].invoice_repartition_line_ids.account_id + self.assertRecordValues(self.st_line2.line_ids, [ + # pylint: disable=C0326 + {'account_id': self.st_line2.journal_id.default_account_id.id, 'balance': 1000.0, 'tax_ids': []}, + {'account_id': self.company_data['default_account_payable'].id, 'balance': -869.57, 'tax_ids': self.company_data['default_tax_sale'].ids}, + {'account_id': tax_account.id, 'balance': -130.43, 'tax_ids': []}, + ]) + + def test_tour_bank_rec_widget_ui(self): + bank2 = self.env['account.journal'].create({ + 'name': 'Bank2', + 'type': 'bank', + 'code': 'BNK2', + }) + self._create_st_line(222.22, payment_ref="line4", sequence=4, journal_id=bank2.id) + # INV/2019/00003: + self._create_invoice_line( + 'out_invoice', + partner_id=self.partner_a.id, + invoice_date='2019-01-01', + invoice_line_ids=[{'price_unit': 2000.0}], + ) + self.st_line2.payment_ref = self.st_line2.payment_ref + ' - ' + 'INV/2019/00001' + self.start_tour('/odoo?debug=assets', 'account_accountant_bank_rec_widget_ui', timeout=120, login=self.env.user.login) + + def test_tour_bank_rec_widget_rainbowman_reset(self): + self.start_tour('/odoo?debug=assets', 'account_accountant_bank_rec_widget_rainbowman_reset', login=self.env.user.login) + + def test_tour_bank_rec_journal_items_export(self): + self.start_tour('/web?debug=assets', 'account_accountant_journal_items_export', login=self.env.user.login) + + def test_tour_bank_rec_widget_statements(self): + self.start_tour('/odoo?debug=assets', 'account_accountant_bank_rec_widget_statements', login=self.env.user.login) + + def test_tour_invoice_creation_from_reco_model(self): + """ Test if move is created and added as a new_aml line in bank reconciliation widget """ + st_line = self._create_st_line(amount=1000, partner_id=self.partner_a.id) + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + # The tour creates a move through reco model button, posts it, returns to widget and validates the move + self.start_tour( + '/odoo', + 'account_accountant_bank_rec_widget_reconciliation_button', + login=self.env.user.login, + ) + # Mount the validated statement line to confirm that information matches. + wizard._js_action_mount_st_line(st_line.id) + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'account_id': st_line.journal_id.default_account_id.id, 'balance': 1000}, + {'flag': 'aml', 'account_id': self.company_data['default_account_receivable'].id, 'balance': -1000}, + ]) + # Check that the aml comes from a move, and not from the auto-balance line + self.assertTrue(wizard.line_ids[1].source_aml_move_id) + + def test_tour_invoice_creation_reco_model_currency(self): + """ Test move creation through reconcile button when a foreign currency is used for the statement line """ + st_line = self._create_st_line( + 1800.0, + date='2019-02-01', + foreign_currency_id=self.other_currency.id, # rate 2:1 + amount_currency=3600.0, + partner_id=self.partner_a.id, + ) + + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + + self.start_tour( + '/odoo', + 'account_accountant_bank_rec_widget_reconciliation_button', + login=self.env.user.login, + ) + # Mount the validated statement line to confirm that information matches. + wizard._js_action_mount_st_line(st_line.id) + + # Move is created in the foreign currency, but in bank widget the balance appears in main currency. + # If aml was created from the reco model button, display name matches payment_ref. + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'balance': 1800, 'amount_currency': 1800}, + {'flag': 'aml', 'balance': -1800, 'amount_currency': -3600}, + ]) + # Confirm that the aml comes from a move, and not from the auto-balance line + self.assertTrue(wizard.line_ids[1].source_aml_move_id) + + def test_tour_invoice_creation_combined_reco_model(self): + """ Test creation of a move from a reconciliation model with different amount types """ + self.reco_model_invoice.name = "old test" # rename previous reco model to be able to reuse the existing tour + self.env['account.reconcile.model'].create({ + 'name': "test reconcile combined", + 'rule_type': 'writeoff_button', + 'counterpart_type': 'purchase', + 'line_ids': [ + Command.create({ + 'amount_type': 'percentage_st_line', + 'amount_string': '50', + }), + Command.create({ + 'amount_type': 'percentage', + 'amount_string': '50', + 'tax_ids': self.tax_purchase_b.ids, + }), + Command.create({ + 'amount_type': 'fixed', + 'amount_string': '100', + 'account_id': self.env.company.expense_currency_exchange_account_id.id, + 'tax_ids': [Command.clear()] # remove default tax added + }), + # Regex line will not be added to move, as the label of st line does not include digits + Command.create({ + 'amount_type': 'regex', + 'amount_string': r'BRT: ([\d,.]+)', + }), + ], + }) + + st_line = self._create_st_line(amount=-1000, partner_id=self.partner_a.id, payment_ref="combined test") + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({}) + # The tour creates a move through reco model button, posts it, returns to widget and validates the move + self.start_tour( + '/odoo', + 'account_accountant_bank_rec_widget_reconciliation_button', + login=self.env.user.login, + ) + # Mount the validated statement line to confirm that widget line matches created move and balance line is added. + wizard._js_action_mount_st_line(st_line.id) + self.assertRecordValues(wizard.line_ids, [ + {'flag': 'liquidity', 'account_id': st_line.journal_id.default_account_id.id, 'balance': -1000}, + {'flag': 'aml', 'account_id': self.company_data['default_account_payable'].id, 'balance': 850}, + {'flag': 'aml', 'account_id': self.company_data['default_account_payable'].id, 'balance': 150}, + ]) + # Check that the aml comes from an existing move + move = wizard.line_ids[1].source_aml_move_id + self.assertTrue(move) + + # The total price of these lines should match the percentage or fixed amount of reco model lines + self.assertRecordValues(move.line_ids, [ + # 50% of statement line (of 1000.0) + {'price_total': 500, 'debit': 434.78, 'credit': 0, 'name': 'combined test', 'account_id': self.company_data['default_account_expense'].id}, + # 50% of balance (of residual value = 500.0) + {'price_total': 250, 'debit': 217.39, 'credit': 0, 'name': 'combined test', 'account_id': self.company_data['default_account_expense'].id}, + # fixed amount of 100.0, no tax in reco model line + {'price_total': 100, 'debit': 100, 'credit': 0, 'name': 'combined test', 'account_id': self.env.company.expense_currency_exchange_account_id.id}, + # Tax for line 1 (65.22 + 434.78 = 500) + {'price_total': 0, 'debit': 65.22, 'credit': 0, 'name': '15%', 'account_id': self.company_data['default_account_tax_purchase'].id}, + # Tax for line 1 (32.61 + 217.39 = 250) + {'price_total': 0, 'debit': 32.61, 'credit': 0, 'name': '15% (Copy)', 'account_id': self.company_data['default_account_tax_purchase'].id}, + {'price_total': 0, 'debit': 0, 'credit': 850, 'name': 'combined test', 'account_id': self.company_data['default_account_payable'].id}, + ]) + + def test_analytic_distribution_saved(self): + """ + Test that the analytic distribution is saved when it is changed on the account.move.line in the banc rec + """ + analytic_plan = self.env['account.analytic.plan'].create({ + 'name': 'Default', + 'sequence': 1, # Used to simplify analytic distribution selector during the tour + }) + analytic_account = self.env['account.analytic.account'].create({ + 'name': 'analytic_account', + 'plan_id': analytic_plan.id, + 'company_id': False, + }) + self.env.user.write({'groups_id': [Command.link(self.env.ref('analytic.group_analytic_accounting').id)]}) + self.start_tour('/web', 'account_accountant_bank_rec_widget_save_analytic_distribution', login=self.env.user.login) + line1 = self.env['account.move.line'].search([('name', '=', 'line1')], limit=1) + self.assertEqual(line1.analytic_distribution, {str(analytic_account.id): 100}) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_change_lock_date_wizard.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_change_lock_date_wizard.py new file mode 100644 index 0000000..7063330 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_change_lock_date_wizard.py @@ -0,0 +1,201 @@ +from datetime import timedelta + +from odoo import fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.odex30_account_accountant.wizard.account_change_lock_date import SOFT_LOCK_DATE_FIELDS +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tools import frozendict + + +@tagged('post_install', '-at_install') +class TestChangeLockDateWizard(AccountTestInvoicingCommon): + + def test_exception_generation(self): + """ + Test the exception generation from the wizard. + Note that exceptions for 'everyone' and 'forever' are not tested here. + They do not create an exception (no 'account.lock_exception' object), but just change the lock date. + (See `test_everyone_forever_exception`.) + """ + self.env['account.lock_exception'].search([]).sudo().unlink() + + for lock_date_field in SOFT_LOCK_DATE_FIELDS: + with self.subTest(lock_date_field=lock_date_field), self.cr.savepoint() as sp: + # We can set the lock date if there is none. + self.env['account.change.lock.date'].create({lock_date_field: '2010-01-01'}).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], fields.Date.from_string('2010-01-01')) + + # We can increase the lock date if there is one. + self.env['account.change.lock.date'].create({lock_date_field: '2011-01-01'}).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], fields.Date.from_string('2011-01-01')) + + # We cannot remove the lock date; but we can create an exception + wizard = self.env['account.change.lock.date'].create({ + lock_date_field: False, + 'exception_applies_to': 'everyone', + 'exception_duration': '1h', + 'exception_reason': ':TestChangeLockDateWizard.test_exception_generation; remove', + }) + wizard.change_lock_date() + self.assertEqual(self.env['account.lock_exception'].search_count([]), 1) + exception = self.env['account.lock_exception'].search([]) + self.assertEqual(len(exception), 1) + self.assertRecordValues(exception, [{ + lock_date_field: False, + 'company_id': self.env.company.id, + 'user_id': False, + 'create_uid': self.env.user.id, + 'end_datetime': self.env.cr.now() + timedelta(hours=1), + 'reason': ':TestChangeLockDateWizard.test_exception_generation; remove', + }]) + exception.sudo().unlink() + + # Ensure we have not created any exceptions yet + self.assertEqual(self.env['account.lock_exception'].search_count([]), 0) + + # We cannot decrease the lock date; but we can create an exception + self.env['account.change.lock.date'].create({lock_date_field: '2009-01-01'}).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], fields.Date.from_string('2011-01-01')) + exception = self.env['account.lock_exception'].search([]) + self.assertEqual(len(exception), 1) + # Check lock date and default values on exception + self.assertRecordValues(exception, [{ + lock_date_field: fields.Date.from_string('2009-01-01'), + 'company_id': self.env.company.id, + 'user_id': self.env.user.id, + 'create_uid': self.env.user.id, + 'end_datetime': self.env.cr.now() + timedelta(minutes=5), + 'reason': False, + }]) + + sp.close() # Rollback to ensure all subtests start in the same situation + + def test_exception_generation_multiple(self): + """ + Test the exception generation from the wizard. + Here we test the case that we create multiple exceptions at once. + This should create an exception object for every changed lock date. + """ + self.env['account.lock_exception'].search([]).sudo().unlink() + + wizard = self.env['account.change.lock.date'].create({ + 'fiscalyear_lock_date': '2010-01-01', + 'tax_lock_date': '2010-01-01', + 'sale_lock_date': '2010-01-01', + 'purchase_lock_date': '2010-01-01', + }) + wizard.change_lock_date() + + self.assertRecordValues(self.env.company, [{ + 'fiscalyear_lock_date': fields.Date.from_string('2010-01-01'), + 'tax_lock_date': fields.Date.from_string('2010-01-01'), + 'sale_lock_date': fields.Date.from_string('2010-01-01'), + 'purchase_lock_date': fields.Date.from_string('2010-01-01'), + }]) + + wizard = self.env['account.change.lock.date'].create({ + 'fiscalyear_lock_date': '2009-01-01', + 'tax_lock_date': '2009-01-01', + 'sale_lock_date': '2009-01-01', + 'purchase_lock_date': '2009-01-01', + 'exception_applies_to': 'everyone', + 'exception_duration': '1h', + 'exception_reason': ':TestChangeLockDateWizard.test_exception_generation; remove', + }) + wizard.change_lock_date() + + exceptions = self.env['account.lock_exception'].search([]) + self.assertEqual(len(exceptions), 4) + expected_exceptions = { + frozendict({ + 'lock_date_field': 'fiscalyear_lock_date', + 'lock_date': fields.Date.from_string('2009-01-01'), + }), + frozendict({ + 'lock_date_field': 'tax_lock_date', + 'lock_date': fields.Date.from_string('2009-01-01'), + }), + frozendict({ + 'lock_date_field': 'sale_lock_date', + 'lock_date': fields.Date.from_string('2009-01-01'), + }), + frozendict({ + 'lock_date_field': 'purchase_lock_date', + 'lock_date': fields.Date.from_string('2009-01-01'), + }), + } + created_exceptions = { + frozendict({ + 'lock_date_field': exception.lock_date_field, + 'lock_date': exception.lock_date, + }) + for exception in exceptions + } + self.assertSetEqual(created_exceptions, expected_exceptions) + + def test_hard_lock_date(self): + self.env['account.lock_exception'].search([]).sudo().unlink() + + # We can set the hard lock date if there is none. + self.env['account.change.lock.date'].create({'hard_lock_date': '2010-01-01'}).change_lock_date() + self.assertEqual(self.env.company.hard_lock_date, fields.Date.from_string('2010-01-01')) + + # We can increase the hard lock date if there is one. + self.env['account.change.lock.date'].create({'hard_lock_date': '2011-01-01'}).change_lock_date() + self.assertEqual(self.env.company.hard_lock_date, fields.Date.from_string('2011-01-01')) + + # We cannot decrease the hard lock date; not even with an exception. + wizard = self.env['account.change.lock.date'].create({ + 'hard_lock_date': '2009-01-01', + 'exception_applies_to': 'everyone', + 'exception_duration': '1h', + 'exception_reason': ':TestChangeLockDateWizard.test_hard_lock_date', + }) + with self.assertRaises(UserError), self.env.cr.savepoint(): + wizard.change_lock_date() + self.assertEqual(self.env.company.hard_lock_date, fields.Date.from_string('2011-01-01')) + + # We cannot remove the hard lock date; not even with an exception. + wizard = self.env['account.change.lock.date'].create({ + 'hard_lock_date': False, + 'exception_applies_to': 'everyone', + 'exception_duration': '1h', + 'exception_reason': ':TestChangeLockDateWizard.test_hard_lock_date', + }) + with self.assertRaises(UserError), self.env.cr.savepoint(): + wizard.change_lock_date() + self.assertEqual(self.env.company.hard_lock_date, fields.Date.from_string('2011-01-01')) + + self.assertEqual(self.env['account.lock_exception'].search_count([]), 0) + + def test_everyone_forever_exception(self): + self.env['account.lock_exception'].search([]).sudo().unlink() + + for lock_date_field in SOFT_LOCK_DATE_FIELDS: + with self.subTest(lock_date_field=lock_date_field), self.cr.savepoint() as sp: + self.env['account.change.lock.date'].create({lock_date_field: '2010-01-01'}).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], fields.Date.from_string('2010-01-01')) + + # We can decrease the lock date with a 'forever' / 'everyone' exception. + self.env['account.change.lock.date'].create({ + lock_date_field: '2009-01-01', + 'exception_applies_to': 'everyone', + 'exception_duration': 'forever', + 'exception_reason': ':TestChangeLockDateWizard.test_everyone_forever_exception; remove', + }).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], fields.Date.from_string('2009-01-01')) + + # We can remove the lock date with a 'forever' / 'everyone' exception. + self.env['account.change.lock.date'].create({ + lock_date_field: False, + 'exception_applies_to': 'everyone', + 'exception_duration': 'forever', + 'exception_reason': ':TestChangeLockDateWizard.test_everyone_forever_exception; remove', + }).change_lock_date() + self.assertEqual(self.env.company[lock_date_field], False) + + # Ensure we have not created any exceptions + self.assertEqual(self.env['account.lock_exception'].search_count([]), 0) + + sp.close() # Rollback to ensure all subtests start in the same situation diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_deferred_management.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_deferred_management.py new file mode 100644 index 0000000..44afaaa --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_deferred_management.py @@ -0,0 +1,778 @@ +# -*- coding: utf-8 -*- +# pylint: disable=C0326 +import datetime + +from odoo import Command, fields +from odoo.tests import tagged +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + +from freezegun import freeze_time + + +@tagged('post_install', '-at_install') +class TestDeferredManagement(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.expense_accounts = [cls.env['account.account'].create({ + 'name': f'Expense {i}', + 'code': f'EXP{i}', + 'account_type': 'expense', + }) for i in range(3)] + cls.revenue_accounts = [cls.env['account.account'].create({ + 'name': f'Revenue {i}', + 'code': f'REV{i}', + 'account_type': 'income', + }) for i in range(3)] + + cls.company.deferred_expense_journal_id = cls.company_data['default_journal_misc'].id + cls.company.deferred_revenue_journal_id = cls.company_data['default_journal_misc'].id + cls.company.deferred_expense_account_id = cls.company_data['default_account_deferred_expense'].id + cls.company.deferred_revenue_account_id = cls.company_data['default_account_deferred_revenue'].id + + cls.expense_lines = [ + [cls.expense_accounts[0], 1000, '2023-01-01', '2023-04-30'], # 4 full months (=250/month) + [cls.expense_accounts[0], 1050, '2023-01-16', '2023-04-30'], # 3 full months + 15 days (=300/month) + [cls.expense_accounts[1], 1225, '2023-01-01', '2023-04-15'], # 3 full months + 15 days (=350/month) + [cls.expense_accounts[2], 1680, '2023-01-21', '2023-04-14'], # 2 full months + 10 days + 14 days (=600/month) + [cls.expense_accounts[2], 225, '2023-04-01', '2023-04-15'], # 15 days (=450/month) + ] + cls.revenue_lines = [ + [cls.revenue_accounts[0], 1000, '2023-01-01', '2023-04-30'], # 4 full months (=250/month) + [cls.revenue_accounts[0], 1050, '2023-01-16', '2023-04-30'], # 3 full months + 15 days (=300/month) + [cls.revenue_accounts[1], 1225, '2023-01-01', '2023-04-15'], # 3 full months + 15 days (=350/month) + [cls.revenue_accounts[2], 1680, '2023-01-21', '2023-04-14'], # 2 full months + 10 days + 14 days (=600/month) + [cls.revenue_accounts[2], 225, '2023-04-01', '2023-04-15'], # 15 days (=450/month) + ] + + def create_invoice(self, move_type, invoice_lines, date=None, post=True): + journal = self.company_data['default_journal_purchase'] if move_type in self.env['account.move'].get_purchase_types() else self.company_data['default_journal_sale'] + move = self.env['account.move'].create({ + 'move_type': move_type, + 'partner_id': self.partner_a.id, + 'date': date or '2023-01-01', + 'invoice_date': date or '2023-01-01', + 'journal_id': journal.id, + 'invoice_line_ids': [ + Command.create({ + 'product_id': self.product_a.id, + 'quantity': 1, + 'account_id': account.id, + 'price_unit': price_unit, + 'deferred_start_date': start_date, + 'deferred_end_date': end_date, + }) for account, price_unit, start_date, end_date in invoice_lines + ] + }) + if post: + move.action_post() + return move + + def test_deferred_management_get_diff_dates(self): + def assert_get_diff_dates(start, end, expected): + diff = self.env['account.move']._get_deferred_diff_dates(fields.Date.to_date(start), fields.Date.to_date(end)) + self.assertAlmostEqual(diff, expected, 3) + + assert_get_diff_dates('2023-01-01', '2023-01-01', 0) + assert_get_diff_dates('2023-01-01', '2023-01-02', 1/30) + assert_get_diff_dates('2023-01-01', '2023-01-20', 19/30) + assert_get_diff_dates('2023-01-01', '2023-01-31', 29/30) + assert_get_diff_dates('2023-01-01', '2023-01-30', 29/30) + assert_get_diff_dates('2023-01-01', '2023-02-01', 1) + assert_get_diff_dates('2023-01-01', '2023-02-28', 1 + 29/30) + assert_get_diff_dates('2023-02-01', '2023-02-28', 29/30) + assert_get_diff_dates('2023-02-10', '2023-02-28', 20/30) + assert_get_diff_dates('2023-01-01', '2023-02-15', 1 + 14/30) + assert_get_diff_dates('2023-01-01', '2023-03-31', 2 + 29/30) + assert_get_diff_dates('2023-01-01', '2023-04-01', 3) + assert_get_diff_dates('2023-01-01', '2023-04-30', 3 + 29/30) + assert_get_diff_dates('2023-01-10', '2023-04-30', 3 + 20/30) + assert_get_diff_dates('2023-01-10', '2023-04-09', 2 + 29/30) + assert_get_diff_dates('2023-01-10', '2023-04-10', 3) + assert_get_diff_dates('2023-01-10', '2023-04-11', 3 + 1/30) + assert_get_diff_dates('2023-02-20', '2023-04-10', 1 + 20/30) + assert_get_diff_dates('2023-01-31', '2023-04-30', 3) + assert_get_diff_dates('2023-02-28', '2023-04-10', 1 + 10/30) + assert_get_diff_dates('2023-03-01', '2023-04-10', 1 + 9/30) + assert_get_diff_dates('2023-04-10', '2023-03-01', 1 + 9/30) + assert_get_diff_dates('2023-01-01', '2023-12-31', 11 + 29/30) + assert_get_diff_dates('2023-01-01', '2024-01-01', 12) + assert_get_diff_dates('2023-01-01', '2024-07-01', 18) + assert_get_diff_dates('2023-01-01', '2024-07-10', 18 + 9/30) + + def test_get_ends_of_month(self): + def assertEndsOfMonths(start_date, end_date, expected): + self.assertEqual( + self.env['account.move.line']._get_deferred_ends_of_month( + fields.Date.to_date(start_date), + fields.Date.to_date(end_date) + ), + [fields.Date.to_date(date) for date in expected] + ) + + assertEndsOfMonths('2023-01-01', '2023-01-01', ['2023-01-31']) + assertEndsOfMonths('2023-01-01', '2023-01-02', ['2023-01-31']) + assertEndsOfMonths('2023-01-01', '2023-01-20', ['2023-01-31']) + assertEndsOfMonths('2023-01-01', '2023-01-30', ['2023-01-31']) + assertEndsOfMonths('2023-01-01', '2023-01-31', ['2023-01-31']) + assertEndsOfMonths('2023-01-01', '2023-02-01', ['2023-01-31', '2023-02-28']) + assertEndsOfMonths('2023-01-01', '2023-02-28', ['2023-01-31', '2023-02-28']) + assertEndsOfMonths('2023-02-01', '2023-02-28', ['2023-02-28']) + assertEndsOfMonths('2023-02-10', '2023-02-28', ['2023-02-28']) + assertEndsOfMonths('2023-01-01', '2023-02-15', ['2023-01-31', '2023-02-28']) + assertEndsOfMonths('2023-01-01', '2023-03-31', ['2023-01-31', '2023-02-28', '2023-03-31']) + assertEndsOfMonths('2023-01-01', '2023-04-01', ['2023-01-31', '2023-02-28', '2023-03-31', '2023-04-30']) + assertEndsOfMonths('2023-01-01', '2023-04-30', ['2023-01-31', '2023-02-28', '2023-03-31', '2023-04-30']) + assertEndsOfMonths('2023-01-10', '2023-04-30', ['2023-01-31', '2023-02-28', '2023-03-31', '2023-04-30']) + assertEndsOfMonths('2023-01-10', '2023-04-09', ['2023-01-31', '2023-02-28', '2023-03-31', '2023-04-30']) + + def test_deferred_abnormal_dates(self): + """ + Test that we correctly detect abnormal dates. + In the deferred computations, we always assume that both the start and end date are inclusive + E.g: 1st January -> 31st December is *exactly* 1 year = 12 months + However, the user may instead put 1st January -> 1st January of next year which is then + 12 months + 1/30 month = 12.03 months which may result in odd amounts when deferrals are created. + This is what we call abnormal dates. + Other cases were the number of months is not round should not be handled and are not considered abnormal. + """ + move = self.create_invoice('in_invoice', [ + [self.expense_accounts[0], 0, '2023-01-01', '2023-12-30'], + [self.expense_accounts[0], 1, '2023-01-01', '2023-12-31'], + [self.expense_accounts[0], 2, '2023-01-01', '2024-01-01'], + [self.expense_accounts[0], 3, '2023-01-01', '2024-01-02'], + [self.expense_accounts[0], 4, '2023-01-01', '2024-01-31'], + [self.expense_accounts[0], 5, '2023-01-01', '2024-02-01'], + [self.expense_accounts[0], 6, '2023-01-02', '2024-02-01'], + [self.expense_accounts[0], 7, '2023-01-02', '2024-02-02'], + [self.expense_accounts[0], 8, '2023-01-31', '2024-01-30'], + [self.expense_accounts[0], 9, '2023-01-31', '2024-02-28'], # 29 days in Feb 2024 + # Following one is abnormal because we have a full months in February (= 30 accounting days) + 1 day in January + [self.expense_accounts[0], 10, '2023-01-31', '2024-02-29'], + [self.expense_accounts[0], 11, '2023-02-01', '2024-02-29'], + ], post=True) + lines = move.invoice_line_ids.sorted('price_unit') + self.assertFalse(lines[0].has_abnormal_deferred_dates) + self.assertFalse(lines[1].has_abnormal_deferred_dates) + self.assertTrue(lines[2].has_abnormal_deferred_dates) + self.assertFalse(lines[3].has_abnormal_deferred_dates) + self.assertFalse(lines[4].has_abnormal_deferred_dates) + self.assertTrue(lines[5].has_abnormal_deferred_dates) + self.assertFalse(lines[6].has_abnormal_deferred_dates) + self.assertTrue(lines[7].has_abnormal_deferred_dates) + self.assertFalse(lines[8].has_abnormal_deferred_dates) + self.assertFalse(lines[9].has_abnormal_deferred_dates) + self.assertTrue(lines[10].has_abnormal_deferred_dates) + self.assertFalse(lines[11].has_abnormal_deferred_dates) + + def test_deferred_expense_generate_entries_method(self): + # The deferred entries are NOT generated when the invoice is validated if the method is set to 'manual'. + self.company.generate_deferred_expense_entries_method = 'manual' + move = self.create_invoice('in_invoice', [self.expense_lines[0]], post=True) + self.assertEqual(len(move.deferred_move_ids), 0) + + move = self.create_invoice('in_refund', [self.expense_lines[0]], post=True) + self.assertEqual(len(move.deferred_move_ids), 0) + + # Test that the deferred entries are generated when the invoice is validated. + self.company.generate_deferred_expense_entries_method = 'on_validation' + move = self.create_invoice('in_invoice', [self.expense_lines[0]], post=True) + self.assertEqual(len(move.deferred_move_ids), 5) # 1 for the invoice deferred + 4 for the deferred entries + + move = self.create_invoice('in_refund', [self.expense_lines[0]], post=True) + self.assertEqual(len(move.deferred_move_ids), 5) + # See test_deferred_expense_credit_note for the values + + def test_deferred_expense_reset_to_draft(self): + """ + Test that the deferred entries are deleted/reverted when the invoice is reset to draft. + """ + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 1680, '2023-01-21', '2023-04-14')], date='2023-03-15') + self.assertEqual(len(move.deferred_move_ids), 5) + move.button_draft() + self.assertFalse(move.deferred_move_ids) + + # With a lock date, we should reverse the moves that cannot be deleted + move.action_post() # Post the move to create the deferred entries with 'on_validation' method + self.assertEqual(len(move.deferred_move_ids), 5) + move.company_id.fiscalyear_lock_date = fields.Date.to_date('2023-02-15') + move.button_draft() + # January deferred entry is in lock period, so it is reversed, not deleted, thus we have one deferred entry and its revert + self.assertEqual(len(move.deferred_move_ids), 2) + self.assertEqual(move.deferred_move_ids[0].date, fields.Date.to_date('2023-01-31')) + self.assertEqual(move.deferred_move_ids[1].date, fields.Date.to_date('2023-02-28')) + + # If we repost the move, it should be allowed + move.action_post() + self.assertEqual(len(move.deferred_move_ids), 2 + 5) + + @freeze_time('2023-03-15') + def test_deferred_invoice_reset_to_draft_with_audit_trail(self): + """ + Test that the deferred entries in draft are deleted when the invoice is reset to draft + and the posted deferred entries are cancelled. + """ + invoice = self.create_invoice('out_invoice', [(self.revenue_accounts[0], 1680, '2023-02-01', '2023-04-30')], date='2023-03-15') + posted_deferred_entries = invoice.deferred_move_ids.filtered(lambda move: move.state == 'posted') + draft_deferred_move_ids = invoice.deferred_move_ids.filtered(lambda move: move.state =="draft") + self.assertEqual(len(posted_deferred_entries), 2) + self.assertEqual(len(draft_deferred_move_ids), 2) + + # Set the company's audit trail to True + self.env.company.check_account_audit_trail = True + + invoice.button_draft() + + # Assert that the draft moves no longer exist + remaining_draft_moves = self.env['account.move'].search([('id', 'in', draft_deferred_move_ids.ids)]) + self.assertFalse(remaining_draft_moves) + # The posted deferred entries should be cancelled + self.assertEqual(len(posted_deferred_entries.filtered(lambda move: move.state == 'cancel')), 2) + + def assert_invoice_lines(self, move, expected_values, source_account, deferred_account): + deferred_moves = move.deferred_move_ids.sorted('date') + for deferred_move, expected_value in zip(deferred_moves, expected_values): + expected_date, expense_line_debit, expense_line_credit, deferred_line_debit, deferred_line_credit = expected_value + self.assertRecordValues(deferred_move, [{ + 'state': 'posted', + 'move_type': 'entry', + 'partner_id': self.partner_a.id, + 'date': fields.Date.to_date(expected_date), + }]) + expense_line = deferred_move.line_ids.filtered(lambda line: line.account_id == source_account) + self.assertRecordValues(expense_line, [ + {'debit': expense_line_debit, 'credit': expense_line_credit, 'partner_id': self.partner_a.id}, + ]) + deferred_line = deferred_move.line_ids.filtered(lambda line: line.account_id == deferred_account) + self.assertEqual(deferred_line.debit, deferred_line_debit) + self.assertEqual(deferred_line.credit, deferred_line_credit) + + def test_default_tax_on_account_not_on_deferred_entries(self): + """ + Test that the default taxes on an account are not calculated on deferral entries, since this would impact the + tax report. + """ + revenue_account_with_taxes = self.env['account.account'].create({ + 'name': 'Revenue with Taxes', + 'code': 'REVWTAXES', + 'account_type': 'income', + 'tax_ids': [Command.set(self.tax_sale_a.ids)] + }) + + move = self.create_invoice( + 'out_invoice', + [[revenue_account_with_taxes, 1000, '2023-01-01', '2023-04-30']], + date='2022-12-10' + ) + + expected_line_values = [ + # Date [Line expense] [Line deferred] + ('2022-12-10', 1000, 0, 0, 1000), + ('2023-01-31', 0, 250, 250, 0), + ('2023-02-28', 0, 250, 250, 0), + ('2023-03-31', 0, 250, 250, 0), + ] + + self.assert_invoice_lines( + move, + expected_line_values, + revenue_account_with_taxes, + self.company_data['default_account_deferred_revenue'] + ) + + for deferred_move in move.deferred_move_ids: + # There are no extra lines besides the two lines we checked before + self.assertEqual(len(deferred_move.line_ids), 2) + + + def test_deferred_values(self): + """ + Test that the debit/credit values are correctly computed, even after a credit note is issued. + """ + + expected_line_values1 = [ + # Date [Line expense] [Line deferred] + ('2022-12-10', 0, 1000, 1000, 0), + ('2023-01-31', 250, 0, 0, 250), + ('2023-02-28', 250, 0, 0, 250), + ('2023-03-31', 250, 0, 0, 250), + ] + expected_line_values2 = [ + # Date [Line expense] [Line deferred] + ('2022-12-10', 1000, 0, 0, 1000), + ('2023-01-31', 0, 250, 250, 0), + ('2023-02-28', 0, 250, 250, 0), + ('2023-03-31', 0, 250, 250, 0), + ] + + # Vendor bill and credit note + move = self.create_invoice('in_invoice', [self.expense_lines[0]], post=True, date='2022-12-10') + self.assert_invoice_lines(move, expected_line_values1, self.expense_accounts[0], self.company_data['default_account_deferred_expense']) + reverse_move = move._reverse_moves() + self.assert_invoice_lines(reverse_move, expected_line_values2, self.expense_accounts[0], self.company_data['default_account_deferred_expense']) + + # Customer invoice and credit note + move2 = self.create_invoice('out_invoice', [self.revenue_lines[0]], post=True, date='2022-12-10') + self.assert_invoice_lines(move2, expected_line_values2, self.revenue_accounts[0], self.company_data['default_account_deferred_revenue']) + reverse_move2 = move2._reverse_moves() + self.assert_invoice_lines(reverse_move2, expected_line_values1, self.revenue_accounts[0], self.company_data['default_account_deferred_revenue']) + + def test_deferred_values_rounding(self): + """ + Test that the debit/credit values are correctly computed when values are rounded + """ + + # Vendor Bill + expense_line = [self.expense_accounts[0], 500, '2020-08-07', '2020-12-07'] + expected_line_values = [ + # Date [Line expense] [Line deferred] + ('2020-08-07', 0, 500, 500, 0), + ('2020-08-31', 99.17, 0, 0, 99.17), + ('2020-09-30', 123.97, 0, 0, 123.97), + ('2020-10-31', 123.97, 0, 0, 123.97), + ('2020-11-30', 123.97, 0, 0, 123.97), + ('2020-12-07', 28.92, 0, 0, 28.92), + ] + self.assertEqual(self.company.currency_id.round(sum(x[1] for x in expected_line_values)), 500) + move = self.create_invoice('in_invoice', [expense_line], date='2020-08-07') + self.assert_invoice_lines(move, expected_line_values, self.expense_accounts[0], self.company_data['default_account_deferred_expense']) + + # Customer invoice + revenue_line = [self.revenue_accounts[0], 500, '2020-08-07', '2020-12-07'] + expected_line_values = [ + # Date [Line expense] [Line deferred] + ('2020-08-07', 500, 0, 0, 500), + ('2020-08-31', 0, 99.17, 99.17, 0), + ('2020-09-30', 0, 123.97, 123.97, 0), + ('2020-10-31', 0, 123.97, 123.97, 0), + ('2020-11-30', 0, 123.97, 123.97, 0), + ('2020-12-07', 0, 28.92, 28.92, 0), + ] + self.assertEqual(self.company.currency_id.round(sum(x[2] for x in expected_line_values)), 500) + move = self.create_invoice('out_invoice', [revenue_line], post=True, date='2020-08-07') + self.assert_invoice_lines(move, expected_line_values, self.revenue_accounts[0], self.company_data['default_account_deferred_revenue']) + + def test_deferred_expense_avoid_useless_deferred_entries(self): + """ + If we have an invoice with a start date in the beginning of the month, and an end date in the end of the month, + we should not create the deferred entries because the original invoice will be totally deferred + on the last day of the month, but the full amount will be accounted for on the same day too, thus + cancelling each other. Therefore we should not create the deferred entries. This is only the case + if the invoice date is also inside the deferred period. + """ + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 1680, '2023-01-01', '2023-01-31')], date='2023-01-01') + self.assertEqual(len(move.deferred_move_ids), 0) + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 1680, '2023-01-01', '2023-01-31')], date='2022-01-01') + self.assertEqual(len(move.deferred_move_ids), 2) + + def test_deferred_expense_single_period_entries(self): + """ + If we have an invoice covering only one period, we should only avoid creating deferral entries when the + accounting date is the same as the period for the deferral. Otherwise we should still generate a deferral entry. + """ + self.company.deferred_expense_amount_computation_method = 'month' + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 1680, '2023-02-01', '2023-02-28')]) + self.assertRecordValues(move.deferred_move_ids, [ + {'date': fields.Date.to_date('2023-01-01')}, + {'date': fields.Date.to_date('2023-02-28')}, + ]) + + def test_taxes_deferred_after_date_added(self): + """ + Test that applicable taxes get deferred also when the dates of the base line are filled in after a first save. + """ + + expected_line_values = [ + # Date [Line expense] [Line deferred] + ('2022-12-10', 0, 100, 100, 0), + ('2022-12-10', 0, 1000, 1000, 0), + ('2023-01-31', 25, 0, 0, 25), + ('2023-01-31', 250, 0, 0, 250), + ('2023-02-28', 25, 0, 0, 25), + ('2023-02-28', 250, 0, 0, 250), + ('2023-03-31', 25, 0, 0, 25), + ('2023-03-31', 250, 0, 0, 250), + ] + + partially_deductible_tax = self.env['account.tax'].create({ + 'name': 'Partially deductible Tax', + 'amount': 20, + 'amount_type': 'percent', + 'type_tax_use': 'purchase', + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'account_id': self.company_data['default_account_tax_purchase'].id, + 'use_in_tax_closing': True + }), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'account_id': self.company_data['default_account_tax_purchase'].id, + 'use_in_tax_closing': True + }), + ], + }) + + move = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'date': '2022-12-10', + 'invoice_date': '2022-12-10', + 'journal_id': self.company_data['default_journal_purchase'].id, + 'invoice_line_ids': [ + Command.create({ + 'quantity': 1, + 'account_id': self.expense_lines[0][0].id, + 'price_unit': self.expense_lines[0][1], + 'tax_ids': [Command.set(partially_deductible_tax.ids)], + }) + ] + }) + + move.invoice_line_ids.write({ + 'deferred_start_date': self.expense_lines[0][2], + 'deferred_end_date': self.expense_lines[0][3], + }) + + move.action_post() + + self.assert_invoice_lines(move, expected_line_values, self.expense_accounts[0], self.company_data['default_account_deferred_expense']) + + def test_deferred_tax_key(self): + """ + Test that the deferred tax key is correctly computed. + and is the same between _compute_tax_key and _compute_all_tax + """ + lines = [ + [self.expense_accounts[0], 1000, '2023-01-01', '2023-04-30'], + [self.expense_accounts[0], 1000, False, False], + ] + move = self.create_invoice('in_invoice', lines, post=True) + original_amount_total = move.amount_total + self.assertEqual(len(move.line_ids.filtered(lambda l: l.display_type == 'tax')), 1) + move.button_draft() + move.action_post() + # The number of tax lines shouldn't change, nor the total amount + self.assertEqual(len(move.line_ids.filtered(lambda l: l.display_type == 'tax')), 1) + self.assertEqual(move.amount_total, original_amount_total) + + def test_compute_empty_start_date(self): + """ + Test that the deferred start date is computed when empty and posting the move. + """ + lines = [[self.expense_accounts[0], 1000, False, '2023-04-30']] + move = self.create_invoice('in_invoice', lines, post=False) + + # We don't have a deferred date in the beginning + self.assertFalse(move.line_ids[0].deferred_start_date) + + move.action_post() + # Deferred start date is set after post + self.assertEqual(move.line_ids[0].deferred_start_date, datetime.date(2023, 1, 1)) + + move.button_draft() + move.line_ids[0].deferred_start_date = False + move.invoice_date = '2023-02-01' + # Start date is set when changing invoice date + self.assertEqual(move.line_ids[0].deferred_start_date, datetime.date(2023, 2, 1)) + + move.line_ids[0].deferred_start_date = False + move.line_ids[0].deferred_end_date = '2023-05-31' + # Start date is set when changing deferred end date + self.assertEqual(move.line_ids[0].deferred_start_date, datetime.date(2023, 2, 1)) + + def test_deferred_on_accounting_date(self): + """ + When we are in `on_validation` mode, the deferral of the total amount should happen on the + accounting date of the move. + """ + move = self.create_invoice( + 'in_invoice', + [(self.expense_accounts[0], 1680, '2023-01-01', '2023-02-28')], + date='2023-01-10', + post=False + ) + move.date = '2023-01-15' + move.action_post() + self.assertRecordValues(move.deferred_move_ids, [ + {'date': fields.Date.to_date('2023-01-15')}, + {'date': fields.Date.to_date('2023-01-31')}, + {'date': fields.Date.to_date('2023-02-28')}, + ]) + + def test_deferred_entries_not_created_on_future_invoice(self): + """Test that we don't create deferred entries on a future posted invoice""" + tomorrow = fields.Date.to_date(fields.Date.today()) + datetime.timedelta(days=1) + move = self.create_invoice( + 'out_invoice', + [(self.expense_accounts[0], 1680, tomorrow, tomorrow + datetime.timedelta(days=100))], + date=tomorrow, + post=False + ) + move.auto_post = "at_date" + move._post() + self.assertFalse(move.deferred_move_ids) + + with freeze_time(tomorrow): + self.env.ref('account.ir_cron_auto_post_draft_entry').method_direct_trigger() + self.assertEqual(move.state, 'posted') + self.assertTrue(move.deferred_move_ids) + + def test_deferred_entries_created_on_auto_post_invoice(self): + """Test that deferred entries are created on an invoice with auto_post set to 'at_date'""" + yesterday = fields.Date.to_date(fields.Date.today()) - datetime.timedelta(days=1) + move = self.create_invoice( + 'out_invoice', + [(self.expense_accounts[0], 1680, yesterday, yesterday + datetime.timedelta(days=45))], + date=yesterday, + post=False + ) + move.auto_post = "at_date" + move._post() + self.assertEqual(move.state, 'posted') + self.assertTrue(move.deferred_move_ids) + + def test_deferred_compute_method_full_months(self): + """ + Test that the deferred amount is correctly computed when the new full_months method computation is used + """ + self.company.deferred_expense_amount_computation_method = 'full_months' + + dates = (('2024-06-05', '2025-06-04'), ('2024-06-30', '2025-06-29')) + for (date_from, date_to) in dates: + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, date_from, date_to)], date='2024-06-05') + self.assertRecordValues(move.deferred_move_ids.sorted('date'), [ + {'date': fields.Date.to_date('2024-06-05'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-06-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-07-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-08-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-09-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-10-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-11-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-12-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-01-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-02-28'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-03-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-04-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-05-31'), 'amount_total': 1000}, + # 0 for June 2025, so no move created + ]) + + # Start of month <=> Equal per month method + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-07-01', '2025-06-30')], date='2024-07-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-07-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-07-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-08-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-09-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-10-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-11-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2024-12-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-01-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-02-28'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-03-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-04-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-05-31'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-06-30'), 'amount_total': 1000}, + ]) + + # Nothing to defer, everything is in the same month + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-01', '2024-01-16')], date='2024-01-01') + self.assertFalse(move.deferred_move_ids) + + # Round period of 2 months -> Divide by 2 + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-01', '2024-02-29')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-01-31'), 'amount_total': 6000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 6000}, + ]) + + # Round period of 2 months -> Divide by 2 + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-15', '2024-03-14')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-01-31'), 'amount_total': 6000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 6000}, + ]) + + # Period of exactly one month: full amount should be in Jan. So we revert 1st Jan, and account for 31st Jan <=> don't generate anything + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-15', '2024-02-14')], date='2024-01-01') + self.assertFalse(move.deferred_move_ids) + + # Not-round period of 1.5 month with only one end of month in January (same explanation as above) + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-01', '2024-02-15')], date='2024-01-01') + self.assertFalse(move.deferred_move_ids) + + # Not-round period of 1.5+ month with only one end of month in January (same explanation as above) + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-05', '2024-02-15')], date='2024-01-01') + self.assertFalse(move.deferred_move_ids) + + # Period of exactly one month: full amount should be in Feb. So we revert 1st Jan, and account for all on 29th Feb. + # Deferrals are in different months for this case, so we should the deferrals should be generated. + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-02-15', '2024-03-14')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 12000}, + ]) + + # Not-round period of 1.5+ month: full amount should be in Feb. So we revert 1st Jan, and account for all on 29th Feb. + # Deferrals are in different months for this case, so we should the deferrals should be generated. + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-02-05', '2024-03-15')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 12000}, + ]) + + # Not-round period of 1.5 month with 2 ends of months, so divide balance by 2 + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-16', '2024-02-29')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-01-31'), 'amount_total': 6000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 6000}, + ]) + + # Not-round period of 2.5 month, with 3 ends of months, so divide balance by 3 + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], 12000, '2024-01-16', '2024-03-31')], date='2024-01-01') + self.assertRecordValues(move.deferred_move_ids.sorted(lambda m: (m.date, m.amount_total)), [ + {'date': fields.Date.to_date('2024-01-01'), 'amount_total': 12000}, + {'date': fields.Date.to_date('2024-01-31'), 'amount_total': 4000}, + {'date': fields.Date.to_date('2024-02-29'), 'amount_total': 4000}, + {'date': fields.Date.to_date('2024-03-31'), 'amount_total': 4000}, + ]) + + def test_deferral_moves_not_removed(self): + """ + Test that when there are multiple amls with the same price on the original invoice the deferral_moves are not removed + """ + move = self.create_invoice('in_invoice', [ + (self.expense_accounts[0], 1000, '2025-05-10', '2025-05-25'), + (self.expense_accounts[0], 1000, '2025-05-10', '2025-05-25') + ], date='2025-04-11') + self.assertEqual(len(move.deferred_move_ids), 4) + + def test_case_1_deferred_entries_computations_period_across_months(self): + """ + Tests deferred expense recognition when the invoice is dated in a month + after the service period has already begun. + Corresponds to Case from Row 6 of the user's image. + + - Service Period: June 25, 2024 -> July 7, 2024 (13 days) + - Invoice Date: July 1, 2024 + - Expected: The system should correctly create a back-dated expense entry for + the 6 days in June (dated June 30) and an entry for the 7 days in July + (dated July 7). + """ + invoice_date = '2024-07-01' + invoice_line_data = [self.expense_accounts[0], 1300, '2024-06-25', '2024-07-07'] + + expected_line_values = [ + ('2024-06-30', 600, 0, 0, 600), + (invoice_date, 0, 1300, 1300, 0), + ('2024-07-07', 700, 0, 0, 700), + ] + + move = self.create_invoice( + 'in_invoice', [invoice_line_data], date=invoice_date + ) + + self.assert_invoice_lines( + move, + expected_line_values, + self.expense_accounts[0], + self.company_data['default_account_deferred_expense'] + ) + + def test_case_2_deferred_entries_computations_period_across_months(self): + """ + Tests deferred expense recognition when the invoice is dated within the + service period, which spans across two months. + Corresponds to Case from Row 7 of the user's image. + + - Service Period: June 25, 2024 -> July 7, 2024 (13 days) + - Invoice Date: June 29, 2024 + - Expected: The system should correctly prorate the expense with 6 days + in June and 7 days in July. + """ + invoice_date = '2024-06-29' + invoice_line_data = [self.expense_accounts[0], 1300, '2024-06-25', '2024-07-07'] + + expected_line_values = [ + (invoice_date, 0, 1300, 1300, 0), + ('2024-06-30', 600, 0, 0, 600), + ('2024-07-07', 700, 0, 0, 700), + ] + + move = self.create_invoice( + 'in_invoice', [invoice_line_data], date=invoice_date + ) + + self.assert_invoice_lines( + move, + expected_line_values, + self.expense_accounts[0], + self.company_data['default_account_deferred_expense'] + ) + + def test_case_3_deferred_entries_computations_period_across_months(self): + """ + Tests deferred expense recognition for a longer service period that spans + across two months, with the invoice dated within the first month. + Corresponds to Case from Row 8 in the user's image. + + - Service Period: June 25, 2024 -> July 23, 2024 (29 days) + - Invoice Date: June 29, 2024 + - Expected: The system should correctly split the expense between June + (6/29 of total) and July (23/29 of total). + """ + invoice_date = '2024-06-29' + # Using 2900 for the amount to keep the numbers clean + invoice_line_data = [self.expense_accounts[0], 2900, '2024-06-25', '2024-07-23'] + + expected_line_values = [ + (invoice_date, 0, 2900, 2900, 0), + ('2024-06-30', 600, 0, 0, 600), + ('2024-07-23', 2300, 0, 0, 2300), + ] + + move = self.create_invoice( + 'in_invoice', [invoice_line_data], date=invoice_date + ) + + self.assert_invoice_lines( + move, + expected_line_values, + self.expense_accounts[0], + self.company_data['default_account_deferred_expense'] + ) + + def test_deferred_moves_from_same_move_different_lines(self): + """ + Test that fully deferred move and deferral move from different lines are not cancelling each other + when having the same amount. + """ + move = self.create_invoice('in_invoice', [(self.expense_accounts[0], amount, '2025-10-01', '2025-11-30') for amount in (1000, 500)], date='2025-11-30') + self.assertRecordValues(move.deferred_move_ids.sorted('date'), [ + {'date': fields.Date.to_date('2025-10-31'), 'amount_total': 500}, + {'date': fields.Date.to_date('2025-10-31'), 'amount_total': 250}, + {'date': fields.Date.to_date('2025-11-30'), 'amount_total': 1000}, + {'date': fields.Date.to_date('2025-11-30'), 'amount_total': 500}, + {'date': fields.Date.to_date('2025-11-30'), 'amount_total': 500}, + {'date': fields.Date.to_date('2025-11-30'), 'amount_total': 250}, + ]) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_prediction.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_prediction.py new file mode 100644 index 0000000..84cdf5b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_prediction.py @@ -0,0 +1,203 @@ +# -*- encoding: utf-8 -*- + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo import fields, Command +from odoo.tests import Form, tagged + + +@tagged('post_install', '-at_install') +class TestBillsPrediction(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.company.predict_bill_product = True + + cls.test_partners = cls.env['res.partner'].create([{'name': 'test partner %s' % i} for i in range(7)]) + + accounts_data = [{ + 'code': 'test%s' % i, + 'name': name, + 'account_type': 'expense', + } for i, name in enumerate(( + "Test Maintenance and Repair", + "Test Purchase of services, studies and preparatory work", + "Test Various Contributions", + "Test Rental Charges", + "Test Purchase of commodity", + ))] + + cls.test_accounts = cls.env['account.account'].create(accounts_data) + + cls.frozen_today = fields.Date.today() + + def _create_bill(self, vendor, line_name, expected_account, account_to_set=None, post=True): + ''' Create a new vendor bill to test the prediction. + :param vendor: The vendor to set on the invoice. + :param line_name: The name of the invoice line that will be used to predict. + :param expected_account: The expected predicted account. + :param account_to_set: The optional account to set as a correction of the predicted account. + :return: The newly created vendor bill. + ''' + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = vendor + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + # Set the default account to avoid "account_id is a required field" in case of bad configuration. + invoice_line_form.account_id = self.company_data['default_journal_purchase'].default_account_id + + invoice_line_form.quantity = 1.0 + invoice_line_form.price_unit = 42.0 + invoice_line_form.name = line_name + invoice = invoice_form.save() + invoice_line = invoice.invoice_line_ids + + self.assertEqual( + invoice_line.account_id, + expected_account, + "Account '%s' should have been predicted instead of '%s'" % ( + expected_account.display_name, + invoice_line.account_id.display_name, + ), + ) + + if account_to_set: + invoice_line.account_id = account_to_set + + if post: + invoice.action_post() + return invoice + + def test_account_prediction_flow(self): + default_account = self.company_data['default_journal_purchase'].default_account_id + self._create_bill(self.test_partners[0], "Maintenance and repair", self.test_accounts[0]) + self._create_bill(self.test_partners[5], "Subsidies obtained", default_account, account_to_set=self.test_accounts[1]) + self._create_bill(self.test_partners[6], "Prepare subsidies file", default_account, account_to_set=self.test_accounts[1]) + self._create_bill(self.test_partners[6], "Prepare subsidies file", self.test_accounts[1]) + self._create_bill(self.test_partners[1], "Contributions January", self.test_accounts[2]) + self._create_bill(self.test_partners[2], "Coca-cola", default_account, account_to_set=self.test_accounts[4]) + self._create_bill(self.test_partners[1], "Contribution February", self.test_accounts[2]) + self._create_bill(self.test_partners[3], "Electricity Bruxelles", default_account, account_to_set=self.test_accounts[3]) + self._create_bill(self.test_partners[3], "Electricity Grand-Rosière", self.test_accounts[3]) + self._create_bill(self.test_partners[2], "Purchase of coca-cola", self.test_accounts[4]) + self._create_bill(self.test_partners[4], "Crate of coca-cola", default_account, account_to_set=self.test_accounts[4]) + self._create_bill(self.test_partners[4], "Crate of coca-cola", self.test_accounts[4]) + self._create_bill(self.test_partners[1], "March", self.test_accounts[2]) + + def test_account_prediction_from_label_expected_behavior(self): + """Prevent the prediction from being annoying.""" + default_account = self.company_data['default_journal_purchase'].default_account_id + payable_account = self.company_data['default_account_payable'].copy() + payable_account.write({'name': f'Account payable - {self.test_accounts[0].name}'}) + + # There is no prior result, we take the default account, but we don't post + self._create_bill(self.test_partners[0], self.test_partners[0].name, default_account, post=False) + + # There is no prior result, we take the default account + self._create_bill(self.test_partners[0], "Drinks", default_account, account_to_set=self.test_accounts[0]) + + # There is only one prior account for the partner, we take that one + self._create_bill(self.test_partners[0], "Desert", self.test_accounts[0], account_to_set=self.test_accounts[1]) + + # We find something close enough, take that one + self._create_bill(self.test_partners[0], "Drinks too", self.test_accounts[0]) + + # There is no clear preference for any account (both previous accounts have the same rank) + # don't make any prediction and let the default behavior fill the account + invoice = self._create_bill(self.test_partners[0], "Main course", default_account) + invoice.button_draft() + + with Form(invoice) as move_form: + with move_form.invoice_line_ids.edit(0) as line_form: + # There isn't any account clearly better than the manually set one, we keep the current one + line_form.account_id = self.test_accounts[2] + line_form.name = "Apple" + self.assertEqual(line_form.account_id, self.test_accounts[2]) + + # There is an account that looks clearly better, use it + line_form.name = "Second desert" + self.assertEqual(line_form.account_id, self.test_accounts[1]) + + def test_account_prediction_with_product(self): + product = self.env['product.product'].create({ + 'name': 'product_a', + 'lst_price': 1000.0, + 'standard_price': 800.0, + 'property_account_income_id': self.company_data['default_account_revenue'].id, + 'property_account_expense_id': self.company_data['default_account_expense'].id, + }) + + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = self.test_partners[0] + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.product_id = product + invoice_line_form.name = "Maintenance and repair" + invoice = invoice_form.save() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'name': "Maintenance and repair", + 'product_id': product.id, + 'account_id': self.company_data['default_account_expense'].id, + }]) + + def test_product_prediction_price_subtotal_computation(self): + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = self.test_partners[0] + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.product_id = self.product_a + invoice = invoice_form.save() + invoice.action_post() + + self.product_a.supplier_taxes_id = [Command.set(self.tax_purchase_b.ids)] + + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = self.test_partners[0] + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = 'product_a' + invoice = invoice_form.save() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'quantity': 1.0, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'balance': 800.0, + 'tax_ids': self.tax_purchase_b.ids, + }]) + + # In case a unit price is already set we do not update the unit price + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = self.test_partners[0] + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.price_unit = 42.0 + invoice_line_form.name = 'product_a' + invoice = invoice_form.save() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'quantity': 1.0, + 'price_unit': 42.0, + 'price_subtotal': 42.0, + 'balance': 42.0, + 'tax_ids': self.tax_purchase_b.ids, + }]) + + # In case a tax is already set we do not update the taxes + invoice_form = Form(self.env['account.move'].with_context(default_move_type='in_invoice')) + invoice_form.partner_id = self.test_partners[0] + invoice_form.invoice_date = self.frozen_today + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.tax_ids = self.tax_purchase_a + invoice_line_form.name = 'product_a' + invoice = invoice_form.save() + + self.assertRecordValues(invoice.invoice_line_ids, [{ + 'quantity': 1.0, + 'price_unit': 800.0, + 'price_subtotal': 800.0, + 'balance': 800.0, + 'tax_ids': self.tax_purchase_a.ids, + }]) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_reconciliation_matching_rules.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_reconciliation_matching_rules.py new file mode 100644 index 0000000..4fb5a0b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_reconciliation_matching_rules.py @@ -0,0 +1,1351 @@ +# -*- coding: utf-8 -*- +from freezegun import freeze_time +from contextlib import closing + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import Form, tagged +from odoo import Command + + +@tagged('post_install', '-at_install') +class TestReconciliationMatchingRules(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + ################# + # Company setup # + ################# + cls.other_currency = cls.setup_other_currency('EUR') + cls.other_currency_2 = cls.setup_other_currency('CAD', rates=[('2016-01-01', 10.0), ('2017-01-01', 20.0)]) + + cls.account_pay = cls.company_data['default_account_payable'] + cls.current_assets_account = cls.env['account.account'].search([ + ('account_type', '=', 'asset_current'), + ('company_ids', '=', cls.company.id)], limit=1) + + cls.bank_journal = cls.env['account.journal'].search([('type', '=', 'bank'), ('company_id', '=', cls.company.id)], limit=1) + cls.cash_journal = cls.env['account.journal'].search([('type', '=', 'cash'), ('company_id', '=', cls.company.id)], limit=1) + + cls.tax21 = cls.env['account.tax'].create({ + 'name': '21%', + 'type_tax_use': 'purchase', + 'amount': 21, + }) + + cls.tax12 = cls.env['account.tax'].create({ + 'name': '12%', + 'type_tax_use': 'purchase', + 'amount': 12, + }) + + cls.partner_1 = cls.env['res.partner'].create({'name': 'partner_1', 'company_id': cls.company.id}) + cls.partner_2 = cls.env['res.partner'].create({'name': 'partner_2', 'company_id': cls.company.id}) + cls.partner_3 = cls.env['res.partner'].create({'name': 'partner_3', 'company_id': cls.company.id}) + + ############### + # Rules setup # + ############### + cls.rule_1 = cls.env['account.reconcile.model'].create({ + 'name': 'Invoices Matching Rule', + 'sequence': '1', + 'rule_type': 'invoice_matching', + 'auto_reconcile': False, + 'match_nature': 'both', + 'match_same_currency': True, + 'allow_payment_tolerance': True, + 'payment_tolerance_type': 'percentage', + 'payment_tolerance_param': 0.0, + 'match_partner': True, + 'match_partner_ids': [(6, 0, (cls.partner_1 + cls.partner_2 + cls.partner_3).ids)], + 'company_id': cls.company.id, + 'line_ids': [(0, 0, {'account_id': cls.current_assets_account.id})], + }) + cls.rule_2 = cls.env['account.reconcile.model'].create({ + 'name': 'write-off model', + 'rule_type': 'writeoff_suggestion', + 'match_partner': True, + 'match_partner_ids': [], + 'line_ids': [(0, 0, {'account_id': cls.current_assets_account.id})], + }) + + ################## + # Invoices setup # + ################## + cls.invoice_line_1 = cls._create_invoice_line(100, cls.partner_1, 'out_invoice') + cls.invoice_line_2 = cls._create_invoice_line(200, cls.partner_1, 'out_invoice') + cls.invoice_line_3 = cls._create_invoice_line(300, cls.partner_1, 'in_refund', name="RBILL/2019/09/0013") + cls.invoice_line_4 = cls._create_invoice_line(1000, cls.partner_2, 'in_invoice') + cls.invoice_line_5 = cls._create_invoice_line(600, cls.partner_3, 'out_invoice') + cls.invoice_line_6 = cls._create_invoice_line(600, cls.partner_3, 'out_invoice', ref="RF12 3456") + cls.invoice_line_7 = cls._create_invoice_line(200, cls.partner_3, 'out_invoice') + + #################### + # Statements setup # + #################### + # TODO : account_number, partner_name, transaction_type, narration + invoice_number = cls.invoice_line_1.move_id.name + cls.bank_line_1, cls.bank_line_2,\ + cls.bank_line_3, cls.bank_line_4,\ + cls.bank_line_5, cls.cash_line_1 = cls.env['account.bank.statement.line'].create([ + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'invoice %s-%s' % tuple(invoice_number.split('/')[1:]), + 'partner_id': cls.partner_1.id, + 'amount': 100, + 'sequence': 1, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'xxxxx', + 'partner_id': cls.partner_1.id, + 'amount': 600, + 'sequence': 2, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'nawak', + 'narration': 'Communication: RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 1, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }, + { + 'journal_id': cls.bank_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'baaaaah', + 'ref': 'RF12 3456', + 'partner_id': cls.partner_3.id, + 'amount': 600, + 'sequence': 2, + }, + { + 'journal_id': cls.cash_journal.id, + 'date': '2020-01-01', + 'payment_ref': 'yyyyy', + 'partner_id': cls.partner_2.id, + 'amount': -1000, + 'sequence': 1, + }, + ]) + + @classmethod + def _create_invoice_line(cls, amount, partner, move_type, currency=None, ref=None, name=None, inv_date='2019-09-01'): + ''' Create an invoice on the fly.''' + invoice_form = Form(cls.env['account.move'].with_context(default_move_type=move_type, default_invoice_date=inv_date, default_date=inv_date)) + invoice_form.partner_id = partner + if currency: + invoice_form.currency_id = currency + if ref: + invoice_form.ref = ref + if name: + invoice_form.name = name + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.name = 'xxxx' + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = amount + invoice_line_form.tax_ids.clear() + invoice = invoice_form.save() + invoice.action_post() + lines = invoice.line_ids + return lines.filtered(lambda l: l.account_id.account_type in ('asset_receivable', 'liability_payable')) + + @classmethod + def _create_st_line(cls, amount=1000.0, date='2019-01-01', payment_ref='turlututu', **kwargs): + st_line = cls.env['account.bank.statement.line'].create({ + 'journal_id': kwargs.get('journal_id', cls.bank_journal.id), + 'amount': amount, + 'date': date, + 'payment_ref': payment_ref, + 'partner_id': cls.partner_a.id, + **kwargs, + }) + return st_line + + @classmethod + def _create_reconcile_model(cls, **kwargs): + return cls.env['account.reconcile.model'].create({ + 'name': "test", + 'rule_type': 'invoice_matching', + 'allow_payment_tolerance': True, + 'payment_tolerance_type': 'percentage', + 'payment_tolerance_param': 0.0, + **kwargs, + 'line_ids': [ + Command.create({ + 'account_id': cls.company_data['default_account_revenue'].id, + 'amount_type': 'percentage', + 'label': f"test {i}", + **line_vals, + }) + for i, line_vals in enumerate(kwargs.get('line_ids', [])) + ], + 'partner_mapping_line_ids': [ + Command.create(line_vals) + for i, line_vals in enumerate(kwargs.get('partner_mapping_line_ids', [])) + ], + }) + + @freeze_time('2020-01-01') + def _check_statement_matching(self, rules, expected_values_list): + for statement_line, expected_values in expected_values_list.items(): + res = rules._apply_rules(statement_line, statement_line._retrieve_partner()) + self.assertDictEqual(res, expected_values) + + def test_matching_fields(self): + # Check without restriction. + self.rule_1.match_text_location_label = False + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + @freeze_time('2020-01-01') + def test_matching_fields_match_text_location(self): + st_line = self._create_st_line(payment_ref="1111", ref="2222 3333", narration="4444 5555 6666") + + inv1 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', ref="bernard 1111 gagnant") + inv2 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', ref="2222 turlututu 3333") + inv3 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', ref="4444 tsoin 5555 tsoin 6666") + + rule = self._create_reconcile_model( + allow_payment_tolerance=False, + match_text_location_label=True, + match_text_location_reference=False, + match_text_location_note=False, + ) + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv1, 'model': rule}, + ) + + rule.match_text_location_reference = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv2, 'model': rule}, + ) + + rule.match_text_location_note = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv3, 'model': rule}, + ) + + def test_matching_fields_match_text_location_no_partner(self): + self.bank_line_2.unlink() # One line is enough for this test + self.bank_line_1.partner_id = None + + self.partner_1.name = "Bernard Gagnant" + + self.rule_1.write({ + 'match_partner': False, + 'match_partner_ids': [(5, 0, 0)], + 'line_ids': [(5, 0, 0)], + }) + + st_line_initial_vals = {'ref': None, 'payment_ref': 'nothing', 'narration': None} + recmod_initial_vals = {'match_text_location_label': False, 'match_text_location_note': False, 'match_text_location_reference': False} + + rec_mod_options_to_fields = { + 'match_text_location_label': 'payment_ref', + 'match_text_location_note': 'narration', + 'match_text_location_reference': 'ref', + } + + for rec_mod_field, st_line_field in rec_mod_options_to_fields.items(): + self.rule_1.write({**recmod_initial_vals, rec_mod_field: True}) + # Fully reinitialize the statement line + self.bank_line_1.write(st_line_initial_vals) + + # Test matching with the invoice ref + self.bank_line_1.write({st_line_field: self.invoice_line_1.move_id.payment_reference}) + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + }) + + def test_matching_fields_match_journal_ids(self): + self.rule_1.match_text_location_label = False + self.rule_1.match_journal_ids |= self.cash_line_1.journal_id + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + def test_matching_fields_match_nature(self): + self.rule_1.match_text_location_label = False + self.rule_1.match_nature = 'amount_received' + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: { + 'amls': self.invoice_line_2 + self.invoice_line_3 + self.invoice_line_1, + 'model': self.rule_1, + }, + self.cash_line_1: {}, + }) + self.rule_1.match_nature = 'amount_paid' + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + def test_matching_fields_match_amount(self): + self.rule_1.match_text_location_label = False + self.rule_1.match_amount = 'lower' + self.rule_1.match_amount_max = 150 + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {}, + }) + self.rule_1.match_amount = 'greater' + self.rule_1.match_amount_min = 200 + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + self.rule_1.match_amount = 'between' + self.rule_1.match_amount_min = 200 + self.rule_1.match_amount_max = 800 + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {}, + }) + + def test_matching_fields_match_label(self): + self.rule_1.match_text_location_label = False + self.rule_1.match_label = 'contains' + self.rule_1.match_label_param = 'yyyyy' + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + self.rule_1.match_label = 'not_contains' + self.rule_1.match_label_param = 'xxxxx' + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + self.rule_1.match_label = 'match_regex' + self.rule_1.match_label_param = 'xxxxx|yyyyy' + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + @freeze_time('2019-01-01') + def test_zero_payment_tolerance(self): + rule = self._create_reconcile_model(line_ids=[{}]) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # Exact matching. + st_line = self._create_st_line(amount=bsl_sign * 1000.0, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + # No matching because there is no tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + st_line = self._create_st_line(amount=bsl_sign * 1010.0, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + @freeze_time('2019-01-01') + def test_zero_payment_tolerance_auto_reconcile(self): + rule = self._create_reconcile_model( + auto_reconcile=True, + match_text_location_label = False, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # No matching because there is no tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456') + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + st_line = self._create_st_line(amount=bsl_sign * 1010.0, payment_ref='123456') + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + @freeze_time('2019-01-01') + def test_not_enough_payment_tolerance(self): + rule = self._create_reconcile_model( + payment_tolerance_param=0.5, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + with self.subTest(inv_type=inv_type, bsl_sign=bsl_sign): + + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # No matching because there is no enough tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {}}, + ) + + # The payment amount is higher than the invoice one. + # However, since the invoice amount is lower than the payment amount, + # the tolerance is not checked and the invoice line is matched. + st_line = self._create_st_line(amount=bsl_sign * 1010.0, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + @freeze_time('2019-01-01') + def test_enough_payment_tolerance(self): + rule = self._create_reconcile_model( + payment_tolerance_param=2.0, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(1210.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line(amount=bsl_sign * 1185.80, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule, 'status': 'write_off'}}, + ) + + # The payment amount is higher than the invoice one. + # However, since the invoice amount is lower than the payment amount, + # the tolerance is not checked and the invoice line is matched. + st_line = self._create_st_line(amount=bsl_sign * 1234.20, payment_ref=invl.name) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + @freeze_time('2019-01-01') + def test_enough_payment_tolerance_auto_reconcile_not_full(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + auto_reconcile=True, + match_text_location_label = False, + line_ids=[{'amount_type': 'percentage_st_line', 'amount_string': '200.0'}], + ) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456') + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule, 'status': 'write_off'}}, + ) + + @freeze_time('2019-01-01') + def test_allow_payment_tolerance_lower_amount(self): + rule = self._create_reconcile_model(line_ids=[{'amount_type': 'percentage_st_line'}]) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(990.0, self.partner_a, inv_type, inv_date='2019-01-01') + st_line = self._create_st_line(amount=bsl_sign * 1000, payment_ref=invl.name) + + # Partial reconciliation. + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) + + @freeze_time('2019-01-01') + def test_enough_payment_tolerance_auto_reconcile(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + auto_reconcile=True, + match_text_location_label = False, + line_ids=[{}], + ) + + for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + + # Enough tolerance to match the invoice line. + st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456') + self._check_statement_matching( + rule, + {st_line: { + 'amls': invl, + 'model': rule, + 'status': 'write_off', + }}, + ) + + @freeze_time('2019-01-01') + def test_percentage_st_line_auto_reconcile(self): + rule = self._create_reconcile_model( + payment_tolerance_param=1.0, + rule_type='writeoff_suggestion', + auto_reconcile=True, + line_ids=[ + {'amount_type': 'percentage_st_line', 'amount_string': '100.0', 'label': 'A'}, + {'amount_type': 'percentage_st_line', 'amount_string': '-100.0', 'label': 'B'}, + {'amount_type': 'percentage_st_line', 'amount_string': '100.0', 'label': 'C'}, + ], + ) + + for bsl_sign in (1, -1): + st_line = self._create_st_line(amount=bsl_sign * 1000.0) + self._check_statement_matching( + rule, + {st_line: { + 'model': rule, + 'status': 'write_off', + 'auto_reconcile': True, + }}, + ) + + def test_matching_fields_match_partner_category_ids(self): + self.rule_1.match_text_location_label = False + test_category = self.env['res.partner.category'].create({'name': 'Consulting Services'}) + test_category2 = self.env['res.partner.category'].create({'name': 'Consulting Services2'}) + + self.partner_2.category_id = test_category + test_category2 + self.rule_1.match_partner_category_ids |= test_category + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + self.rule_1.match_partner_category_ids = False + + def test_mixin_rules(self): + ''' Test usage of rules together.''' + self.rule_1.match_text_location_label = False + # rule_1 is used before rule_2. + self.rule_1.sequence = 1 + self.rule_2.sequence = 2 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + }, + self.bank_line_2: { + 'amls': self.invoice_line_2 + self.invoice_line_3 + self.invoice_line_1, + 'model': self.rule_1, + }, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + # rule_2 is used before rule_1. + self.rule_1.sequence = 2 + self.rule_2.sequence = 1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.bank_line_2: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.cash_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + }) + + # rule_2 is used before rule_1 but only on partner_1. + self.rule_2.match_partner_ids |= self.partner_1 + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.bank_line_2: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, + }) + + def test_auto_reconcile(self): + ''' Test auto reconciliation.''' + self.bank_line_1.amount += 5 + + self.rule_1.sequence = 2 + self.rule_1.auto_reconcile = True + self.rule_1.payment_tolerance_param = 10.0 + self.rule_1.match_text_location_label = False + self.rule_2.sequence = 1 + self.rule_2.match_partner_ids |= self.partner_2 + self.rule_2.auto_reconcile = True + + self._check_statement_matching(self.rule_1 + self.rule_2, { + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + 'auto_reconcile': True, + }, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, + 'model': self.rule_1, + }, + self.cash_line_1: { + 'model': self.rule_2, + 'status': 'write_off', + 'auto_reconcile': True, + }, + }) + + def test_auto_reconcile_ref_with_spaces(self): + space_in_ref_invoice_line = self._create_invoice_line(600, self.partner_3, 'out_invoice', ref="This ref has spaces") + space_in_ref_bank_line = self._create_st_line( + amount=600.0, + date='2020-01-01', + payment_ref="This ref has spaces", + partner_id= self.partner_3.id, + ) + self.rule_1.auto_reconcile = True + + self._check_statement_matching(self.rule_1, { + space_in_ref_bank_line: { + 'model': self.rule_1, + 'auto_reconcile': True, + 'amls': space_in_ref_invoice_line + } + }) + + def test_larger_invoice_auto_reconcile(self): + ''' Test auto reconciliation with an invoice with larger amount than the + statement line's, for rules without write-offs.''' + self.bank_line_1.amount = 40 + self.invoice_line_1.move_id.payment_reference = self.bank_line_1.payment_ref + + self.rule_1.sequence = 2 + self.rule_1.allow_payment_tolerance = False + self.rule_1.auto_reconcile = True + self.rule_1.line_ids = [(5, 0, 0)] + self.rule_1.match_text_location_label = False + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + 'auto_reconcile': True, + }, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, + 'model': self.rule_1, + }, + }) + + def test_auto_reconcile_with_tax(self): + ''' Test auto reconciliation with a tax amount included in the bank statement line''' + self.rule_1.write({ + 'auto_reconcile': True, + 'rule_type': 'writeoff_suggestion', + 'line_ids': [(1, self.rule_1.line_ids.id, { + 'amount': 50, + 'force_tax_included': True, + 'tax_ids': [(6, 0, self.tax21.ids)], + }), (0, 0, { + 'amount': 100, + 'force_tax_included': False, + 'tax_ids': [(6, 0, self.tax12.ids)], + 'account_id': self.current_assets_account.id, + })] + }) + + self.bank_line_1.amount = -121 + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + self.bank_line_2: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + }) + + def test_auto_reconcile_with_tax_fpos(self): + """ Test the fiscal positions are applied by reconcile models when using taxes. + """ + self.rule_1.write({ + 'auto_reconcile': True, + 'rule_type': 'writeoff_suggestion', + 'line_ids': [(1, self.rule_1.line_ids.id, { + 'amount': 100, + 'force_tax_included': True, + 'tax_ids': [(6, 0, self.tax21.ids)], + })] + }) + + self.partner_1.country_id = self.env.ref('base.lu') + belgium = self.env.ref('base.be') + self.partner_2.country_id = belgium + + self.bank_line_2.partner_id = self.partner_2 + + self.bank_line_1.amount = -121 + self.bank_line_2.amount = -112 + + self.env['account.fiscal.position'].create({ + 'name': "Test", + 'country_id': belgium.id, + 'auto_apply': True, + 'tax_ids': [ + Command.create({ + 'tax_src_id': self.tax21.id, + 'tax_dest_id': self.tax12.id, + }), + ] + }) + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + self.bank_line_2: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + }) + + def test_reverted_move_matching(self): + partner = self.partner_1 + AccountMove = self.env['account.move'] + move = AccountMove.create({ + 'journal_id': self.bank_journal.id, + 'line_ids': [ + (0, 0, { + 'account_id': self.account_pay.id, + 'partner_id': partner.id, + 'name': 'One of these days', + 'debit': 10, + }), + (0, 0, { + 'account_id': self.inbound_payment_method_line.payment_account_id.id, + 'partner_id': partner.id, + 'name': 'I\'m gonna cut you into little pieces', + 'credit': 10, + }) + ], + }) + + payment_bnk_line = move.line_ids.filtered(lambda l: l.account_id == self.inbound_payment_method_line.payment_account_id) + + move.action_post() + move_reversed = move._reverse_moves() + self.assertTrue(move_reversed.exists()) + + self.rule_1.match_text_location_label = False + self.bank_line_1.write({ + 'payment_ref': '8', + 'partner_id': partner.id, + 'amount': -10, + }) + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': payment_bnk_line, 'model': self.rule_1}, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, + 'model': self.rule_1, + }, + }) + + def test_match_different_currencies(self): + partner = self.env['res.partner'].create({'name': 'Bernard Gagnant'}) + self.rule_1.write({'match_partner_ids': [(6, 0, partner.ids)], 'match_same_currency': False}) + + currency_inv = self.env.ref('base.EUR') + currency_inv.active = True + currency_statement = self.env.ref('base.JPY') + + currency_statement.active = True + + invoice_line = self._create_invoice_line(100, partner, 'out_invoice', currency=currency_inv) + + self.bank_line_1.write({'partner_id': partner.id, 'foreign_currency_id': currency_statement.id, 'amount_currency': 100, 'payment_ref': invoice_line.name}) + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': invoice_line, 'model': self.rule_1}, + self.bank_line_2: {}, + }) + + def test_invoice_matching_rule_no_partner(self): + """ Tests that a statement line without any partner can be matched to the + right invoice if they have the same payment reference. + """ + self.invoice_line_1.move_id.write({'payment_reference': 'Tournicoti66'}) + self.rule_1.allow_payment_tolerance = False + + self.bank_line_1.write({ + 'payment_ref': 'Tournicoti66', + 'partner_id': None, + 'amount': 95, + }) + + self.rule_1.write({ + 'line_ids': [(5, 0, 0)], + 'match_partner': False, + 'match_label': 'contains', + 'match_label_param': 'Tournicoti', # So that we only match what we want to test + }) + + # TODO: 'invoice_line_1' has no reason to match 'bank_line_1' here... to check + # self._check_statement_matching(self.rule_1, { + # self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + # self.bank_line_2: {'amls': []}, + # }, self.bank_st) + + def test_inv_matching_rule_auto_rec_no_partner_with_writeoff(self): + self.invoice_line_1.move_id.ref = "doudlidou3555" + + self.bank_line_1.write({ + 'payment_ref': 'doudlidou3555', + 'partner_id': None, + 'amount': 95, + }) + + self.rule_1.write({ + 'match_partner': False, + 'match_label': 'contains', + 'match_label_param': 'doudlidou', # So that we only match what we want to test + 'payment_tolerance_param': 10.0, + 'auto_reconcile': True, + }) + + # Check bank reconciliation + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + 'status': 'write_off', + 'auto_reconcile': True, + }, + self.bank_line_2: {}, + }) + + def test_partner_mapping_rule(self): + st_line = self._create_st_line(partner_id=None, payment_ref=None) + + rule = self._create_reconcile_model( + partner_mapping_line_ids=[{ + 'partner_id': self.partner_1.id, + 'payment_ref_regex': 'toto.*', + }], + ) + + # No match because the reference is not matching the regex. + self.assertEqual(st_line._retrieve_partner(), self.env['res.partner']) + + st_line.payment_ref = "toto42" + + # Matching using the regex on payment_ref. + self.assertEqual(st_line._retrieve_partner(), self.partner_1) + + rule.partner_mapping_line_ids.narration_regex = ".*coincoin" + + # No match because the narration is not matching the regex. + self.assertEqual(st_line._retrieve_partner(), self.env['res.partner']) + + st_line.narration = "42coincoin" + + # Matching is back thanks to "coincoin". + self.assertEqual(st_line._retrieve_partner(), self.partner_1) + + # More complex matching to match something from bank sync data. + # Note: the indentation is done with multiple \n to mimic the bank sync behavior. Keep them for this test! + rule.partner_mapping_line_ids.narration_regex = ".*coincoin.*" + st_line.narration = """ + { + "informations": "coincoin turlututu tsoin tsoin", + } + """ + + # Same check with json data into the narration field. + self.assertEqual(st_line._retrieve_partner(), self.partner_1) + + def test_move_name_caba_tax_account(self): + self.env.company.tax_exigibility = True + tax_account = self.company_data['default_account_tax_sale'] + tax_account.reconcile = True + self.rule_1.write({ + 'match_partner': False, + 'payment_tolerance_param': 20.0, + 'auto_reconcile': True, + }) + + caba_tax = self.env['account.tax'].create({ + 'name': "CABA", + 'amount_type': 'percent', + 'amount': 20.0, + 'tax_exigibility': 'on_payment', + 'cash_basis_transition_account_id': self.safe_copy(tax_account).id, + 'invoice_repartition_line_ids': [ + Command.create({ + 'repartition_type': 'base', + }), + Command.create({ + 'repartition_type': 'tax', + 'account_id': tax_account.id, + }), + ], + }) + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2021-07-01', + 'invoice_line_ids': [ + Command.create({ + 'name': "test", + 'price_unit': 100, + 'tax_ids': [Command.set(caba_tax.ids)], + }), + ] + }) + invoice.action_post() + self.env['account.bank.statement.line'].create({ + 'amount': 100.0, + 'date': '2019-01-01', + 'payment_ref': invoice.name, + 'journal_id': self.company_data['default_journal_bank'].id, + }) + with freeze_time('2019-01-01'): + self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines() + caba_move = invoice.tax_cash_basis_created_move_ids + self.assertEqual(caba_move.line_ids[0].move_name, caba_move.name) + + def test_match_multi_currencies(self): + ''' Ensure the matching of candidates is made using the right statement line currency. + + In this test, the value of the statement line is 100 USD = 300 GOL = 900 DAR and we want to match two journal + items of: + - 100 USD = 200 GOL (= 600 DAR from the statement line point of view) + - 14 USD = 280 DAR + + Both journal items should be suggested to the user because they represents 98% of the statement line amount + (DAR). + ''' + partner = self.env['res.partner'].create({'name': 'Bernard Perdant'}) + + journal = self.env['account.journal'].create({ + 'name': 'test_match_multi_currencies', + 'code': 'xxxx', + 'type': 'bank', + 'currency_id': self.other_currency.id, + }) + + matching_rule = self.env['account.reconcile.model'].create({ + 'name': 'test_match_multi_currencies', + 'rule_type': 'invoice_matching', + 'match_partner': True, + 'match_partner_ids': [(6, 0, partner.ids)], + 'allow_payment_tolerance': True, + 'payment_tolerance_type': 'percentage', + 'payment_tolerance_param': 5.0, + 'match_same_currency': False, + 'company_id': self.company_data['company'].id, + 'past_months_limit': False, + 'match_text_location_label': False, + }) + + statement_line = self.env['account.bank.statement.line'].create({ + 'journal_id': journal.id, + 'date': '2016-01-01', + 'payment_ref': 'line', + 'partner_id': partner.id, + 'foreign_currency_id': self.other_currency_2.id, + 'amount': 300.0, # Rate is 3 GOL = 1 USD in 2016. + 'amount_currency': 900.0, # Rate is 10 DAR = 1 USD in 2016 but the rate used by the bank is 9:1. + }) + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'journal_id': self.company_data['default_journal_misc'].id, + 'line_ids': [ + # Rate is 2 GOL = 1 USD in 2017. + # The statement line will consider this line equivalent to 600 DAR. + (0, 0, { + 'account_id': self.company_data['default_account_receivable'].id, + 'partner_id': partner.id, + 'currency_id': self.other_currency.id, + 'debit': 100.0, + 'credit': 0.0, + 'amount_currency': 200.0, + }), + # Rate is 20 GOL = 1 USD in 2017. + (0, 0, { + 'account_id': self.company_data['default_account_receivable'].id, + 'partner_id': partner.id, + 'currency_id': self.other_currency_2.id, + 'debit': 14.0, + 'credit': 0.0, + 'amount_currency': 280.0, + }), + # Line to balance the journal entry: + (0, 0, { + 'account_id': self.company_data['default_account_revenue'].id, + 'debit': 0.0, + 'credit': 114.0, + }), + ], + }) + move.action_post() + + move_line_1 = move.line_ids.filtered(lambda line: line.debit == 100.0) + move_line_2 = move.line_ids.filtered(lambda line: line.debit == 14.0) + + self._check_statement_matching(matching_rule, { + statement_line: {'amls': move_line_1 + move_line_2, 'model': matching_rule} + }) + + @freeze_time('2020-01-01') + def test_matching_with_write_off_foreign_currency(self): + journal_foreign_curr = self.company_data['default_journal_bank'].copy() + journal_foreign_curr.currency_id = self.other_currency + + reco_model = self._create_reconcile_model( + auto_reconcile=True, + rule_type='writeoff_suggestion', + line_ids=[{ + 'amount_type': 'percentage', + 'amount': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + }], + ) + + st_line = self._create_st_line(amount=100.0, payment_ref='123456', journal_id=journal_foreign_curr.id) + self._check_statement_matching(reco_model, { + st_line: { + 'model': reco_model, + 'status': 'write_off', + 'auto_reconcile': True, + }, + }) + + def test_payment_similar_communications(self): + def create_payment_line(amount, memo, partner): + payment = self.env['account.payment'].create({ + 'amount': amount, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': partner.id, + 'memo': memo, + 'destination_account_id': self.company_data['default_account_receivable'].id, + }) + payment.action_post() + + return payment.move_id.line_ids.filtered(lambda x: x.account_id.account_type not in {'asset_receivable', 'liability_payable'}) + + payment_partner = self.env['res.partner'].create({ + 'name': "Bernard Gagnant", + }) + + self.rule_1.match_partner_ids = [(6, 0, payment_partner.ids)] + + pmt_line_1 = create_payment_line(500, 'a1b2c3', payment_partner) + pmt_line_2 = create_payment_line(500, 'a1b2c3', payment_partner) + create_payment_line(500, 'd1e2f3', payment_partner) + + self.bank_line_1.write({ + 'amount': 1000, + 'payment_ref': 'a1b2c3', + 'partner_id': payment_partner.id, + }) + self.bank_line_2.unlink() + self.rule_1.allow_payment_tolerance = False + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {'amls': pmt_line_1 + pmt_line_2, 'model': self.rule_1, 'status': 'write_off'}, + }) + + def test_no_amount_check_keep_first(self): + """ In case the reconciliation model doesn't check the total amount of the candidates, + we still don't want to suggest more than are necessary to match the statement. + For example, if a statement line amounts to 250 and is to be matched with three invoices + of 100, 200 and 300 (retrieved in this order), only 100 and 200 should be proposed. + """ + self.rule_1.allow_payment_tolerance = False + self.rule_1.match_text_location_label = False + self.bank_line_2.amount = 250 + self.bank_line_1.partner_id = None + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2, + 'model': self.rule_1, + 'status': 'write_off', + }, + }) + + def test_no_amount_check_exact_match(self): + """ If a reconciliation model finds enough candidates for a full reconciliation, + it should still check the following candidates, in case one of them exactly + matches the amount of the statement line. If such a candidate exist, all the + other ones are disregarded. + """ + self.rule_1.allow_payment_tolerance = False + self.rule_1.match_text_location_label = False + self.bank_line_2.amount = 300 + self.bank_line_1.partner_id = None + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: {}, + self.bank_line_2: { + 'amls': self.invoice_line_3, + 'model': self.rule_1, + 'status': 'write_off', + }, + }) + + @freeze_time('2019-01-01') + def test_invoice_matching_using_match_text_location(self): + rule = self._create_reconcile_model( + match_partner=False, + allow_payment_tolerance=False, + match_text_location_label=False, + match_text_location_reference=False, + match_text_location_note=False, + ) + st_line = self._create_st_line(amount=1000, partner_id=False) + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [Command.create({ + 'product_id': self.product_a.id, + 'price_unit': 100, + })], + }) + invoice.action_post() + term_line = invoice.line_ids.filtered(lambda x: x.display_type == 'payment_term') + + # No match at all. + self.assertDictEqual( + rule._apply_rules(st_line, None), + {}, + ) + + with closing(self.cr.savepoint()): + term_line.name = "1234" + st_line.payment_ref = "1234" + + # Matching if no checkbox checked. + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_line, 'model': rule}, + ) + + # No matching if other checkbox is checked. + rule.match_text_location_note = True + self.assertDictEqual( + rule._apply_rules(st_line, None), + {}, + ) + + with closing(self.cr.savepoint()): + # Test Matching on exact_token. + term_line.name = "PAY-123" + st_line.payment_ref = "PAY-123" + + # Matching if no checkbox checked. + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_line, 'model': rule}, + ) + + with self.subTest(rule_field='match_text_location_label', st_line_field='payment_ref'): + with closing(self.cr.savepoint()): + term_line.name = '' + st_line.payment_ref = '/?' + + # No exact matching when the term line name is an empty string + self.assertDictEqual( + rule._apply_rules(st_line, None), + {}, + ) + + for rule_field, st_line_field in ( + ('match_text_location_label', 'payment_ref'), + ('match_text_location_reference', 'ref'), + ('match_text_location_note', 'narration'), + ): + with self.subTest(rule_field=rule_field, st_line_field=st_line_field): + + with closing(self.cr.savepoint()): + rule[rule_field] = True + st_line[st_line_field] = "123456" + term_line.name = "123456" + + # Matching if the corresponding flag is enabled. + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_line, 'model': rule}, + ) + + # It works also if the statement line contains the word. + st_line[st_line_field] = "payment for 123456 urgent!" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_line, 'model': rule}, + ) + + # Not if the invoice has nothing in common even if numerical. + term_line.name = "78910" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {}, + ) + + # Exact matching on a single word. + st_line[st_line_field] = "TURLUTUTU21" + term_line.name = "TURLUTUTU21" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_line, 'model': rule}, + ) + + # No matching if not enough numerical values. + st_line[st_line_field] = "12" + term_line.name = "selling 3 apples, 2 tomatoes and 12kg of potatoes" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {}, + ) + + invoice2 = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': '2019-01-01', + 'invoice_line_ids': [Command.create({ + 'product_id': self.product_a.id, + 'price_unit': 100, + })], + }) + invoice2.action_post() + term_lines = (invoice + invoice2).line_ids.filtered(lambda x: x.display_type == 'payment_term') + + # Matching multiple invoices. + rule.match_text_location_label = True + st_line.payment_ref = "paying invoices 1234 & 5678" + term_lines[0].name = "INV/1234" + term_lines[1].name = "INV/5678" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_lines, 'model': rule}, + ) + + # Matching multiple invoices sharing the same reference. + term_lines[1].name = "INV/1234" + self.assertDictEqual( + rule._apply_rules(st_line, None), + {'amls': term_lines, 'model': rule}, + ) + + def test_amount_check_amount_last(self): + """ In case the reconciliation model can't match via text or partner matching + we do a last check to find amls with the exact amount + """ + self.rule_1.write({ + 'match_text_location_label': False, + 'match_partner': False, + 'match_partner_ids': [Command.clear()], + }) + self.bank_line_1.partner_id = None + self.bank_line_1.payment_ref = False + + self._check_statement_matching(self.rule_1, { + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + }, + }) + + # Create bank statement in foreign currency + partner = self.env['res.partner'].create({'name': 'Bernard Gagnant'}) + invoice_line = self._create_invoice_line(300, partner, 'out_invoice', currency=self.other_currency_2) + bank_line_2 = self.env['account.bank.statement.line'].create({ + 'journal_id': self.bank_journal.id, + 'partner_id': False, + 'payment_ref': False, + 'foreign_currency_id': self.other_currency_2.id, + 'amount': 15.0, + 'amount_currency': 300.0, + }) + self._check_statement_matching(self.rule_1, { + bank_line_2: { + 'amls': invoice_line, + 'model': self.rule_1, + }, + }) + + @freeze_time('2019-01-01') + def test_matching_exact_amount_no_partner(self): + """ In case the reconciliation model can't match via text or partner matching + we do a last check to find amls with the exact amount. + """ + self.rule_1.write({ + 'match_text_location_label': False, + 'match_partner': False, + 'match_partner_ids': [Command.clear()], + }) + self.bank_line_1.partner_id = None + self.bank_line_1.payment_ref = False + + with self.subTest(test='single_currency'): + st_line = self._create_st_line(amount=100, payment_ref=None, partner_id=None) + invl = self._create_invoice_line(100, self.partner_1, 'out_invoice') + self._check_statement_matching(self.rule_1, { + st_line: { + 'amls': invl, + 'model': self.rule_1, + }, + }) + + with self.subTest(test='rounding'): + st_line = self._create_st_line(amount=-208.73, payment_ref=None, partner_id=None) + invl = self._create_invoice_line(208.73, self.partner_1, 'in_invoice') + self._check_statement_matching(self.rule_1, { + st_line: { + 'amls': invl, + 'model': self.rule_1, + }, + }) + + with self.subTest(test='multi_currencies'): + foreign_curr = self.other_currency_2 + invl = self._create_invoice_line(300, self.partner_1, 'out_invoice', currency=foreign_curr) + st_line = self._create_st_line( + amount=15.0, foreign_currency_id=foreign_curr.id, amount_currency=300.0, + payment_ref=None, partner_id=None, + ) + self._check_statement_matching(self.rule_1, { + st_line: { + 'amls': invl, + 'model': self.rule_1, + }, + }) diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_signature.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_signature.py new file mode 100644 index 0000000..2099d9c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_signature.py @@ -0,0 +1,88 @@ +import base64 + +from odoo import Command +from odoo.tests import tagged +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged('post_install', '-at_install') +class TestInvoiceSignature(AccountTestInvoicingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + if cls.env.ref('base.module_sign').state != 'installed': + cls.skipTest(cls, "`sign` module not installed") + + cls.env.company.sign_invoice = True + + cls.signature_fake_1 = base64.b64encode(b"fake_signature_1") + cls.signature_fake_2 = base64.b64encode(b"fake_signature_2") + + cls.user.sign_signature = cls.signature_fake_1 + cls.another_user = cls.env['res.users'].create({ + 'name': 'another accountant', + 'login': 'another_accountant', + 'password': 'another_accountant', + 'groups_id': [ + Command.set(cls.env.ref('account.group_account_user').ids), + ], + 'sign_signature': cls.signature_fake_2, + }) + + cls.invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': cls.partner_a.id, + 'journal_id': cls.company_data['default_journal_sale'].id, + 'invoice_line_ids': [ + Command.create({ + 'product_id': cls.product_a.id, + 'quantity': 1, + 'price_unit': 1, + }) + ] + }) + + def test_draft_invoice_shouldnt_have_signature(self): + self.assertEqual(self.invoice.state, 'draft') + self.assertFalse(self.invoice.show_signature_area, "the signature area shouldn't appear on a draft invoice") + + def test_posted_invoice_should_have_signature(self): + self.invoice.action_post() + self.assertTrue(self.invoice.show_signature_area, + "the signature area should appear on posted invoice when the `sign_invoice` settings is True") + + def test_invoice_from_company_without_signature_settings_shouldnt_have_signature(self): + self.env.company.sign_invoice = False + self.invoice.action_post() + self.assertFalse(self.invoice.show_signature_area, + "the signature area shouldn't appear when the `sign_invoice` settings is False") + + def test_invoice_signing_user_should_be_the_user_that_posted_it(self): + self.assertFalse(self.invoice.signing_user, + "invoice that weren't created by automated action shouldn't have a signing user") + self.assertEqual(self.invoice.signature, False, "There shouldn't be any signature if there isn't a signing user") + self.invoice.action_post() + self.assertEqual(self.invoice.signing_user, self.user, "The signing user should be the user that posted the invoice") + self.assertEqual(self.invoice.signature, self.signature_fake_1, "The signature should be from `self.user`") + + self.invoice.button_draft() + self.invoice.with_user(self.another_user).action_post() + self.assertEqual(self.invoice.signing_user, self.another_user, + "The signing user should be the user that posted the invoice") + self.assertEqual(self.invoice.signature, self.signature_fake_2, "The signature should be from `self.another_user`") + + def test_invoice_signing_user_should_be_reprensative_user_if_there_is_one(self): + self.env.company.signing_user = self.user # set the representative user of the company + invoice = self.invoice.with_user(self.another_user) + invoice.action_post() + self.assertEqual(invoice.signing_user, self.user, "The signing user should be the representative person set in the settings") + self.assertEqual(invoice.signature, self.signature_fake_1, "The signature should be from `self.another_user`, the representative user") + + def test_setting_representative_user_shouldnt_change_signer_of_already_posted_invoice(self): + # Note: Changing this behavior might not be a good idea as having all account.move updated at once + # would be very costly + self.invoice.action_post() + self.env.company.signing_user = self.another_user # set the representative user of the company + self.assertEqual(self.invoice.signing_user, self.user, + "The signing user should be the one that posted the invoice even if a representative has been added later on") + self.assertEqual(self.invoice.signature, self.signature_fake_1, "The signature should be from `self.user`") diff --git a/dev_odex30_accounting/odex30_account_accountant/tests/test_ui.py b/dev_odex30_accounting/odex30_account_accountant/tests/test_ui.py new file mode 100644 index 0000000..93e7c36 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/tests/test_ui.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import Command, fields +from odoo.addons.account.tests.common import AccountTestMockOnlineSyncCommon +import odoo.tests + +_logger = logging.getLogger(__name__) + + +@odoo.tests.tagged('-at_install', 'post_install') +class TestUi(AccountTestMockOnlineSyncCommon): + def test_accountant_tour(self): + # Reset country and fiscal country, so that fields added by localizations are + # hidden and non-required, and don't make the tour crash. + # Also remove default taxes from the company and its accounts, to avoid inconsistencies + # with empty fiscal country. + self.env.company.write({ + 'country_id': None, # Also resets account_fiscal_country_id + 'account_sale_tax_id': None, + 'account_purchase_tax_id': None, + }) + + # An unconfigured bank journal is required for the connect bank step + self.env['account.journal'].create({ + 'type': 'bank', + 'name': 'Empty Bank', + 'code': 'EBJ', + }) + + account_with_taxes = self.env['account.account'].search([('tax_ids', '!=', False), ('company_ids', '=', self.env.company.id)]) + account_with_taxes.write({ + 'tax_ids': [Command.clear()], + }) + # This tour doesn't work with demo data on runbot + all_moves = self.env['account.move'].search([('company_id', '=', self.env.company.id), ('move_type', '!=', 'entry')]) + all_moves.filtered(lambda m: not m.inalterable_hash and not m.deferred_move_ids and m.state != 'draft').button_draft() + all_moves.with_context(force_delete=True).unlink() + # We need at least two bank statement lines to reconcile for the tour. + bnk = self.env['account.account'].create({ + 'code': 'X1014', + 'name': 'Bank Current Account - (test)', + 'account_type': 'asset_cash', + }) + journal = self.env['account.journal'].create({ + 'name': 'Bank - Test', + 'code': 'TBNK', + 'type': 'bank', + 'default_account_id': bnk.id, + }) + self.env['account.bank.statement.line'].create([{ + 'journal_id': journal.id, + 'amount': 100, + 'date': fields.Date.today(), + 'payment_ref': 'stl_0001', + }, { + 'journal_id': journal.id, + 'amount': 200, + 'date': fields.Date.today(), + 'payment_ref': 'stl_0002', + }]) + self.start_tour("/odoo", 'odex30_account_accountant_tour', login="admin") diff --git a/dev_odex30_accounting/odex30_account_accountant/views/account_fiscal_year_view.xml b/dev_odex30_accounting/odex30_account_accountant/views/account_fiscal_year_view.xml new file mode 100644 index 0000000..d41d4bf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/account_fiscal_year_view.xml @@ -0,0 +1,53 @@ + + + + Fiscal Years + account.fiscal.year + list,form + +

    + Click here to create a new fiscal year. +

    +
    +
    + + + account.fiscal.year.form + account.fiscal.year + +
    + + + + + + + + +
    +
    +
    + + + account.fiscal.year.search + account.fiscal.year + + + + + + + + + account.fiscal.year.list + account.fiscal.year + + + + + + + + + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/views/account_journal_dashboard_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/account_journal_dashboard_views.xml new file mode 100644 index 0000000..0af6058 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/account_journal_dashboard_views.xml @@ -0,0 +1,44 @@ + + + account.journal.dashboard.kanban + account.journal + + + + + + + + + + + + Last Statement + + + + + +
    + +
    + +
    +
    +
    +
    + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/account_move_views.xml new file mode 100644 index 0000000..a7b3508 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/account_move_views.xml @@ -0,0 +1,145 @@ + + + + account.move.line.list + account.move.line + + extension + + + account_move_line_list + + +
    +
    + + +
    + + + + + + matching_link_widget + +
    +
    + + + account.move.line.payment.list + account.move.line + + extension + + + + + + + + + + + + + + + + account.move.line.deferral.entries.list + account.move.line + + primary + + + date + + + hide + + + hide + + + hide + + + hide + + + hide + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/views/account_payment_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/account_payment_views.xml new file mode 100644 index 0000000..d1e2415 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/account_payment_views.xml @@ -0,0 +1,21 @@ + + + + account.payment.form.inherit.odex30_account_accountant + account.payment + + + + account.group_account_user + + + + + +
    + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/views/bank_rec_widget_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/bank_rec_widget_views.xml new file mode 100644 index 0000000..51afa7a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/bank_rec_widget_views.xml @@ -0,0 +1,469 @@ + + + + + account.bank.statement.form.bank_rec_widget + account.bank.statement + 10 + +
    + + +
    + +
    + + + + + + + + + + + +
    +
    + + + + + + + + Create Statement + account.bank.statement + form + + new + {'dialog_size': 'medium', 'show_running_balance_latest': True} + + + + + account.bank.statement.line.search.bank_rec_widget + account.bank.statement.line + 999 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + account.bank.statement.line.kanban.bank_rec_widget + account.bank.statement.line + + + + + + + + + + + + + + + + + +
    + + + +
    + + + +
    + +
    +
    + + +
    + +
    + To check +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + account.bank.statement.line.list.bank_rec_widget + account.bank.statement.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + account.bank.statement.line.form.bank_rec_widget + account.bank.statement.line + +
    + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + + account.bank.statement.line.form.bank_rec_widget + account.bank.statement.line + +
    + + + + + + + + + + +
    + + + New Transaction + account.bank.statement.line + form + + new + + + + + Bank Reconciliation + account.bank.statement.line + list,kanban + + + reconciliation_list + [('state', '!=', 'cancel')] + {'default_journal_id': active_id, 'search_default_journal_id': active_id} + +

    Nothing to do here!

    +

    No transactions matching your filters were found.

    +
    +
    + + + Bank Reconciliation + account.bank.statement.line + kanban,list + + + reconciliation + [('state', '!=', 'cancel')] + {'default_journal_id': active_id, 'search_default_journal_id': active_id} + +

    Nothing to do here!

    +

    No transactions matching your filters were found.

    +
    +
    + + + account.move.form.bank_rec_widget + account.move + 999 + +
    + + +
    +
    + + + + account.move.line.search.bank_rec_widget + account.move.line + 999 + + + + + + + + + + + + + + + + + + + + + + + account.move.line.list.bank_rec_widget + account.move.line + 999 + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_accountant_menuitems.xml b/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_accountant_menuitems.xml new file mode 100644 index 0000000..9826c54 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_accountant_menuitems.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_reconcile_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_reconcile_views.xml new file mode 100644 index 0000000..dd6f4f6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/odex30_account_reconcile_views.xml @@ -0,0 +1,122 @@ + + + + account.move.line.reconcile.search + account.move.line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + account.move.line.list.reconcile + account.move.line + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + + Reconcile automatically + account.auto.reconcile.wizard + form + new + + + + + Journal Items to reconcile + account.move.line + reconcile + list + + + [('display_type', 'not in', ('line_section', 'line_note')), ('account_id.reconcile', '=', True), ('parent_state', '=', 'posted'), ('full_reconcile_id', '=', False)] + {'journal_type': 'general', 'search_default_unreconciled': True, 'search_default_group_by_account': True, 'search_default_group_by_partner': True} + +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/views/product_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/product_views.xml new file mode 100644 index 0000000..5343473 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/product_views.xml @@ -0,0 +1,26 @@ + + + + res.partner.form + res.partner + + + + account.group_account_user + + + + + + + product.template.form.inherit + product.template + 5 + + + + account.group_account_readonly + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/views/report_invoice.xml b/dev_odex30_accounting/odex30_account_accountant/views/report_invoice.xml new file mode 100644 index 0000000..8949cdc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/report_invoice.xml @@ -0,0 +1,18 @@ + + + + diff --git a/dev_odex30_accounting/odex30_account_accountant/views/res_config_settings_views.xml b/dev_odex30_accounting/odex30_account_accountant/views/res_config_settings_views.xml new file mode 100644 index 0000000..505d82b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/views/res_config_settings_views.xml @@ -0,0 +1,98 @@ + + + + res.config.settings.view.form.inherit.account.accountant + res.config.settings + + + + +
    +
    +
    +
    +
    + + + + + +
    +
    +
    +
    + + 0 + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/__init__.py b/dev_odex30_accounting/odex30_account_accountant/wizard/__init__.py new file mode 100644 index 0000000..31b7ccd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_change_lock_date +from . import account_auto_reconcile_wizard +from . import account_reconcile_wizard diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..521c77d Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_auto_reconcile_wizard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_auto_reconcile_wizard.cpython-311.pyc new file mode 100644 index 0000000..779c5e9 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_auto_reconcile_wizard.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_change_lock_date.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_change_lock_date.cpython-311.pyc new file mode 100644 index 0000000..87e5dd3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_change_lock_date.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_reconcile_wizard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_reconcile_wizard.cpython-311.pyc new file mode 100644 index 0000000..e65aab3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_accountant/wizard/__pycache__/account_reconcile_wizard.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.py b/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.py new file mode 100644 index 0000000..bdf1253 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.py @@ -0,0 +1,176 @@ +from datetime import date + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import UserError + + +class AccountAutoReconcileWizard(models.TransientModel): + """ This wizard is used to automatically reconcile account.move.line. + It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem. + """ + _name = 'account.auto.reconcile.wizard' + _description = 'Account automatic reconciliation wizard' + _check_company_auto = True + + company_id = fields.Many2one( + comodel_name='res.company', + required=True, + readonly=True, + default=lambda self: self.env.company, + ) + line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard + from_date = fields.Date(string='From') + to_date = fields.Date(string='To', default=fields.Date.context_today, required=True) + account_ids = fields.Many2many( + comodel_name='account.account', + string='Accounts', + check_company=True, + domain="[('reconcile', '=', True), ('deprecated', '=', False), ('account_type', '!=', 'off_balance')]", + ) + partner_ids = fields.Many2many( + comodel_name='res.partner', + string='Partners', + check_company=True, + domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]", + ) + search_mode = fields.Selection( + selection=[ + ('one_to_one', "Perfect Match"), + ('zero_balance', "Clear Account"), + ], + string='Reconcile', + required=True, + default='one_to_one', + help="Reconcile journal items with opposite balance or clear accounts with a zero balance", + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + domain = self.env.context.get('domain') + if 'line_ids' in fields_list and 'line_ids' not in res and domain: + amls = self.env['account.move.line'].search(domain) + if amls: + # pre-configure the wizard + res.update(self._get_default_wizard_values(amls)) + res['line_ids'] = [Command.set(amls.ids)] + return res + + @api.model + def _get_default_wizard_values(self, amls): + """ Derive a preset configuration based on amls. + For example if all amls have the same account_id we will set it in the wizard. + :param amls: account move lines from which we will derive a preset + :return: a dict with preset values + """ + return { + 'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [], + 'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [], + 'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one', + 'from_date': min(amls.mapped('date')), + 'to_date': max(amls.mapped('date')), + } + + def _get_wizard_values(self): + """ Get the current configuration of the wizard as a dict of values. + :return: a dict with the current configuration of the wizard. + """ + self.ensure_one() + return { + 'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [], + 'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [], + 'search_mode': self.search_mode, + 'from_date': self.from_date, + 'to_date': self.to_date, + } + + # ==== Business methods ==== + def _get_amls_domain(self): + """ Get the domain of amls to be auto-reconciled. """ + self.ensure_one() + if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids): + domain = [('id', 'in', self.line_ids.ids)] + else: + domain = [ + ('company_id', '=', self.company_id.id), + ('parent_state', '=', 'posted'), + ('display_type', 'not in', ('line_section', 'line_note')), + ('date', '>=', self.from_date or date.min), + ('date', '<=', self.to_date), + ('reconciled', '=', False), + ('account_id.reconcile', '=', True), + ('amount_residual_currency', '!=', 0.0), + ('amount_residual', '!=', 0.0), # excludes exchange difference lines + ] + if self.account_ids: + domain.append(('account_id', 'in', self.account_ids.ids)) + if self.partner_ids: + domain.append(('partner_id', 'in', self.partner_ids.ids)) + return domain + + def _auto_reconcile_one_to_one(self): + """ Auto-reconcile with one-to-one strategy: + We will reconcile 2 amls together if their combined balance is zero. + :return: a recordset of reconciled amls + """ + grouped_amls_data = self.env['account.move.line']._read_group( + self._get_amls_domain(), + ['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'], + ['id:recordset'], + ) + all_reconciled_amls = self.env['account.move.line'] + amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan + for *__, grouped_aml_ids in grouped_amls_data: + positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date') + negative_amls = (grouped_aml_ids - positive_amls).sorted('date') + min_len = min(len(positive_amls), len(negative_amls)) + positive_amls = positive_amls[:min_len] + negative_amls = negative_amls[:min_len] + all_reconciled_amls += positive_amls + negative_amls + amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)] + self.env['account.move.line']._reconcile_plan(amls_grouped_by_2) + return all_reconciled_amls + + def _auto_reconcile_zero_balance(self): + """ Auto-reconcile with zero balance strategy: + We will reconcile all amls grouped by currency/account/partner that have a total balance of zero. + :return: a recordset of reconciled amls + """ + grouped_amls_data = self.env['account.move.line']._read_group( + self._get_amls_domain(), + groupby=['account_id', 'partner_id', 'currency_id'], + aggregates=['id:recordset'], + having=[('amount_residual_currency:sum_rounded', '=', 0)], + ) + all_reconciled_amls = self.env['account.move.line'] + amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan + for aml_data in grouped_amls_data: + all_reconciled_amls += aml_data[-1] + amls_grouped_together += [aml_data[-1]] + self.env['account.move.line']._reconcile_plan(amls_grouped_together) + return all_reconciled_amls + + def auto_reconcile(self): + """ Automatically reconcile amls given wizard's parameters. + :return: an action that opens all reconciled items and related amls (exchange diff, etc) + """ + self.ensure_one() + if self.search_mode == 'zero_balance': + reconciled_amls = self._auto_reconcile_zero_balance() + else: + # search_mode == 'one_to_one' + reconciled_amls = self._auto_reconcile_one_to_one() + reconciled_amls_and_related = self.env['account.move.line'].search([ + ('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids) + ]) + if reconciled_amls_and_related: + return { + 'name': _("Automatically Reconciled Entries"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move.line', + 'context': "{'search_default_group_by_matching': True}", + 'view_mode': 'list', + 'domain': [('id', 'in', reconciled_amls_and_related.ids)], + } + else: + raise UserError("Nothing to reconcile.") diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.xml b/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.xml new file mode 100644 index 0000000..ff0e1c1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_auto_reconcile_wizard.xml @@ -0,0 +1,24 @@ + + + + account.auto.reconcile.wizard.form + account.auto.reconcile.wizard + +
    + + + + + + + + + +
    +
    + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.py b/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.py new file mode 100644 index 0000000..760100a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.py @@ -0,0 +1,416 @@ +from odoo import api, models, fields, _ +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools import date_utils + +from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS, LOCK_DATE_FIELDS + +from datetime import date, timedelta + + +class AccountChangeLockDate(models.TransientModel): + """ + This wizard is used to change the lock date + """ + _name = 'account.change.lock.date' + _description = 'Change Lock Date' + + company_id = fields.Many2one( + comodel_name='res.company', + required=True, + readonly=True, + default=lambda self: self.env.company, + ) + + fiscalyear_lock_date = fields.Date( + string='Lock Everything', + default=lambda self: self.env.company.fiscalyear_lock_date, + help="Any entry up to and including that date will be postponed to a later time, in accordance with its journal's sequence.", + ) + fiscalyear_lock_date_for_me = fields.Date( + string='Lock Everything For Me', + compute='_compute_lock_date_exceptions', + ) + fiscalyear_lock_date_for_everyone = fields.Date( + string='Lock Everything For Everyone', + compute='_compute_lock_date_exceptions', + ) + min_fiscalyear_lock_date_exception_for_me_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + min_fiscalyear_lock_date_exception_for_everyone_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + + tax_lock_date = fields.Date( + string="Lock Tax Return", + default=lambda self: self.env.company.tax_lock_date, + help="Any entry with taxes up to and including that date will be postponed to a later time, in accordance with its journal's sequence. " + "The tax lock date is automatically set when the tax closing entry is posted.", + ) + tax_lock_date_for_me = fields.Date( + string='Lock Tax Return For Me', + compute='_compute_lock_date_exceptions', + ) + tax_lock_date_for_everyone = fields.Date( + string='Lock Tax Return For Everyone', + compute='_compute_lock_date_exceptions', + ) + min_tax_lock_date_exception_for_me_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + min_tax_lock_date_exception_for_everyone_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + + sale_lock_date = fields.Date( + string='Lock Sales', + default=lambda self: self.env.company.sale_lock_date, + help="Any sales entry prior to and including this date will be postponed to a later date, in accordance with its journal's sequence.", + ) + sale_lock_date_for_me = fields.Date( + string='Lock Sales For Me', + compute='_compute_lock_date_exceptions', + ) + sale_lock_date_for_everyone = fields.Date( + string='Lock Sales For Everyone', + compute='_compute_lock_date_exceptions', + ) + min_sale_lock_date_exception_for_me_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + min_sale_lock_date_exception_for_everyone_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + + purchase_lock_date = fields.Date( + string='Lock Purchases', + default=lambda self: self.env.company.purchase_lock_date, + help="Any purchase entry prior to and including this date will be postponed to a later date, in accordance with its journal's sequence.", + ) + purchase_lock_date_for_me = fields.Date( + string='Lock Purchases For Me', + compute='_compute_lock_date_exceptions', + ) + purchase_lock_date_for_everyone = fields.Date( + string='Lock Purchases For Everyone', + compute='_compute_lock_date_exceptions', + ) + min_purchase_lock_date_exception_for_me_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + min_purchase_lock_date_exception_for_everyone_id = fields.Many2one( + comodel_name='account.lock_exception', + compute='_compute_lock_date_exceptions', + ) + + hard_lock_date = fields.Date( + string='Hard Lock', + default=lambda self: self.env.company.hard_lock_date, + help="Any entry up to and including that date will be postponed to a later time, in accordance with its journal sequence. " + "This lock date is irreversible and does not allow any exception.", + ) + current_hard_lock_date = fields.Date( + string='Current Hard Lock', + related='company_id.hard_lock_date', + readonly=True, + ) + + exception_needed = fields.Boolean( # TODO: remove in master (18.1) + string='Exception needed', + compute='_compute_exception_needed', + ) + exception_needed_fields = fields.Char( + # String of comma separated values of the field(s) the exception applies to + compute='_compute_exception_needed_fields', + ) + exception_applies_to = fields.Selection( + string='Exception applies', + selection=[ + ('me', "for me"), + ('everyone', "for everyone"), + ], + default='me', + required=True, + ) + exception_duration = fields.Selection( + string='Exception Duration', + selection=[ + ('5min', "for 5 minutes"), + ('15min', "for 15 minutes"), + ('1h', "for 1 hour"), + ('24h', "for 24 hours"), + ('forever', "forever"), + ], + default='5min', + required=True, + ) + exception_reason = fields.Char( + string='Exception Reason', + ) + + show_draft_entries_warning = fields.Boolean( + string="Show Draft Entries Warning", + compute='_compute_show_draft_entries_warning', + ) + + @api.depends('company_id') + @api.depends_context('user', 'company') + def _compute_lock_date_exceptions(self): + for wizard in self: + exceptions = self.env['account.lock_exception'].search( + self.env['account.lock_exception']._get_active_exceptions_domain(wizard.company_id, SOFT_LOCK_DATE_FIELDS) + ) + for field in SOFT_LOCK_DATE_FIELDS: + field_exceptions = exceptions.filtered(lambda e: e.lock_date_field == field) + field_exceptions_for_me = field_exceptions.filtered(lambda e: e.user_id.id == self.env.user.id) + field_exceptions_for_everyone = field_exceptions.filtered(lambda e: not e.user_id.id) + min_exception_for_me = min(field_exceptions_for_me, key=lambda e: e[field] or date.min) if field_exceptions_for_me else False + min_exception_for_everyone = min(field_exceptions_for_everyone, key=lambda e: e[field] or date.min) if field_exceptions_for_everyone else False + wizard[f"min_{field}_exception_for_me_id"] = min_exception_for_me + wizard[f"min_{field}_exception_for_everyone_id"] = min_exception_for_everyone + wizard[f"{field}_for_me"] = min_exception_for_me.lock_date if min_exception_for_me else False + wizard[f"{field}_for_everyone"] = min_exception_for_everyone.lock_date if min_exception_for_everyone else False + + def _get_draft_moves_in_locked_period_domain(self): + self.ensure_one() + lock_date_domains = [] + if self.hard_lock_date: + lock_date_domains.append([('date', '<=', self.hard_lock_date)]) + if self.fiscalyear_lock_date: + lock_date_domains.append([('date', '<=', self.fiscalyear_lock_date)]) + if self.sale_lock_date: + lock_date_domains.append([ + ('date', '<=', self.sale_lock_date), + ('journal_id.type', '=', 'sale')]) + if self.purchase_lock_date: + lock_date_domains.append([ + ('date', '<=', self.purchase_lock_date), + ('journal_id.type', '=', 'purchase')]) + return [ + ('company_id', 'child_of', self.env.company.id), + ('state', '=', 'draft'), + *expression.OR(lock_date_domains), + ] + + @api.depends('fiscalyear_lock_date', 'tax_lock_date', 'sale_lock_date', 'purchase_lock_date', 'hard_lock_date') + def _compute_show_draft_entries_warning(self): + for wizard in self: + draft_entries = self.env['account.move'].search(self._get_draft_moves_in_locked_period_domain(), limit=1) + wizard.show_draft_entries_warning = bool(draft_entries) + + def _get_changes_needing_exception(self): + self.ensure_one() + return { + field: self[field] + for field in SOFT_LOCK_DATE_FIELDS + if self.env.company[field] and (not self[field] or self[field] < self.env.company[field]) + } + + @api.depends(*SOFT_LOCK_DATE_FIELDS) + def _compute_exception_needed(self): + # TODO: remove in master (18.1) + for wizard in self: + wizard.exception_needed = bool(wizard._get_changes_needing_exception()) + + @api.depends(*SOFT_LOCK_DATE_FIELDS) + def _compute_exception_needed_fields(self): + for wizard in self: + changes_needing_exception = wizard._get_changes_needing_exception() + wizard.exception_needed_fields = ','.join(changes_needing_exception) + + def _prepare_lock_date_values(self, exception_vals_list=None): + """ + Return a dictionary (lock date field -> field value) + It only contains lock dates which are changed and for which no exception is added + """ + self.ensure_one() + if self.env.company.hard_lock_date and (not self.hard_lock_date or self.hard_lock_date < self.env.company.hard_lock_date): + raise UserError(_('It is not possible to decrease or remove the Hard Lock Date.')) + + lock_date_values = { + field: self[field] + for field in LOCK_DATE_FIELDS + if self[field] != self.env.company[field] + } + + for field, lock_date in lock_date_values.items(): + if lock_date and lock_date > fields.Date.context_today(self): + raise UserError(_('You cannot set a Lock Date in the future.')) + + # We do not change fields for which we add an exception + if exception_vals_list: + for exception_vals in exception_vals_list: + for field in LOCK_DATE_FIELDS: + if field in exception_vals: + lock_date_values.pop(field, None) + + return lock_date_values + + def _prepare_exception_values(self): + self.ensure_one() + changes_needing_exception = self._get_changes_needing_exception() + + if not changes_needing_exception: + return False + + # Exceptions for everyone and forever are just "normal" changes to the lock date. + if self.exception_applies_to == 'everyone' and self.exception_duration == 'forever': + return False + + exception_errors = [] + if not self.exception_applies_to: + exception_errors.append(_('You need to select who the exception applies to.')) + if not self.exception_duration: + exception_errors.append(_('You need to select a duration for the exception.')) + if exception_errors: + raise UserError('\n'.join(exception_errors)) + + exception_base_values = { + 'company_id': self.env.company.id, + } + + exception_base_values['user_id'] = { + 'me': self.env.user.id, + 'everyone': False, + }[self.exception_applies_to] + + exception_timedelta = { + '5min': timedelta(minutes=5), + '15min': timedelta(minutes=15), + '1h': timedelta(hours=1), + '24h': timedelta(hours=24), + 'forever': False, + }[self.exception_duration] + if exception_timedelta: + exception_base_values['end_datetime'] = self.env.cr.now() + exception_timedelta + + if self.exception_reason: + exception_base_values['reason'] = self.exception_reason + + exception_vals_list = [ + { + **exception_base_values, + field: value, + } + for field, value in changes_needing_exception.items() + ] + + return exception_vals_list + + def _get_current_period_dates(self, lock_date_field): + """ Gets the date_from - either the previous lock date or the start of the fiscal year. + """ + self.ensure_one() + company_lock_date = self.env.company[lock_date_field] + if company_lock_date: + date_from = company_lock_date + timedelta(days=1) + else: + date_from = date_utils.get_fiscal_year(self[lock_date_field])[0] + return date_from, self[lock_date_field] + + def _create_default_report_external_values(self, lock_date_field): + # to be overriden + pass + + def _change_lock_date(self, lock_date_values=None): + self.ensure_one() + if lock_date_values is None: + lock_date_values = self._prepare_lock_date_values() + + # Possibly create default report external values for tax + tax_lock_date = lock_date_values.get('tax_lock_date', None) + if tax_lock_date and tax_lock_date != self.env.company['tax_lock_date']: + self._create_default_report_external_values('tax_lock_date') + + # Possibly create default report external values for fiscal year + fiscalyear_lock_date = lock_date_values.get('fiscalyear_lock_date', None) + hard_lock_date = lock_date_values.get('hard_lock_date', None) + if fiscalyear_lock_date or hard_lock_date: + fiscal_lock_date, field = max([ + (fiscalyear_lock_date, 'fiscalyear_lock_date'), + (hard_lock_date, 'hard_lock_date'), + ], key=lambda t: t[0] or date.min) + company_fiscal_lock_date = max( + self.env.company.fiscalyear_lock_date or date.min, + self.env.company.hard_lock_date or date.min, + ) + if fiscal_lock_date != company_fiscal_lock_date: + self._create_default_report_external_values(field) + + self.env.company.sudo().write(lock_date_values) + + def change_lock_date(self): + self.ensure_one() + if self.env.user.has_group('account.group_account_manager'): + exception_vals_list = self._prepare_exception_values() + changed_lock_date_values = self._prepare_lock_date_values(exception_vals_list=exception_vals_list) + + if exception_vals_list: + self.env['account.lock_exception'].create(exception_vals_list) + + self._change_lock_date(changed_lock_date_values) + else: + raise UserError(_('Only Billing Administrators are allowed to change lock dates!')) + return {'type': 'ir.actions.act_window_close'} + + def action_show_draft_moves_in_locked_period(self): + self.ensure_one() + return { + 'view_mode': 'list', + 'name': _('Draft Entries'), + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'domain': self._get_draft_moves_in_locked_period_domain(), + 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'], + 'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']], + } + + def action_reopen_wizard(self): + # This action can be used to keep the wizard open after doing something else + return { + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + } + + def _action_revoke_min_exception(self, exception_field): + self.ensure_one() + exception = self[exception_field] + if exception: + exception.action_revoke() + self._compute_lock_date_exceptions() + return self.action_reopen_wizard() + + def action_revoke_min_sale_lock_date_exception_for_me(self): + return self._action_revoke_min_exception('min_sale_lock_date_exception_for_me_id') + + def action_revoke_min_purchase_lock_date_exception_for_me(self): + return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_me_id') + + def action_revoke_min_tax_lock_date_exception_for_me(self): + return self._action_revoke_min_exception('min_tax_lock_date_exception_for_me_id') + + def action_revoke_min_fiscalyear_lock_date_exception_for_me(self): + return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_me_id') + + def action_revoke_min_sale_lock_date_exception_for_everyone(self): + return self._action_revoke_min_exception('min_sale_lock_date_exception_for_everyone_id') + + def action_revoke_min_purchase_lock_date_exception_for_everyone(self): + return self._action_revoke_min_exception('min_purchase_lock_date_exception_for_everyone_id') + + def action_revoke_min_tax_lock_date_exception_for_everyone(self): + return self._action_revoke_min_exception('min_tax_lock_date_exception_for_everyone_id') + + def action_revoke_min_fiscalyear_lock_date_exception_for_everyone(self): + return self._action_revoke_min_exception('min_fiscalyear_lock_date_exception_for_everyone_id') diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.xml b/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.xml new file mode 100644 index 0000000..80fd256 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_change_lock_date.xml @@ -0,0 +1,236 @@ + + + + + + account.change.lock.date.form + account.change.lock.date + +
    + + + + +
    + Lock transactions up to specific dates, inclusive +
    + + + + + + + + + +
    +
    +
    +
    +
    +
    +
    + + + Lock Journal Entries + account.change.lock.date + form + + new + + + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.py b/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.py new file mode 100644 index 0000000..9605d77 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.py @@ -0,0 +1,772 @@ +from collections import defaultdict +from datetime import timedelta + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import groupby, SQL +from odoo.tools.misc import formatLang + + +class AccountReconcileWizard(models.TransientModel): + """ This wizard is used to reconcile selected account.move.line. """ + _name = 'account.reconcile.wizard' + _description = 'Account reconciliation wizard' + _check_company_auto = True + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + if 'move_line_ids' not in fields_list: + return res + if self.env.context.get('active_model') != 'account.move.line' or not self.env.context.get('active_ids'): + raise UserError(_('This can only be used on journal items')) + move_line_ids = self.env['account.move.line'].browse(self.env.context['active_ids']) + accounts = move_line_ids.account_id + if len(accounts) > 2: + raise UserError(_( + 'You can only reconcile entries with up to two different accounts: %s', + ', '.join(accounts.mapped('display_name')), + )) + shadowed_aml_values = None + if len(accounts) == 2: + shadowed_aml_values = { + aml: {'account_id': move_line_ids[0].account_id} + for aml in move_line_ids.filtered(lambda line: line.account_id != move_line_ids[0].account_id) + } + move_line_ids._check_amls_exigibility_for_reconciliation(shadowed_aml_values=shadowed_aml_values) + res['move_line_ids'] = [Command.set(move_line_ids.ids)] + return res + + company_id = fields.Many2one(comodel_name='res.company', required=True, readonly=True, compute='_compute_company_id') + move_line_ids = fields.Many2many( + comodel_name='account.move.line', + string='Move lines to reconcile', + required=True) + reco_account_id = fields.Many2one( + comodel_name='account.account', + string='Reconcile Account', + compute='_compute_reco_wizard_data') + amount = fields.Monetary( + string='Amount in company currency', + currency_field='company_currency_id', + compute='_compute_reco_wizard_data') + company_currency_id = fields.Many2one(comodel_name='res.currency', string='Company currency', related='company_id.currency_id') + amount_currency = fields.Monetary( + string='Amount', + currency_field='reco_currency_id', + compute='_compute_reco_wizard_data') + reco_currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency to use for reconciliation', + compute='_compute_reco_wizard_data') + edit_mode_amount = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_edit_mode_amount', + ) + edit_mode_amount_currency = fields.Monetary( + string='Edit mode amount', + currency_field='edit_mode_reco_currency_id', + compute='_compute_edit_mode_amount_currency', + store=True, + readonly=False, + ) + edit_mode_reco_currency_id = fields.Many2one( + comodel_name='res.currency', + compute='_compute_edit_mode_reco_currency', + ) + edit_mode = fields.Boolean( + compute='_compute_edit_mode', + ) + single_currency_mode = fields.Boolean(compute='_compute_single_currency_mode') + allow_partials = fields.Boolean(string="Allow partials", compute='_compute_allow_partials', store=True, readonly=False) + force_partials = fields.Boolean(compute='_compute_reco_wizard_data') + display_allow_partials = fields.Boolean(compute='_compute_display_allow_partials') + date = fields.Date(string='Date', compute='_compute_date', store=True, readonly=False) + journal_id = fields.Many2one( + comodel_name='account.journal', + string='Journal', + check_company=True, + domain="[('type', '=', 'general')]", + compute='_compute_journal_id', + store=True, + readonly=False, + required=True, + precompute=True) + account_id = fields.Many2one( + comodel_name='account.account', + string='Account', + check_company=True, + domain="[('deprecated', '=', False), ('account_type', '!=', 'off_balance')]") + is_rec_pay_account = fields.Boolean(compute='_compute_is_rec_pay_account') + to_partner_id = fields.Many2one( + comodel_name='res.partner', + string='Partner', + check_company=True, + compute='_compute_to_partner_id', + store=True, + readonly=False, + ) + label = fields.Char(string='Label', default='Write-Off') + tax_id = fields.Many2one( + comodel_name='account.tax', + string='Tax', + default=False, + check_company=True) + to_check = fields.Boolean( + string='To Check', + default=False, + help='Check if you are not certain of all the information of the counterpart.') + is_write_off_required = fields.Boolean( + string='Is a write-off move required to reconcile', + compute='_compute_is_write_off_required') + is_transfer_required = fields.Boolean( + string='Is an account transfer required', + compute='_compute_reco_wizard_data') + transfer_warning_message = fields.Char( + string='Is an account transfer required to reconcile', + compute='_compute_reco_wizard_data') + transfer_from_account_id = fields.Many2one( + comodel_name='account.account', + string='Account Transfer From', + compute='_compute_reco_wizard_data') + lock_date_violated_warning_message = fields.Char( + string='Is the date violating the lock date of moves', + compute='_compute_lock_date_violated_warning_message') + reco_model_id = fields.Many2one( + comodel_name='account.reconcile.model', + string='Reconciliation model', + store=False, + check_company=True) + reco_model_autocomplete_ids = fields.Many2many( + comodel_name='account.reconcile.model', + string='All reconciliation models', + compute='_compute_reco_model_autocomplete_ids') + + # ==== Compute methods ==== + @api.depends('move_line_ids.company_id') + def _compute_company_id(self): + for wizard in self: + wizard.company_id = wizard.move_line_ids[0].company_id + + @api.depends('move_line_ids') + def _compute_single_currency_mode(self): + for wizard in self: + wizard.single_currency_mode = len(wizard.move_line_ids.currency_id - wizard.company_currency_id) <= 1 + + @api.depends('force_partials') + def _compute_allow_partials(self): + for wizard in self: + wizard.allow_partials = wizard.display_allow_partials and wizard.force_partials + + @api.depends('move_line_ids') + def _compute_display_allow_partials(self): + """ We only display the allow partial checkbox if there is both credit and debit lines involved. """ + for wizard in self: + wizard.display_allow_partials = has_debit_line = has_credit_line = False + for aml in wizard.move_line_ids: + if aml.balance > 0.0 or aml.amount_currency > 0.0: + has_debit_line = True + elif aml.balance < 0.0 or aml.amount_currency < 0.0: + has_credit_line = True + if has_debit_line and has_credit_line: + wizard.display_allow_partials = True + break + + @api.depends('move_line_ids', 'journal_id', 'tax_id') + def _compute_date(self): + for wizard in self: + highest_date = max(aml.date for aml in wizard.move_line_ids) + temp_move = self.env['account.move'].new({'journal_id': wizard.journal_id.id}) + wizard.date = temp_move._get_accounting_date(highest_date, bool(wizard.tax_id)) + + @api.depends('company_id') + def _compute_journal_id(self): + for wizard in self: + wizard.journal_id = self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(wizard.company_id), + ('type', '=', 'general') + ], limit=1) + + @api.depends('account_id') + def _compute_is_rec_pay_account(self): + for wizard in self: + wizard.is_rec_pay_account = wizard.account_id.account_type in ('asset_receivable', 'liability_payable') + + @api.depends('is_rec_pay_account') + def _compute_to_partner_id(self): + for wizard in self: + if wizard.is_rec_pay_account: + partners = wizard.move_line_ids.partner_id + wizard.to_partner_id = partners if len(partners) == 1 else None + else: + wizard.to_partner_id = None + + @api.depends('amount', 'amount_currency') + def _compute_is_write_off_required(self): + """ We need a write-off if the balance is not 0 and if we don't allow partial reconciliation.""" + for wizard in self: + wizard.is_write_off_required = not wizard.company_currency_id.is_zero(wizard.amount) \ + or (wizard.reco_currency_id and not wizard.reco_currency_id.is_zero(wizard.amount_currency)) + + @api.depends('move_line_ids') + def _compute_reco_wizard_data(self): + """ Compute various data needed for the reco wizard. + 1. The currency to use for the reconciliation: + - if only one foreign currency is present in move lines we use it, unless the reco_account is not + payable nor receivable, + - if no foreign currency or more than 1 are used we use the company's default currency. + 2. The account the reconciliation will happen on. + 3. Transfer data. + 4. Write-off amounts. + """ + + def get_transfer_data(move_lines): + amounts_per_account = defaultdict(float) + for line in move_lines: + amounts_per_account[line.account_id] += line.amount_residual + if abs(amounts_per_account[accounts[0]]) < abs(amounts_per_account[accounts[1]]): + transfer_from_account, transfer_to_account = accounts[0], accounts[1] + else: + transfer_from_account, transfer_to_account = accounts[1], accounts[0] + + amls_to_transfer = amls.filtered(lambda aml: aml.account_id == transfer_from_account) + transfer_foreign_curr = amls.currency_id - amls.company_currency_id + if len(transfer_foreign_curr) == 1: + transfer_currency = transfer_foreign_curr + transfer_amount_currency = sum(aml.amount_currency for aml in amls_to_transfer) + else: + transfer_currency = amls.company_currency_id + transfer_amount_currency = sum(aml.balance for aml in amls_to_transfer) + + if transfer_amount_currency == 0.0 and transfer_currency != amls.company_currency_id: + # handle the transfer of exchange diff + transfer_currency = amls.company_currency_id + transfer_amount_currency = sum(aml.balance for aml in amls_to_transfer) + + amount_formatted = formatLang(self.env, abs(transfer_amount_currency), currency_obj=transfer_currency) + transfer_warning_message = _( + 'An entry will transfer %(amount)s from %(from_account)s to %(to_account)s.', + amount=amount_formatted, + from_account=transfer_from_account.display_name if transfer_amount_currency < 0 else transfer_to_account.display_name, + to_account=transfer_to_account.display_name if transfer_amount_currency < 0 else transfer_from_account.display_name, + ) + return { + 'transfer_from_account_id': transfer_from_account, + 'reco_account_id': transfer_to_account, + 'transfer_warning_message': transfer_warning_message, + } + + def get_reco_currency(amls, aml_values_map): + company_currency = amls.company_currency_id + foreign_currencies = amls.currency_id - company_currency + if len(foreign_currencies) == 0: + return company_currency + elif len(foreign_currencies) == 1: + return foreign_currencies + else: + lines_with_residuals = self.env['account.move.line'] + for residual, residual_values in aml_values_map.items(): + if residual_values['amount_residual'] or residual_values['amount_residual_currency']: + lines_with_residuals += residual + if lines_with_residuals and len(lines_with_residuals.currency_id - company_currency) > 1: + # there is more than one residual and more than one currency in them + return False + return (lines_with_residuals.currency_id - company_currency) or company_currency + + for wizard in self: + amls = wizard.move_line_ids._origin + accounts = amls.account_id # there is only 1 or 2 possible accounts + + wizard.reco_currency_id = False + wizard.amount_currency = wizard.amount = 0.0 + wizard.force_partials = True + wizard.transfer_from_account_id = wizard.transfer_warning_message = False + wizard.is_transfer_required = len(accounts) == 2 + if wizard.is_transfer_required: + wizard.update(get_transfer_data(amls)) + else: + wizard.reco_account_id = accounts + + # Compute the residual amounts for each account. + shadowed_aml_values = { + aml: {'account_id': wizard.reco_account_id} + for aml in amls + } + + # Batch the amls all together to know what should be reconciled and when. + plan_list, all_amls = amls._optimize_reconciliation_plan([amls], shadowed_aml_values=shadowed_aml_values) + + # Prefetch data + all_amls.move_id + all_amls.matched_debit_ids + all_amls.matched_credit_ids + + # All residual amounts are collected and updated until the creation of partials in batch. + # This is done that way to minimize the orm time for fields invalidation/mark as recompute and + # re-computation. + aml_values_map = { + aml: { + 'aml': aml, + 'amount_residual': aml.amount_residual, + 'amount_residual_currency': aml.amount_residual_currency, + } + for aml in all_amls + } + + disable_partial_exchange_diff = bool(self.env['ir.config_parameter'].sudo().get_param('account.disable_partial_exchange_diff')) + plan = plan_list[0] + # residuals are subtracted from aml_values_map + amls\ + .with_context(no_exchange_difference=self._context.get('no_exchange_difference') or disable_partial_exchange_diff) \ + ._prepare_reconciliation_plan(plan, aml_values_map, shadowed_aml_values=shadowed_aml_values) + + reco_currency = get_reco_currency(amls, aml_values_map) + if not reco_currency: + continue # stop the computation, no possible write-off => force partials + + residual_amounts = { + aml: aml._prepare_move_line_residual_amounts(aml_values, reco_currency, shadowed_aml_values=shadowed_aml_values) + for aml, aml_values in aml_values_map.items() + } + + if all(reco_currency in residual_values for residual_values in residual_amounts.values() if residual_values): + wizard.reco_currency_id = reco_currency + elif all(amls.company_currency_id in residual_values for residual_values in residual_amounts.values() if residual_values): + wizard.reco_currency_id = amls.company_currency_id + reco_currency = wizard.reco_currency_id + else: + continue # stop the computation, no possible write-off => force partials + + # Compute write-off amounts + most_recent_line = max(amls, key=lambda aml: aml.date) + if not most_recent_line.amount_currency: + rate = rate_lower_bound = rate_upper_bound = 0.0 + elif most_recent_line.currency_id == reco_currency: + rate = abs(most_recent_line.balance / most_recent_line.amount_currency) + # By estimating the rate from the most recent line, we are exposing ourselves to an estimation error because + # balance is amount_currency * rate, rounded to the nearest precision of the company currency. We now compute + # the lower/upper bounds of the estimated rate in order to determine which other AMLs share the same rate. + # For AMLs that share the same rate, we will use the existing amount_residual instead of recomputing it + # based on the amount_residual_currency and the rate. + rate_tolerance = amls.company_currency_id.rounding / 2 / abs(most_recent_line.amount_currency) + rate_lower_bound = rate - rate_tolerance + rate_upper_bound = rate + rate_tolerance + else: + rate = self.env['res.currency']._get_conversion_rate(reco_currency, amls.company_currency_id, amls.company_id, most_recent_line.date) + rate_lower_bound = rate_upper_bound = rate + + # If an AML's rate is close enough to the reconciliation rate that it could be the same, + # use the `amount_residual` instead of computing `amount_residual_currency / rate` and rounding. + # We do this for the case where a single rate is used for all reconciled AMLs so we want to avoid + # creating an exchange diff because that would be weird. + amls_where_amounts_at_correct_rate = { + aml + for aml, residual_values in residual_amounts.items() + if ( + aml.currency_id == reco_currency + and abs(aml.balance) >= aml.company_currency_id.round(abs(aml.amount_currency) * rate_lower_bound) + and abs(aml.balance) <= aml.company_currency_id.round(abs(aml.amount_currency) * rate_upper_bound) + ) + } + + wizard.amount_currency = sum( + residual_values[wizard.reco_currency_id]['residual'] + for residual_values in residual_amounts.values() + if residual_values + ) + amount_raw = sum( + ( + residual_values[amls.company_currency_id]['residual'] + if aml in amls_where_amounts_at_correct_rate and amls.company_currency_id in residual_values else + residual_values[wizard.reco_currency_id]['residual'] * rate + ) + for aml, residual_values in residual_amounts.items() + if residual_values + ) + wizard.amount = amls.company_currency_id.round(amount_raw) + wizard.force_partials = False + + @api.depends('move_line_ids') + def _compute_edit_mode_amount_currency(self): + for wizard in self: + if wizard.edit_mode: + wizard.edit_mode_amount_currency = wizard.amount_currency + else: + wizard.edit_mode_amount_currency = 0.0 + + @api.depends('edit_mode_amount_currency') + def _compute_edit_mode_amount(self): + for wizard in self: + if wizard.edit_mode: + single_line = wizard.move_line_ids + rate = abs(single_line.amount_currency / single_line.balance) if single_line.balance else 0.0 + wizard.edit_mode_amount = single_line.company_currency_id.round(wizard.edit_mode_amount_currency / rate) if rate else 0.0 + else: + wizard.edit_mode_amount = 0.0 + + @api.depends('move_line_ids') + def _compute_edit_mode_reco_currency(self): + for wizard in self: + if wizard.edit_mode: + wizard.edit_mode_reco_currency_id = wizard.move_line_ids.currency_id + else: + wizard.edit_mode_reco_currency_id = False + + @api.depends('move_line_ids') + def _compute_edit_mode(self): + for wizard in self: + wizard.edit_mode = len(wizard.move_line_ids) == 1 + + @api.depends('move_line_ids.move_id', 'date') + def _compute_lock_date_violated_warning_message(self): + for wizard in self: + date_after_lock = wizard._get_date_after_lock_date() + lock_date_violated_warning_message = None + if date_after_lock: + lock_date_violated_warning_message = _( + 'The date you set violates the lock date of one of your entry. It will be overriden by the following date : %(replacement_date)s', + replacement_date=date_after_lock, + ) + wizard.lock_date_violated_warning_message = lock_date_violated_warning_message + + @api.depends('company_id') + def _compute_reco_model_autocomplete_ids(self): + """ Computes available reconcile models, we only take models that are of type 'writeoff_button' + and that have one (and only one) line. + """ + for wizard in self: + domain = [ + ('rule_type', '=', 'writeoff_button'), + ('company_id', '=', wizard.company_id.id), + ('counterpart_type', 'not in', ('sale', 'purchase')), + ] + query = self.env['account.reconcile.model']._where_calc(domain) + reco_model_ids = [r[0] for r in self.env.execute_query(SQL(""" + SELECT account_reconcile_model.id + FROM %s + JOIN account_reconcile_model_line line ON line.model_id = account_reconcile_model.id + WHERE %s + GROUP BY account_reconcile_model.id + HAVING COUNT(account_reconcile_model.id) = 1 + """, query.from_clause, query.where_clause or SQL("TRUE")))] + wizard.reco_model_autocomplete_ids = self.env['account.reconcile.model'].browse(reco_model_ids) + + # ==== Onchange methods ==== + @api.onchange('reco_model_id') + def _onchange_reco_model_id(self): + """ We prefill the write-off data with the reconcile model selected. """ + if self.reco_model_id: + self.to_check = self.reco_model_id.to_check + self.label = self.reco_model_id.line_ids.label + self.tax_id = self.reco_model_id.line_ids.tax_ids[0] if self.reco_model_id.line_ids[0].tax_ids else None + self.journal_id = self.reco_model_id.line_ids.journal_id # we limited models to those with one and only one line + self.account_id = self.reco_model_id.line_ids.account_id + + # ==== Python constrains ==== + @api.constrains('edit_mode_amount_currency') + def _check_min_max_edit_mode_amount_currency(self): + for wizard in self: + if wizard.edit_mode: + if wizard.edit_mode_amount_currency == 0.0: + raise UserError(_('The amount of the write-off of a single line cannot be 0.')) + is_debit_line = wizard.move_line_ids.balance > 0.0 or wizard.move_line_ids.amount_currency > 0.0 + if is_debit_line and wizard.edit_mode_amount_currency < 0.0: + raise UserError(_('The amount of the write-off of a single debit line should be strictly positive.')) + elif not is_debit_line and wizard.edit_mode_amount_currency > 0.0: + raise UserError(_('The amount of the write-off of a single credit line should be strictly negative.')) + + # ==== Actions methods ==== + def _action_open_wizard(self): + self.ensure_one() + return { + 'name': _('Write-Off Entry'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.reconcile.wizard', + 'target': 'new', + } + + # ==== Business methods ==== + + def _get_date_after_lock_date(self): + self.ensure_one() + lock_dates = self.company_id._get_violated_lock_dates(self.date, bool(self.tax_id), self.journal_id) + if lock_dates: + return lock_dates[-1][0] + timedelta(days=1) + + def _compute_write_off_taxes_data(self, partner): + """ Computes the data needed to fill the write-off lines related to taxes. + :return: a dict of the form { + 'base_amount': 100.0, + 'base_amount_currency': 200.0, + 'tax_lines_data': [{ + 'tax_amount': 21.0, + 'tax_amount_currency': 42.0, + 'tax_tag_ids': [tax_tags], + 'tax_account_id': id_of_account, + } * nr of repartition lines of the self.tax_id ], + } + """ + AccountTax = self.env['account.tax'] + amount_currency = self.edit_mode_amount_currency or self.amount_currency + amount = self.edit_mode_amount or self.amount + rate = abs(amount_currency / amount) + tax_type = self.tax_id.type_tax_use if self.tax_id else None + is_refund = (tax_type == 'sale' and amount_currency > 0.0) or (tax_type == 'purchase' and amount_currency < 0.0) + base_line = AccountTax._prepare_base_line_for_taxes_computation( + self, + partner_id=partner, + currency_id=self.reco_currency_id, + tax_ids=self.tax_id, + price_unit=amount_currency, + quantity=1.0, + account_id=self.account_id, + is_refund=is_refund, + rate=rate, + special_mode='total_included', + ) + base_lines = [base_line] + AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id) + AccountTax._round_base_lines_tax_details(base_lines, self.company_id) + AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id, include_caba_tags=True) + tax_results = AccountTax._prepare_tax_lines(base_lines, self.company_id) + _base_line, base_to_update = tax_results['base_lines_to_update'][0] # we can only have one baseline + tax_lines_data = [] + for tax_line_vals in tax_results['tax_lines_to_add']: + tax_lines_data.append({ + 'tax_amount': tax_line_vals['balance'], + 'tax_amount_currency': tax_line_vals['amount_currency'], + 'tax_tag_ids': tax_line_vals['tax_tag_ids'], + 'tax_account_id': tax_line_vals['account_id'], + }) + base_amount_currency = base_to_update['amount_currency'] + base_amount = amount - sum(entry['tax_amount'] for entry in tax_lines_data) + + return { + 'base_amount': base_amount, + 'base_amount_currency': base_amount_currency, + 'base_tax_tag_ids': base_to_update['tax_tag_ids'], + 'tax_lines_data': tax_lines_data, + } + + def _create_write_off_lines(self, partner=None): + if not partner: + partner = self.env['res.partner'] + to_partner = self.to_partner_id if self.is_rec_pay_account else partner + tax_data = self._compute_write_off_taxes_data(to_partner) if self.tax_id else None + amount_currency = self.edit_mode_amount_currency or self.amount_currency + amount = self.edit_mode_amount or self.amount + line_ids_commands = [ + Command.create({ + 'name': self.label or _('Write-Off'), + 'account_id': self.reco_account_id.id, + 'partner_id': partner.id, + 'currency_id': self.reco_currency_id.id, + 'amount_currency': -amount_currency, + 'balance': -amount, + }), + Command.create({ + 'name': self.label, + 'account_id': self.account_id.id, + 'partner_id': to_partner.id, + 'currency_id': self.reco_currency_id.id, + 'tax_ids': self.tax_id.ids, + 'tax_tag_ids': None if not tax_data else tax_data['base_tax_tag_ids'], + 'amount_currency': amount_currency if not tax_data else tax_data['base_amount_currency'], + 'balance': amount if not tax_data else tax_data['base_amount'], + }), + ] + # Add taxes lines to the write-off lines, one per repartition line + if tax_data: + for tax_datum in tax_data['tax_lines_data']: + line_ids_commands.append(Command.create({ + 'name': self.tax_id.name, + 'account_id': tax_datum['tax_account_id'], + 'partner_id': to_partner.id, + 'currency_id': self.reco_currency_id.id, + 'tax_tag_ids': tax_datum['tax_tag_ids'], + 'amount_currency': tax_datum['tax_amount_currency'], + 'balance': tax_datum['tax_amount'], + })) + return line_ids_commands + + def create_write_off(self): + """ Create write-off move lines with the data provided in the wizard. """ + self.ensure_one() + partners = self.move_line_ids.partner_id + partner = partners if len(partners) == 1 else None + write_off_vals = { + 'journal_id': self.journal_id.id, + 'company_id': self.company_id.id, + 'date': self._get_date_after_lock_date() or self.date, + 'move_type': 'entry', + 'checked': not self.to_check, + 'line_ids': self._create_write_off_lines(partner=partner) + } + write_off_move = self.env['account.move'].with_context( + skip_invoice_sync=True, + skip_invoice_line_sync=True, + ).create(write_off_vals) + write_off_move.action_post() + return write_off_move + + def create_transfer(self): + """ Create transfer move. + We transfer lines squashed by partner and by currency to keep the partner ledger correct. + By default, the source line and the destination line of each transfer are linked to the same partner. + However, it can happen that the moves that are being reconciled together come from different partners + (e.g. reconciling an invoice of Partner X with a bill of Parnter Y). + In that case, one of the transfer lines should be set to the other partner. + Otherwise, the partner ledger will be wrong for both users. + """ + self.ensure_one() + line_ids = [] + lines_to_transfer = self.move_line_ids.filtered(lambda line: line.account_id == self.transfer_from_account_id) + # The lines that are not transferred will be used to determine the partners of the destination lines + other_lines = self.move_line_ids.filtered(lambda line: line.account_id == self.reco_account_id) + # Default dict for the destination lines. It contains the inverse of the source lines + # {(partner, currency, sign): {'balance': float, 'amount_currency': float}} + default_destination_data = dict() + # Dict generated from the lines linked to the reconcile model. + # It is splitted by partner and represents the possible partners and the amount max that can be used + # for the destination lines. + # {(currency, sign): {(partner): {'balance': float, 'amount_currency': float}}} + amounts_by_partner = defaultdict(lambda: defaultdict(lambda: defaultdict(float))) + # Final dict containing the data from which the destination lines will be created + # {(partner, currency, sign): {'balance': float, 'amount_currency': float}} + destination_data = defaultdict(lambda: defaultdict(float)) + + for (partner, currency), lines_to_transfer_partner in groupby(lines_to_transfer, lambda l: (l.partner_id, l.currency_id)): + amount = sum(line.amount_residual for line in lines_to_transfer_partner) + amount_currency = sum(line.amount_residual_currency for line in lines_to_transfer_partner) + line_ids += [ + Command.create({ + 'name': _('Transfer to %s', self.reco_account_id.display_name), + 'account_id': self.transfer_from_account_id.id, + 'partner_id': partner.id, + 'currency_id': currency.id, + 'amount_currency': -amount_currency, + 'balance': -amount, + }), + ] + sign = -1 if amount < 0 else 1 + # By default, the partner of the destination line will be the same than the one of the source line. + # As the transfer move should be balanced, the amounts defined here represent the total amount that + # will be in the destination lines. + # They may be split between several partners though. + default_destination_data[partner, currency, sign] = { + 'balance': amount, + 'amount_currency': amount_currency, + } + # Run through the lines linked to the reconcile account to determine the possible partners and amounts for the destination + # lines of the transfer + for (partner, currency), lines_to_transfer_partner in groupby(other_lines, lambda l: (l.partner_id, l.currency_id)): + amount = -sum(line.amount_residual for line in lines_to_transfer_partner) + amount_currency = -sum(line.amount_residual_currency for line in lines_to_transfer_partner) + sign = -1 if amount < 0 else 1 + # If the partner, currency and sign of the amounts match one of the default data, it is kept for the destination lines. + # The amounts will be adapted with the remaining amounts. + if (partner, currency, sign) in default_destination_data: + default_amount = default_destination_data[partner, currency, sign]['balance'] + default_amount_currency = default_destination_data[partner, currency, sign]['amount_currency'] + amount_to_transfer = min(abs(amount), abs(default_amount)) * sign + amount_currency_to_transfer = min(abs(amount_currency), abs(default_amount_currency)) * sign + destination_data[partner, currency, sign] = { + 'balance': amount_to_transfer, + 'amount_currency': amount_currency_to_transfer, + } + default_amount -= amount_to_transfer + default_amount_currency -= amount_currency_to_transfer + amount -= amount_to_transfer + amount_currency -= amount_currency_to_transfer + if not currency.is_zero(abs(default_amount)): + # Update the default amounts with the remaining amounts that have not been kept + default_destination_data[partner, currency, sign] = { + 'balance': default_amount, + 'amount_currency': default_amount_currency, + } + else: + # Delete the key if there is no remaining amount + del default_destination_data[partner, currency, sign] + if not currency.is_zero(abs(amount)): + amounts_by_partner[currency, sign][partner] = { + 'balance': amount, + 'amount_currency': amount_currency, + } + # Run through what's left of the default data and try to find a partner that matches the currency and sign. + # If none is found, the partner from the source line is used. + for (partner, currency, sign) in default_destination_data: + amount = default_destination_data[partner, currency, sign]['balance'] + amount_currency = default_destination_data[partner, currency, sign]['amount_currency'] + # Loop on all the partners with the same currency and sign + for p in amounts_by_partner[currency, sign]: + if amount == 0: + break + amount_to_transfer = min(abs(amount), abs(amounts_by_partner[currency, sign][p]['balance'])) * sign + amount_currency_to_transfer = min(abs(amount_currency), abs(amounts_by_partner[currency, sign][p]['amount_currency'])) * sign + amount -= amount_to_transfer + amount_currency -= amount_currency_to_transfer + + destination_data[p, currency, sign]['balance'] += amount_to_transfer + destination_data[p, currency, sign]['amount_currency'] += amount_currency_to_transfer + amounts_by_partner[currency, sign][p]['balance'] -= amount_to_transfer + amounts_by_partner[currency, sign][p]['amount_currency'] -= amount_currency_to_transfer + # Residual amount that couldn't be associated to another partner keeps the partner from the source line + if not currency.is_zero(abs(amount)): + destination_data[partner, currency, sign]['balance'] += amount + destination_data[partner, currency, sign]['amount_currency'] += amount_currency + # Create the destination lines + for (partner, currency, sign) in destination_data: + line_ids += [ + Command.create({ + 'name': _('Transfer from %s', self.transfer_from_account_id.display_name), + 'account_id': self.reco_account_id.id, + 'partner_id': partner.id, + 'currency_id': currency.id, + 'amount_currency': destination_data[partner, currency, sign]['amount_currency'], + 'balance': destination_data[partner, currency, sign]['balance'], + }), + ] + transfer_vals = { + 'journal_id': self.journal_id.id, + 'company_id': self.company_id.id, + 'date': self._get_date_after_lock_date() or self.date, + 'line_ids': line_ids, + } + transfer_move = self.env['account.move'].create(transfer_vals) + transfer_move.action_post() + return transfer_move + + def reconcile(self): + """ Reconcile selected moves, with a transfer and/or write-off move if necessary.""" + self.ensure_one() + move_lines_to_reconcile = self.move_line_ids._origin + do_transfer = self.is_transfer_required + do_write_off = self.edit_mode or (self.is_write_off_required and not self.allow_partials) + if do_transfer: + transfer_move = self.create_transfer() + lines_to_transfer = move_lines_to_reconcile \ + .filtered(lambda line: line.account_id == self.transfer_from_account_id) + transfer_line_from = transfer_move.line_ids \ + .filtered(lambda line: line.account_id == self.transfer_from_account_id) + transfer_line_to = transfer_move.line_ids \ + .filtered(lambda line: line.account_id == self.reco_account_id) + (lines_to_transfer + transfer_line_from).reconcile() + move_lines_to_reconcile = move_lines_to_reconcile - lines_to_transfer + transfer_line_to + + if do_write_off: + write_off_move = self.create_write_off() + write_off_line_to_reconcile = write_off_move.line_ids[0] + move_lines_to_reconcile += write_off_line_to_reconcile + amls_plan = [[move_lines_to_reconcile, write_off_line_to_reconcile]] + else: + amls_plan = [move_lines_to_reconcile] + + self.env['account.move.line']._reconcile_plan(amls_plan) + return move_lines_to_reconcile if not do_transfer else (move_lines_to_reconcile + transfer_move.line_ids) + + def reconcile_open(self): + """ Reconcile selected move lines and open them in dedicated view. """ + self.ensure_one() + return self.reconcile().open_reconcile_view() diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.xml b/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.xml new file mode 100644 index 0000000..f9f751b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/account_reconcile_wizard.xml @@ -0,0 +1,120 @@ + + + + account.reconcile.wizard.form + account.reconcile.wizard + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    + + in + +
    +
    + ( + + + + ) +
    +
    +
    +
    + + in + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_accountant/wizard/reconcile_model_wizard.xml b/dev_odex30_accounting/odex30_account_accountant/wizard/reconcile_model_wizard.xml new file mode 100644 index 0000000..30858ca --- /dev/null +++ b/dev_odex30_accounting/odex30_account_accountant/wizard/reconcile_model_wizard.xml @@ -0,0 +1,45 @@ + + + + + account.reconcile.model.form + account.reconcile.model + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_asset/__init__.py b/dev_odex30_accounting/odex30_account_asset/__init__.py new file mode 100644 index 0000000..35e7c96 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_asset/__manifest__.py b/dev_odex30_accounting/odex30_account_asset/__manifest__.py new file mode 100644 index 0000000..f53d21c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/__manifest__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Assets Management', + 'description': """ +Assets management +================= +Manage assets owned by a company or a person. +Keeps track of depreciations, and creates corresponding journal entries. + + """, + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': 'Expert Co. Ltd.', + 'website': 'http://www.exp-sa.com', 'sequence': 32, + 'depends': ['odex30_account_reports'], + 'data': [ + 'security/account_asset_security.xml', + 'security/ir.model.access.csv', + 'wizard/asset_modify_views.xml', + 'views/account_account_views.xml', + 'views/account_asset_views.xml', + 'views/account_asset_group_views.xml', + 'views/account_move_views.xml', + 'data/assets_report.xml', + 'data/account_report_actions.xml', + 'data/menuitems.xml', + ], + 'demo': [ + 'demo/odex30_account_asset_demo.xml', + ], + 'auto_install': True, + 'assets': { + 'odex30_account_reports.assets_financial_report': [ + 'odex30_odex30_account_asset/static/src/scss/account_asset.scss', + ], + 'web.assets_backend': [ + 'odex30_account_asset/static/src/scss/account_asset.scss', + 'odex30_account_asset/static/src/components/**/*', + 'odex30_account_asset/static/src/views/**/*', + 'odex30_account_asset/static/src/web/**/*', + ], + 'web.assets_web_dark': [ + 'odex30_account_asset/static/src/scss/*.dark.scss', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_asset/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e2a3e96 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/data/account_report_actions.xml b/dev_odex30_accounting/odex30_account_asset/data/account_report_actions.xml new file mode 100644 index 0000000..d85377d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/data/account_report_actions.xml @@ -0,0 +1,8 @@ + + + + Depreciation Schedule + account_report + + + diff --git a/dev_odex30_accounting/odex30_account_asset/data/assets_report.xml b/dev_odex30_accounting/odex30_account_asset/data/assets_report.xml new file mode 100644 index 0000000..cc8f6b3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/data/assets_report.xml @@ -0,0 +1,70 @@ + + + + Depreciation Schedule + optional + + + + + + + + Acquisition Date + acquisition_date + date + + + First Depreciation + first_depreciation + date + + + Method + method + string + + + Duration / Rate + duration_rate + string + + + date from + assets_date_from + + + + + assets_plus + + + - + assets_minus + + + date to + assets_date_to + + + date from + depre_date_from + + + + + depre_plus + + + - + depre_minus + + + date to + depre_date_to + + + book_value + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/data/menuitems.xml b/dev_odex30_accounting/odex30_account_asset/data/menuitems.xml new file mode 100644 index 0000000..88ffc1e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/data/menuitems.xml @@ -0,0 +1,8 @@ + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/demo/account_asset_demo.xml b/dev_odex30_accounting/odex30_account_asset/demo/account_asset_demo.xml new file mode 100644 index 0000000..d18d5b8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/demo/account_asset_demo.xml @@ -0,0 +1,30 @@ + + + + + + Odoo Office + + + + Asset - 5 Years + none + 1000 + + + + + open + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/i18n/ar.po b/dev_odex30_accounting/odex30_account_asset/i18n/ar.po new file mode 100644 index 0000000..b41c9cc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/i18n/ar.po @@ -0,0 +1,1960 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_asset +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-06-20 18:52+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__total_depreciation_entries_count +msgid "# Depreciation Entries" +msgstr "عدد قيود الإهلاك" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__gross_increase_count +msgid "# Gross Increases" +msgstr "عدد الزيادات الإجمالية" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__depreciation_entries_count +msgid "# Posted Depreciation Entries" +msgstr "عدد قيود الإهلاك المُرحلة" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "%(asset)s: Disposal" +msgstr "%(asset)s: التصرف " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "%(asset)s: Sale" +msgstr "%(asset)s: البيع " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "%(months)s m" +msgstr "%(months)s شهور " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "%(move_line)s (%(current)s of %(total)s)" +msgstr "%(move_line)s (%(current)s من %(total)s) " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "%(years)s y" +msgstr "%(years)s سنوات " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "%s (copy)" +msgstr "%s (نسخة)" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"%s Future entries will be recomputed to depreciate the asset following the " +"changes." +msgstr "%s سيتم احتساب القيود المستقبلية لإهلاك الأصل الذي يتبع التغييرات. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "%s: Depreciation" +msgstr "%s: الإهلاك" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "(No %s)" +msgstr "(لا %s) " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "(incl." +msgstr "(شاملة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"A depreciation entry will be posted on and including the date %(date)s. " +"
    %(extra_text)s Future entries will be recomputed to depreciate the " +"asset following the changes." +msgstr "" +"سيتم ترحيل إحدى قيود الإهلاك بتاريخ %(date)s.
    %(extra_text)s سيتم " +"احتساب القيود المستقبلية لإهلاك الأصل الذي يتبع التغييرات. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"A depreciation entry will be posted on and including the date %(date)s.
    " +" A disposal entry will be posted on the %(account_type)s account " +"%(account)s." +msgstr "" +"سيتم ترحيل قيد الإهلاك بتاريخ %(date)s.
    سيتم ترحيل قيد التصرف في " +"%(account_type)s الحساب %(account)s. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"A depreciation entry will be posted on and including the date %(date)s.
    " +" A second entry will neutralize the original income and post the outcome of" +" this sale on account %(account)s." +msgstr "" +"سيتم ترحيل قيد الإهلاك بتاريخ %(date)s.
    سيقوم القيد الثاني بتعطيل الدخل" +" الأصلي وترحيل ناتج عملية البيع هذه في الحساب %(account)s. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "A depreciation entry will be posted on and including the date %s." +msgstr "سيتم ترحيل قيد الإهلاك في التاريخ المتضمن %s." + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "A document linked to %(move_line_name)s has been deleted: %(link)s" +msgstr "تم حذف مستند مرتبط بالحركة %(move_line_name)s: %(link)s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "A document linked to this move has been deleted: %s" +msgstr "تم حذف مستند مرتبط بهذه الحركة: %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "A gross increase has been created: %(link)s" +msgstr "تم إنشاء زيادة إجمالية: %(link)s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"A non deductible tax value of %(tax_value)s was added to %(name)s's initial " +"value of %(purchase_value)s" +msgstr "" +"تمت إضافة قيمة ضريبة غير قابلة للخصم %(tax_value)s إلى قيمة %(name)s " +"المبدئية، وهي %(purchase_value)s " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_account +msgid "Account" +msgstr "الحساب " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__account_type +msgid "" +"Account Type is used for information purpose, to generate country-specific " +"legal reports, and set the rules to close a fiscal year and generate opening" +" entries." +msgstr "" +"يُستخدم نوع الحساب في إنشاء التقارير القانونية المخصصة لكل دولة، وتضع " +"القواعد اللازمة لإقفال السنة المالية وإنشاء القيود الافتتاحية للسنة المالية " +"اللاحقة. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__account_depreciation_id +msgid "Account used in the depreciation entries, to decrease the asset value." +msgstr "الحساب المُستخدَم لقيود الإهلاك، لتخفيض قيمة الأصل. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__account_depreciation_expense_id +msgid "" +"Account used in the periodical entries, to record a part of the asset as " +"expense." +msgstr "الحساب المستخدم للقيود الدورية لتسجيل جزء من الأصل كنفقة. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__account_asset_id +msgid "" +"Account used to record the purchase of the asset at its original price." +msgstr "الحساب المُستخدَم لتسجيل شراء الأصل بسعره الأصلي. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__gain_account_id +msgid "Account used to write the journal item in case of gain" +msgstr "الحساب المُستخدَم لكتابة عنصر اليومية في حالة الربح " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_res_company__gain_account_id +msgid "" +"Account used to write the journal item in case of gain while selling an " +"asset" +msgstr "" +"الحساب المُستخدَم لكتابة عنصر اليومية في حال الربح عند بيع أحد الأصول " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__loss_account_id +msgid "Account used to write the journal item in case of loss" +msgstr "الحساب المُستخدَم لكتابة عنصر اليومية في حال الخسارة " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_res_company__loss_account_id +msgid "" +"Account used to write the journal item in case of loss while selling an " +"asset" +msgstr "" +"الحساب المُستخدَم لكتابة عنصر اليومية في حال الخسارة عند بيع أحد الأصول " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Accounting" +msgstr "المحاسبة " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_report +msgid "Accounting Report" +msgstr "تقرير المحاسبة " + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_acquisition_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__acquisition_date +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_kanban +msgid "Acquisition Date" +msgstr "تاريخ الاستحواذ" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__modify_action +msgid "Action" +msgstr "إجراء" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__active +msgid "Active" +msgstr "نشط" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Add an internal note" +msgstr "إضافة ملاحظة داخلية " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "All the lines should be from the same account" +msgstr "يجب أن تكون كافة البنود من نفس الحساب " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "All the lines should be from the same company" +msgstr "يجب أن تكون كافة البنود من نفس الشركة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "All the lines should be posted" +msgstr "يجب أن تكون كافة البنود مُرحّلة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__already_depreciated_amount_import +msgid "Already Depreciated Amount Import" +msgstr "استيراد المبلغ الذي تم إهلاكه بالفعل " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__parent_id +msgid "An asset has a parent when it is the result of gaining value" +msgstr "يكون للأصل أصل رئيسي عندما يكون نتيجة لزيادة القيمة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "An asset has been created for this move:" +msgstr "تم إنشاء أصل لهذه الحركة: " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_account__asset_model_ids +msgid "" +"An asset wil be created for each asset model when this account is used on a " +"vendor bill or a refund" +msgstr "" +"سيتم إنشاء أصل لكل نموذج من الأصول عند استخدام هذا الحساب في فاتورة المورّد " +"أو عند استرداد الأموال " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "An asset will be created for the value increase of the asset.
    " +msgstr "سيتم إنشاء أصل لزيادة قيمة الأصل.
    " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__analytic_distribution +msgid "Analytic Distribution" +msgstr "التوزيع التحليلي" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__analytic_precision +msgid "Analytic Precision" +msgstr "الدقة التحليلية " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Archived" +msgstr "مؤرشف" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: code:addons/odex30_account_asset/models/account_move.py:0 +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__asset_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Asset" +msgstr "أصل " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Asset Account" +msgstr "حساب الأصل" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Asset Cancelled" +msgstr "تم إلغاء الأصل" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__account_asset_counterpart_id +msgid "Asset Counterpart Account" +msgstr "حساب القيد المناظر للأصل " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_asset_group +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__asset_group_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_group_form_view +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_group_list_view +msgid "Asset Group" +msgstr "مجموعة الأصول " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_id_display_name +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_id_display_name +msgid "Asset Id Display Name" +msgstr "اسم عرض مُعرِّف الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__asset_lifetime_days +msgid "Asset Lifetime Days" +msgstr "أيام عمر الأصل" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_account__asset_model_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_form_asset_inherit +msgid "Asset Model" +msgstr "نموذج الأصل " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Asset Model name" +msgstr "اسم نموذج الأصل " + +#. module: odex30_account_asset +#: model:ir.actions.act_window,name:odex30_account_asset.action_account_asset_model_form +#: model:ir.ui.menu,name:odex30_account_asset.menu_action_account_asset_model_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_tree +msgid "Asset Models" +msgstr "نماذج الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_move_type +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_move_type +msgid "Asset Move Type" +msgstr "نوع حركة الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__name +msgid "Asset Name" +msgstr "اسم الأصل" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__asset_paused_days +msgid "Asset Paused Days" +msgstr "أيام توقف الأصل مؤقتاً" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_value_change +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_value_change +msgid "Asset Value Change" +msgstr "تغير قيمة الأصل " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Asset Values" +msgstr "قيم الأصول " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Asset created" +msgstr "تم إنشاء الأصل" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "Asset created from invoice: %s" +msgstr "الأصل الذي تم إنشاؤه من الفاتورة: %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Asset disposed. %s" +msgstr "تم التخلص من الأصل. %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Asset paused. %s" +msgstr "تم إيقاف الأصل مؤقتاً. %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Asset sold. %s" +msgstr "بيع الأصل. %s" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "Asset unpaused. %s" +msgstr "تم تعطيل الإيقاف المؤقت للأصل %s" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_group_form_view +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_move_form_asset_inherit +msgid "Asset(s)" +msgstr "الأصل (الأصول) " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_asset +msgid "Asset/Revenue Recognition" +msgstr "إثبات الأصل/الإيرادات " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +#: model:ir.actions.act_window,name:odex30_account_asset.action_account_asset_form +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_ids +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_ids +#: model:ir.ui.menu,name:odex30_account_asset.menu_action_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_tree +msgid "Assets" +msgstr "أصول " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_asset_report_handler +msgid "Assets Report Custom Handler" +msgstr "المعالج المخصص لتقارير الأصل " + +#. module: odex30_account_asset +#: model:ir.ui.menu,name:odex30_account_asset.menu_finance_config_assets +msgid "Assets and Revenues" +msgstr "الأصول والإيرادات" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Assets in closed state" +msgstr "الأصول المُقفلة" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Assets in draft and open states" +msgstr "الأصول المسودة والجارية" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"Atleast one asset (%s) couldn't be set as running because it lacks any " +"required information" +msgstr "" +"هناك أصل واحد على الأقل (%s) لم نتمكن من تعيينه كجارٍ لعدم توافر أي من " +"المعلومات المطلوبة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_form_asset_inherit +msgid "Automate Asset" +msgstr "أتمتة الأصل " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_form_asset_inherit +msgid "Automation" +msgstr "تشغيل تلقائي" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__prorata_computation_type__daily_computation +msgid "Based on days per period" +msgstr "بناءً على الأيام لكل فترة " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Bills" +msgstr "الفواتير" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__book_value +msgid "Book Value" +msgstr "القيمة الدفترية " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_account__can_create_asset +msgid "Can Create Asset" +msgstr "يمكن إنشاء الأصل " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Cancel" +msgstr "إلغاء" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Cancel Asset" +msgstr "إلغاء الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__cancelled +msgid "Cancelled" +msgstr "تم الإلغاء " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "Characteristics" +msgstr "الصفات " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__children_ids +msgid "Children" +msgstr "الفروع" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__method +msgid "" +"Choose the method to use to compute the amount of depreciation lines.\n" +" * Straight Line: Calculated on basis of: Gross Value / Duration\n" +" * Declining: Calculated on basis of: Residual Value * Declining Factor\n" +" * Declining then Straight Line: Like Declining but with a minimum depreciation value equal to the straight line value." +msgstr "" +"اختر الطريقة لاستخدامها لاحتساب عدد بنود الإهلاك.\n" +" * القيمة الثابتة: يتم احتسابها على أساس: القيمة الكلية / المدة\n" +" * المنخفضة: يتم احتسابها على أساس: القيمة المتبقية * عامل الانخفاض\n" +" * الانخفاض ثم قيمة ثابتة: مثل القيمة المنخفضة ولكن مع قيمة إهلاك دنيا تساوي القيمة الثابتة. " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__close +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Closed" +msgstr "مغلق" + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_res_company +msgid "Companies" +msgstr "الشركات" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__company_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__company_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__prorata_computation_type +msgid "Computation" +msgstr "احتساب" + +#. module: odex30_account_asset +#: model:ir.actions.server,name:odex30_account_asset.action_account_asset_compute_depreciations +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Compute Depreciation" +msgstr "احتساب الإهلاك " + +#. module: odex30_account_asset +#: model:ir.actions.server,name:odex30_account_asset.action_account_asset_run +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Confirm" +msgstr "تأكيد" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__prorata_computation_type__constant_periods +msgid "Constant Periods" +msgstr "فترات ثابتة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__count_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__count_asset +msgid "Count Asset" +msgstr "احتساب الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__count_linked_asset +msgid "Count Linked Asset" +msgstr "عدد الأصول المرتبطة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__count_linked_assets +msgid "Count Linked Assets" +msgstr "عدّ الأصول المرتبطة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__country_code +msgid "Country Code" +msgstr "رمز الدولة" + +#. module: odex30_account_asset +#: model:ir.actions.server,name:odex30_account_asset.action_account_aml_to_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_account__create_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_move_line_tree +msgid "Create Asset" +msgstr "إنشاء الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_account__create_asset__validate +msgid "Create and validate" +msgstr "إنشاء وتصديق " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_account__create_asset__draft +msgid "Create in draft" +msgstr "إنشاء في مسودة " + +#. module: odex30_account_asset +#: model_terms:ir.actions.act_window,help:odex30_account_asset.action_account_asset_form +msgid "Create new asset" +msgstr "إنشاء أصل جديد " + +#. module: odex30_account_asset +#: model_terms:ir.actions.act_window,help:odex30_account_asset.action_account_asset_model_form +msgid "Create new asset model" +msgstr "إنشاء نموذج أصل جديد " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__create_uid +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__create_uid +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__create_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__create_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_depreciated_value +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_depreciated_value +msgid "Cumulative Depreciation" +msgstr "الإهلاك التراكمي " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__currency_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__currency_id +msgid "Currency" +msgstr "العملة" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Current" +msgstr "الحالي " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Current Values" +msgstr "القيم الحالية " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__invoice_ids +msgid "Customer Invoice" +msgstr "فاتورة العميل" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__date +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Date" +msgstr "التاريخ" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_depreciation_beginning_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_depreciation_beginning_date +msgid "Date of the beginning of the depreciation" +msgstr "تاريخ بداية الإهلاك " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "Dec. then Straight" +msgstr "انخفاض ثم ثبات " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__method__degressive +msgid "Declining" +msgstr "منخفض " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__method_progress_factor +msgid "Declining Factor" +msgstr "عامل الانخفاض " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__method__degressive_then_linear +msgid "Declining then Straight Line" +msgstr "الانخفاض ثم الثبات في خط مستقيم " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__value_residual +msgid "Depreciable Amount" +msgstr "المبلغ القابل للإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__value_residual +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_remaining_value +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_remaining_value +msgid "Depreciable Value" +msgstr "القيمة القابلة للإهلاك " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciated Amount" +msgstr "المبلغ المُهلَك " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__depreciation_value +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__depreciation_value +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__depreciation +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciation" +msgstr "إهلاك" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__account_depreciation_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__account_depreciation_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +msgid "Depreciation Account" +msgstr "حساب الإهلاك" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciation Board" +msgstr "اللائحة الاستهلاكية " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciation Date" +msgstr "تاريخ الإهلاك" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__depreciation_move_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciation Lines" +msgstr "بنود الإهلاك" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Depreciation Method" +msgstr "طريقة الإهلاك" + +#. module: odex30_account_asset +#: model:account.report,name:odex30_account_asset.assets_report +#: model:ir.actions.client,name:odex30_account_asset.action_account_report_assets +#: model:ir.ui.menu,name:odex30_account_asset.menu_action_account_report_assets +msgid "Depreciation Schedule" +msgstr "جدول الإهلاك " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "Depreciation board modified %s" +msgstr "تم تعديل لوح الإهلاك %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "Depreciation entry %(name)s posted (%(value)s)" +msgstr "قيد الإهلاك %(name)s تم ترحيله (%(value)s) " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "Depreciation entry %(name)s reversed (%(value)s)" +msgstr "قيد الإهلاك %(name)s تم عكسه (%(value)s) " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__display_account_asset_id +msgid "Display Account Asset" +msgstr "أصل حساب العرض " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__display_name +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__display_name +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__disposal +msgid "Disposal" +msgstr "التصرف " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__disposal_date +msgid "Disposal Date" +msgstr "تاريخ التصرف " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Disposal Move" +msgstr "حركة التصرف " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Disposal Moves" +msgstr "حركات التصرف " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Dispose" +msgstr "تصرف " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__distribution_analytic_account_ids +msgid "Distribution Analytic Account" +msgstr "حساب التوزيع التحليلي " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__draft +msgid "Draft" +msgstr "مسودة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__draft_asset_exists +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__draft_asset_exists +msgid "Draft Asset Exists" +msgstr "مسودة الأصل موجودة بالفعل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__method_number +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__method_number +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_kanban +msgid "Duration" +msgstr "المدة" + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_duration_rate +msgid "Duration / Rate" +msgstr "المدة / المعدل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__account_depreciation_expense_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__account_depreciation_expense_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +msgid "Expense Account" +msgstr "حساب النفقات " + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_first_depreciation +msgid "First Depreciation" +msgstr "الإهلاك الأول " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__account_asset_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +msgid "Fixed Asset Account" +msgstr "حساب الأصل الثابت " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "أيقونة من Font awesome مثال: fa-tasks " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_account__form_view_ref +msgid "Form View Ref" +msgstr "مرجع عرض الاستمارة " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Future Activities" +msgstr "الأنشطة المستقبلية" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__asset_modify__gain_or_loss__gain +msgid "Gain" +msgstr "الربح " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__gain_account_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_res_company__gain_account_id +msgid "Gain Account" +msgstr "حساب الربح " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__gain_or_loss +msgid "Gain Or Loss" +msgstr "الربح أو الخسارة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__gain_value +msgid "Gain Value" +msgstr "قيمة الربح " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Gross Increase" +msgstr "الزيادة الإجمالية " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__account_asset_id +msgid "Gross Increase Account" +msgstr "حساب الزيادة الإجمالية" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__gross_increase_value +msgid "Gross Increase Value" +msgstr "قيمة الزيادة الإجمالية " + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml:0 +msgid "Group By Account" +msgstr "تجميع حسب حساب" + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml:0 +msgid "Group By Asset Group" +msgstr "تجميع حسب مجموعة الأصول " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Group By..." +msgstr "تجميع حسب.." + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml:0 +msgid "Group by Account" +msgstr "تجميع حسب الحساب " + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml:0 +msgid "Group by Asset Group" +msgstr "تجميع حسب مجموعة الأصول " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__id +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__id +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى النشاط المستثنى. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__message_has_error +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__already_depreciated_amount_import +msgid "" +"In case of an import from another software, you might need to use this field" +" to have the right depreciation table report. This is the value that was " +"already depreciated with entries not computed from this model" +msgstr "" +"في حال الاستيراد من برنامج آخر، قد تحتاج إلى استخدام هذا الحقل حتى يكون لديك" +" تقرير جدول الإهلاك المناسب. هذه هي القيمة التي تم استهلاكها بالفعل مع كون " +"القيود غير محسوبة لهذا النموذج " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__informational_text +msgid "Informational Text" +msgstr "نص معلوماتي " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__invoice_line_ids +msgid "Invoice Line" +msgstr "بند الفاتورة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__salvage_value +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__salvage_value_pct +msgid "It is the amount you plan to have that you cannot depreciate." +msgstr "لا يمكنك إهلاك المبلغ الذي تخطط للحصول عليه. " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__journal_id +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_search +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Journal Entries" +msgstr "قيود اليومية " + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_move +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: odex30_account_asset +#: model:ir.model,name:odex30_account_asset.model_account_move_line +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__original_move_line_ids +msgid "Journal Items" +msgstr "عناصر اليومية" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "" +"Journal Items of %(account)s should have a label in order to generate an " +"asset" +msgstr "" +"يجب أن يكون لعناصر يومية %(account)s علامة تصنيف حتى تتمكن من إنشاء أصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__write_uid +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__write_uid +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__write_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__write_date +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Late Activities" +msgstr "الأنشطة المتأخرة" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "Linear" +msgstr "خطي " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__linked_assets_ids +msgid "Linked Assets" +msgstr "الأصول المرتبطة " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__asset_modify__gain_or_loss__loss +msgid "Loss" +msgstr "خسارة" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__loss_account_id +#: model:ir.model.fields,field_description:odex30_account_asset.field_res_company__loss_account_id +msgid "Loss Account" +msgstr "حساب الخسائر " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_form_asset_inherit +msgid "Manage Items" +msgstr "إدارة العناصر " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_first_method +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__method +msgid "Method" +msgstr "الطريقة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__model_id +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__model +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_kanban +msgid "Model" +msgstr "النموذج " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__asset_properties_definition +msgid "Model Properties" +msgstr "خصائص النموذج " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Modify" +msgstr "تعديل" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: model:ir.model,name:odex30_account_asset.model_asset_modify +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Modify Asset" +msgstr "تعديل الأصل" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Modify Depreciation" +msgstr "تعديل الإهلاكات" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__method_period__1 +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__asset_modify__method_period__1 +msgid "Months" +msgstr "شهور" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_account__multiple_assets_per_line +msgid "Multiple Assets per Line" +msgstr "أصول متعددة لكل بند " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_account__multiple_assets_per_line +msgid "" +"Multiple asset items will be generated depending on the bill line quantity " +"instead of 1 global asset." +msgstr "" +"سوف يتم إنشاء عدة عناصر أصول بناء على كمية بنود الفاتورة عوضاً عن أصل شامل " +"واحد. " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "الموعد النهائي لنشاطاتي " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__negative_revaluation +msgid "Negative revaluation" +msgstr "التقييم السالب " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__net_gain_on_sale +msgid "Net gain on sale" +msgstr "صافي أرباح المبيعات " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__net_gain_on_sale +msgid "Net value of gain or loss on sale of an asset" +msgstr "صافي الأرباح أو الخسائر عند بيع الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__value_residual +msgid "New residual amount for the asset" +msgstr "مبلغ متبقي جديد للأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__salvage_value +msgid "New salvage amount for the asset" +msgstr "مبلغ مسترد جديد للأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "الفعالية التالية في تقويم الأنشطة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_account__create_asset__no +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__asset_modify__gain_or_loss__no +msgid "No" +msgstr "لا" + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml:0 +msgid "No Grouping" +msgstr "بلا تجميع " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__prorata_computation_type__none +msgid "No Prorata" +msgstr "بلاتناسب " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__non_deductible_tax_value +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move_line__non_deductible_tax_value +msgid "Non Deductible Tax Value" +msgstr "قيمة الضريبة غير القابلة للخصم" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__salvage_value +msgid "Not Depreciable Amount" +msgstr "ليس مبلغاً قابلاً للإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__salvage_value +msgid "Not Depreciable Value" +msgstr "ليست قيمة قابلة للإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__salvage_value_pct +msgid "Not Depreciable Value Percent" +msgstr "ليست نسبة قيمة قابلة للإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__name +msgid "Note" +msgstr "الملاحظات" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_tree +msgid "Number of Depreciations" +msgstr "عدد الإهلاكات" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__method_period +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__method_period +msgid "Number of Months in a Period" +msgstr "عدد الشهور في الفترة" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__gross_increase_count +msgid "Number of assets made to increase the value of the asset" +msgstr "عدد الأصول التي تم إنشاؤها لزيادة قيمة الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_bank_statement_line__asset_number_days +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move__asset_number_days +msgid "Number of days" +msgstr "عدد الأيام" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__total_depreciation_entries_count +msgid "Number of depreciation entries (posted or not)" +msgstr "عدد قيود الإهلاك (مُرحّلة أو غير مُرحّلة) " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__paused +msgid "On Hold" +msgstr "قيد الانتظار " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "Open Asset" +msgstr "أصل مفتوح " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__original_value +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_kanban +msgid "Original Value" +msgstr "القيمة الأصلية " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__parent_id +msgid "Parent" +msgstr "الأصل" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Parent Asset" +msgstr "الأصل الرئيسي " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Pause" +msgstr "إيقاف مؤقت " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__paused_prorata_date +msgid "Paused Prorata Date" +msgstr "إيقاف التاريخ النسبي مؤقتاً " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_model_tree +msgid "Period length" +msgstr "طول المدة " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__positive_revaluation +msgid "Positive revaluation" +msgstr "التقييم الموجب " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Posted Entries" +msgstr "القيود المُرحّلة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__asset_properties +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Properties" +msgstr "الخصائص " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__prorata_date +msgid "Prorata Date" +msgstr "التاريخ النسبي " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__purchase +msgid "Purchase" +msgstr "الشراء" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__rating_ids +msgid "Ratings" +msgstr "التقييمات " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "Re-evaluate" +msgstr "إعادة التقييم" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset_group__linked_asset_ids +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_move_line__asset_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_move_line_form_asset_inherit +msgid "Related Assets" +msgstr "الأصول ذات الصلة " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__related_purchase_value +msgid "Related Purchase Value" +msgstr "قيمة الشراء ذات الصلة" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Reset to running" +msgstr "إعادة التعيين كجارٍ " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__activity_user_id +msgid "Responsible User" +msgstr "المستخدم المسؤول" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Resume" +msgstr "المتابعة " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Resume Depreciation" +msgstr "متابعة الإهلاك " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"Reverse the depreciation entries posted in the future in order to modify the" +" depreciation" +msgstr "" +"قم بعكس قيود الإهلاك المُرحّلة إلى المستقبل حتى تتمكن من تعديل الإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__state__open +msgid "Running" +msgstr "جاري" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل النصية القصيرة " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_move__asset_move_type__sale +msgid "Sale" +msgstr "بيع " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Save as Model" +msgstr "حفظ كنموذج" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Save model" +msgstr "حفظ النموذج " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_asset_modify__select_invoice_line_id +msgid "Select Invoice Line" +msgstr "تحديد بند الفاتورة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.asset_modify_form +msgid "Sell" +msgstr "بيع " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Set to Draft" +msgstr "تعيين كمسودة" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Set to Running" +msgstr "التعيين كجارٍ " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Show all records which has next action date is before today" +msgstr "" +"عرض كافة السجلات التي يسبق تاريخ الإجراء التالي فيها تاريخ اليوم الجاري " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "Some fields are missing %s" +msgstr "بعض الحقول مفقودة %s " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Some required values are missing" +msgstr "بعض القيم المطلوبة مفقودة " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__prorata_date +msgid "" +"Starting date of the period used in the prorata calculation of the first " +"depreciation" +msgstr "تاريخ بداية الفترة المستخدمة في الحساب التناسبي للإهلاك الأول " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__state +msgid "Status" +msgstr "الحالة" + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"الأنشطة المعتمدة على الحالة\n" +"المتأخرة: تاريخ الاستحقاق مر\n" +"اليوم: تاريخ النشاط هو اليوم\n" +"المخطط: الأنشطة المستقبلية." + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__method__linear +msgid "Straight Line" +msgstr "خط مستقيم " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__book_value +msgid "" +"Sum of the depreciable value, the salvage value and the book value of all " +"value increase items" +msgstr "" +"إجمالي قيمة الإهلاك، القيمة المستردة والقيمة الدفترية لكافة عناصر زيادة " +"القيمة " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__country_code +msgid "" +"The ISO country code in two chars. \n" +"You can use this field for quick search." +msgstr "" +"كود الدولة حسب المعيار الدولي أيزو المكون من حرفين.\n" +"يمكنك استخدام هذا الحقل لإجراء بحث سريع." + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"The account %(exp_acc)s has been credited by %(exp_delta)s, while the " +"account %(dep_acc)s has been debited by %(dep_delta)s. This corresponds to " +"%(move_count)s cancelled %(word)s:" +msgstr "" +"الحساب %(exp_acc)s تم الإيداع فيه من قِبَل %(exp_delta)s، بينما الحساب " +"%(dep_acc)s تم الخصم منه من قِبَل %(dep_delta)s. يتوافق ذلك مع " +"%(move_count)s الملغية %(word)s: " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__method_period +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__method_period +msgid "The amount of time between two depreciations" +msgstr "المدة الزمنية بين عمليتي إهلاك " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"The amount you have entered (%(entered_amount)s) does not match the Related " +"Purchase's value (%(purchase_value)s). Please make sure this is what you " +"want." +msgstr "" +"لا يطابق المبلغ الذي أدخلته (%(entered_amount)s) قيمة الشراء ذات الصلة " +"(%(purchase_value)s). يرجى التأكد من أن ذلك هو ما تريده. " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__asset_id +msgid "The asset to be modified by this wizard" +msgstr "الأصل بانتظار التعديل من قِبَل مُعالج الربط " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__children_ids +msgid "The children are the gains in value of this asset" +msgstr "الفروع هي الأرباح بالقيمة لهذا الأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__invoice_ids +msgid "" +"The disposal invoice is needed in order to generate the closing journal " +"entry." +msgstr "فاتورة التصرف ضرورية حتى يتم إنشاء قيد إقفال اليومية " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__method_number +msgid "The number of depreciations needed to depreciate your asset" +msgstr "عدد الإهلاكات المطلوبة لإهلاك الأصل " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "The remaining value on the last depreciation line must be 0" +msgstr "يجب أن تكون القيمة المتبقية في آخر بند إهلاك تساوي 0 " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_asset_modify__invoice_line_ids +msgid "There are multiple lines that could be the related to this asset" +msgstr "توجد العديد من البنود التي يمكن أن تكون ذات صلة بهذا الأصل " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"There are unposted depreciations prior to the selected operation date, " +"please deal with them first." +msgstr "" +"توجد عمليات إهلاك غير مُرحّلة قبل تاريخ التشغيل المحدد. يرجى التعامل معها " +"أولاً. " + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/components/move_reversed/move_reversed.xml:0 +msgid "This move has been reversed" +msgstr "لقد تم عكس هذه الحركة " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_search +msgid "Today Activities" +msgstr "أنشطة اليوم " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_assets_report.py:0 +msgid "Total" +msgstr "الإجمالي" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__total_depreciable_value +msgid "Total Depreciable Value" +msgstr "إجمالي القيمة القابلة للاستهلاك" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "Turn as an asset" +msgstr "التحويل كأصل " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__account_type +msgid "Type of the account" +msgstr "نوع الحساب " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط المستثنى في السجل. " + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "Value at Import" +msgstr "القيمة عند الاستيراد" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "Value decrease for: %(asset)s" +msgstr "نقصان القيمة لـ : %(asset)s" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "Value increase for: %(asset)s" +msgstr "زيادة القيمة لـ : %(asset)s" + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__warning_count_assets +msgid "Warning Count Assets" +msgstr "تحذير عدد الأصول " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "Warning for the Original Value of %s" +msgstr "تحذير للقيمة الأصلية %s " + +#. module: odex30_account_asset +#: model:ir.model.fields,field_description:odex30_account_asset.field_account_asset__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع الإلكتروني " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع الإلكتروني " + +#. module: odex30_account_asset +#: model:ir.model.fields,help:odex30_account_asset.field_account_asset__state +msgid "" +"When an asset is created, the status is 'Draft'.\n" +"If the asset is confirmed, the status goes in 'Running' and the depreciation lines can be posted in the accounting.\n" +"The 'On Hold' status can be set manually when you want to pause the depreciation of an asset for some time.\n" +"You can manually close an asset when the depreciation is over.\n" +"By cancelling an asset, all depreciation entries will be reversed" +msgstr "" +"عندما يتم إنشاء أصل، يكون في وضع 'المسودة'.\n" +"إذا تم تأكيد الأصل، تتغير الحالة إلى 'جاري' ويمكن ترحيل بنود الإهلاك في المحاسبة.\n" +"يمكن تعيين الحالة 'قيد الانتظار' يدوياً عندما ترغب في إيقاف إهلاك أصل من الأصول بشكل مؤقت لفترة من الزمن.\n" +"يمكنك إقفال الأصل يدوياً عند انتهاء الإهلاك.\n" +"عند إلغائك للأصل، سيتم عكس كافة قيود الإهلاك " + +#. module: odex30_account_asset +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__account_asset__method_period__12 +#: model:ir.model.fields.selection,name:odex30_account_asset.selection__asset_modify__method_period__12 +msgid "Years" +msgstr "سنوات" + +#. module: odex30_account_asset +#. odoo-javascript +#: code:addons/odex30_account_asset/static/src/views/fields/properties/properties_field.js:0 +msgid "You can add Property fields only on Assets with an Asset Model set." +msgstr "" +"لا يمكنك إضافة حقول الخصائص إلا في الأصول التي تحتوي على مجموعة لنموذج " +"الأصول. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "" +"You can't post an entry related to a draft asset. Please post the asset " +"before." +msgstr "" +"لا يمكنك ترحيل قيد متعلق بأصل في حالة المسودة. يرحى ترحيل الأصل أولاً. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "You can't re-evaluate the asset before the lock date." +msgstr "لا يمكنك إعادة تقييم الأصل قبل تاريخ الإقفال. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"You cannot add or remove bills when the asset is already running or closed." +msgstr "" +"لا يمكنك إضافة أو إزالة الفواتير عندما يكون الأصل جارياً أو مغلقاً بالفعل. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "You cannot archive a record that is not closed" +msgstr "لا يمكنك أرشفة سجل غير مقفل " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "" +"You cannot automate the journal entry for an asset that has a running gross " +"increase. Please use 'Dispose' on the increase(s)." +msgstr "" +"لا يمكنك أتمتة قيد اليومية لأصل ذي زيادة إجمالية جارية. الرجاء استخدام " +"'التصرف' في الزيادة (الزيادات). " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"You cannot create an asset from lines containing credit and debit on the " +"account or with a null amount" +msgstr "" +"لا يمكنك إنشاء أصل من البنود التي تحتوي على رصيد وخصم في الحساب أو بقيمة " +"فارغة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "You cannot delete a document that is in %s state." +msgstr "لا يمكنك حذف ملف في حالة %s." + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "" +"You cannot delete an asset linked to posted entries.\n" +"You should either confirm the asset, then, sell or dispose of it, or cancel the linked journal entries." +msgstr "" +"لا يمكنك حذف أصل مرتبط بقيود مُرحّلة. \n" +"عليك إما تأكيد الأصل ثم بيعه أو التخلص منه أو إلغاء قيود اليومية المرتبطة. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "You cannot dispose of an asset before the lock date." +msgstr "لا يمكنك التخلص من أصل قبل تاريخ الإقفال. " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_move.py:0 +msgid "You cannot reset to draft an entry related to a posted asset" +msgstr "لا يمكنك إعادة تعيين قيد مرتبط بأصل مُرحّل إلى حالة المسودة " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "You cannot resume at a date equal to or before the pause date" +msgstr "لا يمكنك الاستئناف في تاريخ يساوي أو قبل تاريخ الإيقاف المؤقت" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "You cannot select the same account as the Depreciation Account" +msgstr "لا يمكنك تحديد نفس الحساب كحساب الإهلاك " + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_balance +msgid "book_value" +msgstr "book_value" + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_date_from +#: model:account.report.column,name:odex30_account_asset.assets_report_depre_date_from +msgid "date from" +msgstr "التاريخ من" + +#. module: odex30_account_asset +#: model:account.report.column,name:odex30_account_asset.assets_report_assets_date_to +#: model:account.report.column,name:odex30_account_asset.assets_report_depre_date_to +msgid "date to" +msgstr "التاريخ إلى" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "depreciable)" +msgstr "قابل للإهلاك)" + +#. module: odex30_account_asset +#: model_terms:ir.ui.view,arch_db:odex30_account_asset.view_account_asset_form +msgid "e.g. Laptop iBook" +msgstr "مثال: كمبيوتر محمول" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "entries" +msgstr "القيود" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/models/account_asset.py:0 +msgid "entry" +msgstr "دخول" + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "gain" +msgstr "الأرباح " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "gain/loss" +msgstr "الأرباح/الخسائر " + +#. module: odex30_account_asset +#. odoo-python +#: code:addons/odex30_account_asset/wizard/asset_modify.py:0 +msgid "loss" +msgstr "الخسائر " diff --git a/dev_odex30_accounting/odex30_account_asset/models/__init__.py b/dev_odex30_accounting/odex30_account_asset/models/__init__.py new file mode 100644 index 0000000..39358d2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from . import account +from . import account_asset +from . import account_asset_group +from . import account_assets_report +from . import account_move +from . import res_company diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..252000f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..ca2023e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset.cpython-311.pyc new file mode 100644 index 0000000..d3a747a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset_group.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset_group.cpython-311.pyc new file mode 100644 index 0000000..fc49a83 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_asset_group.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_assets_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_assets_report.cpython-311.pyc new file mode 100644 index 0000000..f16422f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_assets_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_move.cpython-311.pyc new file mode 100644 index 0000000..dd1d3af Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/account_move.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/__pycache__/res_company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/res_company.cpython-311.pyc new file mode 100644 index 0000000..0da5f6b Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/models/__pycache__/res_company.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/models/account.py b/dev_odex30_accounting/odex30_account_asset/models/account.py new file mode 100644 index 0000000..213d29a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/account.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ + + +class AccountAccount(models.Model): + _inherit = 'account.account' + + asset_model_ids = fields.Many2many( + 'account.asset', + domain=[('state', '=', 'model')], + help="An asset wil be created for each asset model when this account is used on a vendor bill or a refund", + tracking=True, + ) + create_asset = fields.Selection([('no', 'No'), ('draft', 'Create in draft'), ('validate', 'Create and validate')], + required=True, default='no', tracking=True) + # specify if the account can generate asset depending on it's type. It is used in the account form view + can_create_asset = fields.Boolean(compute="_compute_can_create_asset") + form_view_ref = fields.Char(compute='_compute_can_create_asset') + # decimal quantities are not supported, quantities are rounded to the lower int + multiple_assets_per_line = fields.Boolean(string='Multiple Assets per Line', default=False, tracking=True, + help="Multiple asset items will be generated depending on the bill line quantity instead of 1 global asset.") + + @api.depends('account_type') + def _compute_can_create_asset(self): + for record in self: + record.can_create_asset = record.account_type in ('asset_fixed', 'asset_non_current') + record.form_view_ref = 'odex30_account_asset.view_account_asset_form' + + @api.onchange('create_asset') + def _onchange_multiple_assets_per_line(self): + for record in self: + if record.create_asset == 'no': + record.multiple_assets_per_line = False diff --git a/dev_odex30_accounting/odex30_account_asset/models/account_asset.py b/dev_odex30_accounting/odex30_account_asset/models/account_asset.py new file mode 100644 index 0000000..8eeff98 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/account_asset.py @@ -0,0 +1,1242 @@ +# -*- coding: utf-8 -*- + +import psycopg2 +import datetime +from dateutil.relativedelta import relativedelta +from markupsafe import Markup +from math import copysign + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_is_zero, formatLang +from odoo.tools.date_utils import end_of + +DAYS_PER_MONTH = 30 +DAYS_PER_YEAR = DAYS_PER_MONTH * 12 + +class AccountAsset(models.Model): + _name = 'account.asset' + _description = 'Asset/Revenue Recognition' + _inherit = ['mail.thread', 'mail.activity.mixin', 'analytic.mixin'] + + depreciation_entries_count = fields.Integer(compute='_compute_counts', string='# Posted Depreciation Entries') + gross_increase_count = fields.Integer(compute='_compute_counts', string='# Gross Increases', help="Number of assets made to increase the value of the asset") + total_depreciation_entries_count = fields.Integer(compute='_compute_counts', string='# Depreciation Entries', help="Number of depreciation entries (posted or not)") + + name = fields.Char(string='Asset Name', compute='_compute_name', store=True, required=True, readonly=False, tracking=True) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) + country_code = fields.Char(related='company_id.account_fiscal_country_id.code') + currency_id = fields.Many2one('res.currency', related='company_id.currency_id', store=True) + state = fields.Selection( + selection=[('model', 'Model'), + ('draft', 'Draft'), + ('open', 'Running'), + ('paused', 'On Hold'), + ('close', 'Closed'), + ('cancelled', 'Cancelled')], + string='Status', + copy=False, + default='draft', + readonly=True, + help="When an asset is created, the status is 'Draft'.\n" + "If the asset is confirmed, the status goes in 'Running' and the depreciation lines can be posted in the accounting.\n" + "The 'On Hold' status can be set manually when you want to pause the depreciation of an asset for some time.\n" + "You can manually close an asset when the depreciation is over.\n" + "By cancelling an asset, all depreciation entries will be reversed") + active = fields.Boolean(default=True) + + # Depreciation params + method = fields.Selection( + selection=[ + ('linear', 'Straight Line'), + ('degressive', 'Declining'), + ('degressive_then_linear', 'Declining then Straight Line') + ], + string='Method', + default='linear', + help="Choose the method to use to compute the amount of depreciation lines.\n" + " * Straight Line: Calculated on basis of: Gross Value / Duration\n" + " * Declining: Calculated on basis of: Residual Value * Declining Factor\n" + " * Declining then Straight Line: Like Declining but with a minimum depreciation value equal to the straight line value." + ) + method_number = fields.Integer(string='Duration', default=5, help="The number of depreciations needed to depreciate your asset") + method_period = fields.Selection([('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', default='12', + help="The amount of time between two depreciations") + method_progress_factor = fields.Float(string='Declining Factor', default=0.3) + prorata_computation_type = fields.Selection( + selection=[ + ('none', 'No Prorata'), + ('constant_periods', 'Constant Periods'), + ('daily_computation', 'Based on days per period'), + ], + string="Computation", + required=True, default='constant_periods', + ) + prorata_date = fields.Date( + string='Prorata Date', + compute='_compute_prorata_date', store=True, readonly=False, + help='Starting date of the period used in the prorata calculation of the first depreciation', + required=True, precompute=True, + copy=True, + ) + paused_prorata_date = fields.Date(compute='_compute_paused_prorata_date') # number of days to shift the computation of future deprecations + account_asset_id = fields.Many2one( + 'account.account', + string='Fixed Asset Account', + compute='_compute_account_asset_id', + help="Account used to record the purchase of the asset at its original price.", + store=True, readonly=False, + check_company=True, + domain="[('account_type', '!=', 'off_balance')]", + ) + asset_group_id = fields.Many2one('account.asset.group', string='Asset Group', tracking=True, index=True) + account_depreciation_id = fields.Many2one( + comodel_name='account.account', + string='Depreciation Account', + check_company=True, + domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card', 'off_balance')), ('deprecated', '=', False)]", + help="Account used in the depreciation entries, to decrease the asset value." + ) + account_depreciation_expense_id = fields.Many2one( + comodel_name='account.account', + string='Expense Account', + check_company=True, + domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'asset_cash', 'liability_credit_card', 'off_balance')), ('deprecated', '=', False)]", + help="Account used in the periodical entries, to record a part of the asset as expense.", + ) + asset_type = fields.Selection( + [('sale', 'Sale: Revenue Recognition'), ('purchase', 'Purchase: Asset'), ('expense', 'Deferred Expense')], + compute='_compute_asset_type', store=True, index=True) + + @api.depends('original_move_line_ids') + @api.depends_context('asset_type') + def _compute_asset_type(self): + for record in self: + if not record.asset_type and 'asset_type' in self.env.context: + record.asset_type = self.env.context['asset_type'] + if not record.asset_type and record.original_move_line_ids: + account = record.original_move_line_ids.account_id + record.asset_type = account.asset_type + + journal_id = fields.Many2one( + 'account.journal', + string='Journal', + check_company=True, + domain="[('type', '=', 'general')]", + compute='_compute_journal_id', store=True, readonly=False, + ) + + # Values + original_value = fields.Monetary(string="Original Value", compute='_compute_value', store=True, readonly=False) + book_value = fields.Monetary(string='Book Value', readonly=True, compute='_compute_book_value', recursive=True, store=True, help="Sum of the depreciable value, the salvage value and the book value of all value increase items") + value_residual = fields.Monetary(string='Depreciable Value', compute='_compute_value_residual') + salvage_value = fields.Monetary(string='Not Depreciable Value', + help="It is the amount you plan to have that you cannot depreciate.", + compute="_compute_salvage_value", + store=True, readonly=False) + salvage_value_pct = fields.Float(string='Not Depreciable Value Percent', + help="It is the amount you plan to have that you cannot depreciate.") + total_depreciable_value = fields.Monetary(compute='_compute_total_depreciable_value') + gross_increase_value = fields.Monetary(string="Gross Increase Value", compute="_compute_gross_increase_value", compute_sudo=True) + non_deductible_tax_value = fields.Monetary(string="Non Deductible Tax Value", compute="_compute_non_deductible_tax_value", store=True, readonly=True) + related_purchase_value = fields.Monetary(compute='_compute_related_purchase_value') + + # Links with entries + depreciation_move_ids = fields.One2many('account.move', 'asset_id', string='Depreciation Lines') + original_move_line_ids = fields.Many2many('account.move.line', 'asset_move_line_rel', 'asset_id', 'line_id', string='Journal Items', copy=False) + + asset_properties_definition = fields.PropertiesDefinition('Model Properties') + asset_properties = fields.Properties('Properties', definition='model_id.asset_properties_definition', copy=True) + + # Dates + acquisition_date = fields.Date( + compute='_compute_acquisition_date', store=True, precompute=True, + readonly=False, + copy=True, + ) + disposal_date = fields.Date(readonly=False, compute="_compute_disposal_date", store=True) + + # model-related fields + model_id = fields.Many2one('account.asset', string='Model', change_default=True, domain="[('company_id', '=', company_id)]") + account_type = fields.Selection(string="Type of the account", related='account_asset_id.account_type') + display_account_asset_id = fields.Boolean(compute="_compute_display_account_asset_id") + + # Capital gain + parent_id = fields.Many2one('account.asset', help="An asset has a parent when it is the result of gaining value") + children_ids = fields.One2many('account.asset', 'parent_id', help="The children are the gains in value of this asset") + + # Adapt for import fields + already_depreciated_amount_import = fields.Monetary( + help="In case of an import from another software, you might need to use this field to have the right " + "depreciation table report. This is the value that was already depreciated with entries not computed from this model", + ) + + asset_lifetime_days = fields.Float(compute="_compute_lifetime_days", recursive=True) # total number of days to consider for the computation of an asset depreciation board + asset_paused_days = fields.Float(copy=False) + + net_gain_on_sale = fields.Monetary(string="Net gain on sale", help="Net value of gain or loss on sale of an asset", copy=False) + + linked_assets_ids = fields.One2many( + comodel_name='account.asset', + string="Linked Assets", + compute='_compute_linked_assets', + ) + count_linked_asset = fields.Integer(compute="_compute_linked_assets") + warning_count_assets = fields.Boolean(compute="_compute_linked_assets") + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('company_id') + def _compute_journal_id(self): + for asset in self: + if asset.journal_id and asset.journal_id.company_id == asset.company_id: + asset.journal_id = asset.journal_id + else: + asset.journal_id = self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(asset.company_id), + ('type', '=', 'general'), + ], limit=1) + + @api.depends('salvage_value', 'original_value') + def _compute_total_depreciable_value(self): + for asset in self: + asset.total_depreciable_value = asset.original_value - asset.salvage_value + + @api.depends('original_value', 'model_id') + def _compute_salvage_value(self): + for asset in self: + if asset.model_id.salvage_value_pct != 0.0: + asset.salvage_value = asset.original_value * asset.model_id.salvage_value_pct + + @api.depends('depreciation_move_ids.date', 'state') + def _compute_disposal_date(self): + for asset in self: + if asset.state == 'close': + dates = asset.depreciation_move_ids.filtered(lambda m: m.date).mapped('date') + asset.disposal_date = dates and max(dates) + else: + asset.disposal_date = False + + @api.depends('original_move_line_ids', 'original_move_line_ids.account_id', 'non_deductible_tax_value') + def _compute_value(self): + for record in self: + if not record.original_move_line_ids: + record.original_value = record.original_value or False + continue + if any(line.move_id.state == 'draft' for line in record.original_move_line_ids): + raise UserError(_("All the lines should be posted")) + record.original_value = record.related_purchase_value + if record.non_deductible_tax_value: + record.original_value += record.non_deductible_tax_value + + @api.depends('original_move_line_ids') + @api.depends_context('form_view_ref') + def _compute_display_account_asset_id(self): + for record in self: + # Hide the field when creating an asset model from the CoA. (form_view_ref is set from there) + model_from_coa = self.env.context.get('form_view_ref') and record.state == 'model' + record.display_account_asset_id = not record.original_move_line_ids and not model_from_coa + + @api.depends('account_depreciation_id', 'account_depreciation_expense_id', 'original_move_line_ids') + def _compute_account_asset_id(self): + for record in self: + if record.original_move_line_ids: + if len(record.original_move_line_ids.account_id) > 1: + raise UserError(_("All the lines should be from the same account")) + record.account_asset_id = record.original_move_line_ids.account_id + if not record.account_asset_id: + # Only set a default value, do not erase user inputs + record._onchange_account_depreciation_id() + + @api.depends('original_move_line_ids') + def _compute_analytic_distribution(self): + for asset in self: + distribution_asset = {} + amount_total = sum(asset.original_move_line_ids.mapped("balance")) + if not float_is_zero(amount_total, precision_rounding=asset.currency_id.rounding): + for line in asset.original_move_line_ids._origin: + if line.analytic_distribution: + for account, distribution in line.analytic_distribution.items(): + distribution_asset[account] = distribution_asset.get(account, 0) + distribution * line.balance + for account, distribution_amount in distribution_asset.items(): + distribution_asset[account] = distribution_amount / amount_total + asset.analytic_distribution = distribution_asset if distribution_asset else asset.analytic_distribution + + @api.depends('method_number', 'method_period', 'prorata_computation_type') + def _compute_lifetime_days(self): + for asset in self: + if not asset.parent_id: + if asset.prorata_computation_type == 'daily_computation': + asset.asset_lifetime_days = (asset.prorata_date + relativedelta(months=int(asset.method_period) * asset.method_number) - asset.prorata_date).days + else: + asset.asset_lifetime_days = int(asset.method_period) * asset.method_number * DAYS_PER_MONTH + else: + # if it has a parent, we want the asset to only depreciate on the remaining days left of the parent + if asset.prorata_computation_type == 'daily_computation': + parent_end_date = asset.parent_id.paused_prorata_date + relativedelta(days=int(asset.parent_id.asset_lifetime_days - 1)) + else: + parent_end_date = asset.parent_id.paused_prorata_date + relativedelta( + months=int(asset.parent_id.asset_lifetime_days / DAYS_PER_MONTH), + days=int(asset.parent_id.asset_lifetime_days % DAYS_PER_MONTH) - 1 + ) + asset.asset_lifetime_days = asset._get_delta_days(asset.prorata_date, parent_end_date) + + @api.depends('acquisition_date', 'company_id', 'prorata_computation_type') + def _compute_prorata_date(self): + for asset in self: + if asset.prorata_computation_type == 'none' and asset.acquisition_date: + fiscalyear_date = asset.company_id.compute_fiscalyear_dates(asset.acquisition_date).get('date_from') + asset.prorata_date = fiscalyear_date + else: + asset.prorata_date = asset.acquisition_date + + @api.depends('prorata_date', 'prorata_computation_type', 'asset_paused_days') + def _compute_paused_prorata_date(self): + for asset in self: + if asset.prorata_computation_type == 'daily_computation': + asset.paused_prorata_date = asset.prorata_date + relativedelta(days=asset.asset_paused_days) + else: + asset.paused_prorata_date = asset.prorata_date + relativedelta( + months=int(asset.asset_paused_days / DAYS_PER_MONTH), + days=asset.asset_paused_days % DAYS_PER_MONTH + ) + + @api.depends('original_move_line_ids') + def _compute_related_purchase_value(self): + for asset in self: + related_purchase_value = sum(asset.original_move_line_ids.mapped('balance')) + if asset.account_asset_id.multiple_assets_per_line and len(asset.original_move_line_ids) == 1: + related_purchase_value /= max(1, int(asset.original_move_line_ids.quantity)) + asset.related_purchase_value = related_purchase_value + + @api.depends('original_move_line_ids') + def _compute_acquisition_date(self): + for asset in self: + asset.acquisition_date = asset.acquisition_date or min( + [(aml.invoice_date or aml.date) for aml in asset.original_move_line_ids] + [fields.Date.today()] + ) + + @api.depends('original_move_line_ids') + def _compute_name(self): + for record in self: + record.name = record.name or (record.original_move_line_ids and record.original_move_line_ids[0].name or '') + + @api.depends( + 'original_value', 'salvage_value', 'already_depreciated_amount_import', + 'depreciation_move_ids.state', + 'depreciation_move_ids.depreciation_value', + 'depreciation_move_ids.reversal_move_ids' + ) + def _compute_value_residual(self): + for record in self: + posted_depreciation_moves = record.depreciation_move_ids.filtered(lambda mv: mv.state == 'posted') + record.value_residual = ( + record.original_value + - record.salvage_value + - record.already_depreciated_amount_import + - sum(posted_depreciation_moves.mapped('depreciation_value')) + ) + + @api.depends('value_residual', 'salvage_value', 'children_ids.book_value') + def _compute_book_value(self): + for record in self: + record.book_value = record.value_residual + record.salvage_value + sum(record.children_ids.mapped('book_value')) + if record.state == 'close' and all(move.state == 'posted' for move in record.depreciation_move_ids): + record.book_value -= record.salvage_value + + @api.depends('children_ids.original_value') + def _compute_gross_increase_value(self): + for record in self: + record.gross_increase_value = sum(record.children_ids.mapped('original_value')) + + @api.depends('original_move_line_ids') + def _compute_non_deductible_tax_value(self): + for record in self: + record.non_deductible_tax_value = 0.0 + for line in record.original_move_line_ids: + if line.non_deductible_tax_value: + account = line.account_id + auto_create_multi = account.create_asset != 'no' and account.multiple_assets_per_line + quantity = line.quantity if auto_create_multi else 1 + converted_non_deductible_tax_value = line.currency_id._convert(line.non_deductible_tax_value / quantity, record.currency_id, record.company_id, line.date) + record.non_deductible_tax_value += record.currency_id.round(converted_non_deductible_tax_value) + + @api.depends('depreciation_move_ids.state', 'parent_id') + def _compute_counts(self): + depreciation_per_asset = { + group.id: count + for group, count in self.env['account.move']._read_group( + domain=[ + ('asset_id', 'in', self.ids), + ('state', '=', 'posted'), + ], + groupby=['asset_id'], + aggregates=['__count'], + ) + } + for asset in self: + asset.depreciation_entries_count = depreciation_per_asset.get(asset.id, 0) + asset.total_depreciation_entries_count = len(asset.depreciation_move_ids) + asset.gross_increase_count = len(asset.children_ids) + + @api.depends('original_move_line_ids.asset_ids') + def _compute_linked_assets(self): + for asset in self: + asset.linked_assets_ids = asset.original_move_line_ids.asset_ids - self + asset.count_linked_asset = len(asset.linked_assets_ids) + confirmed_assets = asset.linked_assets_ids.filtered(lambda x: x.state == "open") + # The warning_count_assets is useful to put the smart button in red, in case at least one asset has been confirmed + asset.warning_count_assets = len(confirmed_assets) > 0 + + # ------------------------------------------------------------------------- + # ONCHANGE METHODS + # ------------------------------------------------------------------------- + @api.onchange('account_depreciation_id') + def _onchange_account_depreciation_id(self): + if not self.original_move_line_ids: + if not self.account_asset_id and self.state != 'model': + # Only set a default value since it is visible in the form + self.account_asset_id = self.account_depreciation_id + + @api.onchange('original_value', 'original_move_line_ids') + def _display_original_value_warning(self): + if self.original_move_line_ids: + computed_original_value = self.related_purchase_value + self.non_deductible_tax_value + if self.original_value != computed_original_value: + warning = { + 'title': _("Warning for the Original Value of %s", self.name), + 'message': _("The amount you have entered (%(entered_amount)s) does not match the Related Purchase's value (%(purchase_value)s). " + "Please make sure this is what you want.", + entered_amount=formatLang(self.env, self.original_value, currency_obj=self.currency_id), + purchase_value=formatLang(self.env, computed_original_value, currency_obj=self.currency_id)) + } + return {'warning': warning} + + @api.onchange('original_move_line_ids') + def _onchange_original_move_line_ids(self): + # Force the recompute + self.acquisition_date = False + self._compute_acquisition_date() + + @api.onchange('account_asset_id') + def _onchange_account_asset_id(self): + self.account_depreciation_id = self.account_depreciation_id or self.account_asset_id + + @api.onchange('model_id') + def _onchange_model_id(self): + model = self.model_id + if model: + self.method = model.method + self.method_number = model.method_number + self.method_period = model.method_period + self.method_progress_factor = model.method_progress_factor + self.prorata_computation_type = model.prorata_computation_type + self.analytic_distribution = model.analytic_distribution or self.analytic_distribution + self.account_asset_id = model.account_asset_id + self.account_depreciation_id = model.account_depreciation_id + self.account_depreciation_expense_id = model.account_depreciation_expense_id + self.journal_id = model.journal_id + + @api.onchange('original_value', 'salvage_value', 'acquisition_date', 'method', 'method_progress_factor', 'method_period', + 'method_number', 'prorata_computation_type', 'already_depreciated_amount_import', 'prorata_date',) + def onchange_consistent_board(self): + """ When changing the fields that should change the values of the entries, we unlink the entries, so the + depreciation board is not inconsistent with the values of the asset""" + self.write( + {'depreciation_move_ids': [Command.set([])]} + ) + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + @api.constrains('active', 'state') + def _check_active(self): + for record in self: + if not record.active and record.state not in ('close', 'model'): + raise UserError(_('You cannot archive a record that is not closed')) + + @api.constrains('depreciation_move_ids') + def _check_depreciations(self): + for asset in self: + if ( + asset.state == 'open' + and asset.depreciation_move_ids + and not asset.currency_id.is_zero( + asset.depreciation_move_ids.sorted(lambda x: (x.date, x.id))[-1].asset_remaining_value + ) + ): + raise UserError(_("The remaining value on the last depreciation line must be 0")) + + @api.constrains('original_move_line_ids') + def _check_related_purchase(self): + for asset in self: + if asset.original_move_line_ids and asset.related_purchase_value == 0: + raise UserError(_("You cannot create an asset from lines containing credit and debit on the account or with a null amount")) + if asset.state not in ('model', 'draft'): + raise UserError(_("You cannot add or remove bills when the asset is already running or closed.")) + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + @api.ondelete(at_uninstall=True) + def _unlink_if_model_or_draft(self): + for asset in self: + if asset.state in ['open', 'paused', 'close']: + raise UserError(_( + 'You cannot delete a document that is in %s state.', + dict(self._fields['state']._description_selection(self.env)).get(asset.state) + )) + + posted_amount = len(asset.depreciation_move_ids.filtered(lambda x: x.state == 'posted')) + if posted_amount > 0: + raise UserError(_('You cannot delete an asset linked to posted entries.' + '\nYou should either confirm the asset, then, sell or dispose of it,' + ' or cancel the linked journal entries.')) + + def unlink(self): + for asset in self: + for line in asset.original_move_line_ids: + if line.name: + body = _('A document linked to %(move_line_name)s has been deleted: %(link)s', + move_line_name=line.name, + link=asset._get_html_link(), + ) + else: + body = _('A document linked to this move has been deleted: %s', + asset._get_html_link()) + line.move_id.message_post(body=body) + if len(line.move_id.asset_ids) == 1: + line.move_id.asset_move_type = False + return super(AccountAsset, self).unlink() + + def copy_data(self, default=None): + vals_list = super().copy_data(default) + for asset, vals in zip(self, vals_list): + if asset.state == 'model': + vals['state'] = 'model' + vals['name'] = _('%s (copy)', asset.name) + vals['account_asset_id'] = asset.account_asset_id.id + return vals_list + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'state' in vals and vals['state'] != 'draft' and not (set(vals) - set({'account_depreciation_id', 'account_depreciation_expense_id', 'journal_id'})): + raise UserError(_("Some required values are missing")) + if self._context.get('default_state') != 'model' and vals.get('state') != 'model': + vals['state'] = 'draft' + new_recs = super(AccountAsset, self.with_context(mail_create_nolog=True)).create(vals_list) + # if original_value is passed in vals, make sure the right value is set (as a different original_value may have been computed by _compute_value()) + for i, vals in enumerate(vals_list): + if 'original_value' in vals: + new_recs[i].original_value = vals['original_value'] + if self.env.context.get('original_asset'): + # When original_asset is set, only one asset is created since its from the form view + original_asset = self.env['account.asset'].browse(self.env.context.get('original_asset')) + original_asset.model_id = new_recs + return new_recs + + def write(self, vals): + result = super().write(vals) + for asset in self: + for move in asset.depreciation_move_ids: + if move.state == 'draft' and 'analytic_distribution' in vals: + # Only draft entries to avoid recreating all the analytic items + move.line_ids.analytic_distribution = vals['analytic_distribution'] + lock_date = move.company_id._get_user_fiscal_lock_date(asset.journal_id) + if move.date > lock_date: + if 'account_depreciation_id' in vals: + # ::2 (0, 2, 4, ...) because we want all first lines of the depreciation entries, which corresponds to the + # lines with account_depreciation_id as account + move.line_ids[::2].account_id = vals['account_depreciation_id'] + if 'account_depreciation_expense_id' in vals: + # 1::2 (1, 3, 5, ...) because we want all second lines of the depreciation entries, which corresponds to the + # lines with account_depreciation_expense_id as account + move.line_ids[1::2].account_id = vals['account_depreciation_expense_id'] + if 'journal_id' in vals: + move.journal_id = vals['journal_id'] + return result + + # ------------------------------------------------------------------------- + # BOARD COMPUTATION + # ------------------------------------------------------------------------- + def _get_linear_amount(self, days_before_period, days_until_period_end, total_depreciable_value): + + amount_expected_previous_period = total_depreciable_value * days_before_period / self.asset_lifetime_days + amount_after_expected = total_depreciable_value * days_until_period_end / self.asset_lifetime_days + number_days_for_period = days_until_period_end - days_before_period + # In case of a decrease, we need to lower the amount of the depreciation with the amount of the decrease + # spread over the remaining lifetime + amount_of_decrease_spread_over_period = [ + number_days_for_period * mv.depreciation_value / (self.asset_lifetime_days - self._get_delta_days(self.paused_prorata_date, mv.asset_depreciation_beginning_date)) + for mv in self.depreciation_move_ids.filtered(lambda mv: mv.asset_value_change) + ] + computed_linear_amount = self.currency_id.round(amount_after_expected - self.currency_id.round(amount_expected_previous_period) - sum(amount_of_decrease_spread_over_period)) + return computed_linear_amount + + def _compute_board_amount(self, residual_amount, period_start_date, period_end_date, days_already_depreciated, + days_left_to_depreciated, residual_declining, start_yearly_period=None, total_lifetime_left=None, + residual_at_compute=None, start_recompute_date=None): + + def _get_max_between_linear_and_degressive(linear_amount, effective_start_date=start_yearly_period): + """ + Compute the degressive amount that could be depreciated and returns the biggest between it and linear_amount + The degressive amount corresponds to the difference between what should have been depreciated at the end of + the period and the residual_amount (to deal with rounding issues at the end of each month) + """ + fiscalyear_dates = self.company_id.compute_fiscalyear_dates(period_end_date) + days_in_fiscalyear = self._get_delta_days(fiscalyear_dates['date_from'], fiscalyear_dates['date_to']) + + degressive_total_value = residual_declining * (1 - self.method_progress_factor * self._get_delta_days(effective_start_date, period_end_date) / days_in_fiscalyear) + degressive_amount = residual_amount - degressive_total_value + return self._degressive_linear_amount(residual_amount, degressive_amount, linear_amount) + + if float_is_zero(self.asset_lifetime_days, 2) or float_is_zero(residual_amount, 2): + return 0, 0 + + days_until_period_end = self._get_delta_days(self.paused_prorata_date, period_end_date) + days_before_period = self._get_delta_days(self.paused_prorata_date, period_start_date + relativedelta(days=-1)) + days_before_period = max(days_before_period, 0) # if disposed before the beginning of the asset for example + number_days = days_until_period_end - days_before_period + + # The amount to depreciate are computed by computing how much the asset should be depreciated at the end of the + # period minus how much difference it is actually depreciated. It is done that way to avoid having the last move to take + # every single small difference that could appear over the time with the classic computation method. + if self.method == 'linear': + if total_lifetime_left and float_compare(total_lifetime_left, 0, 2) > 0: + computed_linear_amount = residual_amount - residual_at_compute * (1 - self._get_delta_days(start_recompute_date, period_end_date) / total_lifetime_left) + else: + computed_linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + amount = min(computed_linear_amount, residual_amount, key=abs) + elif self.method == 'degressive': + # Linear amount + # We first calculate the total linear amount for the period left from the beginning of the year + # to get the linear amount for the period in order to avoid big delta at the end of the period + effective_start_date = max(start_yearly_period, self.paused_prorata_date) if start_yearly_period else self.paused_prorata_date + days_left_from_beginning_of_year = self._get_delta_days(effective_start_date, period_start_date - relativedelta(days=1)) + days_left_to_depreciated + expected_remaining_value_with_linear = residual_declining - residual_declining * self._get_delta_days(effective_start_date, period_end_date) / days_left_from_beginning_of_year + linear_amount = residual_amount - expected_remaining_value_with_linear + + amount = _get_max_between_linear_and_degressive(linear_amount, effective_start_date) + elif self.method == 'degressive_then_linear': + if not self.parent_id: + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + else: + # we want to know the amount before the reeval for the parent so the child can follow the same curve, + # so it transitions from degressive to linear at the same moment + parent_moves = self.parent_id.depreciation_move_ids.filtered(lambda mv: mv.date <= self.prorata_date).sorted(key=lambda mv: (mv.date, mv.id)) + parent_cumulative_depreciation = parent_moves[-1].asset_depreciated_value if parent_moves else self.parent_id.already_depreciated_amount_import + parent_depreciable_value = parent_moves[-1].asset_remaining_value if parent_moves else self.parent_id.total_depreciable_value + if self.currency_id.is_zero(parent_depreciable_value): + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, self.total_depreciable_value) + else: + # To have the same curve as the parent, we need to have the equivalent amount before the reeval. + # The child's depreciable value corresponds to the amount that is left to depreciate for the parent. + # So, we use the proportion between them to compute the equivalent child's total to depreciate. + # We use it then with the duration of the parent to compute the depreciation amount + depreciable_value = self.total_depreciable_value * (1 + parent_cumulative_depreciation/parent_depreciable_value) + linear_amount = self._get_linear_amount(days_before_period, days_until_period_end, depreciable_value) * self.asset_lifetime_days / self.parent_id.asset_lifetime_days + + amount = _get_max_between_linear_and_degressive(linear_amount) + + amount = max(amount, 0) if self.currency_id.compare_amounts(residual_amount, 0) > 0 else min(amount, 0) + amount = self._get_depreciation_amount_end_of_lifetime(residual_amount, amount, days_until_period_end) + + return number_days, self.currency_id.round(amount) + + def compute_depreciation_board(self, date=False): + # Need to unlink draft moves before adding new ones because if we create new moves before, it will cause an error + self.depreciation_move_ids.filtered(lambda mv: mv.state == 'draft' and (mv.date >= date if date else True)).unlink() + + new_depreciation_moves_data = [] + for asset in self: + new_depreciation_moves_data.extend(asset._recompute_board(date)) + + new_depreciation_moves = self.env['account.move'].create(new_depreciation_moves_data) + new_depreciation_moves_to_post = new_depreciation_moves.filtered(lambda move: move.asset_id.state == 'open') + # In case of the asset is in running mode, we post in the past and set to auto post move in the future + new_depreciation_moves_to_post._post() + + def _recompute_board(self, start_depreciation_date=False): + self.ensure_one() + # All depreciation moves that are posted + posted_depreciation_move_ids = self.depreciation_move_ids.filtered( + lambda mv: mv.state == 'posted' and not mv.asset_value_change + ).sorted(key=lambda mv: (mv.date, mv.id)) + + imported_amount = self.already_depreciated_amount_import + residual_amount = self.value_residual - sum(self.depreciation_move_ids.filtered(lambda mv: mv.state == 'draft').mapped('depreciation_value')) + if not posted_depreciation_move_ids: + residual_amount += imported_amount + residual_declining = residual_at_compute = residual_amount + # start_yearly_period is needed in the 'degressive' and 'degressive_then_linear' methods to compute the amount when the period is monthly + start_recompute_date = start_depreciation_date = start_yearly_period = start_depreciation_date or self.paused_prorata_date + + last_day_asset = self._get_last_day_asset() + final_depreciation_date = self._get_end_period_date(last_day_asset) + total_lifetime_left = self._get_delta_days(start_depreciation_date, last_day_asset) + + depreciation_move_values = [] + if not float_is_zero(self.value_residual, precision_rounding=self.currency_id.rounding): + while not self.currency_id.is_zero(residual_amount) and start_depreciation_date < final_depreciation_date: + period_end_depreciation_date = self._get_end_period_date(start_depreciation_date) + period_end_fiscalyear_date = self.company_id.compute_fiscalyear_dates(period_end_depreciation_date).get('date_to') + lifetime_left = self._get_delta_days(start_depreciation_date, last_day_asset) + + days, amount = self._compute_board_amount(residual_amount, start_depreciation_date, period_end_depreciation_date, False, lifetime_left, residual_declining, start_yearly_period, total_lifetime_left, residual_at_compute, start_recompute_date) + residual_amount -= amount + + if not posted_depreciation_move_ids: + # self.already_depreciated_amount_import management. + # Subtracts the imported amount from the first depreciation moves until we reach it + # (might skip several depreciation entries) + if abs(imported_amount) <= abs(amount): + amount -= imported_amount + imported_amount = 0 + else: + imported_amount -= amount + amount = 0 + + if self.method == 'degressive_then_linear' and final_depreciation_date < period_end_depreciation_date: + period_end_depreciation_date = final_depreciation_date + + if not float_is_zero(amount, precision_rounding=self.currency_id.rounding): + # For deferred revenues, we should invert the amounts. + depreciation_move_values.append(self.env['account.move']._prepare_move_for_asset_depreciation({ + 'amount': amount, + 'asset_id': self, + 'depreciation_beginning_date': start_depreciation_date, + 'date': period_end_depreciation_date, + 'asset_number_days': days, + })) + + if period_end_depreciation_date == period_end_fiscalyear_date: + start_yearly_period = self.company_id.compute_fiscalyear_dates(period_end_depreciation_date).get('date_from') + relativedelta(years=1) + residual_declining = residual_amount + + start_depreciation_date = period_end_depreciation_date + relativedelta(days=1) + + return depreciation_move_values + + def _get_end_period_date(self, start_depreciation_date): + """Get the end of the period in which the depreciation is posted. + + Can be the end of the month if the asset is depreciated monthly, or the end of the fiscal year is it is depreciated yearly. + """ + self.ensure_one() + fiscalyear_date = self.company_id.compute_fiscalyear_dates(start_depreciation_date).get('date_to') + period_end_depreciation_date = fiscalyear_date if start_depreciation_date <= fiscalyear_date else fiscalyear_date + relativedelta(years=1) + + if self.method_period == '1': # If method period is set to monthly computation + max_day_in_month = end_of(datetime.date(start_depreciation_date.year, start_depreciation_date.month, 1), 'month').day + period_end_depreciation_date = min(start_depreciation_date.replace(day=max_day_in_month), period_end_depreciation_date) + return period_end_depreciation_date + + def _get_delta_days(self, start_date, end_date): + """Compute how many days there are between 2 dates. + + The computation is different if the asset is in daily_computation or not. + """ + self.ensure_one() + if self.prorata_computation_type == 'daily_computation': + # Compute how many days there are between 2 dates using a daily_computation method + return (end_date - start_date).days + 1 + else: + # Compute how many days there are between 2 dates counting 30 days per month + # Get how many days there are in the start date month + start_date_days_month = end_of(start_date, 'month').day + # Get how many days there are in the start date month (e.g: June 20th: (30 * (30 - 20 + 1)) / 30 = 11) + start_prorata = (start_date_days_month - start_date.day + 1) / start_date_days_month + # Get how many days there are in the end date month (e.g: You're the August 14th: (14 * 30) / 31 = 13.548387096774194) + end_prorata = end_date.day / end_of(end_date, 'month').day + # Compute how many days there are between these 2 dates + # e.g: 13.548387096774194 + 11 + 360 * (2020 - 2020) + 30 * (8 - 6 - 1) = 24.548387096774194 + 360 * 0 + 30 * 1 = 54.548387096774194 day + return sum(( + start_prorata * DAYS_PER_MONTH, + end_prorata * DAYS_PER_MONTH, + (end_date.year - start_date.year) * DAYS_PER_YEAR, + (end_date.month - start_date.month - 1) * DAYS_PER_MONTH + )) + + def _get_last_day_asset(self): + this = self.parent_id if self.parent_id else self + return this.paused_prorata_date + relativedelta(months=int(this.method_period) * this.method_number, days=-1) + + # ------------------------------------------------------------------------- + # PUBLIC ACTIONS + # ------------------------------------------------------------------------- + + def action_open_linked_assets(self): + action = self.linked_assets_ids.open_asset(['list', 'form']) + action.get('context', {}).update({ + 'from_linked_assets': 0, + }) + return action + + def action_asset_modify(self): + """ Returns an action opening the asset modification wizard. + """ + self.ensure_one() + new_wizard = self.env['asset.modify'].create({ + 'asset_id': self.id, + 'modify_action': 'resume' if self.env.context.get('resume_after_pause') else 'dispose', + }) + return { + 'name': _('Modify Asset'), + 'view_mode': 'form', + 'res_model': 'asset.modify', + 'type': 'ir.actions.act_window', + 'target': 'new', + 'res_id': new_wizard.id, + 'context': self.env.context, + } + + def action_save_model(self): + return { + 'name': _('Save model'), + 'views': [[self.env.ref('odex30_account_asset.view_account_asset_form').id, "form"]], + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'context': { + 'default_state': 'model', + 'default_account_asset_id': self.account_asset_id.id, + 'default_account_depreciation_id': self.account_depreciation_id.id, + 'default_account_depreciation_expense_id': self.account_depreciation_expense_id.id, + 'default_journal_id': self.journal_id.id, + 'default_method': self.method, + 'default_method_number': self.method_number, + 'default_method_period': self.method_period, + 'default_method_progress_factor': self.method_progress_factor, + 'default_prorata_date': self.prorata_date, + 'default_prorata_computation_type': self.prorata_computation_type, + 'default_analytic_distribution': self.analytic_distribution, + 'original_asset': self.id, + } + } + + def open_entries(self): + return { + 'name': _('Journal Entries'), + 'view_mode': 'list,form', + 'res_model': 'account.move', + 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'], + 'views': [(self.env.ref('account.view_move_tree').id, 'list'), (False, 'form')], + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.depreciation_move_ids.ids)], + 'context': dict(self._context, create=False), + } + + def open_related_entries(self): + return { + 'name': _('Journal Items'), + 'view_mode': 'list,form', + 'res_model': 'account.move.line', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.original_move_line_ids.ids)], + } + + def open_increase(self): + result = { + 'name': _('Gross Increase'), + 'view_mode': 'list,form', + 'res_model': 'account.asset', + 'context': {**self.env.context, 'create': False}, + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.children_ids.ids)], + 'views': [(False, 'list'), (False, 'form')], + } + if len(self.children_ids) == 1: + result['views'] = [(False, 'form')] + result['res_id'] = self.children_ids.id + return result + + def open_parent_id(self): + result = { + 'name': _('Parent Asset'), + 'view_mode': 'form', + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'res_id': self.parent_id.id, + 'views': [(False, 'form')], + } + return result + + def validate(self): + fields = [ + 'method', + 'method_number', + 'method_period', + 'method_progress_factor', + 'salvage_value', + 'original_move_line_ids', + ] + ref_tracked_fields = self.env['account.asset'].fields_get(fields) + self.write({'state': 'open'}) + for asset in self: + tracked_fields = ref_tracked_fields.copy() + if asset.method == 'linear': + del tracked_fields['method_progress_factor'] + dummy, tracking_value_ids = asset._mail_track(tracked_fields, dict.fromkeys(fields)) + asset_name = (_('Asset created'), _('An asset has been created for this move:')) + msg = asset_name[1] + ' ' + asset._get_html_link() + asset.message_post(body=asset_name[0], tracking_value_ids=tracking_value_ids) + for move_id in asset.original_move_line_ids.mapped('move_id'): + move_id.message_post(body=msg) + try: + if not asset.depreciation_move_ids: + asset.compute_depreciation_board() + asset._check_depreciations() + asset.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post() + except psycopg2.errors.CheckViolation: + raise ValidationError(_("Atleast one asset (%s) couldn't be set as running because it lacks any required information", asset.name)) + + if asset.account_asset_id.create_asset == 'no': + asset._post_non_deductible_tax_value() + + def set_to_close(self, invoice_line_ids, date=None, message=None): + self.ensure_one() + disposal_date = date or fields.Date.today() + if disposal_date <= self.company_id._get_user_fiscal_lock_date(self.journal_id): + raise UserError(_("You cannot dispose of an asset before the lock date.")) + if invoice_line_ids and self.children_ids.filtered(lambda a: a.state in ('draft', 'open') or a.value_residual > 0): + raise UserError(_("You cannot automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s).")) + full_asset = self + self.children_ids + full_asset.state = 'close' + move_ids = full_asset._get_disposal_moves([invoice_line_ids] * len(full_asset), disposal_date) + for asset in full_asset: + asset.message_post(body= + _('Asset sold. %s', message if message else "") + if invoice_line_ids else + _('Asset disposed. %s', message if message else "") + ) + + selling_price = abs(sum(invoice_line.balance for invoice_line in invoice_line_ids)) + self.net_gain_on_sale = self.currency_id.round(selling_price - self.book_value) + + if move_ids: + name = _('Disposal Move') + view_mode = 'form' + if len(move_ids) > 1: + name = _('Disposal Moves') + view_mode = 'list,form' + return { + 'name': name, + 'view_mode': view_mode, + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'target': 'current', + 'res_id': move_ids[0], + 'domain': [('id', 'in', move_ids)] + } + + def set_to_cancelled(self): + for asset in self: + posted_moves = asset.depreciation_move_ids.filtered(lambda m: ( + not m.reversal_move_ids + and not m.reversed_entry_id + and m.state == 'posted' + )) + if posted_moves: + depreciation_change = sum(posted_moves.line_ids.mapped( + lambda l: l.debit if l.account_id == asset.account_depreciation_expense_id else 0.0 + )) + acc_depreciation_change = sum(posted_moves.line_ids.mapped( + lambda l: l.credit if l.account_id == asset.account_depreciation_id else 0.0 + )) + entries = Markup('
    ').join(posted_moves.sorted('date').mapped(lambda m: + f'{m.ref} - {m.date} - ' + f'{formatLang(self.env, m.depreciation_value, currency_obj=m.currency_id)} - ' + f'{m.name}' + )) + asset._cancel_future_moves(datetime.date.min) + msg = _('Asset Cancelled') + Markup('
    ') + \ + _('The account %(exp_acc)s has been credited by %(exp_delta)s, ' + 'while the account %(dep_acc)s has been debited by %(dep_delta)s. ' + 'This corresponds to %(move_count)s cancelled %(word)s:', + exp_acc=asset.account_depreciation_expense_id.display_name, + exp_delta=formatLang(self.env, depreciation_change, currency_obj=asset.currency_id), + dep_acc=asset.account_depreciation_id.display_name, + dep_delta=formatLang(self.env, acc_depreciation_change, currency_obj=asset.currency_id), + move_count=len(posted_moves), + word=_('entries') if len(posted_moves) > 1 else _('entry'), + ) + Markup('
    ') + entries + asset._message_log(body=msg) + else: + asset._message_log(body=_('Asset Cancelled')) + asset.depreciation_move_ids.filtered(lambda m: m.state == 'draft').with_context(force_delete=True).unlink() + asset.asset_paused_days = 0 + asset.write({'state': 'cancelled'}) + + def set_to_draft(self): + self.write({'state': 'draft'}) + + def set_to_running(self): + if self.depreciation_move_ids and not max(self.depreciation_move_ids, key=lambda m: (m.date, m.id)).asset_remaining_value == 0: + self.env['asset.modify'].create({'asset_id': self.id, 'name': _('Reset to running')}).modify() + self.write({ + 'state': 'open', + 'net_gain_on_sale': 0 + }) + + def resume_after_pause(self): + """ Sets an asset in 'paused' state back to 'open'. + A Depreciation line is created automatically to remove from the + depreciation amount the proportion of time spent + in pause in the current period. + """ + self.ensure_one() + return self.with_context(resume_after_pause=True).action_asset_modify() + + def pause(self, pause_date, message=None): + """ Sets an 'open' asset in 'paused' state, generating first a depreciation + line corresponding to the ratio of time spent within the current depreciation + period before putting the asset in pause. This line and all the previous + unposted ones are then posted. + """ + self.ensure_one() + self._create_move_before_date(pause_date) + self.write({'state': 'paused'}) + self.message_post(body=_("Asset paused. %s", message if message else "")) + + def open_asset(self, view_mode): + if len(self) == 1: + view_mode = ['form'] + views = [v for v in [(False, 'list'), (False, 'form')] if v[1] in view_mode] + ctx = dict(self._context) + ctx.pop('default_move_type', None) + action = { + 'name': _('Asset'), + 'view_mode': ','.join(view_mode), + 'type': 'ir.actions.act_window', + 'res_id': self.id if 'list' not in view_mode else False, + 'res_model': 'account.asset', + 'views': views, + 'domain': [('id', 'in', self.ids)], + 'context': ctx + } + return action + + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + def _insert_depreciation_line(self, amount, beginning_depreciation_date, depreciation_date, days_depreciated): + """ Inserts a new line in the depreciation board, shifting the sequence of + all the following lines from one unit. + :param amount: The depreciation amount of the new line. + :param label: The name to give to the new line. + :param date: The date to give to the new line. + """ + self.ensure_one() + AccountMove = self.env['account.move'] + + return AccountMove.create(AccountMove._prepare_move_for_asset_depreciation({ + 'amount': amount, + 'asset_id': self, + 'depreciation_beginning_date': beginning_depreciation_date, + 'date': depreciation_date, + 'asset_number_days': days_depreciated, + })) + + def _post_non_deductible_tax_value(self): + # If the asset has a non-deductible tax, the value is posted in the chatter to explain why + # the original value does not match the related purchase(s). + if self.non_deductible_tax_value: + currency = self.env.company.currency_id + msg = _('A non deductible tax value of %(tax_value)s was added to %(name)s\'s initial value of %(purchase_value)s', + tax_value=formatLang(self.env, self.non_deductible_tax_value, currency_obj=currency), + name=self.name, + purchase_value=formatLang(self.env, self.related_purchase_value, currency_obj=currency)) + self.message_post(body=msg) + + def _create_move_before_date(self, date): + """Cancel all the moves after the given date and replace them by a new one. + + The new depreciation/move is depreciating the residual value. + """ + all_move_dates_before_date = (self.depreciation_move_ids.filtered( + lambda x: + x.date <= date + and not x.reversal_move_ids + and not x.reversed_entry_id + and x.state == 'posted' + ).sorted('date')).mapped('date') + + beginning_fiscal_year = self.company_id.compute_fiscalyear_dates(date).get('date_from') if self.method != 'linear' else False + first_fiscalyear_move = self.env['account.move'] + if all_move_dates_before_date: + last_move_date_not_reversed = max(all_move_dates_before_date) + # We don't know when begins the period that the move is supposed to cover + # So, we use the earliest beginning of a move that comes after the last move not cancelled + future_moves_beginning_date = self.depreciation_move_ids.filtered( + lambda m: m.date > last_move_date_not_reversed and ( + not m.reversal_move_ids and not m.reversed_entry_id and m.state == 'posted' + or m.state == 'draft' + ) + ).mapped('asset_depreciation_beginning_date') + beginning_depreciation_date = min(future_moves_beginning_date) if future_moves_beginning_date else self.paused_prorata_date + + if self.method != 'linear': + # In degressive and degressive_then_linear, we need to find the first move of the fiscal year that comes after the last move not cancelled + # in order to correctly compute the moves just before and after the pause date + first_moves = self.depreciation_move_ids.filtered( + lambda m: m.asset_depreciation_beginning_date >= beginning_fiscal_year and ( + not m.reversal_move_ids and not m.reversed_entry_id and m.state == 'posted' + or m.state == 'draft' + ) + ).sorted(lambda m: (m.asset_depreciation_beginning_date, m.id)) + first_fiscalyear_move = next(iter(first_moves), first_fiscalyear_move) + else: + beginning_depreciation_date = self.paused_prorata_date + + residual_declining = first_fiscalyear_move.asset_remaining_value + first_fiscalyear_move.depreciation_value + self._cancel_future_moves(date) + + imported_amount = self.already_depreciated_amount_import if not all_move_dates_before_date else 0 + value_residual = self.value_residual + self.already_depreciated_amount_import if not all_move_dates_before_date else self.value_residual + residual_declining = residual_declining or value_residual + + last_day_asset = self._get_last_day_asset() + lifetime_left = self._get_delta_days(beginning_depreciation_date, last_day_asset) + days_depreciated, amount = self._compute_board_amount(self.value_residual, beginning_depreciation_date, date, False, lifetime_left, residual_declining, beginning_fiscal_year, lifetime_left, value_residual, beginning_depreciation_date) + + if abs(imported_amount) <= abs(amount): + amount -= imported_amount + if not float_is_zero(amount, precision_rounding=self.currency_id.rounding): + new_line = self._insert_depreciation_line(amount, beginning_depreciation_date, date, days_depreciated) + new_line._post() + + def _cancel_future_moves(self, date): + """Cancel all the depreciation entries after the date given as parameter. + + When possible, it will reset those to draft before unlinking them, reverse them otherwise. + + :param date: date after which the moves are deleted/reversed + """ + for asset in self: + obsolete_moves = asset.depreciation_move_ids.filtered(lambda m: m.state == 'draft' or ( + not m.reversal_move_ids + and not m.reversed_entry_id + and m.state == 'posted' + and m.date > date + )) + obsolete_moves._unlink_or_reverse() + + def _get_disposal_moves(self, invoice_lines_list, disposal_date): + """Create the move for the disposal of an asset. + + :param invoice_lines_list: list of recordset of `account.move.line` + Each element of the list corresponds to one record of `self` + These lines are used to generate the disposal move + :param disposal_date: the date of the disposal + """ + def get_line(name, asset, amount, account): + return (0, 0, { + 'name': name, + 'account_id': account.id, + 'balance': -amount, + 'analytic_distribution': analytic_distribution, + 'currency_id': asset.currency_id.id, + 'amount_currency': -asset.company_id.currency_id._convert( + from_amount=amount, + to_currency=asset.currency_id, + company=asset.company_id, + date=disposal_date, + ) + }) + + move_ids = [] + assert len(self) == len(invoice_lines_list) + for asset, invoice_line_ids in zip(self, invoice_lines_list): + asset._create_move_before_date(disposal_date) + + analytic_distribution = asset.analytic_distribution + + dict_invoice = {} + invoice_amount = 0 + + initial_amount = asset.original_value + initial_account = asset.original_move_line_ids.account_id if len(asset.original_move_line_ids.account_id) == 1 else asset.account_asset_id + + all_lines_before_disposal = asset.depreciation_move_ids.filtered(lambda x: x.date <= disposal_date) + depreciated_amount = asset.currency_id.round(copysign( + sum(all_lines_before_disposal.mapped('depreciation_value')) + asset.already_depreciated_amount_import, + -initial_amount, + )) + depreciation_account = asset.account_depreciation_id + for invoice_line in invoice_line_ids: + dict_invoice[invoice_line.account_id] = copysign(invoice_line.balance, -initial_amount) + dict_invoice.get(invoice_line.account_id, 0) + invoice_amount += copysign(invoice_line.balance, -initial_amount) + list_accounts = [(amount, account) for account, amount in dict_invoice.items()] + difference = -initial_amount - depreciated_amount - invoice_amount + difference_account = asset.company_id.gain_account_id if difference > 0 else asset.company_id.loss_account_id + line_datas = [(initial_amount, initial_account), (depreciated_amount, depreciation_account)] + list_accounts + [(difference, difference_account)] + name = _("%(asset)s: Disposal", asset=asset.name) if not invoice_line_ids else _("%(asset)s: Sale", asset=asset.name) + vals = { + 'asset_id': asset.id, + 'ref': name, + 'asset_depreciation_beginning_date': disposal_date, + 'date': disposal_date, + 'journal_id': asset.journal_id.id, + 'move_type': 'entry', + 'asset_move_type': 'disposal' if not invoice_line_ids else 'sale', + 'line_ids': [get_line(name, asset, amount, account) for amount, account in line_datas if account], + } + asset.write({'depreciation_move_ids': [(0, 0, vals)]}) + move_ids += self.env['account.move'].search([('asset_id', '=', asset.id), ('state', '=', 'draft')]).ids + + return move_ids + + def _degressive_linear_amount(self, residual_amount, degressive_amount, linear_amount): + if self.currency_id.compare_amounts(residual_amount, 0) > 0: + return max(degressive_amount, linear_amount) + else: + return min(degressive_amount, linear_amount) + + def _get_depreciation_amount_end_of_lifetime(self, residual_amount, amount, days_until_period_end): + if abs(residual_amount) < abs(amount) or days_until_period_end >= self.asset_lifetime_days: + # If the residual amount is less than the computed amount, we keep the residual amount + # If total_days is greater or equals to asset lifetime days, it should mean that + # the asset will finish in this period and the value for this period is equal to the residual amount. + amount = residual_amount + return amount + + def _get_own_book_value(self, date=None): + self.ensure_one() + return (self._get_residual_value_at_date(date) if date else self.value_residual) + self.salvage_value + + def _get_residual_value_at_date(self, date): + """ Computes the theoretical value of the asset at a specific date. + + :param date: the date at which we want the asset's value + :return: the value at date of the asset without taking reverse entries into account (as it should be in a "normal" flow of the asset) + """ + current_and_previous_depreciation = self.depreciation_move_ids.filtered( + lambda mv: + mv.asset_depreciation_beginning_date < date + and not mv.reversed_entry_id + ).sorted('asset_depreciation_beginning_date', reverse=True) + if not current_and_previous_depreciation: + return 0 + + if len(current_and_previous_depreciation) > 1: + previous_value_residual = current_and_previous_depreciation[1].asset_remaining_value + else: + # If there is only one depreciation, we take the original depreciation value + previous_value_residual = self.original_value - self.salvage_value - self.already_depreciated_amount_import + + # We compare the amount_residuals of the depreciations before and during the given date. + # It applies the ratio of the period (to-given-date / total-days-of-the-period) to the amount of the depreciation. + cur_depr_end_date = self._get_end_period_date(date) + current_depreciation = current_and_previous_depreciation[0] + cur_depr_beg_date = current_depreciation.asset_depreciation_beginning_date + + rate = self._get_delta_days(cur_depr_beg_date, date) / self._get_delta_days(cur_depr_beg_date, cur_depr_end_date) + lost_value_at_date = (previous_value_residual - current_depreciation.asset_remaining_value) * rate + residual_value_at_date = self.currency_id.round(previous_value_residual - lost_value_at_date) + if self.currency_id.compare_amounts(self.original_value, 0) > 0: + return max(residual_value_at_date, 0) + else: + return min(residual_value_at_date, 0) diff --git a/dev_odex30_accounting/odex30_account_asset/models/account_asset_group.py b/dev_odex30_accounting/odex30_account_asset/models/account_asset_group.py new file mode 100644 index 0000000..71dc28b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/account_asset_group.py @@ -0,0 +1,37 @@ +from odoo import fields, models, api + + +class AccountAssetGroup(models.Model): + _name = 'account.asset.group' + _description = 'Asset Group' + _order = 'name' + + name = fields.Char("Name", index="trigram") + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) + linked_asset_ids = fields.One2many('account.asset', 'asset_group_id', string='Related Assets') + count_linked_assets = fields.Integer(compute='_compute_count_linked_asset') + + @api.depends('linked_asset_ids') + def _compute_count_linked_asset(self): + count_per_asset_group = { + asset_group.id: count + for asset_group, count in self.env['account.asset']._read_group( + domain=[ + ('asset_group_id', 'in', self.ids), + ], + groupby=['asset_group_id'], + aggregates=['__count'], + ) + } + for asset_group in self: + asset_group.count_linked_assets = count_per_asset_group.get(asset_group.id, 0) + + def action_open_linked_assets(self): + self.ensure_one() + return { + 'name': self.name, + 'view_mode': 'list,form', + 'res_model': 'account.asset', + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.linked_asset_ids.ids)], + } diff --git a/dev_odex30_accounting/odex30_account_asset/models/account_assets_report.py b/dev_odex30_accounting/odex30_account_asset/models/account_assets_report.py new file mode 100644 index 0000000..1316b14 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/account_assets_report.py @@ -0,0 +1,468 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models, _ +from odoo.tools import format_date, SQL, Query +from collections import defaultdict + +MAX_NAME_LENGTH = 50 + + +class AssetsReportCustomHandler(models.AbstractModel): + _name = 'account.asset.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Assets Report Custom Handler' + + def _get_custom_display_config(self): + return { + 'client_css_custom_class': 'depreciation_schedule', + 'templates': { + 'AccountReportFilters': 'odex30_account_asset.DepreciationScheduleFilters', + } + } + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + lines, totals_by_column_group = self._generate_report_lines_without_grouping(report, options) + # add the groups by grouping_field + if options['assets_grouping_field'] != 'none': + lines = self._group_by_field(report, lines, options) + else: + lines = report._regroup_lines_by_name_prefix(options, lines, '_report_expand_unfoldable_line_assets_report_prefix_group', 0) + + # add the total line + total_columns = [] + for column_data in options['columns']: + col_value = totals_by_column_group[column_data['column_group_key']].get(column_data['expression_label']) + col_value = col_value if column_data.get('figure_type') == 'monetary' else '' + + total_columns.append(report._build_column_dict(col_value, column_data, options=options)) + + if lines: + lines.append({ + 'id': report._get_generic_line_id(None, None, markup='total'), + 'level': 1, + 'name': _('Total'), + 'columns': total_columns, + 'unfoldable': False, + 'unfolded': False, + }) + + return [(0, line) for line in lines] + + def _generate_report_lines_without_grouping(self, report, options, prefix_to_match=None, parent_id=None, forced_account_id=None): + # construct a dictionary: + # {(account_id, asset_id, asset_group_id): {col_group_key: {expression_label_1: value, expression_label_2: value, ...}}} + all_asset_ids = set() + all_lines_data = {} + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + # the lines returned are already sorted by account_id! + lines_query_results = self._query_lines(column_group_options, prefix_to_match=prefix_to_match, forced_account_id=forced_account_id) + for account_id, asset_id, asset_group_id, cols_by_expr_label in lines_query_results: + line_id = (account_id, asset_id, asset_group_id) + all_asset_ids.add(asset_id) + if line_id not in all_lines_data: + all_lines_data[line_id] = {column_group_key: []} + all_lines_data[line_id][column_group_key] = cols_by_expr_label + + column_names = [ + 'assets_date_from', 'assets_plus', 'assets_minus', 'assets_date_to', 'depre_date_from', + 'depre_plus', 'depre_minus', 'depre_date_to', 'balance' + ] + totals_by_column_group = defaultdict(lambda: dict.fromkeys(column_names, 0.0)) + + # Browse all the necessary assets in one go, to minimize the number of queries + assets_cache = {asset.id: asset for asset in self.env['account.asset'].browse(all_asset_ids)} + + # construct the lines, 1 at a time + lines = [] + company_currency = self.env.company.currency_id + column_expression = self.env['account.report.expression'] + for (account_id, asset_id, asset_group_id), col_group_totals in all_lines_data.items(): + all_columns = [] + for column_data in options['columns']: + col_group_key = column_data['column_group_key'] + expr_label = column_data['expression_label'] + if col_group_key not in col_group_totals or expr_label not in col_group_totals[col_group_key]: + all_columns.append(report._build_column_dict(None, None)) + continue + + col_value = col_group_totals[col_group_key][expr_label] + col_data = None if col_value is None else column_data + + all_columns.append(report._build_column_dict(col_value, col_data, options=options, column_expression=column_expression, currency=company_currency)) + + # add to the total line + if column_data['figure_type'] == 'monetary': + totals_by_column_group[column_data['column_group_key']][column_data['expression_label']] += col_value + + name = assets_cache[asset_id].name + line = { + 'id': report._get_generic_line_id('account.asset', asset_id, parent_line_id=parent_id), + 'level': 2, + 'name': name, + 'columns': all_columns, + 'unfoldable': False, + 'unfolded': False, + 'caret_options': 'account_asset_line', + 'assets_account_id': account_id, + 'assets_asset_group_id': asset_group_id, + } + if parent_id: + line['parent_id'] = parent_id + if len(name) >= MAX_NAME_LENGTH: + line['title_hover'] = name + lines.append(line) + + return lines, totals_by_column_group + + def _caret_options_initializer(self): + # Use 'caret_option_open_record_form' defined in account_reports rather than a custom function + return { + 'account_asset_line': [ + {'name': _("Open Asset"), 'action': 'caret_option_open_record_form'}, + ] + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + column_group_options_map = report._split_options_per_column_group(options) + + for col in options['columns']: + column_group_options = column_group_options_map[col['column_group_key']] + # Dynamic naming of columns containing dates + if col['expression_label'] == 'balance': + col['name'] = '' # The column label will be displayed in the subheader + if col['expression_label'] in ['assets_date_from', 'depre_date_from']: + col['name'] = format_date(self.env, column_group_options['date']['date_from']) + elif col['expression_label'] in ['assets_date_to', 'depre_date_to']: + col['name'] = format_date(self.env, column_group_options['date']['date_to']) + + options['custom_columns_subheaders'] = [ + {"name": _("Characteristics"), "colspan": 4}, + {"name": _("Assets"), "colspan": 4}, + {"name": _("Depreciation"), "colspan": 4}, + {"name": _("Book Value"), "colspan": 1} + ] + + # Group by account by default + options['assets_grouping_field'] = previous_options.get('assets_grouping_field') or 'account_id' + # If group by account is activated, activate the hierarchy (which will group by account group as well) if + # the company has at least one account group, otherwise only group by account + has_account_group = self.env['account.group'].search_count([('company_id', '=', self.env.company.id)], limit=1) + hierarchy_activated = previous_options.get('hierarchy', True) + options['hierarchy'] = has_account_group and hierarchy_activated or False + + def _query_lines(self, options, prefix_to_match=None, forced_account_id=None): + """ + Returns a list of tuples: [(asset_id, account_id, asset_group_id, [{expression_label: value}])] + """ + lines = [] + asset_lines = self._query_values(options, prefix_to_match=prefix_to_match, forced_account_id=forced_account_id) + + # Assign the gross increases sub assets to their main asset (parent) + parent_lines = [] + children_lines = defaultdict(list) + for al in asset_lines: + if al['parent_id']: + children_lines[al['parent_id']] += [al] + else: + parent_lines += [al] + + for al in parent_lines: + + asset_children_lines = children_lines[al['asset_id']] + asset_parent_values = self._get_parent_asset_values(options, al, asset_children_lines) + + # Format the data + columns_by_expr_label = { + "acquisition_date": al["asset_acquisition_date"] and format_date(self.env, al["asset_acquisition_date"]) or "", # Characteristics + "first_depreciation": al["asset_date"] and format_date(self.env, al["asset_date"]) or "", + "method": (al["asset_method"] == "linear" and _("Linear")) or (al["asset_method"] == "degressive" and _("Declining")) or _("Dec. then Straight"), + **asset_parent_values + } + + lines.append((al['account_id'], al['asset_id'], al['asset_group_id'], columns_by_expr_label)) + return lines + + def _get_parent_asset_values(self, options, asset_line, asset_children_lines): + """ Compute the values needed for the depreciation schedule for each parent asset + Overridden in l10n_ro_saft.account_general_ledger""" + + # Compute the depreciation rate string + if asset_line['asset_method'] == 'linear' and asset_line['asset_method_number']: # some assets might have 0 depreciation because they don't lose value + total_months = int(asset_line['asset_method_number']) * int(asset_line['asset_method_period']) + months = total_months % 12 + years = total_months // 12 + asset_depreciation_rate = " ".join(part for part in [ + years and _("%(years)s y", years=years), + months and _("%(months)s m", months=months), + ] if part) + elif asset_line['asset_method'] == 'linear': + asset_depreciation_rate = '0.00 %' + else: + asset_depreciation_rate = ('{:.2f} %').format(float(asset_line['asset_method_progress_factor']) * 100) + + # Manage the opening of the asset + opening = (asset_line['asset_acquisition_date'] or asset_line['asset_date']) < fields.Date.to_date(options['date']['date_from']) + + # Get the main values of the board for the asset + depreciation_opening = asset_line['depreciated_before'] + depreciation_add = asset_line['depreciated_during'] + depreciation_minus = 0.0 + + asset_disposal_value = ( + asset_line['asset_disposal_value'] + if ( + asset_line['asset_disposal_date'] + and asset_line['asset_disposal_date'] <= fields.Date.to_date(options['date']['date_to']) + ) + else 0.0 + ) + + asset_opening = asset_line['asset_original_value'] if opening else 0.0 + asset_add = 0.0 if opening else asset_line['asset_original_value'] + asset_minus = 0.0 + asset_salvage_value = asset_line.get('asset_salvage_value', 0.0) + + # Add the main values of the board for all the sub assets (gross increases) + for child in asset_children_lines: + depreciation_opening += child['depreciated_before'] + depreciation_add += child['depreciated_during'] + + opening = (child['asset_acquisition_date'] or child['asset_date']) < fields.Date.to_date(options['date']['date_from']) + asset_opening += child['asset_original_value'] if opening else 0.0 + asset_add += 0.0 if opening else child['asset_original_value'] + + # Compute the closing values + asset_closing = asset_opening + asset_add - asset_minus + depreciation_closing = depreciation_opening + depreciation_add - depreciation_minus + asset_currency = self.env['res.currency'].browse(asset_line['asset_currency_id']) + + # Manage the closing of the asset + if ( + asset_line['asset_state'] == 'close' + and asset_line['asset_disposal_date'] + and asset_line['asset_disposal_date'] <= fields.Date.to_date(options['date']['date_to']) + and asset_currency.compare_amounts(depreciation_closing, asset_closing - asset_salvage_value) == 0 + ): + depreciation_add -= asset_disposal_value + depreciation_minus += depreciation_closing - asset_disposal_value + depreciation_closing = 0.0 + asset_minus += asset_closing + asset_closing = 0.0 + + # Manage negative assets (credit notes) + if asset_currency.compare_amounts(asset_line['asset_original_value'], 0) < 0: + asset_add, asset_minus = -asset_minus, -asset_add + depreciation_add, depreciation_minus = -depreciation_minus, -depreciation_add + + return { + 'duration_rate': asset_depreciation_rate, + 'asset_disposal_value': asset_disposal_value, + 'assets_date_from': asset_opening, + 'assets_plus': asset_add, + 'assets_minus': asset_minus, + 'assets_date_to': asset_closing, + 'depre_date_from': depreciation_opening, + 'depre_plus': depreciation_add, + 'depre_minus': depreciation_minus, + 'depre_date_to': depreciation_closing, + 'balance': asset_closing - depreciation_closing, + } + + def _group_by_field(self, report, lines, options): + """ + This function adds the grouping lines on top of each group of account.asset + It iterates over the lines, change the line_id of each line to include the account.account.id and the + account.asset.id. + """ + if not lines: + return lines + + line_vals_per_grouping_field_id = {} + parent_model = 'account.account' if options['assets_grouping_field'] == 'account_id' else 'account.asset.group' + for line in lines: + parent_id = line.get('assets_account_id') if options['assets_grouping_field'] == 'account_id' else line.get('assets_asset_group_id') + + model, res_id = report._get_model_info_from_id(line['id']) + + # replace the line['id'] to add the parent id + line['id'] = report._build_line_id([ + (None, parent_model, parent_id), + (None, 'account.asset', res_id) + ]) + + is_parent_in_unfolded_lines = any( + report._get_model_info_from_id(unfolded_line_id) == (parent_model, parent_id) + for unfolded_line_id in options.get('unfolded_lines') + ) + line_vals_per_grouping_field_id.setdefault(parent_id, { + # We don't assign a name to the line yet, so that we can batch the browsing of the parent objects + 'id': report._build_line_id([(None, parent_model, parent_id)]), + 'columns': [], # Filled later + 'unfoldable': True, + 'unfolded': is_parent_in_unfolded_lines or options.get('unfold_all'), + 'level': 1, + + # This value is stored here for convenience; it will be removed from the result + 'group_lines': [], + })['group_lines'].append(line) + + # Generate the result + rslt_lines = [] + idx_monetary_columns = [idx_col for idx_col, col in enumerate(options['columns']) if col['figure_type'] == 'monetary'] + parent_recordset = self.env[parent_model].browse(line_vals_per_grouping_field_id.keys()) + + for parent_field in parent_recordset: + parent_line_vals = line_vals_per_grouping_field_id[parent_field.id] + if options['assets_grouping_field'] == 'account_id': + parent_line_vals['name'] = f"{parent_field.code} {parent_field.name}" + else: + parent_line_vals['name'] = parent_field.name or _('(No %s)', parent_field._description) + + rslt_lines.append(parent_line_vals) + + group_totals = {column_index: 0 for column_index in idx_monetary_columns} + group_lines = report._regroup_lines_by_name_prefix( + options, + parent_line_vals.pop('group_lines'), + '_report_expand_unfoldable_line_assets_report_prefix_group', + parent_line_vals['level'], + parent_line_dict_id=parent_line_vals['id'], + ) + + for parent_subline in group_lines: + # Add this line to the group totals + for column_index in idx_monetary_columns: + group_totals[column_index] += parent_subline['columns'][column_index].get('no_format', 0) + + # Setup the parent and add the line to the result + parent_subline['parent_id'] = parent_line_vals['id'] + rslt_lines.append(parent_subline) + + # Add totals (columns) to the parent line + for column_index in range(len(options['columns'])): + parent_line_vals['columns'].append(report._build_column_dict( + group_totals.get(column_index, ''), + options['columns'][column_index], + options=options, + )) + + return rslt_lines + + def _query_values(self, options, prefix_to_match=None, forced_account_id=None): + "Get the data from the database" + + self.env['account.move.line'].check_access('read') + self.env['account.asset'].check_access('read') + + query = Query(self.env, alias='asset', table=SQL.identifier('account_asset')) + account_alias = query.join(lhs_alias='asset', lhs_column='account_asset_id', rhs_table='account_account', rhs_column='id', link='account_asset_id') + query.add_join('LEFT JOIN', alias='move', table='account_move', condition=SQL(f""" + move.asset_id = asset.id AND move.state {"!= 'cancel'" if options.get('all_entries') else "= 'posted'"} + """)) + + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_id = SQL.identifier(account_alias, 'id') + + if prefix_to_match: + query.add_where(SQL("asset.name ILIKE %s", f"{prefix_to_match}%")) + if forced_account_id: + query.add_where(SQL("%s = %s", account_id, forced_account_id)) + + analytic_account_ids = [] + if options.get('analytic_accounts') and not any(x in options.get('analytic_accounts_list', []) for x in options['analytic_accounts']): + analytic_account_ids += [[str(account_id) for account_id in options['analytic_accounts']]] + if options.get('analytic_accounts_list'): + analytic_account_ids += [[str(account_id) for account_id in options.get('analytic_accounts_list')]] + if analytic_account_ids: + query.add_where(SQL('%s && %s', analytic_account_ids, self.env['account.asset']._query_analytic_accounts('asset'))) + + selected_journals = tuple(journal['id'] for journal in options.get('journals', []) if journal['model'] == 'account.journal' and journal['selected']) + if selected_journals: + query.add_where(SQL("asset.journal_id in %s", selected_journals)) + + sql = SQL( + """ + SELECT asset.id AS asset_id, + asset.parent_id AS parent_id, + asset.name AS asset_name, + asset.asset_group_id AS asset_group_id, + asset.original_value AS asset_original_value, + asset.currency_id AS asset_currency_id, + COALESCE(asset.salvage_value, 0) as asset_salvage_value, + MIN(move.date) AS asset_date, + asset.disposal_date AS asset_disposal_date, + asset.acquisition_date AS asset_acquisition_date, + asset.method AS asset_method, + asset.method_number AS asset_method_number, + asset.method_period AS asset_method_period, + asset.method_progress_factor AS asset_method_progress_factor, + asset.state AS asset_state, + asset.company_id AS company_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_id)s AS account_id, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date < %(date_from)s), 0) + COALESCE(asset.already_depreciated_amount_import, 0) AS depreciated_before, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date BETWEEN %(date_from)s AND %(date_to)s), 0) AS depreciated_during, + COALESCE(SUM(move.depreciation_value) FILTER (WHERE move.date BETWEEN %(date_from)s AND %(date_to)s AND move.asset_number_days IS NULL), 0) AS asset_disposal_value + FROM %(from_clause)s + WHERE %(where_clause)s + AND asset.company_id in %(company_ids)s + AND (asset.acquisition_date <= %(date_to)s OR move.date <= %(date_to)s) + AND (asset.disposal_date >= %(date_from)s OR asset.disposal_date IS NULL) + AND (asset.state not in ('model', 'draft', 'cancelled') OR (asset.state = 'draft' AND %(include_draft)s)) + AND asset.active = 't' + GROUP BY asset.id, account_id, account_code, account_name + ORDER BY account_code, asset.acquisition_date, asset.id; + """, + account_code=account_code, + account_name=account_name, + account_id=account_id, + date_from=options['date']['date_from'], + date_to=options['date']['date_to'], + from_clause=query.from_clause, + where_clause=query.where_clause or SQL('TRUE'), + company_ids=tuple(self.env['account.report'].get_report_company_ids(options)), + include_draft=options.get('all_entries', False), + ) + + self._cr.execute(sql) + results = self._cr.dictfetchall() + return results + + def _report_expand_unfoldable_line_assets_report_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + matched_prefix = self.env['account.report']._get_prefix_groups_matched_prefix_from_line_id(line_dict_id) + report = self.env['account.report'].browse(options['report_id']) + + lines, _totals_by_column_group = self._generate_report_lines_without_grouping( + report, + options, + prefix_to_match=matched_prefix, + parent_id=line_dict_id, + forced_account_id=self.env['account.report']._get_res_id_from_line_id(line_dict_id, 'account.account'), + ) + + lines = report._regroup_lines_by_name_prefix( + options, + lines, + '_report_expand_unfoldable_line_assets_report_prefix_group', + len(matched_prefix), + matched_prefix=matched_prefix, + parent_line_dict_id=line_dict_id, + ) + + return { + 'lines': lines, + 'offset_increment': len(lines), + 'has_more': False, + } + + +class AssetsReport(models.Model): + _inherit = 'account.report' + + def _get_caret_option_view_map(self): + view_map = super()._get_caret_option_view_map() + view_map['account.asset.line'] = 'odex30_account_asset.view_account_asset_expense_form' + return view_map diff --git a/dev_odex30_accounting/odex30_account_asset/models/account_move.py b/dev_odex30_accounting/odex30_account_asset/models/account_move.py new file mode 100644 index 0000000..78f5f44 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/account_move.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, Command +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, _, SQL +from odoo.tools.misc import formatLang +from dateutil.relativedelta import relativedelta + + +class AccountMove(models.Model): + _inherit = 'account.move' + + asset_id = fields.Many2one('account.asset', string='Asset', index=True, ondelete='cascade', copy=False, domain="[('company_id', '=', company_id)]") + asset_remaining_value = fields.Monetary(string='Depreciable Value', compute='_compute_depreciation_cumulative_value' ,store=True) + asset_depreciated_value = fields.Monetary(string='Cumulative Depreciation', compute='_compute_depreciation_cumulative_value',store=True) + # true when this move is the result of the changing of value of an asset + asset_value_change = fields.Boolean() + # how many days of depreciation this entry corresponds to + asset_number_days = fields.Integer(string="Number of days", copy=False) # deprecated + asset_depreciation_beginning_date = fields.Date(string="Date of the beginning of the depreciation", copy=False) # technical field stating when the depreciation associated with this entry has begun + depreciation_value = fields.Monetary( + string="Depreciation", + compute="_compute_depreciation_value", inverse="_inverse_depreciation_value", store=True, + ) + + asset_ids = fields.One2many('account.asset', string='Assets', compute="_compute_asset_ids") + asset_id_display_name = fields.Char(compute="_compute_asset_ids") # just a button label. That's to avoid a plethora of different buttons defined in xml + count_asset = fields.Integer(compute="_compute_asset_ids") + draft_asset_exists = fields.Boolean(compute="_compute_asset_ids") + asset_move_type = fields.Selection( + selection=[ + ('depreciation', 'Depreciation'), + ('sale', 'Sale'), + ('purchase', 'Purchase'), + ('disposal', 'Disposal'), + ('negative_revaluation', 'Negative revaluation'), + ('positive_revaluation', 'Positive revaluation'), + ], + string='Asset Move Type', + compute='_compute_asset_move_type', store=True, + copy=False, + ) + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('asset_id', 'depreciation_value', 'asset_id.total_depreciable_value', 'asset_id.already_depreciated_amount_import', 'state') + def _compute_depreciation_cumulative_value(self): + self.asset_depreciated_value = 0 + self.asset_remaining_value = 0 + + # make sure to protect all the records being assigned, because the + # assignments invoke method write() on non-protected records, which may + # cause an infinite recursion in case method write() needs to read one + # of these fields (like in case of a base automation) + fields = [self._fields['asset_remaining_value'], self._fields['asset_depreciated_value']] + with self.env.protecting(fields, self.asset_id.depreciation_move_ids): + for asset in self.asset_id: + depreciated = asset.already_depreciated_amount_import + remaining = asset.total_depreciable_value - asset.already_depreciated_amount_import + for move in asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv._origin.id)): + if move.state != 'cancel': + remaining -= move.depreciation_value + depreciated += move.depreciation_value + move.asset_remaining_value = remaining + move.asset_depreciated_value = depreciated + + @api.depends('line_ids.balance') + def _compute_depreciation_value(self): + for move in self: + asset = move.asset_id or move.reversed_entry_id.asset_id # reversed moves are created before being assigned to the asset + if asset: + depreciation_lines = move._get_asset_depreciation_line() + asset_depreciation = sum(depreciation_lines.mapped('balance')) + # Special case of closing entry - only disposed assets of type 'purchase' should match this condition + # The condition on len(move.line_ids) is to avoid the case where there is only one depreciation move, and it is not a disposal move + # The condition will be matched because a disposal move from a disposal move will always have more than 2 lines, unlike a normal depreciation move + if any( + line.account_id == asset.account_asset_id + and float_compare(-line.balance, asset.original_value, precision_rounding=asset.currency_id.rounding) == 0 + for line in move.line_ids + ) and len(move.line_ids) > 2: + asset_depreciation = ( + asset.original_value + - asset.salvage_value + - ( + move.line_ids[1].debit if asset.original_value > 0 else move.line_ids[1].credit + ) * (-1 if asset.original_value < 0 else 1) + ) + else: + asset_depreciation = 0 + move.depreciation_value = asset_depreciation + + @api.depends('asset_id', 'asset_ids') + def _compute_asset_move_type(self): + for move in self: + if move.asset_ids: + move.asset_move_type = 'positive_revaluation' if move.asset_ids.parent_id else 'purchase' + elif not move.asset_move_type or not move.asset_id: + move.asset_move_type = False + + # ------------------------------------------------------------------------- + # INVERSE METHODS + # ------------------------------------------------------------------------- + def _inverse_depreciation_value(self): + for move in self: + depreciation_lines = set(move._get_asset_depreciation_line()) + move.write({'line_ids': [ + Command.update(line.id, { + 'balance': move.depreciation_value * (1 if line in depreciation_lines else -1), + }) + for line in move.line_ids + ]}) + + # ------------------------------------------------------------------------- + # CONSTRAINT METHODS + # ------------------------------------------------------------------------- + @api.constrains('state', 'asset_id') + def _constrains_check_asset_state(self): + for move in self.filtered(lambda mv: mv.asset_id): + asset_id = move.asset_id + if asset_id.state == 'draft' and move.state == 'posted': + raise ValidationError(_("You can't post an entry related to a draft asset. Please post the asset before.")) + + def _post(self, soft=True): + # OVERRIDE + posted = super()._post(soft) + + # log the post of a depreciation + posted._log_depreciation_asset() + + # look for any asset to create, in case we just posted a bill on an account + # configured to automatically create assets + posted.sudo()._auto_create_asset() + + return posted + + def _reverse_moves(self, default_values_list=None, cancel=False): + if default_values_list is None: + default_values_list = [{} for _i in self] + for move, default_values in zip(self, default_values_list): + # Report the value of this move to the next draft move or create a new one + if move.asset_id: + # Recompute the status of the asset for all depreciations posted after the reversed entry + + first_draft = min(move.asset_id.depreciation_move_ids.filtered(lambda m: m.state == 'draft'), key=lambda m: m.date, default=None) + if first_draft: + # If there is a draft, simply move/add the depreciation amount here + first_draft.depreciation_value += move.depreciation_value + elif move.asset_id.state != 'close': + # If there was no draft move left, create one. + # Unless the asset is being closed, then the closing move + # takes care of balancing the asset. + last_date = max(move.asset_id.depreciation_move_ids.mapped('date')) + method_period = move.asset_id.method_period + + self.create(self._prepare_move_for_asset_depreciation({ + 'asset_id': move.asset_id, + 'amount': move.depreciation_value, + 'depreciation_beginning_date': last_date + (relativedelta(months=1) if method_period == "1" else relativedelta(years=1)), + 'date': last_date + (relativedelta(months=1) if method_period == "1" else relativedelta(years=1)), + 'asset_number_days': 0 + })) + + msg = _('Depreciation entry %(name)s reversed (%(value)s)', name=move.name, value=formatLang(self.env, move.depreciation_value, currency_obj=move.company_id.currency_id)) + move.asset_id.message_post(body=msg) + default_values['asset_id'] = move.asset_id.id + default_values['asset_number_days'] = -move.asset_number_days + default_values['asset_depreciation_beginning_date'] = default_values.get('date', move.date) + + return super(AccountMove, self)._reverse_moves(default_values_list, cancel) + + def button_cancel(self): + # OVERRIDE + res = super(AccountMove, self).button_cancel() + self.env['account.asset'].sudo().search([('original_move_line_ids.move_id', 'in', self.ids)]).write({'active': False}) + return res + + def button_draft(self): + for move in self: + if any(asset_id.state != 'draft' for asset_id in move.asset_ids): + raise UserError(_('You cannot reset to draft an entry related to a posted asset')) + # Remove any draft asset that could be linked to the account move being reset to draft + move.asset_ids.filtered(lambda x: x.state == 'draft').unlink() + return super(AccountMove, self).button_draft() + + def _log_depreciation_asset(self): + for move in self.filtered(lambda m: m.asset_id): + asset = move.asset_id + msg = _('Depreciation entry %(name)s posted (%(value)s)', name=move.name, value=formatLang(self.env, move.depreciation_value, currency_obj=move.company_id.currency_id)) + asset.message_post(body=msg) + + def _auto_create_asset(self): + create_list = [] + invoice_list = [] + auto_validate = [] + for move in self: + if not move.is_invoice(): + continue + + for move_line in move.line_ids: + if ( + move_line.account_id + and (move_line.account_id.can_create_asset) + and move_line.account_id.create_asset != "no" + and not (move_line.currency_id or move.currency_id).is_zero(move_line.price_total) + and not move_line.asset_ids + and not move_line.tax_line_id + and move_line.price_total > 0 + and not (move.move_type in ('out_invoice', 'out_refund') and move_line.account_id.internal_group == 'asset') + ): + if not move_line.name: + if move_line.product_id: + move_line.name = move_line.product_id.display_name + else: + raise UserError(_('Journal Items of %(account)s should have a label in order to generate an asset', account=move_line.account_id.display_name)) + if move_line.account_id.multiple_assets_per_line: + # decimal quantities are not supported, quantities are rounded to the lower int + units_quantity = max(1, int(move_line.quantity)) + else: + units_quantity = 1 + + model_ids = move_line.account_id.asset_model_ids.filtered(lambda model: model.company_id in move_line.company_id.parent_ids) + vals = { + 'name': move_line.name, + 'company_id': move_line.company_id.id, + 'currency_id': move_line.company_currency_id.id, + 'analytic_distribution': move_line.analytic_distribution, + 'original_move_line_ids': [(6, False, move_line.ids)], + 'state': 'draft', + 'acquisition_date': move.invoice_date if not move.reversed_entry_id else move.reversed_entry_id.invoice_date, + } + for model_id in model_ids or [None]: + if model_id: + vals['model_id'] = model_id.id + + auto_validate.extend([move_line.account_id.create_asset == 'validate'] * units_quantity) + invoice_list.extend([move] * units_quantity) + for i in range(1, units_quantity + 1): + if units_quantity > 1: + vals['name'] = _("%(move_line)s (%(current)s of %(total)s)", move_line=move_line.name, current=i, total=units_quantity) + create_list.extend([vals.copy()]) + + assets = self.env['account.asset'].with_context({}).create(create_list) + for asset, vals, invoice, validate in zip(assets, create_list, invoice_list, auto_validate): + if 'model_id' in vals: + asset._onchange_model_id() + if validate: + asset.validate() + if invoice: + asset.message_post(body=_('Asset created from invoice: %s', invoice._get_html_link())) + asset._post_non_deductible_tax_value() + return assets + + @api.model + def _prepare_move_for_asset_depreciation(self, vals): + missing_fields = {'asset_id', 'amount', 'depreciation_beginning_date', 'date', 'asset_number_days'} - set(vals) + if missing_fields: + raise UserError(_('Some fields are missing %s', ', '.join(missing_fields))) + asset = vals['asset_id'] + analytic_distribution = asset.analytic_distribution + depreciation_date = vals.get('date', fields.Date.context_today(self)) + company_currency = asset.company_id.currency_id + current_currency = asset.currency_id + prec = company_currency.decimal_places + amount_currency = vals['amount'] + amount = current_currency._convert(amount_currency, company_currency, asset.company_id, depreciation_date) + # Keep the partner on the original invoice if there is only one + partner = asset.original_move_line_ids.mapped('partner_id') + partner = partner[:1] if len(partner) <= 1 else self.env['res.partner'] + name = _("%s: Depreciation", asset.name) + move_line_1 = { + 'name': name, + 'partner_id': partner.id, + 'account_id': asset.account_depreciation_id.id, + 'debit': 0.0 if float_compare(amount, 0.0, precision_digits=prec) > 0 else -amount, + 'credit': amount if float_compare(amount, 0.0, precision_digits=prec) > 0 else 0.0, + 'currency_id': current_currency.id, + 'amount_currency': -amount_currency, + } + move_line_2 = { + 'name': name, + 'partner_id': partner.id, + 'account_id': asset.account_depreciation_expense_id.id, + 'credit': 0.0 if float_compare(amount, 0.0, precision_digits=prec) > 0 else -amount, + 'debit': amount if float_compare(amount, 0.0, precision_digits=prec) > 0 else 0.0, + 'currency_id': current_currency.id, + 'amount_currency': amount_currency, + } + # Only set the 'analytic_distribution' key if there is an analytic distribution on the asset. + # Otherwise, it prevents the computation of the analytic distribution. + if analytic_distribution: + move_line_1['analytic_distribution'] = analytic_distribution + move_line_2['analytic_distribution'] = analytic_distribution + move_vals = { + 'partner_id': partner.id, + 'date': depreciation_date, + 'journal_id': asset.journal_id.id, + 'line_ids': [(0, 0, move_line_1), (0, 0, move_line_2)], + 'asset_id': asset.id, + 'ref': name, + 'asset_depreciation_beginning_date': vals['depreciation_beginning_date'], + 'asset_number_days': vals['asset_number_days'], + 'asset_value_change': vals.get('asset_value_change', False), + 'move_type': 'entry', + 'currency_id': current_currency.id, + 'asset_move_type': vals.get('asset_move_type', 'depreciation'), + 'company_id': asset.company_id.id, + } + return move_vals + + def _get_asset_depreciation_line(self): + asset = self.asset_id + return self.line_ids.filtered(lambda line: line.account_id.internal_group == 'expense' or line.account_id == asset.account_depreciation_expense_id) + + @api.depends('line_ids.asset_ids') + def _compute_asset_ids(self): + for record in self: + record.asset_ids = record.line_ids.asset_ids + record.count_asset = len(record.asset_ids) + record.asset_id_display_name = _('Asset') + record.draft_asset_exists = bool(record.asset_ids.filtered(lambda x: x.state == "draft")) + + def open_asset_view(self): + return self.asset_id.open_asset(['form']) + + def action_open_asset_ids(self): + return self.asset_ids.open_asset(['list', 'form']) + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + asset_ids = fields.Many2many('account.asset', 'asset_move_line_rel', 'line_id', 'asset_id', string='Related Assets', copy=False) + non_deductible_tax_value = fields.Monetary(compute='_compute_non_deductible_tax_value', currency_field='company_currency_id') + + def _get_computed_taxes(self): + if self.move_id.asset_id: + return self.tax_ids + return super()._get_computed_taxes() + + def turn_as_asset(self): + if len(self.company_id) != 1: + raise UserError(_("All the lines should be from the same company")) + if any(line.move_id.state == 'draft' for line in self): + raise UserError(_("All the lines should be posted")) + if any(account != self[0].account_id for account in self.mapped('account_id')): + raise UserError(_("All the lines should be from the same account")) + ctx = self.env.context.copy() + ctx.update({ + 'default_original_move_line_ids': [(6, False, self.env.context['active_ids'])], + 'default_company_id': self.company_id.id, + }) + return { + "name": _("Turn as an asset"), + "type": "ir.actions.act_window", + "res_model": "account.asset", + "views": [[False, "form"]], + "target": "current", + "context": ctx, + } + + @api.depends('tax_ids.invoice_repartition_line_ids') + def _compute_non_deductible_tax_value(self): + """ Handle the specific case of non deductible taxes, + such as "50% Non Déductible - Frais de voiture (Prix Excl.)" in Belgium. + """ + non_deductible_tax_ids = self.tax_ids.invoice_repartition_line_ids.filtered( + lambda line: line.repartition_type == 'tax' and not line.use_in_tax_closing + ).tax_id + + res = {} + if non_deductible_tax_ids: + domain = [('move_id', 'in', self.move_id.ids)] + tax_details_query = self._get_query_tax_details_from_domain(domain) + + self.flush_model() + self._cr.execute(SQL( + ''' + SELECT + tdq.base_line_id, + SUM(tdq.tax_amount_currency) + FROM (%(tax_details_query)s) AS tdq + JOIN account_move_line aml ON aml.id = tdq.tax_line_id + JOIN account_tax_repartition_line trl ON trl.id = tdq.tax_repartition_line_id + WHERE tdq.base_line_id IN %(base_line_ids)s + AND trl.use_in_tax_closing IS FALSE + GROUP BY tdq.base_line_id + ''', + tax_details_query=tax_details_query, + base_line_ids=tuple(self.ids), + )) + + res = {row['base_line_id']: row['sum'] for row in self._cr.dictfetchall()} + + for record in self: + record.non_deductible_tax_value = res.get(record._origin.id, 0.0) diff --git a/dev_odex30_accounting/odex30_account_asset/models/res_company.py b/dev_odex30_accounting/odex30_account_asset/models/res_company.py new file mode 100644 index 0000000..53c3bff --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/models/res_company.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ + +class ResCompany(models.Model): + _inherit = "res.company" + + gain_account_id = fields.Many2one( + 'account.account', + domain="[('deprecated', '=', False)]", + check_company=True, + help="Account used to write the journal item in case of gain while selling an asset", + ) + loss_account_id = fields.Many2one( + 'account.account', + domain="[('deprecated', '=', False)]", + check_company=True, + help="Account used to write the journal item in case of loss while selling an asset", + ) diff --git a/dev_odex30_accounting/odex30_account_asset/security/account_asset_security.xml b/dev_odex30_accounting/odex30_account_asset/security/account_asset_security.xml new file mode 100644 index 0000000..2e736fa --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/security/account_asset_security.xml @@ -0,0 +1,18 @@ + + + + + Account Asset multi-company + + + [('company_id', 'parent_of', company_ids)] + + + + Account Asset Group multi-company + + + [('company_id', 'parent_of', company_ids)] + + + diff --git a/dev_odex30_accounting/odex30_account_asset/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_asset/security/ir.model.access.csv new file mode 100644 index 0000000..0c6f796 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_asset,account.asset,model_account_asset,account.group_account_readonly,1,0,0,0 +access_account_asset_manager,account.asset,model_account_asset,account.group_account_user,1,1,1,1 +access_account_asset_invoicing_payment,account.asset,model_account_asset,account.group_account_invoice,1,0,1,0 +access_asset_modify,access.asset.modify,model_asset_modify,account.group_account_user,1,1,1,0 +access_account_asset_group,account.asset.group,model_account_asset_group,account.group_account_readonly,1,0,0,0 +access_account_asset_group_manager,account.asset.group,model_account_asset_group,account.group_account_manager,1,1,1,1 diff --git a/dev_odex30_accounting/odex30_account_asset/static/description/icon.png b/dev_odex30_accounting/odex30_account_asset/static/description/icon.png new file mode 100644 index 0000000..575fa15 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/static/description/icon.png differ diff --git a/dev_odex30_accounting/odex30_account_asset/static/description/icon.svg b/dev_odex30_accounting/odex30_account_asset/static/description/icon.svg new file mode 100644 index 0000000..83b8e79 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/depreciation_schedule.scss b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/depreciation_schedule.scss new file mode 100644 index 0000000..a1c16d1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/depreciation_schedule.scss @@ -0,0 +1,37 @@ +.account_report.depreciation_schedule { + table.striped { + > thead > tr:not(:first-child) { + > th:nth-child(2n+3) { background: inherit } + > th[data-expression_label='assets_date_from'], + > th[data-expression_label='assets_plus'], + > th[data-expression_label='assets_minus'], + > th[data-expression_label='assets_date_to'], + > th[data-expression_label='balance'] { + background: $o-gray-100 + } + } + > tbody { + > tr:not(.line_level_0):not(.empty) { + > td:nth-child(2n+3) { background: inherit } + > td[data-expression_label='assets_date_from'], + > td[data-expression_label='assets_plus'], + > td[data-expression_label='assets_minus'], + > td[data-expression_label='assets_date_to'], + > td[data-expression_label='balance'] { + background: $o-gray-100 + } + } + > tr.line_level_0 + { + > td:nth-child(2n+3) { background: inherit } + > td[data-expression_label='assets_date_from'], + > td[data-expression_label='assets_plus'], + > td[data-expression_label='assets_minus'], + > td[data-expression_label='assets_date_to'], + > td[data-expression_label='balance'] { + background: $o-gray-300 + } + } + } + } +} diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/filters.xml b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/filters.xml new file mode 100644 index 0000000..9c3f76b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/filters.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml new file mode 100644 index 0000000..7460c98 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/components/depreciation_schedule/groupby.xml @@ -0,0 +1,42 @@ + + + + + + + + + Group By Account + + + + Group By Asset Group + + + + No Grouping + + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.js b/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.js new file mode 100644 index 0000000..6fdd866 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.js @@ -0,0 +1,16 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +export class MoveReversed extends Component { + static template = "odex30_account_asset.moveReversed"; + static props = {...standardFieldProps}; +} + +export const moveReversed = { + component: MoveReversed, +}; + +registry.category("fields").add("deprec_lines_reversed", moveReversed); diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.xml b/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.xml new file mode 100644 index 0000000..3d5e1c9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/components/move_reversed/move_reversed.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.dark.scss b/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.dark.scss new file mode 100644 index 0000000..4fc1b1b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.dark.scss @@ -0,0 +1,8 @@ +// = ACCOUNT ASSET +// ============================================================================ +// No CSS hacks, variables overrides only + +.o_account_reports_page.o_account_assets_report { + --DepreciationSchedule-background-color: #{$o-gray-300}; + --DepreciationSchedule-border-color: #{$o-gray-600}; +} diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.scss b/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.scss new file mode 100644 index 0000000..bee20cd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/scss/account_asset.scss @@ -0,0 +1,77 @@ +.o_account_reports_page.o_account_assets_report .o_account_reports_table { + + .o_account_reports_header_hierarchy { + + white-space: nowrap; + + .o_account_asset_report_top_header_row th:not(:first-child) { + border: 1px solid var(--DepreciationSchedule-border-color, #{$o-gray-200}); + padding-top: 8px; + padding-bottom: 8px; + } + + .o_account_report_column_header{ + padding-top: 8px; + padding-bottom: 8px; + } + + & > tr:first-child th{ + text-align: right !important; + } + } + + .o_account_reports_header_hierarchy tr:nth-child(3) th:nth-child(2n+6), + .o_account_asset_column_contrast{ + background-color: var(--DepreciationSchedule-background-color, #{$o-gray-100}); + background-clip: padding-box; + } + + .o_account_asset_contrast_inner{ + font-weight: normal; + } + + .o_asset_blank_if_zero_value{ + opacity: 0.3; + } +} + +.o_account_asset_kanban_title { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.o_account_reports_body_print .o_account_assets_report{ + + .o_account_asset_contrast_inner{ + font-weight: normal; + } + + td.o_foldable_total { + font-weight: bold; + } + + .o_account_reports_header_hierarchy { + white-space: nowrap; + .o_account_asset_report_top_header_row th:not(:first-child) { + border: 1px solid lightgrey; + padding-top: 8px; + padding-bottom: 8px; + + font-weight: bold; + font-size: 0.8rem; + } + + tr:nth-child(3) { + th:nth-child(2), th:nth-child(3), th:nth-child(4), th:nth-child(5) { + table > tbody > tr > td{ + text-align: center !important; + } + } + th > table > tbody > tr > td { + font-weight: normal; + font-size: 0.8rem; + } + } + } +} diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/views/fields/properties/properties_field.js b/dev_odex30_accounting/odex30_account_asset/static/src/views/fields/properties/properties_field.js new file mode 100644 index 0000000..6fe1814 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/views/fields/properties/properties_field.js @@ -0,0 +1,21 @@ +/** @odoo-module **/ + +import { PropertiesField } from "@web/views/fields/properties/properties_field"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(PropertiesField.prototype, { + async onPropertyCreate() { + if ( + this.props.record.resModel === 'account.asset' + && (!this.state.canChangeDefinition || !(await this.checkDefinitionWriteAccess())) + ) { + this.notification.add( + _t("You can add Property fields only on Assets with an Asset Model set."), + { type: "warning" } + ); + return; + } + super.onPropertyCreate(); + } +}); diff --git a/dev_odex30_accounting/odex30_account_asset/static/src/web/form_controller_patch.js b/dev_odex30_accounting/odex30_account_asset/static/src/web/form_controller_patch.js new file mode 100644 index 0000000..1ff60fd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/static/src/web/form_controller_patch.js @@ -0,0 +1,15 @@ +/** @odoo-module */ + +import { FormController } from "@web/views/form/form_controller"; +import { patch } from "@web/core/utils/patch"; + +patch(FormController.prototype, { + getStaticActionMenuItems() { + const menuItems = super.getStaticActionMenuItems(); + if (this.props.resModel === 'account.asset' && this.model.root.data.state === 'model') { + menuItems.addPropertyFieldValue.isAvailable = () => false; + } + return menuItems; + }, +}); + diff --git a/dev_odex30_accounting/odex30_account_asset/tests/__init__.py b/dev_odex30_accounting/odex30_account_asset/tests/__init__.py new file mode 100644 index 0000000..c889a0c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_account_asset +from . import test_board_compute +from . import test_reevaluation_asset diff --git a/dev_odex30_accounting/odex30_account_asset/tests/common.py b/dev_odex30_accounting/odex30_account_asset/tests/common.py new file mode 100644 index 0000000..d750f00 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/tests/common.py @@ -0,0 +1,36 @@ +from odoo import fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class TestAccountAssetCommon(AccountTestInvoicingCommon): + + @classmethod + def create_asset(cls, value, periodicity, periods, degressive_factor=None, import_depreciation=0, **kwargs): + if degressive_factor is not None: + kwargs["method_progress_factor"] = degressive_factor + return cls.env['account.asset'].create({ + 'name': 'nice asset', + 'account_asset_id': cls.company_data['default_account_assets'].id, + 'account_depreciation_id': cls.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': cls.company_data['default_account_expense'].id, + 'journal_id': cls.company_data['default_journal_misc'].id, + 'acquisition_date': "2020-02-01", + 'prorata_computation_type': 'none', + 'original_value': value, + 'salvage_value': 0, + 'method_number': periods, + 'method_period': '12' if periodicity == "yearly" else '1', + 'method': "linear", + 'already_depreciated_amount_import': import_depreciation, + **kwargs, + }) + + @classmethod + def _get_depreciation_move_values(cls, date, depreciation_value, remaining_value, depreciated_value, state): + return { + 'date': fields.Date.from_string(date), + 'depreciation_value': depreciation_value, + 'asset_remaining_value': remaining_value, + 'asset_depreciated_value': depreciated_value, + 'state': state, + } diff --git a/dev_odex30_accounting/odex30_account_asset/tests/test_account_asset.py b/dev_odex30_accounting/odex30_account_asset/tests/test_account_asset.py new file mode 100644 index 0000000..418bb2e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/tests/test_account_asset.py @@ -0,0 +1,3172 @@ +# -*- coding: utf-8 -*- + +import time + +from dateutil.relativedelta import relativedelta +from odoo import fields, Command +from odoo.exceptions import UserError, MissingError +from odoo.tests import Form, tagged, freeze_time +from odoo.addons.account_reports.tests.common import TestAccountReportsCommon + + +@freeze_time('2021-07-01') +@tagged('post_install', '-at_install') +class TestAccountAsset(TestAccountReportsCommon): + + @classmethod + def setUpClass(cls): + super(TestAccountAsset, cls).setUpClass() + today = fields.Date.today() + cls.truck = cls.env['account.asset'].create({ + 'account_asset_id': cls.company_data['default_account_assets'].id, + 'account_depreciation_id': cls.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': cls.company_data['default_account_expense'].id, + 'journal_id': cls.company_data['default_journal_misc'].id, + 'name': 'truck', + 'acquisition_date': today + relativedelta(years=-6, months=-6), + 'original_value': 10000, + 'salvage_value': 2500, + 'method_number': 10, + 'method_period': '12', + 'method': 'linear', + }) + cls.truck.validate() + cls.env['account.move']._autopost_draft_entries() + + cls.account_asset_model_fixedassets = cls.env['account.asset'].create({ + 'account_depreciation_id': cls.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': cls.company_data['default_account_expense'].id, + 'account_asset_id': cls.company_data['default_account_assets'].id, + 'journal_id': cls.company_data['default_journal_purchase'].id, + 'name': 'Hardware - 3 Years', + 'method_number': 3, + 'method_period': '12', + 'state': 'model', + }) + + + cls.closing_invoice = cls.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_line_ids': [(0, 0, {'price_unit': 100})] + }) + + cls.env.company.loss_account_id = cls.company_data['default_account_expense'].copy() + cls.env.company.gain_account_id = cls.company_data['default_account_revenue'].copy() + cls.assert_counterpart_account_id = cls.company_data['default_account_expense'].copy().id + + cls.env.user.groups_id += cls.env.ref('analytic.group_analytic_accounting') + analytic_plan = cls.env['account.analytic.plan'].create({ + 'name': "Default Plan", + }) + cls.analytic_account = cls.env['account.analytic.account'].create({ + 'name': "Test Account", + 'plan_id': analytic_plan.id, + }) + + def update_form_values(self, asset_form): + for i in range(len(asset_form.depreciation_move_ids)): + with asset_form.depreciation_move_ids.edit(i) as line_edit: + line_edit.asset_remaining_value + + def test_account_asset_no_tax(self): + self.account_asset_model_fixedassets.account_depreciation_expense_id.tax_ids = self.tax_purchase_a + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + }) + CEO_car._onchange_model_id() + CEO_car.prorata_computation_type = 'constant_periods' + CEO_car.method_number = 5 + + # In order to test the process of Account Asset, I perform a action to confirm Account Asset. + CEO_car.validate() + + self.assertFalse(any(CEO_car.depreciation_move_ids.line_ids.mapped('tax_line_id'))) + + def test_00_account_asset(self): + """Test the lifecycle of an asset""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + }) + CEO_car._onchange_model_id() + CEO_car.prorata_computation_type = 'constant_periods' + CEO_car.method_number = 5 + + # In order to test the process of Account Asset, I perform a action to confirm Account Asset. + CEO_car.validate() + + # TOFIX: the method validate() makes the field account.asset.asset_type + # dirty, but this field has to be flushed in CEO_car's environment. + # This is because the field 'asset_type' is stored, computed and + # context-dependent, which explains why its value must be retrieved + # from the right environment. + CEO_car.flush_recordset() + + # I check Asset is now in Open state. + self.assertEqual(CEO_car.state, 'open', + 'Asset should be in Open state') + + # I compute depreciation lines for asset of CEOs Car. + self.assertEqual(CEO_car.method_number + 1, len(CEO_car.depreciation_move_ids), + 'Depreciation lines not created correctly') + + # Check that auto_post is set on the entries, in the future, and we cannot post them. + self.assertTrue(all(CEO_car.depreciation_move_ids.mapped(lambda m: m.auto_post != 'no'))) + with self.assertRaises(UserError): + CEO_car.depreciation_move_ids.action_post() + + # I Check that After creating all the moves of depreciation lines the state "Running". + CEO_car.depreciation_move_ids.write({'auto_post': 'no'}) + CEO_car.depreciation_move_ids.action_post() + self.assertEqual(CEO_car.state, 'open', + 'State of asset should be runing') + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 2000, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + + self.assertRecordValues(CEO_car.depreciation_move_ids.sorted(lambda l: l.date), [{ + 'amount_total': 1000, + 'asset_remaining_value': 9000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 7000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 5000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 3000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 1000, + }, { + 'amount_total': 1000, + 'asset_remaining_value': 0, + }]) + + # Revert posted entries in order to be able to close + CEO_car.depreciation_move_ids._reverse_moves(cancel=True) + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 12000, + 'value_residual': 10000, + 'salvage_value': 2000, + }]) + reversed_moves_values = [{ + 'amount_total': 1000, + 'asset_remaining_value': 11000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 13000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 15000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 17000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 19000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 20000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 19000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 17000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 15000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 13000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 11000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 10000, + 'state': 'posted', + }, { + 'amount_total': 10000, + 'asset_remaining_value': 0, + 'state': 'draft', + }] + + self.assertRecordValues(CEO_car.depreciation_move_ids.sorted(lambda l: l.date), reversed_moves_values) + self.assertRecordValues(CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft').line_ids, [{ + 'debit': 0, + 'credit': 10000, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 10000, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_expense_id.id, + }]) + + # Close + CEO_car.set_to_close(self.closing_invoice.invoice_line_ids, date=fields.Date.today() + relativedelta(days=-1)) + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 12000, + 'value_residual': 10000, + 'salvage_value': 2000, + }]) + self.assertRecordValues(CEO_car.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 12000, + 'asset_remaining_value': 0, + 'state': 'draft', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 1000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 3000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 5000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 7000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 9000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 10000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 9000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 7000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 5000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 3000, + 'state': 'posted', + }, { + 'amount_total': 2000, + 'asset_remaining_value': 1000, + 'state': 'posted', + }, { + 'amount_total': 1000, + 'asset_remaining_value': 0, + 'state': 'posted', + }]) + closing_move = CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 12000, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 0, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 100, + 'credit': 0, + 'account_id': self.closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 11900, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + closing_move.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + + def test_00_account_asset_new(self): + """Test the lifecycle of an asset""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + }) + CEO_car._onchange_model_id() + CEO_car.prorata_computation_type = 'constant_periods' + CEO_car.method_number = 5 + + # In order to test the process of Account Asset, I perform a action to confirm Account Asset. + CEO_car.validate() + + # I Check that After creating all the moves of depreciation lines the state of the asset is "Running". + CEO_car.depreciation_move_ids.write({'auto_post': 'no'}) + CEO_car.depreciation_move_ids.action_post() + self.assertEqual(CEO_car.state, 'open', + 'State of the asset should be running') + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 2000, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + self.assertRecordValues(CEO_car.depreciation_move_ids.sorted(lambda l: l.date), [{ + 'amount_total': 1000, + 'asset_remaining_value': 9000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 7000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 5000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 3000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': 1000, + }, { + 'amount_total': 1000, + 'asset_remaining_value': 0, + }]) + + # Close + CEO_car.set_to_close(self.closing_invoice.invoice_line_ids, date=fields.Date.today() + relativedelta(days=30)) + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 12000, + 'value_residual': 10000, + 'salvage_value': 2000, + }]) + self.assertRecordValues(CEO_car.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 166.67, + 'asset_remaining_value': 9833.33, + 'state': 'draft', + }, { + 'amount_total': 12000, + 'asset_remaining_value': 0, + 'state': 'draft', + }]) + closing_move = max(CEO_car.depreciation_move_ids, key=lambda m: (m.date, m.id)) + self.assertRecordValues(closing_move, [{ + 'date': fields.Date.today() + relativedelta(days=30), + }]) + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 12000, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 166.67, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 100, + 'credit': 0, + 'account_id': self.closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 11733.33, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + CEO_car.depreciation_move_ids.auto_post = 'no' + CEO_car.depreciation_move_ids.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 2000, + 'state': 'close', + }]) + + def test_01_account_asset(self): + """ Test if an an asset is created when an invoice is validated with an + item on an account for generating entries. + """ + account_asset_model = self.env['account.asset'].create({ + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Typical car - 3 Years', + 'method_number': 3, + 'method_period': '12', + 'prorata_computation_type': 'daily_computation', + 'state': 'model', + }) + + # The account needs a default model for the invoice to validate the revenue + self.company_data['default_account_assets'].create_asset = 'validate' + self.company_data['default_account_assets'].asset_model_ids = account_asset_model + + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.env['res.partner'].create({'name': 'Res Partner 12'}).id, + 'invoice_date': '2020-12-31', + 'invoice_line_ids': [(0, 0, { + 'name': 'Very little red car', + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 450, + 'quantity': 1, + })], + }) + invoice.action_post() + + asset = invoice.asset_ids + self.assertEqual(len(asset), 1, 'One and only one asset should have been created from invoice.') + + self.assertTrue(asset.state == 'open', + 'Asset should be in Open state') + first_invoice_line = invoice.invoice_line_ids[0] + self.assertEqual(asset.original_value, first_invoice_line.price_subtotal, + 'Asset value is not same as invoice line.') + + # I check data in move line and depreciation line. + first_depreciation_line = asset.depreciation_move_ids.sorted(lambda r: r.id)[0] + self.assertAlmostEqual(first_depreciation_line.asset_remaining_value, asset.original_value - first_depreciation_line.amount_total, + msg='Remaining value is incorrect.') + self.assertAlmostEqual(first_depreciation_line.asset_depreciated_value, first_depreciation_line.amount_total, + msg='Depreciated value is incorrect.') + + # I check next installment date. + last_depreciation_date = first_depreciation_line.date + installment_date = last_depreciation_date + relativedelta(months=+int(asset.method_period)) + self.assertEqual(asset.depreciation_move_ids.sorted(lambda r: r.id)[1].date, installment_date, + 'Installment date is incorrect.') + + def test_02_account_asset(self): + """Test the lifecycle of an asset""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': '2010-01-31', + 'already_depreciated_amount_import': 10000.0, + }) + CEO_car._onchange_model_id() + + CEO_car.validate() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 2000, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + self.assertFalse(CEO_car.depreciation_move_ids) + CEO_car.set_to_close(self.closing_invoice.invoice_line_ids) + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 2000, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + closing_move = CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 12000, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 10000, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 100, + 'credit': 0, + 'account_id': self.closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 1900, + 'credit': 0, + 'account_id': CEO_car.company_id.loss_account_id.id, + }]) + closing_move.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 2000, + }]) + + def test_03_account_asset(self): + """Test the salvage of an asset with gain""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': '2010-01-31', + 'already_depreciated_amount_import': 12000.0, + }) + CEO_car._onchange_model_id() + + CEO_car.validate() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 0, + }]) + self.assertFalse(CEO_car.depreciation_move_ids) + CEO_car.set_to_close(self.closing_invoice.invoice_line_ids) + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 0, + }]) + closing_move = CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 12000, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 12000, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 100, + 'credit': 0, + 'account_id': self.closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 0, + 'credit': 100, + 'account_id': CEO_car.company_id.gain_account_id.id, + }]) + closing_move.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 0, + }]) + + def test_04_account_asset(self): + """Test the salvage of an asset with gain""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 800.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': '2021-01-01', + 'already_depreciated_amount_import': 300.0, + }) + CEO_car._onchange_model_id() + CEO_car.method_number = 5 + + CEO_car.validate() + self.assertRecordValues(CEO_car, [{ + 'original_value': 800, + 'book_value': 500, + 'value_residual': 500, + 'salvage_value': 0, + }]) + self.assertEqual(len(CEO_car.depreciation_move_ids), 4) + CEO_car.set_to_close(self.closing_invoice.invoice_line_ids, date=fields.Date.today() + relativedelta(months=-6, days=-1)) + self.assertRecordValues(CEO_car, [{ + 'original_value': 800, + 'book_value': 500, + 'value_residual': 500, + 'salvage_value': 0, + }]) + closing_move = CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 800, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 300, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 100, + 'credit': 0, + 'account_id': self.closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 400, + 'credit': 0, + 'account_id': CEO_car.company_id.loss_account_id.id, + }]) + closing_move.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 800, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 0, + }]) + + def test_05_account_asset(self): + """Test the salvage of an asset with gain""" + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 1000.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': '2020-01-01', + }) + CEO_car._onchange_model_id() + CEO_car.method_number = 5 + CEO_car.account_depreciation_id = CEO_car.account_asset_id + + CEO_car.validate() + self.assertRecordValues(CEO_car, [{ + 'original_value': 1000, + 'book_value': 800, + 'value_residual': 800, + 'salvage_value': 0, + }]) + self.assertEqual(len(CEO_car.depreciation_move_ids), 5) + CEO_car.set_to_close(self.env['account.move.line'], date=fields.Date.today() + relativedelta(days=-1)) + self.assertRecordValues(CEO_car, [{ + 'original_value': 1000, + 'book_value': 700, + 'value_residual': 700, + 'salvage_value': 0, + }]) + closing_move = CEO_car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 1000, + 'account_id': CEO_car.account_asset_id.id, + }, { + 'debit': 300, + 'credit': 0, + 'account_id': CEO_car.account_depreciation_id.id, + }, { + 'debit': 700, + 'credit': 0, + 'account_id': CEO_car.company_id.loss_account_id.id, + }]) + closing_move.action_post() + self.assertRecordValues(CEO_car, [{ + 'original_value': 1000, + 'book_value': 0, + 'value_residual': 0, + 'salvage_value': 0, + }]) + + def test_06_account_asset(self): + """Test the correct computation of asset amounts""" + asset_account = self.env['account.account'].create({ + "name": "test_06_account_asset", + "code": "test.06.account.asset", + "account_type": 'asset_non_current', + "create_asset": "no", + "multiple_assets_per_line": True, + }) + + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 0, + 'state': 'draft', + 'method_period': '12', + 'method_number': 4, + 'name': "CEO's Car", + 'original_value': 1000.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=3), + 'account_asset_id': asset_account.id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': asset_account.id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + }) + + CEO_car.validate() + posted_entries = len(CEO_car.depreciation_move_ids.filtered(lambda x: x.state == 'posted')) + self.assertEqual(posted_entries, 3) + + self.assertRecordValues(CEO_car, [{ + 'original_value': 1000, + 'book_value': 250, + 'value_residual': 250, + 'salvage_value': 0, + }]) + + def test_account_asset_cancel(self): + """Test the cancellation of an asset""" + today = fields.Date.today() + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': today + relativedelta(years=-3, month=1, day=1), + }) + CEO_car._onchange_model_id() + CEO_car.method_number = 5 + CEO_car.validate() + + self.assertRecordValues(CEO_car, [{ + 'original_value': 12000, + 'book_value': 6000, + 'value_residual': 4000, + 'salvage_value': 2000, + }]) + CEO_car.set_to_cancelled() + + self.assertEqual(CEO_car.state, 'cancelled') + self.assertFalse(CEO_car.depreciation_move_ids) + + # Hashed journals should reverse entries instead of deleting + Hashed_car = CEO_car.copy() + Hashed_car.write({ + 'original_value': 12000.0, + 'method_number': 5, + 'name': "Hashed Car", + 'journal_id': CEO_car.journal_id.copy().id, + 'acquisition_date': today + relativedelta(years=-3, month=1, day=1), + }) + Hashed_car.journal_id.restrict_mode_hash_table = True + Hashed_car.validate() + self.assertTrue(False not in Hashed_car.depreciation_move_ids[:3].mapped('inalterable_hash')) + + for i in range(0, 4): + self.assertFalse(Hashed_car.depreciation_move_ids[i].reversal_move_ids) + + Hashed_car.set_to_cancelled() + + self.assertEqual(Hashed_car.state, 'cancelled') + for i in range(0, 2): + self.assertTrue(Hashed_car.depreciation_move_ids[i].reversal_move_ids.id > 0 or Hashed_car.depreciation_move_ids[i].reversed_entry_id.id > 0) + + # The depreciation schedule report should not contain cancelled assets + report = self.env.ref('odex30_account_asset.assets_report') + options = self._generate_options(report, today + relativedelta(years=-6, month=1, day=1), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + assets_in_report = [x['name'] for x in lines[:-1]] + + self.assertNotIn(CEO_car.name, assets_in_report) + self.assertNotIn(Hashed_car.name, assets_in_report) + + # When a lock date is applied, only the moves before the date are reversed, others are deleted + Locked_car = CEO_car.copy() + Locked_car.write({ + 'original_value': 12000.0, + 'method_number': 10, + 'name': "Locked Car", + 'acquisition_date': today + relativedelta(years=-3, month=1, day=1), + }) + Locked_car.validate() + Locked_car.company_id.fiscalyear_lock_date = today + relativedelta(years=-1) + + self.assertEqual(len(Locked_car.depreciation_move_ids), 10) + Locked_car.set_to_cancelled() + self.assertRecordValues(Locked_car, [{ + 'state': 'cancelled', + 'book_value': 12000.0, + 'value_residual': 10000, + 'salvage_value': 2000, + }]) + self.assertEqual(len(Locked_car.depreciation_move_ids), 4) + for depreciation in Locked_car.depreciation_move_ids: + self.assertTrue(depreciation.reversal_move_ids or depreciation.reversed_entry_id) + + + def test_asset_form(self): + """Test the form view of assets""" + asset_form = Form(self.env['account.asset']) + asset_form.name = "Test Asset" + asset_form.original_value = 10000 + asset_form.account_depreciation_id = self.company_data['default_account_assets'] + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + asset_form.journal_id = self.company_data['default_journal_misc'] + asset_form.prorata_computation_type = 'none' + asset = asset_form.save() + asset.validate() + + # Test that the depreciations are created upon validation of the asset according to the default values + self.assertEqual(len(asset.depreciation_move_ids), 5) + for move in asset.depreciation_move_ids: + self.assertEqual(move.amount_total, 2000) + + # Test that we cannot validate an asset with non zero remaining value of the last depreciation line + asset_form = Form(asset) + with self.assertRaises(UserError): + with self.cr.savepoint(): + with asset_form.depreciation_move_ids.edit(4) as line_edit: + line_edit.depreciation_value = 1000.0 + asset_form.save() + + # ... but we can with a zero remaining value on the last line. + asset_form = Form(asset) + with asset_form.depreciation_move_ids.edit(4) as line_edit: + line_edit.depreciation_value = 1000.0 + with asset_form.depreciation_move_ids.edit(3) as line_edit: + line_edit.depreciation_value = 3000.0 + self.update_form_values(asset_form) + asset_form.save() + + def test_negative_asset_balance_inversion(self): + """ + Test that an asset with a negative original value generates depreciation moves + with inverted balances (i.e., credit instead of debit) compared to a positive asset. + Also check that manual adjustments to depreciation values correctly reflect in invoice lines. + """ + asset_account = self.company_data['default_account_assets'].id + expense_account = self.company_data['default_account_expense'].id + asset = self.env['account.asset'].create({ + 'name': "Test Asset", + 'original_value': -10000, + 'account_depreciation_id': asset_account, + 'account_depreciation_expense_id': expense_account, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + }) + asset.compute_depreciation_board() + + # Test that the depreciations are created upon validation of the asset according to the default values + self.assertEqual(len(asset.depreciation_move_ids), 5) + for move in asset.depreciation_move_ids: + self.assertEqual(move.depreciation_value, -2000) + + with Form(asset) as asset_form: + with asset_form.depreciation_move_ids.edit(4) as line_edit: + line_edit.depreciation_value = -1000.0 + with asset_form.depreciation_move_ids.edit(3) as line_edit: + line_edit.depreciation_value = -3000.0 + self.update_form_values(asset_form) + + self.assertRecordValues(asset.depreciation_move_ids[0].line_ids, [ + {'account_id': asset_account, 'balance': 1000.0}, + {'account_id': expense_account, 'balance': -1000.0}, + ]) + + self.assertRecordValues(asset.depreciation_move_ids[1].line_ids, [ + {'account_id': asset_account, 'balance': 3000.0}, + {'account_id': expense_account, 'balance': -3000.0}, + ]) + + def test_asset_change_depreciation_expense_account(self): + """Check computation of depreciation_value is correct even when expense account was changed""" + self.env['account.move'].search([('state', '=', 'draft')]).unlink() # allow setting the lock date below + asset = self.env['account.asset'].create({ + 'name': 'Test asset', + 'acquisition_date': '2011-07-01', + 'original_value': 1000.0, + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + }) + asset.validate() + + sorted_depreciation_moves = asset.depreciation_move_ids.sorted(lambda l: l.date) + td = fields.Date.to_date + self.assertRecordValues(sorted_depreciation_moves, [ + {'date': td('2011-12-31'), 'depreciation_value': 100}, + {'date': td('2012-12-31'), 'depreciation_value': 200}, + {'date': td('2013-12-31'), 'depreciation_value': 200}, + {'date': td('2014-12-31'), 'depreciation_value': 200}, + {'date': td('2015-12-31'), 'depreciation_value': 200}, + {'date': td('2016-12-31'), 'depreciation_value': 100}, + ]) + + # Simulate life cycle of the asset by doing the following: + # - Auto posting of depreciation move at their planned date + # - Change the depreciation expense account after depreciation entry for 3rd period is posted + # - Set a lock date after each period so that changing the depreciation expense account + # does not modify the account from expense line on existing posted depreciation entries + new_depreciation_expense_account = asset.account_depreciation_expense_id.copy() + for period, depreciation_move in enumerate(sorted_depreciation_moves): + with self.subTest(period=period, depreciation_date=depreciation_move.date), freeze_time(depreciation_move.date): + + self.env["account.move"]._autopost_draft_entries() + + if period == 3: + asset.account_depreciation_expense_id = new_depreciation_expense_account + + # Ensure expense line of depreciation entry use the right account + expense_line = depreciation_move.line_ids.filtered(lambda line: line.account_id.internal_group == "expense") + if period > 2: + self.assertEqual(expense_line.account_id, new_depreciation_expense_account) + else: + self.assertEqual(expense_line.account_id, self.company_data['default_account_expense']) + + lock_wiz = self.env["account.change.lock.date"].create({"fiscalyear_lock_date": depreciation_move.date}) + with freeze_time('9999-12-31'): + lock_wiz.change_lock_date() + + # Force recomputation of depreciation_value (this would fail due to unbalanced entry in case + # we consider only the asset's expense account in the inverse function) + depreciation_field = self.env['account.move']._fields['depreciation_value'] + self.env.add_to_compute(depreciation_field, sorted_depreciation_moves) + + self.assertRecordValues(sorted_depreciation_moves, [ + {'date': td('2011-12-31'), 'depreciation_value': 100}, + {'date': td('2012-12-31'), 'depreciation_value': 200}, + {'date': td('2013-12-31'), 'depreciation_value': 200}, + {'date': td('2014-12-31'), 'depreciation_value': 200}, + {'date': td('2015-12-31'), 'depreciation_value': 200}, + {'date': td('2016-12-31'), 'depreciation_value': 100}, + ]) + + def test_asset_from_entry_line_form(self): + """Test that the asset is correcly created from a move line""" + + move_ids = self.env['account.move'].create([{ + 'ref': 'line1', + 'line_ids': [ + (0, 0, { + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 300, + 'name': 'Furniture', + }), + (0, 0, { + 'account_id': self.company_data['default_account_assets'].id, + 'credit': 300, + }), + ] + }, { + 'ref': 'line2', + 'line_ids': [ + (0, 0, { + 'account_id': self.company_data['default_account_expense'].id, + 'debit': 600, + 'name': 'Furniture too', + }), + (0, 0, { + 'account_id': self.company_data['default_account_assets'].id, + 'credit': 600, + }), + ] + }, + ]) + move_ids.action_post() + move_line_ids = move_ids.mapped('line_ids').filtered(lambda x: x.debit) + + asset_form = Form(self.env['account.asset'].with_context(default_original_move_line_ids=move_line_ids.ids)) + asset_form.original_move_line_ids = move_line_ids + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + + asset = asset_form.save() + self.assertEqual(asset.value_residual, 900.0) + self.assertIn(asset.name, ['Furniture', 'Furniture too']) + self.assertEqual(asset.journal_id.type, 'general') + self.assertEqual(asset.account_asset_id, self.company_data['default_account_expense']) + self.assertEqual(asset.account_depreciation_id, self.company_data['default_account_expense']) + self.assertEqual(asset.account_depreciation_expense_id, self.company_data['default_account_expense']) + self.assertEqual(asset.acquisition_date, min(move_ids.mapped('date'))) + + def test_asset_from_bill_move_line_form(self): + """Test that the asset is correcly created from a move line""" + + move_ids = self.env['account.move'].create([{ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'ref': 'line1', + 'date': '2020-06-01', + 'invoice_date': '2020-06-15', + 'invoice_line_ids': [ + Command.create({ + 'account_id': self.company_data['default_account_expense'].id, + 'price_unit': 300, + 'name': 'Furniture', + 'tax_ids': [], + }), + ] + }, { + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'ref': 'line2', + 'date': '2020-06-01', + 'invoice_date': '2020-06-14', + 'invoice_line_ids': [ + Command.create({ + 'account_id': self.company_data['default_account_expense'].id, + 'price_unit': 600, + 'name': 'Furniture too', + 'tax_ids': [], + }), + ] + }, + ]) + move_ids.action_post() + move_line_ids = move_ids.mapped('line_ids').filtered(lambda x: x.debit) + + asset_form = Form(self.env['account.asset'].with_context(default_original_move_line_ids=move_line_ids.ids)) + asset_form.original_move_line_ids = move_line_ids + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + + asset = asset_form.save() + self.assertEqual(asset.value_residual, 900.0) + self.assertRecordValues(asset, [{ + 'name': 'Furniture', + 'account_asset_id': self.company_data['default_account_expense'].id, + 'account_depreciation_id': self.company_data['default_account_expense'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'acquisition_date': min(move_ids.mapped('invoice_date')), + }]) + + def test_asset_from_bill_move_line_form_multicurrency(self): + """Test that the asset is correcly created from a move line using a foreign currency""" + + asset_account = self.company_data['default_account_assets'] + non_deductible_tax = self.env['account.tax'].create({ + 'name': 'Non-deductible Tax', + 'amount': 21, + 'amount_type': 'percent', + 'type_tax_use': 'purchase', + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': True + }), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': True + }), + ], + }) + asset_account.tax_ids = non_deductible_tax + + asset_account.create_asset = 'no' + asset_account.multiple_assets_per_line = False + + vendor_bill = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'currency_id': self.other_currency.id, + 'invoice_date': '2020-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [ + Command.create({ + 'account_id': asset_account.id, + 'currency_id': self.other_currency.id, + 'name': 'Asus Laptop', + 'price_unit': 1000.0, + 'quantity': 1, + 'tax_ids': [Command.set(non_deductible_tax.ids)] + }), + Command.create({ + 'account_id': asset_account.id, + 'currency_id': self.other_currency.id, + 'name': 'Lenovo Laptop', + 'price_unit': 500.0, + 'quantity': 1, + 'tax_ids': [Command.set(non_deductible_tax.ids)] + }), + ], + }) + vendor_bill.action_post() + self.env.flush_all() + + move_line_ids = vendor_bill.mapped('line_ids').filtered(lambda x: x.name and 'Laptop' in x.name) + asset_form = Form(self.env['account.asset'].with_context( + default_original_move_line_ids=move_line_ids.ids, + asset_type='purchase' + )) + asset_form.original_move_line_ids = move_line_ids + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + + new_assets = asset_form.save() + self.assertEqual(len(new_assets), 1) + self.assertEqual(new_assets.original_value, 828.75) + self.assertEqual(new_assets.non_deductible_tax_value, 78.75) + + def test_asset_modify_value_00(self): + """Test the values of the asset and value increase 'assets' after a + modification of residual and/or salvage values. + Increase the residual value, increase the salvage value""" + self.assertEqual(self.truck.value_residual, 3000) + self.assertEqual(self.truck.salvage_value, 2500) + + self.env['asset.modify'].create({ + 'name': 'New beautiful sticker :D', + 'asset_id': self.truck.id, + 'value_residual': 4000, + 'salvage_value': 3000, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + "account_asset_counterpart_id": self.assert_counterpart_account_id, + "account_depreciation_id": self.company_data['default_account_assets'].id, + }).modify() + self.assertEqual(self.truck.value_residual, 3000) + self.assertEqual(self.truck.salvage_value, 2500) + self.assertEqual(self.truck.children_ids.value_residual, 1000) + self.assertEqual(self.truck.children_ids.salvage_value, 500) + self.assertEqual(self.truck.account_depreciation_id.id, self.company_data['default_account_assets'].id) + + def test_asset_modify_value_01(self): + "Decrease the residual value, decrease the salvage value" + self.env['asset.modify'].create({ + 'name': "Accident :'(", + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'asset_id': self.truck.id, + 'value_residual': 1000, + 'salvage_value': 2000, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertEqual(self.truck.value_residual, 1000) + self.assertEqual(self.truck.salvage_value, 2000) + self.assertEqual(self.truck.children_ids.value_residual, 0) + self.assertEqual(self.truck.children_ids.salvage_value, 0) + self.assertEqual(max(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'posted'), key=lambda m: (m.date, m.id)).amount_total, 2500) + + def test_asset_modify_value_02(self): + "Decrease the residual value, increase the salvage value; same book value" + self.env['asset.modify'].create({ + 'name': "Don't wanna depreciate all of it", + 'asset_id': self.truck.id, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'value_residual': 1000, + 'salvage_value': 4500, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertEqual(self.truck.value_residual, 1000) + self.assertEqual(self.truck.salvage_value, 4500) + self.assertEqual(self.truck.children_ids.value_residual, 0) + self.assertEqual(self.truck.children_ids.salvage_value, 0) + + def test_asset_modify_value_03(self): + "Decrease the residual value, increase the salvage value; increase of book value" + self.env['asset.modify'].create({ + 'name': "Some aliens did something to my truck", + 'asset_id': self.truck.id, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'value_residual': 1000, + 'salvage_value': 6000, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertEqual(self.truck.value_residual, 1000) + self.assertEqual(self.truck.salvage_value, 4500) + self.assertEqual(self.truck.children_ids.value_residual, 0) + self.assertEqual(self.truck.children_ids.salvage_value, 1500) + + def test_asset_modify_value_04(self): + "Increase the residual value, decrease the salvage value; increase of book value" + self.env['asset.modify'].create({ + 'name': 'GODZILA IS REAL!', + 'asset_id': self.truck.id, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'value_residual': 4000, + 'salvage_value': 2000, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertEqual(self.truck.value_residual, 3500) + self.assertEqual(self.truck.salvage_value, 2000) + self.assertEqual(self.truck.children_ids.value_residual, 500) + self.assertEqual(self.truck.children_ids.salvage_value, 0) + + def test_asset_modify_report(self): + """Test the asset value modification flows""" + # PY + - Final PY + - Final Bookvalue + # -6 0 10000 0 10000 0 750 0 750 9250 + # -5 10000 0 0 10000 750 750 0 1500 8500 + # -4 10000 0 0 10000 1500 750 0 2250 7750 + # -3 10000 0 0 10000 2250 750 0 3000 7000 + # -2 10000 0 0 10000 3000 750 0 3750 6250 + # -1 10000 0 0 10000 3750 750 0 4500 5500 + # 0 10000 0 0 10000 4500 750 0 5250 4750 <-- today + # 1 10000 0 0 10000 5250 750 0 6000 4000 + # 2 10000 0 0 10000 6000 750 0 6750 3250 + # 3 10000 0 0 10000 6750 750 0 7500 2500 + + today = fields.Date.today() + + report = self.env.ref('odex30_account_asset.assets_report') + # TEST REPORT + # look at all period, with unposted entries + options = self._generate_options(report, today + relativedelta(years=-6, month=1, day=1), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([ 0.0, 10000.0, 0.0, 10000.0, 0.0, 7500.0, 0.0, 7500.0, 2500.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + # look at all period, without unposted entries + options = self._generate_options(report, today + relativedelta(years=-6, month=1, day=1), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': False}}) + self.assertListEqual([ 0.0, 10000.0, 0.0, 10000.0, 0.0, 4500.0, 0.0, 4500.0, 5500.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + # look only at this period + options = self._generate_options(report, today + relativedelta(years=0, month=1, day=1), today + relativedelta(years=0, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([10000.0, 0.0, 0.0, 10000.0, 4500.0, 750.0, 0.0, 5250.0, 4750.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + # test value increase + # PY + - Final PY + - Final Bookvalue + # -6 0 10000 0 10000 750 0 750 9250 + # -5 10000 0 0 10000 750 750 0 1500 8500 + # -4 10000 0 0 10000 1500 750 0 2250 7750 + # -3 10000 0 0 10000 2250 750 0 3000 7000 + # -2 10000 0 0 10000 3000 750 0 3750 6250 + # -1 10000 1500 0 10000 3750 950 0 4700 6800 + # 0 10000 0 0 11500 4700 950 0 5650 5850 <-- today + # 1 11500 0 0 11500 5650 950 0 6600 4900 + # 2 11500 0 0 11500 6600 950 0 7550 3950 + # 3 11500 0 0 11500 7550 950 0 8500 3000 + self.assertEqual(self.truck.value_residual, 3000) + self.assertEqual(self.truck.salvage_value, 2500) + self.env['asset.modify'].create({ + 'name': 'New beautiful sticker :D', + 'asset_id': self.truck.id, + 'date': fields.Date.today() + relativedelta(years=-1, months=-6, days=-1), + 'value_residual': 4750, + 'salvage_value': 3000, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + + self.assertEqual(self.truck.value_residual + sum(self.truck.children_ids.mapped('value_residual')), 3800) + self.assertEqual(self.truck.salvage_value + sum(self.truck.children_ids.mapped('salvage_value')), 3000) + + # look at all period, with unposted entries + options = self._generate_options(report, today + relativedelta(years=-6, months=-6), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([0.0, 11500.0, 0.0, 11500.0, 0.0, 8500.0, 0.0, 8500.0, 3000.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + self.assertEqual('10 y', lines[1]['columns'][3]['name'], 'Depreciation Rate = 10%') + + # look only at this period + options = self._generate_options(report, today + relativedelta(years=0, month=1, day=1), today + relativedelta(years=0, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([11500.0, 0.0, 0.0, 11500.0, 4700.0, 950.0, 0.0, 5650.0, 5850.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + # test value decrease + self.env['asset.modify'].create({ + 'name': "Huge scratch on beautiful sticker :'( It is ruined", + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'asset_id': self.truck.children_ids.id, + 'value_residual': 0, + 'salvage_value': 500, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.env['asset.modify'].create({ + 'name': "Huge scratch on beautiful sticker :'( It went through...", + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'asset_id': self.truck.id, + 'value_residual': 1000, + 'salvage_value': 2500, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertEqual(self.truck.value_residual + sum(self.truck.children_ids.mapped('value_residual')), 1000) + self.assertEqual(self.truck.salvage_value + sum(self.truck.children_ids.mapped('salvage_value')), 3000) + + # look at all period, with unposted entries + options = self._generate_options(report, today + relativedelta(years=-6, month=1, day=1), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([0.0, 11500.0, 0.0, 11500.0, 0.0, 8500.0, 0.0, 8500.0, 3000.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + # look only at previous period + options = self._generate_options(report, today + relativedelta(years=-1, month=1, day=1), today + relativedelta(years=-1, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + self.assertListEqual([10000.0, 1500.0, 0.0, 11500.0, 3750.0, 3750.0, 0.0, 7500.0, 4000.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + def test_asset_pause_resume(self): + """Test that depreciation remains the same after a pause and resume at a later date""" + today = fields.Date.today() + self.assertEqual(len(self.truck.depreciation_move_ids.filtered(lambda e: e.state == 'draft')), 4) + self.env['asset.modify'].create({ + 'date': fields.Date.today() + relativedelta(days=-1), + 'asset_id': self.truck.id, + }).pause() + self.assertEqual(len(self.truck.depreciation_move_ids.filtered(lambda e: e.state == 'draft')), 0) + with freeze_time(today) as frozen_time: + frozen_time.move_to(today + relativedelta(years=1)) + self.env['asset.modify'].with_context(resume_after_pause=True).create({ + 'asset_id': self.truck.id, + }).modify() + self.assertEqual(len(self.truck.depreciation_move_ids.filtered(lambda e: e.state == 'posted')), 7) + self.assertEqual( + self.truck.depreciation_move_ids.filtered(lambda e: e.state == 'draft').mapped('amount_total'), + [375.0, 750.0, 750.0, 750.0]) + + def test_asset_modify_sell_profit(self): + """Test that a credit is realised in the gain account when selling an asset for a sum greater than book value""" + closing_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_line_ids': [(0, 0, {'price_unit': self.truck.book_value + 100})] + }) + self.env['asset.modify'].create({ + 'asset_id': self.truck.id, + 'invoice_line_ids': closing_invoice.invoice_line_ids, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'modify_action': 'sell', + }).sell_dispose() + + closing_move = self.truck.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'ref': 'truck: Sale', + 'debit': 0, + 'credit': 10000, + 'account_id': self.truck.account_asset_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 4500, + 'credit': 0, + 'account_id': self.truck.account_depreciation_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 5600, + 'credit': 0, + 'account_id': closing_invoice.invoice_line_ids.account_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 0, + 'credit': 100, + 'account_id': self.env.company.gain_account_id.id, + }]) + + def test_asset_modify_sell_loss(self): + """Test that a debit is realised in the loss account when selling an asset for a sum less than book value""" + closing_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_line_ids': [(0, 0, {'price_unit': self.truck.book_value - 100})] + }) + self.env['asset.modify'].create({ + 'asset_id': self.truck.id, + 'invoice_line_ids': closing_invoice.invoice_line_ids, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'modify_action': 'sell', + }).sell_dispose() + closing_move = self.truck.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + + self.assertRecordValues(closing_move.line_ids, [{ + 'ref': 'truck: Sale', + 'debit': 0, + 'credit': 10000, + 'account_id': self.truck.account_asset_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 4500, + 'credit': 0, + 'account_id': self.truck.account_depreciation_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 5400, + 'credit': 0, + 'account_id': closing_invoice.invoice_line_ids.account_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 100, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + + def test_asset_sale_same_account_as_invoice(self): + """Test the sale of an asset with an invoice that has the same account as the Depreciation Account""" + closing_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_line_ids': [ + Command.create({ + 'account_id': self.truck.account_depreciation_id.id, + 'price_unit': self.truck.book_value - 100 + }) + ] + }) + self.env['asset.modify'].create({ + 'asset_id': self.truck.id, + 'invoice_line_ids': closing_invoice.invoice_line_ids, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'modify_action': 'sell', + }).sell_dispose() + closing_move = self.truck.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'ref': 'truck: Sale', + 'debit': 0, + 'credit': 10000, + 'account_id': self.truck.account_asset_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 4500, + 'credit': 0, + 'account_id': self.truck.account_depreciation_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 5400, + 'credit': 0, + 'account_id': closing_invoice.invoice_line_ids.account_id.id, + }, { + 'ref': 'truck: Sale', + 'debit': 100, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + + self.assertEqual(closing_move.depreciation_value, 3000, "Should be the remaining amount before the sale") + + def test_asset_modify_dispose(self): + """Test the loss of the remaining book_value when an asset is disposed using the wizard""" + self.env['asset.modify'].create({ + 'asset_id': self.truck.id, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'modify_action': 'dispose', + }).sell_dispose() + closing_move = self.truck.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + self.assertRecordValues(closing_move.line_ids, [{ + 'ref': 'truck: Disposal', + 'debit': 0, + 'credit': 10000, + 'account_id': self.truck.account_asset_id.id, + }, { + 'ref': 'truck: Disposal', + 'debit': 4500, + 'credit': 0, + 'account_id': self.truck.account_depreciation_id.id, + }, { + 'ref': 'truck: Disposal', + 'debit': 5500, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + + def test_asset_reverse_depreciation(self): + """Test the reversal of a depreciation move""" + + self.assertEqual(sum(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'posted').mapped('depreciation_value')), 4500) + self.assertEqual(sum(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'draft').mapped('depreciation_value')), 3000) + self.assertEqual(max(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'posted'), key=lambda m: m.date).asset_remaining_value, 3000) + + report = self.env.ref('odex30_account_asset.assets_report') + today = fields.Date.today() + + move_to_reverse = self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'posted').sorted(lambda m: m.date)[-1] + reversed_move = move_to_reverse._reverse_moves() + + # Check that the depreciation has been reported on the next move + min_date_draft = min(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'draft' and m.date > reversed_move.date), key=lambda m: m.date) + self.assertEqual(move_to_reverse.asset_remaining_value - min_date_draft.depreciation_value - reversed_move.depreciation_value, min_date_draft.asset_remaining_value) + self.assertEqual(move_to_reverse.asset_depreciated_value + min_date_draft.depreciation_value + reversed_move.depreciation_value, min_date_draft.asset_depreciated_value) + + # The amount is still there, it only has been reversed. But it has been added on the next draft move to complete the depreciation table + self.assertEqual(sum(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'posted').mapped('depreciation_value')), 4500) + self.assertEqual(sum(self.truck.depreciation_move_ids.filtered(lambda m: m.state == 'draft').mapped('depreciation_value')), 3000) + + # Check that the table shows fully depreciated at the end + self.assertEqual(max(self.truck.depreciation_move_ids, key=lambda m: m.date).asset_remaining_value, 0) + self.assertEqual(max(self.truck.depreciation_move_ids, key=lambda m: m.date).asset_depreciated_value, 7500) + + reversed_move.action_post() + + options = self._generate_options(report, today + relativedelta(years=0, month=7, day=1), today + relativedelta(years=0, month=7, day=31)) + lines = report._get_lines({**options, 'unfold_all': False, 'all_entries': True}) + # We take the reversal entry into account + self.assertListEqual([10000.0, 0.0, 0.0, 10000.0, 4500.0, -750.0, 0.0, 3750.0, 6250.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + options = self._generate_options(report, today + relativedelta(years=0, month=1, day=1), today + relativedelta(years=0, month=12, day=31)) + lines = report._get_lines({**options, 'unfold_all': False, 'all_entries': True}) + # With the report on the next entry, we get a normal depreciation amount for the year + self.assertListEqual([10000.0, 0.0, 0.0, 10000.0, 4500.0, 750.0, 0.0, 5250.0, 4750.0], + [x['no_format'] for x in lines[0]['columns'][4:]]) + + def test_ref_asset_depreciation(self): + """Test that the reference used in depreciation moves is correct""" + + for ref in self.truck.depreciation_move_ids.mapped('ref'): + self.assertEqual(ref, 'truck: Depreciation') + + def test_credit_note_out_refund(self): + """ + Test the behaviour of the asset creation when a credit note is created. + The asset created from the credit note should be the same as the one created from the invoice + with a negative value. + """ + depreciation_account = self.company_data['default_account_assets'].copy() + revenue_model = self.env['account.asset'].create({ + 'account_depreciation_id': depreciation_account.id, + 'account_depreciation_expense_id': self.company_data['default_account_revenue'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Hardware - 5 Years', + 'method_number': 5, + 'method_period': '12', + 'state': 'model', + }) + + depreciation_account.write({'create_asset': 'draft', 'asset_model_ids': revenue_model}) + + invoice = self.env['account.move'].create({ + 'invoice_date': '2019-07-01', + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Hardware', + 'account_id': depreciation_account.id, + 'price_unit': 5000, + 'quantity': 1, + 'tax_ids': False, + })], + }) + + invoice.action_post() + self.assertTrue(invoice.asset_ids) + + credit_note = invoice._reverse_moves([{'invoice_date': fields.Date.today()}]) + credit_note.action_post() + + invoice_asset = invoice.asset_ids + credit_note_asset = credit_note.asset_ids + + # check if invoice_asset still exists after validate the credit note + self.assertTrue(invoice_asset) + self.assertTrue(credit_note_asset) + + (invoice_asset + credit_note_asset).validate() + + self.assertRecordValues(credit_note_asset, [ + { + 'acquisition_date': invoice_asset.acquisition_date, + 'book_value': -invoice_asset.book_value, + 'value_residual': -invoice_asset.value_residual, + } + ]) + + for invoice_asset_move, credit_note_asset_move in zip(invoice_asset.depreciation_move_ids.sorted('date'), credit_note_asset.depreciation_move_ids.sorted('date')): + self.assertRecordValues(credit_note_asset_move, [ + { + 'date': invoice_asset_move.date, + 'state': invoice_asset_move.state, + 'depreciation_value': -invoice_asset_move.depreciation_value, + } + ]) + + def test_asset_multiple_assets_from_one_move_line_00(self): + """ Test the creation of a as many assets as the value of + the quantity property of a move line. """ + + account = self.env['account.account'].create({ + "name": "test account", + "code": "TEST", + "account_type": 'asset_non_current', + "create_asset": "draft", + "multiple_assets_per_line": True, + }) + move = self.env['account.move'].create({ + "partner_id": self.env['res.partner'].create({'name': 'Johny'}).id, + "ref": "line1", + "move_type": "in_invoice", + "invoice_date": "2020-12-31", + "invoice_line_ids": [ + (0, 0, { + "account_id": account.id, + "price_unit": 400.0, + "name": "stuff", + "quantity": 2, + "product_uom_id": self.env.ref('uom.product_uom_unit').id, + "tax_ids": [], + }), + ] + }) + move.action_post() + assets = move.asset_ids + assets = sorted(assets, key=lambda i: i['original_value'], reverse=True) + self.assertEqual(len(assets), 2, '3 assets should have been created') + self.assertEqual(assets[0].original_value, 400.0) + self.assertEqual(assets[1].original_value, 400.0) + + def test_asset_multiple_assets_from_one_move_line_01(self): + """ Test the creation of a as many assets as the value of + the quantity property of a move line. """ + + account = self.env['account.account'].create({ + "name": "test account", + "code": "TEST", + "account_type": 'asset_non_current', + "create_asset": "draft", + "multiple_assets_per_line": True, + }) + move = self.env['account.move'].create({ + "partner_id": self.env['res.partner'].create({'name': 'Johny'}).id, + "ref": "line1", + "move_type": "in_invoice", + "invoice_date": "2020-12-31", + "invoice_line_ids": [ + (0, 0, { + "account_id": account.id, + "name": "stuff", + "quantity": 3.0, + "price_unit": 1000.0, + "product_uom_id": self.env.ref('uom.product_uom_categ_unit').id, + }), + (0, 0, { + 'account_id': self.company_data['default_account_assets'].id, + "name": "stuff", + 'quantity': 1.0, + 'price_unit': -500.0, + }), + ] + }) + move.action_post() + self.assertEqual(sum(asset.original_value for asset in move.asset_ids), move.line_ids[0].debit) + + def test_asset_credit_note(self): + """Test the generated entries created from an in_refund invoice with asset""" + asset_model = self.env['account.asset'].create({ + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'account_asset_id': self.company_data['default_account_assets'].id, + 'journal_id': self.company_data['default_journal_purchase'].id, + 'name': 'Small car - 3 Years', + 'method_number': 3, + 'method_period': '12', + 'state': 'model', + }) + + self.company_data['default_account_assets'].create_asset = "validate" + self.company_data['default_account_assets'].asset_model_ids = asset_model + + invoice = self.env['account.move'].create({ + 'move_type': 'in_refund', + 'invoice_date': '2020-01-01', + 'date': '2020-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [(0, 0, { + 'name': 'Very little red car', + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 450, + 'quantity': 1, + })], + }) + invoice.action_post() + depreciation_lines = self.env['account.move.line'].search([ + ('account_id', '=', asset_model.account_depreciation_id.id), + ('move_id.asset_id', '=', invoice.asset_ids.id), + ('debit', '=', 150), + ]) + self.assertEqual( + len(depreciation_lines), 3, + 'Three entries with a debit of 150 must be created on the Deferred Expense Account' + ) + + def test_asset_partial_credit_note(self): + """Test partial credit note on an in invoice that has generated draft assets. + + Test case: + - Create in invoice with the following lines: + + Product | Unit Price | Quantity | Multiple assets + --------------------------------------------------------- + Product B | 200 | 4 | TRUE + Product A | 100 | 7 | FALSE + Product A | 100 | 5 | TRUE + Product A | 150 | 6 | TRUE + Product A | 100 | 7 | FALSE + + - Add a credit note with the following lines: + + Product | Unit Price | Quantity + --------------------------------------- + Product A | 100 | 1 + Product A | 150 | 2 + Product A | 100 | 7 + """ + asset_model = self.env['account.asset'].create({ + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_sale'].id, + 'name': 'Maintenance Contract - 3 Years', + 'method_number': 3, + 'method_period': '12', + 'prorata_computation_type': 'none', + 'state': 'model', + }) + self.company_data['default_account_assets'].create_asset = 'draft' + self.company_data['default_account_assets'].asset_model_ids = asset_model + account_assets_multiple = self.company_data['default_account_assets'].copy() + account_assets_multiple.multiple_assets_per_line = True + + product_a = self.env['product.product'].create({ + 'name': 'Product A', + 'default_code': 'PA', + 'lst_price': 100.0, + 'standard_price': 100.0, + }) + product_b = self.env['product.product'].create({ + 'name': 'Product B', + 'default_code': 'PB', + 'lst_price': 200.0, + 'standard_price': 200.0, + }) + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'invoice_date': '2020-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [ + (0, 0, { + 'product_id': product_b.id, + 'name': 'Product B', + 'account_id': account_assets_multiple.id, + 'price_unit': 200.0, + 'quantity': 4, + }), + (0, 0, { + 'product_id': product_a.id, + 'name': 'Product A', + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 100.0, + 'quantity': 7, + }), + (0, 0, { + 'product_id': product_a.id, + 'name': 'Product A', + 'account_id': account_assets_multiple.id, + 'price_unit': 100.0, + 'quantity': 5, + }), + (0, 0, { + 'product_id': product_a.id, + 'name': 'Product A', + 'account_id': account_assets_multiple.id, + 'price_unit': 150.0, + 'quantity': 6, + }), + (0, 0, { + 'product_id': product_a.id, + 'name': 'Product A', + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 100.0, + 'quantity': 7, + }), + ], + }) + invoice.action_post() + product_a_100_lines = invoice.line_ids.filtered(lambda l: l.product_id == product_a and l.price_unit == 100.0) + product_a_150_lines = invoice.line_ids.filtered(lambda l: l.product_id == product_a and l.price_unit == 150.0) + product_b_lines = invoice.line_ids.filtered(lambda l: l.product_id == product_b) + self.assertEqual(len(invoice.line_ids.mapped(lambda l: l.asset_ids)), 17) + self.assertEqual(len(product_b_lines.asset_ids), 4) + self.assertEqual(len(product_a_100_lines.asset_ids), 7) + self.assertEqual(len(product_a_150_lines.asset_ids), 6) + credit_note = invoice._reverse_moves() + with Form(credit_note) as move_form: + move_form.invoice_date = move_form.date + move_form.invoice_line_ids.remove(0) + move_form.invoice_line_ids.remove(0) + with move_form.invoice_line_ids.edit(0) as line_form: + line_form.quantity = 1 + with move_form.invoice_line_ids.edit(1) as line_form: + line_form.quantity = 2 + credit_note.action_post() + self.assertEqual(len(invoice.line_ids.mapped(lambda l: l.asset_ids)), 17) + self.assertEqual(len(product_b_lines.asset_ids), 4) + self.assertEqual(len(product_a_100_lines.asset_ids), 7) + self.assertEqual(len(product_a_150_lines.asset_ids), 6) + + def test_asset_with_non_deductible_tax(self): + """Test that the assets' original_value and non_deductible_tax_value are correctly computed + from a move line with a non-deductible tax.""" + + asset_account = self.company_data['default_account_assets'] + non_deductible_tax = self.env['account.tax'].create({ + 'name': 'Non-deductible Tax', + 'amount': 21, + 'amount_type': 'percent', + 'type_tax_use': 'purchase', + 'invoice_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': True + }), + ], + 'refund_repartition_line_ids': [ + Command.create({'repartition_type': 'base'}), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': False + }), + Command.create({ + 'factor_percent': 50, + 'repartition_type': 'tax', + 'use_in_tax_closing': True + }), + ], + }) + asset_account.tax_ids = non_deductible_tax + + # 1. Automatic creation + asset_account.create_asset = 'draft' + asset_account.asset_model_ids = self.account_asset_model_fixedassets + asset_account.multiple_assets_per_line = True + + vendor_bill_auto = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'invoice_date': '2020-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [Command.create({ + 'account_id': asset_account.id, + 'name': 'Asus Laptop', + 'price_unit': 1000.0, + 'quantity': 2, + 'tax_ids': [Command.set(non_deductible_tax.ids)], + })], + }) + vendor_bill_auto.action_post() + + new_assets_auto = vendor_bill_auto.asset_ids + self.assertEqual(len(new_assets_auto), 2) + self.assertEqual(new_assets_auto.mapped('original_value'), [1105.0, 1105.0]) + self.assertEqual(new_assets_auto.mapped('non_deductible_tax_value'), [105.0, 105.0]) + + # 2. Manual creation + asset_account.create_asset = 'no' + asset_account.asset_model_ids = None + asset_account.multiple_assets_per_line = False + + vendor_bill_manu = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'invoice_date': '2020-01-01', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [ + Command.create({ + 'account_id': asset_account.id, + 'name': 'Asus Laptop', + 'price_unit': 1000.0, + 'quantity': 2, + 'tax_ids': [Command.set(non_deductible_tax.ids)] + }), + Command.create({ + 'account_id': asset_account.id, + 'name': 'Lenovo Laptop', + 'price_unit': 500.0, + 'quantity': 3, + 'tax_ids': [Command.set(non_deductible_tax.ids)] + }), + ], + }) + vendor_bill_manu.action_post() + + # TOFIX: somewhere above this the field account.asset.asset_type is made + # dirty, but this field has to be flushed in a specific environment. + # This is because the field 'asset_type' is stored, computed and + # context-dependent, which explains why its value must be retrieved + # from the right environment. + self.env.flush_all() + + move_line_ids = vendor_bill_manu.mapped('line_ids').filtered(lambda x: x.name and 'Laptop' in x.name) + asset_form = Form(self.env['account.asset'].with_context( + default_original_move_line_ids=move_line_ids.ids, + )) + asset_form.original_move_line_ids = move_line_ids + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + + new_assets_manu = asset_form.save() + self.assertEqual(len(new_assets_manu), 1) + self.assertEqual(new_assets_manu.original_value, 3867.5) + self.assertEqual(new_assets_manu.non_deductible_tax_value, 367.5) + + def test_asset_degressive_01(self): + """ Check the computation of an asset with degressive method, + start at middle of the year + """ + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Degressive', + 'acquisition_date': '2021-07-01', + 'prorata_computation_type': 'constant_periods', + 'original_value': 10000, + 'method_number': 5, + 'method_period': '12', + 'method': 'degressive', + 'method_progress_factor': 0.5, + }) + + asset.validate() + + self.assertEqual(asset.method_number + 1, len(asset.depreciation_move_ids)) + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 2500, + 'asset_remaining_value': 7500, + }, { + 'amount_total': 3750, + 'asset_remaining_value': 3750, + }, { + 'amount_total': 1875, + 'asset_remaining_value': 1875, + }, { + 'amount_total': 937.5, + 'asset_remaining_value': 937.5, + }, { + 'amount_total': 625.00, + 'asset_remaining_value': 312.50, + }, { + 'amount_total': 312.50, + 'asset_remaining_value': 0, + }]) + + def test_asset_degressive_02(self): + """ Check the computation of an asset with degressive method, + start at beginning of the year. + """ + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Degressive', + 'acquisition_date': '2021-01-01', + 'original_value': 10000, + 'method_number': 5, + 'method_period': '12', + 'method': 'degressive', + 'method_progress_factor': 0.5, + }) + + asset.validate() + + self.assertEqual(asset.method_number, len(asset.depreciation_move_ids)) + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 5000, + 'asset_remaining_value': 5000, + }, { + 'amount_total': 2500, + 'asset_remaining_value': 2500, + }, { + 'amount_total': 1250, + 'asset_remaining_value': 1250, + }, { + 'amount_total': 625, + 'asset_remaining_value': 625, + }, { + 'amount_total': 625, + 'asset_remaining_value': 0, + }]) + + def test_asset_negative_01(self): + """ Check the computation of an asset with negative value. """ + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Degressive Linear', + 'acquisition_date': '2021-07-01', + 'original_value': -10000, + 'method_number': 5, + 'method_period': '12', + 'method': 'linear', + }) + asset.prorata_computation_type = 'constant_periods' + + asset.validate() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 1000, + 'asset_remaining_value': -9000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': -7000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': -5000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': -3000, + }, { + 'amount_total': 2000, + 'asset_remaining_value': -1000, + }, { + 'amount_total': 1000, + 'asset_remaining_value': 0, + }]) + + def test_asset_daily_computation_01(self): + """ Check the computation of an asset with daily_computation. """ + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Degressive Linear', + 'acquisition_date': '2021-07-01', + 'prorata_computation_type': 'daily_computation', + 'original_value': 10000, + 'method_number': 5, + 'method_period': '12', + 'method': 'linear', + }) + + asset.validate() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), [{ + 'amount_total': 1007.67, + 'asset_remaining_value': 8992.33, + }, { + 'amount_total': 1998.90, + 'asset_remaining_value': 6993.43, + }, { + 'amount_total': 1998.91, + 'asset_remaining_value': 4994.52, + }, { + 'amount_total': 2004.38, + 'asset_remaining_value': 2990.14, + }, { + 'amount_total': 1998.90, + 'asset_remaining_value': 991.24, + }, { + 'amount_total': 991.24, + 'asset_remaining_value': 0, + }]) + + def test_decrement_book_value_with_negative_asset(self): + """ + Test the computation of book value and remaining value + when posting a depreciation move related with a negative asset + """ + depreciation_account = self.company_data['default_account_assets'].copy() + asset_model = self.env['account.asset'].create({ + 'name': 'test', + 'state': 'model', + 'active': True, + 'method': 'linear', + 'method_number': 5, + 'method_period': '1', + 'prorata_computation_type': 'constant_periods', + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': depreciation_account.id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_purchase'].id, + }) + + depreciation_account.can_create_asset = True + depreciation_account.create_asset = 'draft' + depreciation_account.asset_model_ids = asset_model + + refund = self.env['account.move'].create({ + 'move_type': 'in_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': '2021-06-01', + 'invoice_line_ids': [Command.create({'name': 'refund', 'account_id': depreciation_account.id, 'price_unit': 500, 'tax_ids': False})], + }) + refund.action_post() + + self.assertTrue(refund.asset_ids) + + asset = refund.asset_ids + + self.assertEqual(asset.book_value, -refund.amount_total) + self.assertEqual(asset.value_residual, -refund.amount_total) + + asset.validate() + + self.assertEqual(len(asset.depreciation_move_ids.filtered(lambda m: m.state == 'posted')), 1) + self.assertEqual(asset.book_value, -400.0) + self.assertEqual(asset.value_residual, -400.0) + + def test_depreciation_schedule_report_with_negative_asset(self): + """ + Test the computation of depreciation schedule with negative asset + """ + asset = self.env['account.asset'].create({ + 'name': 'test', + 'original_value': -500, + 'method': 'linear', + 'method_number': 5, + 'method_period': '1', + 'acquisition_date': fields.Date.today() + relativedelta(months=-1), + 'prorata_computation_type': 'none', + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + }) + + asset.validate() + + report = self.env.ref('odex30_account_asset.assets_report') + + options = self._generate_options(report, fields.Date.today() + relativedelta(months=-7, day=1), fields.Date.today() + relativedelta(months=-6, day=31)) + + expected_values_open_asset = [ + ("test", 0, 0, 500.0, -500.0, 0, 0, 100.0, -100.0, -400.0), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_open_asset, options) + + expense_account_copy = self.company_data['default_account_expense'].copy() + + disposal_action_view = self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'modify_action': 'dispose', + 'loss_account_id': expense_account_copy.id, + 'date': fields.Date.today() + }).sell_dispose() + + self.env['account.move'].browse(disposal_action_view['res_id']).action_post() + + expected_values_closed_asset = [ + ("test", 0, 500.0, 500.0, 0, 0, 500.0, 500.0, 0, 0), + ] + options = self._generate_options(report, fields.Date.today() + relativedelta(months=-7, day=1), fields.Date.today()) + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_closed_asset, options) + + def test_depreciation_schedule_hierarchy(self): + # Remove previously existing assets. + assets = self.env['account.asset'].search([ + ('company_id', '=', self.env.company.id), + ('state', '!=', 'model'), + ]) + assets.state = 'draft' + assets.mapped('depreciation_move_ids').state = 'draft' + assets.unlink() + + # Create the account groups. + self.env['account.group'].create([ + {'name': 'Group 1', 'code_prefix_start': '1', 'code_prefix_end': '1'}, + {'name': 'Group 11', 'code_prefix_start': '11', 'code_prefix_end': '11'}, + {'name': 'Group 12', 'code_prefix_start': '12', 'code_prefix_end': '12'}, + ]) + + # Create the accounts. + account_a, account_a1, account_b, account_c, account_d, account_e = self.env['account.account'].create([ + {'code': '1100', 'name': 'Account A', 'account_type': 'asset_non_current'}, + {'code': '1110', 'name': 'Account A1', 'account_type': 'asset_non_current'}, + {'code': '1200', 'name': 'Account B', 'account_type': 'asset_non_current'}, + {'code': '1300', 'name': 'Account C', 'account_type': 'asset_non_current'}, + {'code': '1400', 'name': 'Account D', 'account_type': 'asset_non_current'}, + {'code': '9999', 'name': 'Account E', 'account_type': 'asset_non_current'}, + ]) + + # Create and validate the assets, and post the depreciation entries. + self.env['account.asset'].create([ + { + 'account_asset_id': account_id, + 'account_depreciation_id': account_id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': name, + 'acquisition_date': fields.Date.to_date('2020-07-01'), + 'original_value': original_value, + 'method': 'linear', + 'prorata_computation_type': 'none', + } + for account_id, name, original_value in [ + (account_a.id, 'ZenBook', 1250), + (account_a.id, 'ThinkBook', 1500), + (account_a1.id, 'XPS', 1750), + (account_b.id, 'MacBook', 2000), + (account_c.id, 'Aspire', 1600), + (account_d.id, 'Playstation', 550), + (account_e.id, 'Xbox', 500), + ] + ]).validate() + + # Configure the depreciation schedule report. + report = self.env.ref('odex30_account_asset.assets_report') + options = self._generate_options(report, '2022-01-01', '2022-12-31') + options['hierarchy'] = True + self.env.company.totals_below_sections = True + + # Generate and compare actual VS expected values. + lines = [ + { + 'name': line['name'], + 'level': line['level'], + 'book_value': line['columns'][-1]['name'] + } + for line in (report._get_lines(options)) + ] + + expected_values = [ + # pylint: disable=C0326 + {'name': '1 Group 1', 'level': 1, 'book_value': '$\xa06,920.00'}, + {'name': '11 Group 11', 'level': 2, 'book_value': '$\xa03,600.00'}, + {'name': '1100 Account A', 'level': 3, 'book_value': '$\xa02,200.00'}, + {'name': 'ZenBook', 'level': 4, 'book_value': '$\xa01,000.00'}, + {'name': 'ThinkBook', 'level': 4, 'book_value': '$\xa01,200.00'}, + {'name': 'Total 1100 Account A', 'level': 3, 'book_value': '$\xa02,200.00'}, + {'name': '1110 Account A1', 'level': 3, 'book_value': '$\xa01,400.00'}, + {'name': 'XPS', 'level': 4, 'book_value': '$\xa01,400.00'}, + {'name': 'Total 1110 Account A1', 'level': 3, 'book_value': '$\xa01,400.00'}, + {'name': 'Total 11 Group 11', 'level': 2, 'book_value': '$\xa03,600.00'}, + {'name': '12 Group 12', 'level': 2, 'book_value': '$\xa01,600.00'}, + {'name': '1200 Account B', 'level': 3, 'book_value': '$\xa01,600.00'}, + {'name': 'MacBook', 'level': 4, 'book_value': '$\xa01,600.00'}, + {'name': 'Total 1200 Account B', 'level': 3, 'book_value': '$\xa01,600.00'}, + {'name': 'Total 12 Group 12', 'level': 2, 'book_value': '$\xa01,600.00'}, + {'name': '1300 Account C', 'level': 2, 'book_value': '$\xa01,280.00'}, + {'name': 'Aspire', 'level': 3, 'book_value': '$\xa01,280.00'}, + {'name': 'Total 1300 Account C', 'level': 2, 'book_value': '$\xa01,280.00'}, + {'name': '1400 Account D', 'level': 2, 'book_value': '$\xa0440.00'}, + {'name': 'Playstation', 'level': 3, 'book_value': '$\xa0440.00'}, + {'name': 'Total 1400 Account D', 'level': 2, 'book_value': '$\xa0440.00'}, + {'name': 'Total 1 Group 1', 'level': 1, 'book_value': '$\xa06,920.00'}, + {'name': '(No Group)', 'level': 1, 'book_value': '$\xa0400.00'}, + {'name': '9999 Account E', 'level': 2, 'book_value': '$\xa0400.00'}, + {'name': 'Xbox', 'level': 3, 'book_value': '$\xa0400.00'}, + {'name': 'Total 9999 Account E', 'level': 2, 'book_value': '$\xa0400.00'}, + {'name': 'Total (No Group)', 'level': 1, 'book_value': '$\xa0400.00'}, + {'name': 'Total', 'level': 1, 'book_value': '$\xa07,320.00'}, + ] + + self.assertEqual(len(lines), len(expected_values)) + self.assertEqual(lines, expected_values) + + def test_depreciation_schedule_disposal_move_unposted(self): + """ + Test the computation of values when disposing an asset, and the difference if the disposal move is posted + """ + asset = self.env['account.asset'].create({ + 'name': 'test asset', + 'method': 'linear', + 'original_value': 1000, + 'method_number': 5, + 'method_period': '12', + 'acquisition_date': fields.Date.today() + relativedelta(years=-2, month=1, day=1), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + }) + asset.validate() + + expense_account_copy = self.company_data['default_account_expense'].copy() + + disposal_action_view = self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'modify_action': 'dispose', + 'loss_account_id': expense_account_copy.id, + 'date': fields.Date.today() + relativedelta(days=-1) + }).sell_dispose() + + report = self.env.ref('odex30_account_asset.assets_report') + options = self._generate_options(report, '2021-01-01', '2021-12-31') + + # The disposal move is in draft and should not be considered (depreciation and book value) + # Values are: name, assets_before, assets+, assets-, assets_after, depreciation_before, depreciation+, depreciation-, depreciation_after, book_value + expected_values_asset_disposal_unposted = [ + ("test asset", 1000.0, 0.0, 0, 1000.0, 400.0, 100.0, 0.0, 500.0, 500.0), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_asset_disposal_unposted, options) + + self.env['account.move'].browse(disposal_action_view.get('res_id')).action_post() + + expected_values_asset_disposal_posted = [ + ("test asset", 1000.0, 0.0, 1000.0, 0.0, 400.0, 100.0, 500.0, 0.0, 0.0), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_asset_disposal_posted, options) + + def test_depreciation_schedule_disposal_move_unposted_with_non_depreciable_value(self): + """ + Test the computation of values when disposing an asset with non-depreciable value, and the difference if the disposal move is posted + """ + asset = self.env['account.asset'].create({ + 'name': 'test asset', + 'method': 'linear', + 'original_value': 10000, + 'salvage_value': 8000, + 'method_number': 24, + 'method_period': '1', + 'acquisition_date': fields.Date.today() + relativedelta(months=-1, day=1), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + }) + asset.validate() + + report = self.env.ref('odex30_account_asset.assets_report') + + options = self._generate_options(report, '2021-07-01', '2021-07-31') + + expected_values_asset_disposal_unposted = [ + ("test asset", 10000.0, 0.0, 0.0, 10000.0, 83.33, 0.0, 0.0, 83.33, 9916.67), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_asset_disposal_unposted, options) + + expense_account_copy = self.company_data['default_account_expense'].copy() + + disposal_action_view = self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'modify_action': 'dispose', + 'loss_account_id': expense_account_copy.id, + 'date': fields.Date.today() + }).sell_dispose() + + expected_values_asset_disposal_unposted = [ + ("test asset", 10000.0, 0.0, 0.0, 10000.0, 83.33, 2.69, 0.0, 86.02, 9913.98), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_asset_disposal_unposted, options) + + self.env['account.move'].browse(disposal_action_view['res_id']).action_post() + + expected_values_asset_disposal_posted = [ + ("test asset", 10000.0, 0.0, 10000.0, 0.0, 83.33, 2.69, 86.02, 0.0, 0.0), + ] + + self.assertLinesValues(report._get_lines(options)[2:3], [0, 5, 6, 7, 8, 9, 10, 11, 12, 13], expected_values_asset_disposal_posted, options) + + def test_asset_analytic_on_lines(self): + CEO_car = self.env['account.asset'].create({ + 'salvage_value': 2000.0, + 'state': 'open', + 'method_period': '12', + 'method_number': 5, + 'name': "CEO's Car", + 'original_value': 12000.0, + 'model_id': self.account_asset_model_fixedassets.id, + 'acquisition_date': '2020-01-01', + }) + CEO_car._onchange_model_id() + CEO_car.method_number = 5 + CEO_car.analytic_distribution = {self.analytic_account.id: 100} + + # In order to test the process of Account Asset, I perform a action to confirm Account Asset. + CEO_car.validate() + + for move in CEO_car.depreciation_move_ids: + self.assertRecordValues(move.line_ids, [ + { + 'analytic_distribution': {str(self.analytic_account.id): 100}, + }, + { + 'analytic_distribution': {str(self.analytic_account.id): 100}, + }, + ]) + + CEO_car.analytic_distribution = {str(self.analytic_account.id): 200} + + # Only draft moves should have a changed analytic distribution + for move in CEO_car.depreciation_move_ids.filtered(lambda m: m.state == 'posted'): + self.assertRecordValues(move.line_ids, [ + { + 'analytic_distribution': {str(self.analytic_account.id): 100}, + }, + { + 'analytic_distribution': {str(self.analytic_account.id): 100}, + }, + ]) + + for move in CEO_car.depreciation_move_ids.filtered(lambda m: m.state == 'draft'): + self.assertRecordValues(move.line_ids, [ + { + 'analytic_distribution': {str(self.analytic_account.id): 200}, + }, + { + 'analytic_distribution': {str(self.analytic_account.id): 200}, + }, + ]) + + + def test_asset_analytic_filter(self): + """ + Test that the analytic filter works correctly. + """ + truck_b = self.truck.copy() + truck_b.acquisition_date = self.truck.acquisition_date + truck_b.validate() + self.truck.analytic_distribution = {self.analytic_account.id: 100} + self.env['account.move']._autopost_draft_entries() + + self.env.company.totals_below_sections = False + report = self.env.ref('odex30_account_asset.assets_report') + + # No prefix group, no group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'none', 'unfold_all': False}) + + # without Analytic Filter + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('truck (copy)', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Total', 20000, 0, 0, 20000, 9000, 0, 0, 9000, 11000,), + ], + options + ) + # with Analytic Filter + options['analytic_accounts'] = [self.analytic_account.id] + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Total', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ], + options + ) + + def test_asset_analytic_groupby(self): + """ + Test that the analytic groupby works correctly. + """ + truck_b = self.truck.copy() + truck_b.acquisition_date = self.truck.acquisition_date + truck_b.validate() + self.truck.analytic_distribution = {self.analytic_account.id: 100} + self.env['account.move']._autopost_draft_entries() + + self.env.company.totals_below_sections = False + report = self.env.ref('odex30_account_asset.assets_report') + report.filter_analytic_groupby = True + + # No prefix group, no group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'none', 'unfold_all': False}) + + # without Analytic Groupby + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('truck (copy)', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Total', 20000, 0, 0, 20000, 9000, 0, 0, 9000, 11000,), + ], + options + ) + # with Analytic Groupby + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={ + 'assets_grouping_field': 'none', + 'unfold_all': False, + 'analytic_accounts_groupby': [self.analytic_account.id], + }) + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Group | ANALYTIC | | ALL | + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23, 24, 25, 26], + [ + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500, 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500), + ('truck (copy)', '', '', '', '', '', '', '', '', '', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500), + ('Total', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500, 20000, 0, 0, 20000, 9000, 0, 0, 9000, 11000), + ], + options + ) + + def test_depreciation_schedule_report_first_depreciation(self): + """Test that the depreciation schedule report displays the correct first depreciation date.""" + # check that the truck's first depreciation date is correct: + # the truck has a yearly linear depreciation and it's prorate_date is 2015-01-01 + # therefore we expect it's first depreciation date to be the last day of 2015 + + today = fields.Date.today() + report = self.env.ref('odex30_account_asset.assets_report') + options = self._generate_options(report, today + relativedelta(years=-6, month=1, day=1), today + relativedelta(years=+4, month=12, day=31)) + lines = report._get_lines({**options, **{'unfold_all': False, 'all_entries': True}}) + + self.assertEqual(lines[1]['columns'][1]['name'], '12/31/2015') + + def test_asset_modify_sell_multicurrency(self): + """ Test that the closing invoice's currency is taken into account when selling an asset. """ + closing_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'currency_id': self.other_currency.id, + 'invoice_line_ids': [Command.create({'price_unit': 5000})] + }) + self.env['asset.modify'].create({ + 'asset_id': self.truck.id, + 'invoice_line_ids': closing_invoice.invoice_line_ids, + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'modify_action': 'sell', + }).sell_dispose() + + closing_move = self.truck.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + + self.assertRecordValues(closing_move.line_ids, [{ + 'debit': 0, + 'credit': 10000, + 'account_id': self.truck.account_asset_id.id, + }, { + 'debit': 4500, + 'credit': 0, + 'account_id': self.truck.account_depreciation_id.id, + }, { + 'debit': 2500, + 'credit': 0, + 'account_id': closing_invoice.invoice_line_ids.account_id.id, + }, { + 'debit': 3000, + 'credit': 0, + 'account_id': self.env.company.loss_account_id.id, + }]) + + def test_depreciation_schedule_prefix_groups(self): + asset_group = self.env['account.asset.group'].create({'name': 'Odoo Office'}) + for i in range(1, 3): + asset = self.env['account.asset'].create({ + 'method_period': '12', + 'method_number': 4, + 'name': f"Asset {i}", + 'original_value': i * 100.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=3), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'asset_group_id': asset_group.id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + }) + asset.validate() + + self.env['account.move']._autopost_draft_entries() + + self.env.company.totals_below_sections = False + report = self.env.ref('odex30_account_asset.assets_report') + + # No prefix group, no group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'none'}) + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Asset 1', 100, 0, 0, 100, 75, 0, 0, 75, 25,), + ('Asset 2', 200, 0, 0, 200, 150, 0, 0, 150, 50,), + ('Total', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ], + options, + ) + + # No prefix group, group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'account_id'}) + options['unfold_all'] = True + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('151000 Fixed Asset', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Asset 1', 100, 0, 0, 100, 75, 0, 0, 75, 25,), + ('Asset 2', 200, 0, 0, 200, 150, 0, 0, 150, 50,), + ('Total', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ], + options, + ) + + report.prefix_groups_threshold = 3 + # Prefix group, no group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'none', 'unfold_all': True}) + options['unfold_all'] = True + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('A (2 lines)', 300, 0, 0, 300, 225, 0, 0, 225, 75,), + ('Asset 1', 100, 0, 0, 100, 75, 0, 0, 75, 25,), + ('Asset 2', 200, 0, 0, 200, 150, 0, 0, 150, 50,), + ('T (1 line)', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Total', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ], + options, + ) + + # Prefix group, group by account + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'account_id', 'unfold_all': True}) + options['unfold_all'] = True + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('151000 Fixed Asset', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ('A (2 lines)', 300, 0, 0, 300, 225, 0, 0, 225, 75,), + ('Asset 1', 100, 0, 0, 100, 75, 0, 0, 75, 25,), + ('Asset 2', 200, 0, 0, 200, 150, 0, 0, 150, 50,), + ('T (1 line)', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500,), + ('Total', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575,), + ], + options, + ) + + # No prefix group, group by asset group + options = self._generate_options(report, '2021-01-01', '2021-12-31', default_options={'assets_grouping_field': 'asset_group_id'}) + options['unfold_all'] = True + self.assertLinesValues( + # pylint: disable=C0326 + report._get_lines(options), + # Name Assets/start Assets/+ Assets/- Assets/end Depreciation/start Depreciation/+ Depreciation/- Depreciation/end Book Value + [ 0, 5, 6, 7, 8, 9, 10, 11, 12, 13], + [ + ('(No Asset Group)', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500), + ('truck', 10000, 0, 0, 10000, 4500, 0, 0, 4500, 5500), + ('Odoo Office', 300, 0, 0, 300, 225, 0, 0, 225, 75), + ('Asset 1', 100, 0, 0, 100, 75, 0, 0, 75, 25), + ('Asset 2', 200, 0, 0, 200, 150, 0, 0, 150, 50), + ('Total', 10300, 0, 0, 10300, 4725, 0, 0, 4725, 5575), + ], + options, + ) + + def test_archive_asset_model(self): + """ Test that we can archive an asset model. """ + self.account_asset_model_fixedassets.active = False + self.assertFalse(self.account_asset_model_fixedassets.active) + + def test_asset_increase_with_lock_year(self): + """ Test the dates at which the moves are posted even with increase, with lock date""" + self.company_data['company'].fiscalyear_lock_date = fields.Date.to_date('2021-03-01') + + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Car', + 'acquisition_date': fields.Date.today() + relativedelta(months=-6), + 'original_value': 12000, + 'method_number': 12, + 'method_period': '1', + 'method': 'linear', + }) + + asset.validate() + + self.assertRecordValues( + asset.depreciation_move_ids.sorted(lambda l: (l.date, l.id)), + [ + {'date': fields.Date.to_date('2021-03-31')}, + {'date': fields.Date.to_date('2021-03-31')}, + {'date': fields.Date.to_date('2021-03-31')}, + {'date': fields.Date.to_date('2021-04-30')}, + {'date': fields.Date.to_date('2021-05-31')}, + {'date': fields.Date.to_date('2021-06-30')}, + {'date': fields.Date.to_date('2021-07-31')}, + {'date': fields.Date.to_date('2021-08-31')}, + {'date': fields.Date.to_date('2021-09-30')}, + {'date': fields.Date.to_date('2021-10-31')}, + {'date': fields.Date.to_date('2021-11-30')}, + {'date': fields.Date.to_date('2021-12-31')} + ] + ) + + self.assertEqual(asset.book_value, 6000) + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test increase with lock date', + 'value_residual': 8000.0, + 'date': fields.Date.today() + relativedelta(days=-1), + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + + self.assertEqual(asset.book_value, 8000) + + self.assertRecordValues( + asset.children_ids.depreciation_move_ids.sorted(lambda dep: (dep.date, dep.id)), + [ + {'date': fields.Date.to_date('2021-07-31'), 'depreciation_value': 333.33}, + {'date': fields.Date.to_date('2021-08-31'), 'depreciation_value': 333.34}, + {'date': fields.Date.to_date('2021-09-30'), 'depreciation_value': 333.33}, + {'date': fields.Date.to_date('2021-10-31'), 'depreciation_value': 333.33}, + {'date': fields.Date.to_date('2021-11-30'), 'depreciation_value': 333.34}, + {'date': fields.Date.to_date('2021-12-31'), 'depreciation_value': 333.33} + ] + ) + + def test_asset_decrease_with_lock_year(self): + """ Test the dates and values for the moves that are posted with decrease and lock date""" + self.company_data['company'].fiscalyear_lock_date = fields.Date.to_date('2021-03-01') + + asset = self.env['account.asset'].create({ + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'name': 'Car', + 'acquisition_date': fields.Date.today() + relativedelta(months=-6), + 'original_value': 12000, + 'method_number': 12, + 'method_period': '1', + 'method': 'linear', + }) + + asset.validate() + + self.assertEqual(asset.book_value, 6000) + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test decrease with lock date', + 'value_residual': 4000.0, + 'date': fields.Date.today() + relativedelta(days=-1), + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + + self.assertEqual(asset.book_value, 4000) + + self.assertRecordValues( + asset.depreciation_move_ids.sorted(lambda dep: (dep.date, dep.id)), + [ + {'date': fields.Date.to_date('2021-03-31'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-03-31'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-03-31'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-04-30'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-05-31'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-06-30'), 'depreciation_value': 1000}, + {'date': fields.Date.to_date('2021-06-30'), 'depreciation_value': 2000}, + {'date': fields.Date.to_date('2021-07-31'), 'depreciation_value': 666.67}, + {'date': fields.Date.to_date('2021-08-31'), 'depreciation_value': 666.66}, + {'date': fields.Date.to_date('2021-09-30'), 'depreciation_value': 666.67}, + {'date': fields.Date.to_date('2021-10-31'), 'depreciation_value': 666.67}, + {'date': fields.Date.to_date('2021-11-30'), 'depreciation_value': 666.66}, + {'date': fields.Date.to_date('2021-12-31'), 'depreciation_value': 666.67} + ] + ) + + def test_asset_onchange_model(self): + """ + Test the changes of account_asset_id when changing asset models + """ + account_asset = self.company_data['default_account_assets'].copy() + asset_model = self.env['account.asset'].create({ + 'name': 'test model', + 'state': 'model', + 'active': True, + 'method': 'linear', + 'method_number': 5, + 'method_period': '1', + 'prorata_computation_type': 'none', + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'account_asset_id': account_asset.id, + 'journal_id': self.company_data['default_journal_misc'].id, + }) + + asset_model_with_account = self.env['account.asset'].create({ + 'name': 'test model with account', + 'state': 'model', + 'active': True, + 'method': 'linear', + 'method_number': 5, + 'method_period': '1', + 'prorata_computation_type': 'none', + 'account_depreciation_id': self.company_data['default_account_assets'].id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + }) + + asset_form = Form(self.env['account.asset']) + asset_form.name = "Test Asset" + asset_form.original_value = 10000 + asset_form.model_id = asset_model + + self.assertEqual(asset_form.account_asset_id, account_asset, "The account_asset_id should be the one from the model") + + asset_form.model_id = asset_model_with_account + self.assertEqual(asset_form.account_asset_id, self.company_data['default_account_assets'], "The account_asset_id should be computed from the depreciation account from the model") + + other_account_on_bill = self.company_data['default_account_assets'].copy() + other_account_on_bill.create_asset = 'draft' + other_account_on_bill.asset_model_ids = asset_model + invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'invoice_date': '2020-12-31', + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [ + (0, 0, { + 'name': 'A beautiful small bomb', + 'account_id': other_account_on_bill.id, + 'price_unit': 200.0, + 'quantity': 1, + }), + ], + }) + invoice.action_post() + + self.assertEqual(invoice.asset_ids.account_asset_id, other_account_on_bill, + "The account should be the one from the bill, not the model") + + asset_form = Form(invoice.asset_ids) + asset_form.model_id = asset_model + + self.assertEqual(asset_form.account_asset_id, other_account_on_bill, "We keep the account from the bill") + + def test_asset_reevaluation_degressive_linear(self): + """ Tests the reevaluation of an asset in degressive_then_linear with a gross increase""" + asset = self.env['account.asset'].create({ + 'method_period': '12', + 'method_number': 5, + 'name': "Car with purple sticker", + 'original_value': 10000.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=2), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + 'method': 'degressive_then_linear', + 'method_progress_factor': 0.4, + }) + asset.validate() + self.assertRecordValues(asset.depreciation_move_ids, [{ + 'depreciation_value': 4000, + 'asset_remaining_value': 6000, + 'state': 'posted', + }, { + 'depreciation_value': 2400, + 'asset_remaining_value': 3600, + 'state': 'posted', + }, { + 'depreciation_value': 2000, + 'asset_remaining_value': 1600, + 'state': 'draft', + }, { + 'depreciation_value': 1600, + 'asset_remaining_value': 0, + 'state': 'draft', + }]) + self.env['asset.modify'].create({ + 'name': "Inflation made it take 20%!", + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'asset_id': asset.id, + 'value_residual': 5600, + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [{ + # (2000 + 2000*6400/3600) / 5 + 'depreciation_value': 1111.11, + 'asset_remaining_value': 888.89, + 'state': 'draft', + }, { + 'depreciation_value': 888.89, + 'asset_remaining_value': 0, + 'state': 'draft', + }]) + + def test_asset_move_type(self): + """ Test the field asset_move_type set on account.move describing the + relation that a move can have towards an asset + """ + asset_account_id = self.company_data['default_account_assets'].id + + bill = self.env['account.move'].create([ + { + 'move_type': 'in_invoice', + 'invoice_date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'date': fields.Date.today() + relativedelta(months=-6, days=-1), + 'partner_id': self.partner_a.id, + 'invoice_line_ids': [Command.create({ + 'name': 'Truck', + 'account_id': asset_account_id, + 'quantity': 1.0, + 'price_unit': 1000.0, + 'tax_ids': [Command.set(self.company_data['default_tax_sale'].ids)], + })], + }, + ]) + bill.action_post() + asset_line = bill.line_ids.filtered(lambda x: x.account_id.id == asset_account_id) + asset_form = Form(self.env['account.asset'].with_context(default_original_move_line_ids=asset_line.ids)) + asset_form.original_move_line_ids = asset_line + asset_form.account_depreciation_expense_id = self.company_data['default_account_expense'] + car = asset_form.save() + car.validate() + + # All depreciation move must be defined as depreciation + self.assertTrue(all(car.depreciation_move_ids.mapped(lambda m: m.asset_move_type == 'depreciation'))) + + # Negative revaluation + self.env['asset.modify'].create({ + 'name': 'Little scratch :(', + 'asset_id': car.id, + 'value_residual': car.book_value - 150, + 'date': fields.Date.today(), + }).modify() + + # Ensure that the added depreciation moves are one 'depreciation' and the other is 'negative_revaluation' + added_move_on_revaluation = car.depreciation_move_ids.filtered(lambda m: m.date == fields.Date.today()) + self.assertRecordValues(added_move_on_revaluation.sorted(lambda mv: mv.id), [ + {'asset_move_type': 'depreciation'}, + {'asset_move_type': 'negative_revaluation'} + ]) + + # Sell + closing_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'invoice_line_ids': [Command.create( + {'price_unit': car.book_value + 100} # selling price: 849.46, net_gain_on_sale: 100.45 + )] + }) + + self.env['asset.modify'].create({ + 'asset_id': car.id, + 'modify_action': 'sell', + 'invoice_line_ids': closing_invoice.invoice_line_ids, + 'date': fields.Date.today(), + }).sell_dispose() + selling_move = car.depreciation_move_ids.filtered(lambda l: l.state == 'draft') + selling_move.action_post() + + # Ensure that the added depreciation moves are one 'depreciation' and the other is 'sale' + added_move_on_sale = car.depreciation_move_ids.filtered(lambda m: m.date == fields.Date.today()) - added_move_on_revaluation + self.assertTrue(added_move_on_sale.asset_move_type == 'sale') + self.assertEqual(car.net_gain_on_sale, 100) + + # Create new asset to test positive revaluation and disposal + new_car = car.copy() + new_car.validate() + + # Positive revaluation + self.env['asset.modify'].create({ + 'name': 'New beautiful sticker :D', + 'asset_id': new_car.id, + 'value_residual': new_car.book_value + 50, + 'salvage_value': 0, + 'date': fields.Date.today(), + "account_asset_counterpart_id": self.assert_counterpart_account_id, + }).modify() + + self.assertEqual( + new_car.children_ids.original_move_line_ids.move_id.asset_move_type, + 'positive_revaluation', + "the original move of the child asset is set as 'positive_revaluation'" + ) + + disposal_action_view = self.env['asset.modify'].create({ + 'asset_id': new_car.id, + 'modify_action': 'dispose', + 'date': fields.Date.today() + }).sell_dispose() + + self.env['account.move'].browse(disposal_action_view['res_id']).action_post() + self.assertEqual(self.env['account.move'].browse(disposal_action_view['res_id']).asset_move_type, 'disposal') + + def test_asset_already_depreciated(self): + asset = self.env['account.asset'].create({ + 'method_period': '12', + 'method_number': 5, + 'name': "Car with purple sticker", + 'original_value': 10000.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=1), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + 'already_depreciated_amount_import': 3000, + }) + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.today() - relativedelta(days=1), + 'name': 'Test reason', + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids, [{ + 'depreciation_value': 1000, + 'date': fields.Date.to_date('2021-12-31'), + }, { + 'depreciation_value': 2000, + 'date': fields.Date.to_date('2022-12-31'), + }, { + 'depreciation_value': 2000, + 'date': fields.Date.to_date('2023-12-31'), + }, { + 'depreciation_value': 2000, + 'date': fields.Date.to_date('2024-12-31'), + }, + ]) + + fully_depreciated_asset = self.env['account.asset'].create({ + 'method_period': '12', + 'method_number': 5, + 'name': "Car with purple sticker", + 'original_value': 10000.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=2), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + 'prorata_computation_type': 'none', + 'salvage_value': 4000, + 'already_depreciated_amount_import': 6000, + }) + fully_depreciated_asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': fully_depreciated_asset.id, + 'date': fields.Date.today(), + 'modify_action': 'dispose', + }).sell_dispose() + self.assertEqual(len(fully_depreciated_asset.depreciation_move_ids), 1, "Only the disposal should be created") + + def test_asset_acquisition_date_from_bill(self): + """Test that the invoice date is used as acquisition date instead of date""" + self.company_data['default_account_assets'].create_asset = 'draft' + self.company_data['default_account_assets'].asset_model_ids = self.account_asset_model_fixedassets + + bill = self.env['account.move'].with_context(asset_type='purchase').create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'date': '2020-06-15', + 'invoice_date': '2020-06-01', + 'invoice_line_ids': [Command.create({ + 'name': 'Insurance claim', + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 450, + 'quantity': 1, + })], + }) + bill.action_post() + asset = bill.asset_ids + self.assertEqual(asset.acquisition_date, bill.invoice_date) + + def test_asset_write_multi_company(self): + assets = self.env['account.asset'].create([ + { + 'company_id': company_data['company'].id, + 'name': 'test asset', + } for company_data in [self.company_data, self.company_data_2] + ]) + self.assertEqual(assets[0].company_id, self.company_data['company']) + self.assertEqual(assets[1].company_id, self.company_data_2['company']) + assets.validate() + + def test_depreciation_moves_company_with_sub_company(self): + """The depreciation moves should have the company of the asset, even in multicompany setup""" + company = self.env.company + branch_x = self.env['res.company'].create({ + 'name': 'Branch X', + 'country_id': company.country_id.id, + 'parent_id': company.id, + }) + + asset_vals = { + 'method_period': '12', + 'method_number': 5, + 'name': "Car with purple sticker", + 'original_value': 10000.0, + 'acquisition_date': fields.Date.today() - relativedelta(years=1), + 'account_asset_id': self.company_data['default_account_assets'].id, + 'account_depreciation_id': self.company_data['default_account_assets'].copy().id, + 'account_depreciation_expense_id': self.company_data['default_account_expense'].id, + 'journal_id': self.company_data['default_journal_misc'].id, + } + + setup_list = [ + {'company_ids': (company + branch_x).ids, 'company_id': branch_x.id}, + {'company_ids': branch_x.ids, 'company_id': branch_x.id}, + {'company_ids': (company + branch_x).ids, 'company_id': company.id}, + {'company_ids': company.ids, 'company_id': company.id}, + ] + + expected_vals_list = [branch_x, branch_x, company, company] + + for setup, expected in zip(setup_list, expected_vals_list): + with self.subTest(setup=setup, expected_company=expected): + self.env.user.write({ + 'company_ids': [Command.set(setup['company_ids'])], + 'company_id': setup['company_id'], + }) + asset = self.env['account.asset'].create(asset_vals) + asset.compute_depreciation_board() + self.assertEqual(asset.depreciation_move_ids.mapped('company_id'), expected) + + def test_multiple_asset_models_with_branches(self): + """Check that asset models are not shared between siblings, only with ancestors.""" + + branch_a = self.setup_other_company(name='Test Branch A', parent_id=self.company_data['company'].id) + asset_model_a = self.account_asset_model_fixedassets.copy() + asset_model_a.company_id = branch_a['company'] + + branch_b = self.setup_other_company(name='Test Branch B', parent_id=self.company_data['company'].id) + asset_model_b = self.account_asset_model_fixedassets.copy() + asset_model_b.company_id = branch_b['company'] + + # We need to set both branch's asset models on the parent company's asset account. + self.company_data['default_account_assets'].asset_model_ids = asset_model_a + asset_model_b + self.company_data['default_account_assets'].create_asset = 'draft' + + vendor_bill = self.env['account.move'].with_company(branch_a['company']).create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': fields.Date.today(), + 'invoice_line_ids': [Command.create({ + 'name': 'Very little red car', + # Make sure the bill uses the asset account with both asset models. + 'account_id': self.company_data['default_account_assets'].id, + 'price_unit': 1000, + 'quantity': 1, + })], + }) + vendor_bill.action_post() + + self.assertEqual(len(vendor_bill.asset_ids), 1, "Only one asset should have been created.") + self.assertEqual(vendor_bill.asset_ids.company_id, branch_a['company'], f"The asset should have been created on company: {branch_a['company'].name}") diff --git a/dev_odex30_accounting/odex30_account_asset/tests/test_board_compute.py b/dev_odex30_accounting/odex30_account_asset/tests/test_board_compute.py new file mode 100644 index 0000000..facffa9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/tests/test_board_compute.py @@ -0,0 +1,1321 @@ +from odoo.tests.common import tagged, freeze_time +from odoo.addons.odex30_account_asset.tests.common import TestAccountAssetCommon +from odoo import fields + + +@freeze_time('2022-07-01') +@tagged('post_install', '-at_install') +class TestAccountAssetComputation(TestAccountAssetCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.car = cls.create_asset(value=60000, periodicity="yearly", periods=5, method="linear", salvage_value=0) + + def test_linear_5_years_no_prorata_asset(self): + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_5_years_no_prorata_with_imported_amount_asset(self): + self.car.write({'already_depreciated_amount_import': 1000}) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=11000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_5_years_no_prorata_with_salvage_value_asset(self): + self.car.write({'salvage_value': 1000}) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36400) + self.assertEqual(self.car.value_residual, 35400) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=11800, remaining_value=47200, depreciated_value=11800, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=11800, remaining_value=35400, depreciated_value=23600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=11800, remaining_value=23600, depreciated_value=35400, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=11800, remaining_value=11800, depreciated_value=47200, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=11800, remaining_value=0, depreciated_value=59000, state='draft'), + ]) + + def test_linear_5_years_constant_periods_asset(self): + self.car.write({ + 'prorata_computation_type': 'constant_periods', + 'prorata_date': '2020-07-01', + }) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 42000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=6000, remaining_value=54000, depreciated_value=6000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=30000, depreciated_value=30000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=18000, depreciated_value=42000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=6000, depreciated_value=54000, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=6000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_5_years_daily_computation_asset(self): + self.car.write({ + 'prorata_computation_type': 'daily_computation', + 'prorata_date': '2020-07-01', + }) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 41960.57) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=6046, remaining_value=53954, depreciated_value=6046, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=11993.43, remaining_value=41960.57, depreciated_value=18039.43, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=11993.43, remaining_value=29967.14, depreciated_value=30032.86, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=11993.43, remaining_value=17973.71, depreciated_value=42026.29, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12026.28, remaining_value=5947.43, depreciated_value=54052.57, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=5947.43, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_degressive_5_years_no_prorata_asset(self): + self.car.write({ + 'method': 'degressive', + 'method_progress_factor': 0.3, + }) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 29400) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=18000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12600, remaining_value=29400, depreciated_value=30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=9800, remaining_value=19600, depreciated_value=40400, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=9800, remaining_value=9800, depreciated_value=50200, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=9800, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_degressive_5_years_no_prorata_with_imported_amount_asset(self): + self.car.write({ + 'method': 'degressive', + 'method_progress_factor': 0.3, + 'already_depreciated_amount_import': 1000, + }) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 29400) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=17000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12600, remaining_value=29400, depreciated_value=30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=9800, remaining_value=19600, depreciated_value=40400, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=9800, remaining_value=9800, depreciated_value=50200, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=9800, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_degressive_5_years_no_prorata_with_salvage_value_asset(self): + self.car.write({ + 'method': 'degressive', + 'method_progress_factor': 0.3, + 'salvage_value': 1000, + }) + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 29910) + self.assertEqual(self.car.value_residual, 28910) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=17700, remaining_value=41300, depreciated_value=17700, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12390, remaining_value=28910, depreciated_value=30090, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=9636.67, remaining_value=19273.33, depreciated_value=39726.67, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=9636.67, remaining_value=9636.66, depreciated_value=49363.34, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=9636.66, remaining_value=0, depreciated_value=59000, state='draft'), + ]) + + def test_degressive_then_linear_5_years_no_prorata_asset(self): + asset = self.create_asset(value=60000, periodicity="yearly", periods=5, method="degressive_then_linear", degressive_factor=0.3) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertEqual(asset.book_value, 29400) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=18000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12600, remaining_value=29400, depreciated_value=30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=17400, depreciated_value=42600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=5400, depreciated_value=54600, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=5400, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_degressive_then_linear_5_years_no_prorata_negative_asset(self): + asset = self.create_asset(value=-60000, periodicity="yearly", periods=5, method="degressive_then_linear", degressive_factor=0.3) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertEqual(asset.book_value, -29400) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=-18000, remaining_value=-42000, depreciated_value=-18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=-12600, remaining_value=-29400, depreciated_value=-30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=-12000, remaining_value=-17400, depreciated_value=-42600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=-12000, remaining_value=-5400, depreciated_value=-54600, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=-5400, remaining_value=0, depreciated_value=-60000, state='draft'), + ]) + + def test_degressive_than_linear_5_years_no_prorata_with_imported_amount_asset(self): + asset = self.create_asset(value=60000, periodicity="yearly", periods=5, method="degressive_then_linear", degressive_factor=0.3, import_depreciation=1000) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertEqual(asset.book_value, 29400) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=18000 - 1000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12600, remaining_value=29400, depreciated_value=30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=17400, depreciated_value=42600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=5400, depreciated_value=54600, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=5400, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_degressive_than_linear_5_years_no_prorata_with_imported_amount_negative_asset(self): + asset = self.create_asset(value=-60000, periodicity="yearly", periods=5, method="degressive_then_linear", degressive_factor=0.3, import_depreciation=-1000) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertEqual(asset.book_value, -29400) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=-18000 + 1000, remaining_value=-42000, depreciated_value=-18000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=-12600, remaining_value=-29400, depreciated_value=-30600, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=-12000, remaining_value=-17400, depreciated_value=-42600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=-12000, remaining_value=-5400, depreciated_value=-54600, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=-5400, remaining_value=0, depreciated_value=-60000, state='draft'), + ]) + + def test_degressive_than_linear_5_years_no_prorata_with_salvage_value_asset(self): + asset = self.create_asset(value=60000, periodicity="yearly", periods=5, salvage_value=1000, method="degressive_then_linear", degressive_factor=0.3) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertEqual(asset.value_residual, 28910) + self.assertEqual(asset.book_value, 28910 + 1000) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=17700, remaining_value=41300, depreciated_value=17700, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12390, remaining_value=28910, depreciated_value=30090, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=11800, remaining_value=17110, depreciated_value=41890, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=11800, remaining_value=5310, depreciated_value=53690, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=5310, remaining_value=0, depreciated_value=59000, state='draft'), + ]) + + def test_degressive_then_linear_36_month_constant_period_asset(self): + """ + The depreciation amount is computed that way: Compute a degressive amount for each year and split it by month linearly. + The depreciation value could vary by one currency unit to absorb small differences that are created over time. + """ + asset = self.create_asset(value=10000, periodicity="monthly", periods=36, method="degressive_then_linear", degressive_factor=0.4) + asset.validate() + self.assertEqual(asset.state, 'open') + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=333.33, remaining_value=9666.67, depreciated_value=333.33, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=333.34, remaining_value=9333.33, depreciated_value=666.67, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=333.33, remaining_value=9000.00, depreciated_value=1000.00, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=333.33, remaining_value=8666.67, depreciated_value=1333.33, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=333.34, remaining_value=8333.33, depreciated_value=1666.67, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=333.33, remaining_value=8000.00, depreciated_value=2000.00, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=333.33, remaining_value=7666.67, depreciated_value=2333.33, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=333.34, remaining_value=7333.33, depreciated_value=2666.67, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=333.33, remaining_value=7000.00, depreciated_value=3000.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=333.33, remaining_value=6666.67, depreciated_value=3333.33, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=333.34, remaining_value=6333.33, depreciated_value=3666.67, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=333.33, remaining_value=6000.00, depreciated_value=4000.00, state='posted'), + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=277.78, remaining_value=5722.22, depreciated_value=4277.78, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=277.78, remaining_value=5444.44, depreciated_value=4555.56, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=277.78, remaining_value=5166.66, depreciated_value=4833.34, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=277.77, remaining_value=4888.89, depreciated_value=5111.11, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=277.78, remaining_value=4611.11, depreciated_value=5388.89, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=277.78, remaining_value=4333.33, depreciated_value=5666.67, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=277.78, remaining_value=4055.55, depreciated_value=5944.45, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=277.78, remaining_value=3777.77, depreciated_value=6222.23, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=277.77, remaining_value=3500.00, depreciated_value=6500.00, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=277.78, remaining_value=3222.22, depreciated_value=6777.78, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=277.78, remaining_value=2944.44, depreciated_value=7055.56, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=277.78, remaining_value=2666.66, depreciated_value=7333.34, state='posted'), + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=277.77, remaining_value=2388.89, depreciated_value=7611.11, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=277.78, remaining_value=2111.11, depreciated_value=7888.89, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=277.78, remaining_value=1833.33, depreciated_value=8166.67, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=277.78, remaining_value=1555.55, depreciated_value=8444.45, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=277.78, remaining_value=1277.77, depreciated_value=8722.23, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=277.77, remaining_value=1000.00, depreciated_value=9000.00, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=277.78, remaining_value=722.22, depreciated_value=9277.78, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=277.78, remaining_value=444.44, depreciated_value=9555.56, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=277.78, remaining_value=166.66, depreciated_value=9833.34, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=166.66, remaining_value=0.00, depreciated_value=10000.00, state='draft'), + ]) + + + @freeze_time('2022-06-15') + def test_asset_degressive_then_linear_prorata_start_middle_of_year(self): + """ Check the computation of an asset with degressive-linear method, + start at middle of the year + """ + asset = self.create_asset( + value=10000, + periodicity="yearly", + periods=5, + method="degressive_then_linear", + degressive_factor=0.3, + acquisition_date="2021-07-01", + prorata_computation_type="constant_periods", + ) + asset.validate() + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1500.00, remaining_value=8500.00, depreciated_value=1500.0000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=2550.00, remaining_value=5950.00, depreciated_value=4050.000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=2000.00, remaining_value=3950.00, depreciated_value=6050.000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=2000.00, remaining_value=1950.00, depreciated_value=8050.000, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=1950.00, remaining_value=0.00, depreciated_value=10000.000, state='draft'), + ]) + + def test_asset_degressive_then_linear_prorata_start_middle_of_year_monthly(self): + """ Check the computation of an asset with degressive-linear method, + start at middle of the year, monthly depreciations + """ + asset = self.create_asset( + value=10000, + periodicity="monthly", + periods=36, + method="degressive_then_linear", + degressive_factor=0.6, + acquisition_date="2021-07-01", + prorata_computation_type="constant_periods", + ) + asset.validate() + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=500.00, remaining_value=9500.00, depreciated_value=500.00, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=500.00, remaining_value=9000.00, depreciated_value=1000.00, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=500.00, remaining_value=8500.00, depreciated_value=1500.00, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=500.00, remaining_value=8000.00, depreciated_value=2000.00, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=500.00, remaining_value=7500.00, depreciated_value=2500.00, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=500.00, remaining_value=7000.00, depreciated_value=3000.00, state='posted'), + + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=350.00, remaining_value=6650.00, depreciated_value=3350.00, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=350.00, remaining_value=6300.00, depreciated_value=3700.00, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=350.00, remaining_value=5950.00, depreciated_value=4050.00, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=350.00, remaining_value=5600.00, depreciated_value=4400.00, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=350.00, remaining_value=5250.00, depreciated_value=4750.00, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=350.00, remaining_value=4900.00, depreciated_value=5100.00, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=350.00, remaining_value=4550.00, depreciated_value=5450.00, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=350.00, remaining_value=4200.00, depreciated_value=5800.00, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=350.00, remaining_value=3850.00, depreciated_value=6150.00, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=350.00, remaining_value=3500.00, depreciated_value=6500.00, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=350.00, remaining_value=3150.00, depreciated_value=6850.00, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=350.00, remaining_value=2800.00, depreciated_value=7200.00, state='draft'), + + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=277.78, remaining_value=2522.22, depreciated_value=7477.78, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=277.78, remaining_value=2244.44, depreciated_value=7755.56, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=277.77, remaining_value=1966.67, depreciated_value=8033.33, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=277.78, remaining_value=1688.89, depreciated_value=8311.11, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=277.78, remaining_value=1411.11, depreciated_value=8588.89, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=277.78, remaining_value=1133.33, depreciated_value=8866.67, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=277.77, remaining_value=855.56, depreciated_value=9144.44, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=277.78, remaining_value=577.78, depreciated_value=9422.22, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=277.78, remaining_value=300.00, depreciated_value=9700.00, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=277.78, remaining_value=22.22, depreciated_value=9977.78, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=22.22, remaining_value=0.00, depreciated_value=10000.00, state='draft'), + ]) + + def test_linear_60_months_no_prorata_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 30000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=1000, remaining_value=59000, depreciated_value=1000, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=1000, remaining_value=58000, depreciated_value=2000, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=1000, remaining_value=57000, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=1000, remaining_value=56000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=1000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=1000, remaining_value=54000, depreciated_value=6000, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1000, remaining_value=53000, depreciated_value=7000, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1000, remaining_value=52000, depreciated_value=8000, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1000, remaining_value=51000, depreciated_value=9000, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1000, remaining_value=49000, depreciated_value=11000, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1000, remaining_value=48000, depreciated_value=12000, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1000, remaining_value=47000, depreciated_value=13000, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1000, remaining_value=46000, depreciated_value=14000, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1000, remaining_value=44000, depreciated_value=16000, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1000, remaining_value=43000, depreciated_value=17000, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1000, remaining_value=42000, depreciated_value=18000, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1000, remaining_value=41000, depreciated_value=19000, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1000, remaining_value=40000, depreciated_value=20000, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1000, remaining_value=39000, depreciated_value=21000, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1000, remaining_value=38000, depreciated_value=22000, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1000, remaining_value=37000, depreciated_value=23000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1000, remaining_value=36000, depreciated_value=24000, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=1000, remaining_value=35000, depreciated_value=25000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=1000, remaining_value=34000, depreciated_value=26000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=1000, remaining_value=33000, depreciated_value=27000, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=1000, remaining_value=32000, depreciated_value=28000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=31000, depreciated_value=29000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=30000, depreciated_value=30000, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=29000, depreciated_value=31000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=28000, depreciated_value=32000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=27000, depreciated_value=33000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=26000, depreciated_value=34000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=25000, depreciated_value=35000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=24000, depreciated_value=36000, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=1000, remaining_value=23000, depreciated_value=37000, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=1000, remaining_value=22000, depreciated_value=38000, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=1000, remaining_value=21000, depreciated_value=39000, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=1000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=1000, remaining_value=19000, depreciated_value=41000, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=1000, remaining_value=18000, depreciated_value=42000, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=1000, remaining_value=17000, depreciated_value=43000, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=1000, remaining_value=16000, depreciated_value=44000, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=1000, remaining_value=15000, depreciated_value=45000, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=1000, remaining_value=14000, depreciated_value=46000, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=1000, remaining_value=13000, depreciated_value=47000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=1000, remaining_value=12000, depreciated_value=48000, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1000, remaining_value=11000, depreciated_value=49000, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=1000, remaining_value=10000, depreciated_value=50000, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1000, remaining_value=9000, depreciated_value=51000, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=1000, remaining_value=8000, depreciated_value=52000, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1000, remaining_value=7000, depreciated_value=53000, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=1000, remaining_value=6000, depreciated_value=54000, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=55000, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=56000, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=57000, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=58000, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=59000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_60_months_no_prorata_with_imported_amount_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'already_depreciated_amount_import': 1500, + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 30000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=500.0, remaining_value=58000.0, depreciated_value=2000.0, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=1000.0, remaining_value=57000.0, depreciated_value=3000.0, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=1000.0, remaining_value=56000.0, depreciated_value=4000.0, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=1000.0, remaining_value=55000.0, depreciated_value=5000.0, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=1000.0, remaining_value=54000.0, depreciated_value=6000.0, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1000.0, remaining_value=53000.0, depreciated_value=7000.0, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1000.0, remaining_value=52000.0, depreciated_value=8000.0, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1000.0, remaining_value=51000.0, depreciated_value=9000.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1000.0, remaining_value=50000.0, depreciated_value=10000.0, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1000.0, remaining_value=49000.0, depreciated_value=11000.0, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1000.0, remaining_value=48000.0, depreciated_value=12000.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1000.0, remaining_value=47000.0, depreciated_value=13000.0, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1000.0, remaining_value=46000.0, depreciated_value=14000.0, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1000.0, remaining_value=45000.0, depreciated_value=15000.0, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1000.0, remaining_value=44000.0, depreciated_value=16000.0, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1000.0, remaining_value=43000.0, depreciated_value=17000.0, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1000.0, remaining_value=42000.0, depreciated_value=18000.0, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1000.0, remaining_value=41000.0, depreciated_value=19000.0, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1000.0, remaining_value=40000.0, depreciated_value=20000.0, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1000.0, remaining_value=39000.0, depreciated_value=21000.0, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1000.0, remaining_value=38000.0, depreciated_value=22000.0, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1000.0, remaining_value=37000.0, depreciated_value=23000.0, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1000.0, remaining_value=36000.0, depreciated_value=24000.0, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=1000.0, remaining_value=35000.0, depreciated_value=25000.0, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=1000.0, remaining_value=34000.0, depreciated_value=26000.0, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=1000.0, remaining_value=33000.0, depreciated_value=27000.0, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=1000.0, remaining_value=32000.0, depreciated_value=28000.0, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000.0, remaining_value=31000.0, depreciated_value=29000.0, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000.0, remaining_value=30000.0, depreciated_value=30000.0, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000.0, remaining_value=29000.0, depreciated_value=31000.0, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000.0, remaining_value=28000.0, depreciated_value=32000.0, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000.0, remaining_value=27000.0, depreciated_value=33000.0, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000.0, remaining_value=26000.0, depreciated_value=34000.0, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000.0, remaining_value=25000.0, depreciated_value=35000.0, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000.0, remaining_value=24000.0, depreciated_value=36000.0, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=1000.0, remaining_value=23000.0, depreciated_value=37000.0, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=1000.0, remaining_value=22000.0, depreciated_value=38000.0, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=1000.0, remaining_value=21000.0, depreciated_value=39000.0, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=1000.0, remaining_value=20000.0, depreciated_value=40000.0, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=1000.0, remaining_value=19000.0, depreciated_value=41000.0, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=1000.0, remaining_value=18000.0, depreciated_value=42000.0, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=1000.0, remaining_value=17000.0, depreciated_value=43000.0, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=1000.0, remaining_value=16000.0, depreciated_value=44000.0, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=1000.0, remaining_value=15000.0, depreciated_value=45000.0, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=1000.0, remaining_value=14000.0, depreciated_value=46000.0, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=1000.0, remaining_value=13000.0, depreciated_value=47000.0, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=1000.0, remaining_value=12000.0, depreciated_value=48000.0, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1000.0, remaining_value=11000.0, depreciated_value=49000.0, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=1000.0, remaining_value=10000.0, depreciated_value=50000.0, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1000.0, remaining_value=9000.0, depreciated_value=51000.0, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=1000.0, remaining_value=8000.0, depreciated_value=52000.0, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1000.0, remaining_value=7000.0, depreciated_value=53000.0, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=1000.0, remaining_value=6000.0, depreciated_value=54000.0, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1000.0, remaining_value=5000.0, depreciated_value=55000.0, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1000.0, remaining_value=4000.0, depreciated_value=56000.0, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=1000.0, remaining_value=3000.0, depreciated_value=57000.0, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1000.0, remaining_value=2000.0, depreciated_value=58000.0, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=1000.0, remaining_value=1000.0, depreciated_value=59000.0, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1000.0, remaining_value=0.0, depreciated_value=60000.0, state='draft'), + ]) + + def test_linear_60_months_no_prorata_with_salvage_value_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'method_progress_factor': 0.3, + 'salvage_value': 2000, + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 31000) + self.assertEqual(self.car.value_residual, 29000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=966.67, remaining_value=57033.33, depreciated_value=966.67, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=966.66, remaining_value=56066.67, depreciated_value=1933.33, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=966.67, remaining_value=55100.0, depreciated_value=2900.0, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=966.67, remaining_value=54133.33, depreciated_value=3866.67, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=966.66, remaining_value=53166.67, depreciated_value=4833.33, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=966.67, remaining_value=52200.0, depreciated_value=5800.0, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=966.67, remaining_value=51233.33, depreciated_value=6766.67, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=966.66, remaining_value=50266.67, depreciated_value=7733.33, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=966.67, remaining_value=49300.0, depreciated_value=8700.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=966.67, remaining_value=48333.33, depreciated_value=9666.67, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=966.66, remaining_value=47366.67, depreciated_value=10633.33, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=966.67, remaining_value=46400.0, depreciated_value=11600.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=966.67, remaining_value=45433.33, depreciated_value=12566.67, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=966.66, remaining_value=44466.67, depreciated_value=13533.33, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=966.67, remaining_value=43500.0, depreciated_value=14500.0, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=966.67, remaining_value=42533.33, depreciated_value=15466.67, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=966.66, remaining_value=41566.67, depreciated_value=16433.33, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=966.67, remaining_value=40600.0, depreciated_value=17400.0, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=966.67, remaining_value=39633.33, depreciated_value=18366.67, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=966.66, remaining_value=38666.67, depreciated_value=19333.33, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=966.67, remaining_value=37700.0, depreciated_value=20300.0, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=966.67, remaining_value=36733.33, depreciated_value=21266.67, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=966.66, remaining_value=35766.67, depreciated_value=22233.33, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=966.67, remaining_value=34800.0, depreciated_value=23200.0, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=966.67, remaining_value=33833.33, depreciated_value=24166.67, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=966.66, remaining_value=32866.67, depreciated_value=25133.33, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=966.67, remaining_value=31900.0, depreciated_value=26100.0, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=966.67, remaining_value=30933.33, depreciated_value=27066.67, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=966.66, remaining_value=29966.67, depreciated_value=28033.33, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=966.67, remaining_value=29000.0, depreciated_value=29000.0, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=966.67, remaining_value=28033.33, depreciated_value=29966.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=966.66, remaining_value=27066.67, depreciated_value=30933.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=966.67, remaining_value=26100.0, depreciated_value=31900.0, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=966.67, remaining_value=25133.33, depreciated_value=32866.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=966.66, remaining_value=24166.67, depreciated_value=33833.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=966.67, remaining_value=23200.0, depreciated_value=34800.0, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=966.67, remaining_value=22233.33, depreciated_value=35766.67, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=966.66, remaining_value=21266.67, depreciated_value=36733.33, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=966.67, remaining_value=20300.0, depreciated_value=37700.0, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=966.67, remaining_value=19333.33, depreciated_value=38666.67, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=966.66, remaining_value=18366.67, depreciated_value=39633.33, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=966.67, remaining_value=17400.0, depreciated_value=40600.0, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=966.67, remaining_value=16433.33, depreciated_value=41566.67, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=966.66, remaining_value=15466.67, depreciated_value=42533.33, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=966.67, remaining_value=14500.0, depreciated_value=43500.0, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=966.67, remaining_value=13533.33, depreciated_value=44466.67, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=966.66, remaining_value=12566.67, depreciated_value=45433.33, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=966.67, remaining_value=11600.0, depreciated_value=46400.0, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=966.67, remaining_value=10633.33, depreciated_value=47366.67, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=966.66, remaining_value=9666.67, depreciated_value=48333.33, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=966.67, remaining_value=8700.0, depreciated_value=49300.0, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=966.67, remaining_value=7733.33, depreciated_value=50266.67, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=966.66, remaining_value=6766.67, depreciated_value=51233.33, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=966.67, remaining_value=5800.0, depreciated_value=52200.0, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=966.67, remaining_value=4833.33, depreciated_value=53166.67, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=966.66, remaining_value=3866.67, depreciated_value=54133.33, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=966.67, remaining_value=2900.0, depreciated_value=55100.0, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=966.67, remaining_value=1933.33, depreciated_value=56066.67, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=966.66, remaining_value=966.67, depreciated_value=57033.33, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=966.67, remaining_value=0.0, depreciated_value=58000.0, state='draft'), + ]) + + def test_linear_60_months_constant_periods_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'prorata_computation_type': 'constant_periods', + 'prorata_date': '2020-07-01', + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1000, remaining_value=59000, depreciated_value=1000, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1000, remaining_value=58000, depreciated_value=2000, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1000, remaining_value=57000, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1000, remaining_value=56000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1000, remaining_value=54000, depreciated_value=6000, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1000, remaining_value=53000, depreciated_value=7000, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1000, remaining_value=52000, depreciated_value=8000, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1000, remaining_value=51000, depreciated_value=9000, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1000, remaining_value=49000, depreciated_value=11000, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1000, remaining_value=47000, depreciated_value=13000, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1000, remaining_value=46000, depreciated_value=14000, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1000, remaining_value=44000, depreciated_value=16000, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1000, remaining_value=43000, depreciated_value=17000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1000, remaining_value=42000, depreciated_value=18000, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=1000, remaining_value=41000, depreciated_value=19000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=1000, remaining_value=40000, depreciated_value=20000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=1000, remaining_value=39000, depreciated_value=21000, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=1000, remaining_value=38000, depreciated_value=22000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=37000, depreciated_value=23000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=35000, depreciated_value=25000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=34000, depreciated_value=26000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=33000, depreciated_value=27000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=32000, depreciated_value=28000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=31000, depreciated_value=29000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=30000, depreciated_value=30000, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=1000, remaining_value=29000, depreciated_value=31000, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=1000, remaining_value=28000, depreciated_value=32000, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=1000, remaining_value=27000, depreciated_value=33000, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=1000, remaining_value=26000, depreciated_value=34000, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=1000, remaining_value=25000, depreciated_value=35000, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=1000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=1000, remaining_value=23000, depreciated_value=37000, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=1000, remaining_value=22000, depreciated_value=38000, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=1000, remaining_value=21000, depreciated_value=39000, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=1000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=1000, remaining_value=19000, depreciated_value=41000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=1000, remaining_value=18000, depreciated_value=42000, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1000, remaining_value=17000, depreciated_value=43000, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=1000, remaining_value=16000, depreciated_value=44000, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1000, remaining_value=15000, depreciated_value=45000, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=1000, remaining_value=14000, depreciated_value=46000, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1000, remaining_value=13000, depreciated_value=47000, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=1000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1000, remaining_value=11000, depreciated_value=49000, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1000, remaining_value=10000, depreciated_value=50000, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=1000, remaining_value=9000, depreciated_value=51000, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1000, remaining_value=8000, depreciated_value=52000, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=1000, remaining_value=7000, depreciated_value=53000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1000, remaining_value=6000, depreciated_value=54000, state='draft'), + # 2025 + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=1000, remaining_value=5000, depreciated_value=55000, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=1000, remaining_value=4000, depreciated_value=56000, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=1000, remaining_value=3000, depreciated_value=57000, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=1000, remaining_value=2000, depreciated_value=58000, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=1000, remaining_value=1000, depreciated_value=59000, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=1000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_60_months_daily_computation_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'prorata_computation_type': 'daily_computation', + 'prorata_date': '2020-07-01', + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36013.14) + + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1018.62, remaining_value=58981.38, depreciated_value=1018.62, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1018.62, remaining_value=57962.76, depreciated_value=2037.24, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=985.76, remaining_value=56977.0, depreciated_value=3023.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1018.62, remaining_value=55958.38, depreciated_value=4041.62, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=985.76, remaining_value=54972.62, depreciated_value=5027.38, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1018.62, remaining_value=53954.0, depreciated_value=6046.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1018.62, remaining_value=52935.38, depreciated_value=7064.62, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=920.05, remaining_value=52015.33, depreciated_value=7984.67, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1018.62, remaining_value=50996.71, depreciated_value=9003.29, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=985.76, remaining_value=50010.95, depreciated_value=9989.05, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1018.62, remaining_value=48992.33, depreciated_value=11007.67, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=985.76, remaining_value=48006.57, depreciated_value=11993.43, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1018.62, remaining_value=46987.95, depreciated_value=13012.05, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1018.62, remaining_value=45969.33, depreciated_value=14030.67, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=985.76, remaining_value=44983.57, depreciated_value=15016.43, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1018.62, remaining_value=43964.95, depreciated_value=16035.05, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=985.76, remaining_value=42979.19, depreciated_value=17020.81, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1018.62, remaining_value=41960.57, depreciated_value=18039.43, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=1018.62, remaining_value=40941.95, depreciated_value=19058.05, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=920.04, remaining_value=40021.91, depreciated_value=19978.09, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=1018.62, remaining_value=39003.29, depreciated_value=20996.71, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=985.77, remaining_value=38017.52, depreciated_value=21982.48, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1018.62, remaining_value=36998.9, depreciated_value=23001.10, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=985.76, remaining_value=36013.14, depreciated_value=23986.86, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1018.62, remaining_value=34994.52, depreciated_value=25005.48, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1018.62, remaining_value=33975.9, depreciated_value=26024.10, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=985.76, remaining_value=32990.14, depreciated_value=27009.86, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1018.62, remaining_value=31971.52, depreciated_value=28028.48, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=985.76, remaining_value=30985.76, depreciated_value=29014.24, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1018.62, remaining_value=29967.14, depreciated_value=30032.86, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=1018.62, remaining_value=28948.52, depreciated_value=31051.48, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=920.04, remaining_value=28028.48, depreciated_value=31971.52, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=1018.62, remaining_value=27009.86, depreciated_value=32990.14, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=985.76, remaining_value=26024.10, depreciated_value=33975.9, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=1018.62, remaining_value=25005.48, depreciated_value=34994.52, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=985.76, remaining_value=24019.72, depreciated_value=35980.28, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=1018.62, remaining_value=23001.10, depreciated_value=36998.9, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=1018.62, remaining_value=21982.48, depreciated_value=38017.52, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=985.77, remaining_value=20996.71, depreciated_value=39003.29, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=1018.62, remaining_value=19978.09, depreciated_value=40021.91, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=985.76, remaining_value=18992.33, depreciated_value=41007.67, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=1018.62, remaining_value=17973.71, depreciated_value=42026.29, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1018.62, remaining_value=16955.09, depreciated_value=43044.91, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=952.9, remaining_value=16002.19, depreciated_value=43997.81, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1018.62, remaining_value=14983.57, depreciated_value=45016.43, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=985.76, remaining_value=13997.81, depreciated_value=46002.19, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1018.62, remaining_value=12979.19, depreciated_value=47020.81, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=985.76, remaining_value=11993.43, depreciated_value=48006.57, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1018.62, remaining_value=10974.81, depreciated_value=49025.19, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1018.62, remaining_value=9956.19, depreciated_value=50043.81, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=985.76, remaining_value=8970.43, depreciated_value=51029.57, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1018.62, remaining_value=7951.81, depreciated_value=52048.19, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=985.76, remaining_value=6966.05, depreciated_value=53033.95, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1018.62, remaining_value=5947.43, depreciated_value=54052.57, state='draft'), + # 2025 + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=1018.62, remaining_value=4928.81, depreciated_value=55071.19, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=920.05, remaining_value=4008.76, depreciated_value=55991.24, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=1018.62, remaining_value=2990.14, depreciated_value=57009.86, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=985.76, remaining_value=2004.38, depreciated_value=57995.62, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=1018.62, remaining_value=985.76, depreciated_value=59014.24, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=985.76, remaining_value=0.0, depreciated_value=60000.0, state='draft'), + ]) + + def test_degressive_60_months_no_prorata_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'method': 'degressive', + 'method_progress_factor': 0.3, + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 24500) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=1500.0, remaining_value=58500.0, depreciated_value=1500.0, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=1500.0, remaining_value=57000.0, depreciated_value=3000.0, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=1500.0, remaining_value=55500.0, depreciated_value=4500.0, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=1500.0, remaining_value=54000.0, depreciated_value=6000.0, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=1500.0, remaining_value=52500.0, depreciated_value=7500.0, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=1500.0, remaining_value=51000.0, depreciated_value=9000.0, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1500.0, remaining_value=49500.0, depreciated_value=10500.0, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1500.0, remaining_value=48000.0, depreciated_value=12000.0, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1500.0, remaining_value=46500.0, depreciated_value=13500.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1500.0, remaining_value=45000.0, depreciated_value=15000.0, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1500.0, remaining_value=43500.0, depreciated_value=16500.0, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1500.0, remaining_value=42000.0, depreciated_value=18000.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1050.0, remaining_value=40950.0, depreciated_value=19050.0, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1050.0, remaining_value=39900.0, depreciated_value=20100.0, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1050.0, remaining_value=38850.0, depreciated_value=21150.0, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1050.0, remaining_value=37800.0, depreciated_value=22200.0, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1050.0, remaining_value=36750.0, depreciated_value=23250.0, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1050.0, remaining_value=35700.0, depreciated_value=24300.0, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1050.0, remaining_value=34650.0, depreciated_value=25350.0, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1050.0, remaining_value=33600.0, depreciated_value=26400.0, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1050.0, remaining_value=32550.0, depreciated_value=27450.0, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1050.0, remaining_value=31500.0, depreciated_value=28500.0, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1050.0, remaining_value=30450.0, depreciated_value=29550.0, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1050.0, remaining_value=29400.0, depreciated_value=30600.0, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=816.67, remaining_value=28583.33, depreciated_value=31416.67, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=816.66, remaining_value=27766.67, depreciated_value=32233.33, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=816.67, remaining_value=26950.0, depreciated_value=33050.0, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=816.67, remaining_value=26133.33, depreciated_value=33866.67, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=816.66, remaining_value=25316.67, depreciated_value=34683.33, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=816.67, remaining_value=24500.0, depreciated_value=35500.0, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=816.67, remaining_value=23683.33, depreciated_value=36316.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=816.66, remaining_value=22866.67, depreciated_value=37133.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=816.67, remaining_value=22050.0, depreciated_value=37950.0, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=816.67, remaining_value=21233.33, depreciated_value=38766.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=816.66, remaining_value=20416.67, depreciated_value=39583.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=816.67, remaining_value=19600.0, depreciated_value=40400.0, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=816.67, remaining_value=18783.33, depreciated_value=41216.67, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=816.66, remaining_value=17966.67, depreciated_value=42033.33, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=816.67, remaining_value=17150.0, depreciated_value=42850.0, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=816.67, remaining_value=16333.33, depreciated_value=43666.67, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=816.66, remaining_value=15516.67, depreciated_value=44483.33, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=816.67, remaining_value=14700.0, depreciated_value=45300.0, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=816.67, remaining_value=13883.33, depreciated_value=46116.67, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=816.66, remaining_value=13066.67, depreciated_value=46933.33, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=816.67, remaining_value=12250.0, depreciated_value=47750.0, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=816.67, remaining_value=11433.33, depreciated_value=48566.67, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=816.66, remaining_value=10616.67, depreciated_value=49383.33, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=816.67, remaining_value=9800.0, depreciated_value=50200.0, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=816.67, remaining_value=8983.33, depreciated_value=51016.67, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=816.66, remaining_value=8166.67, depreciated_value=51833.33, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=816.67, remaining_value=7350.0, depreciated_value=52650.0, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=816.67, remaining_value=6533.33, depreciated_value=53466.67, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=816.66, remaining_value=5716.67, depreciated_value=54283.33, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=816.67, remaining_value=4900.0, depreciated_value=55100.0, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=816.67, remaining_value=4083.33, depreciated_value=55916.67, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=816.66, remaining_value=3266.67, depreciated_value=56733.33, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=816.67, remaining_value=2450.0, depreciated_value=57550.0, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=816.67, remaining_value=1633.33, depreciated_value=58366.67, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=816.66, remaining_value=816.67, depreciated_value=59183.33, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=816.67, remaining_value=0.0, depreciated_value=60000.0, state='draft'), + ]) + + def test_degressive_60_months_from_middle_year(self): + asset = self.create_asset( + value=100000, + periodicity='monthly', + periods=60, + method='degressive', + method_progress_factor=0.35, + acquisition_date='2022-07-01', + prorata_computation_type='constant_periods' + ) + asset.compute_depreciation_board() + self.assertEqual(asset.state, 'draft') + self.assertEqual(asset.book_value, 100000) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=2916.67, remaining_value=97083.33, depreciated_value=2916.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=2916.66, remaining_value=94166.67, depreciated_value=5833.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=2916.67, remaining_value=91250.00, depreciated_value=8750.00, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=2916.67, remaining_value=88333.33, depreciated_value=11666.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=2916.66, remaining_value=85416.67, depreciated_value=14583.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=2916.67, remaining_value=82500.00, depreciated_value=17500.00, state='draft'), + + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=2406.25, remaining_value=80093.75, depreciated_value=19906.25, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=2406.25, remaining_value=77687.50, depreciated_value=22312.50, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=2406.25, remaining_value=75281.25, depreciated_value=24718.75, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=2406.25, remaining_value=72875.00, depreciated_value=27125.00, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=2406.25, remaining_value=70468.75, depreciated_value=29531.25, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=2406.25, remaining_value=68062.50, depreciated_value=31937.50, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=2406.25, remaining_value=65656.25, depreciated_value=34343.75, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=2406.25, remaining_value=63250.00, depreciated_value=36750.00, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=2406.25, remaining_value=60843.75, depreciated_value=39156.25, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=2406.25, remaining_value=58437.50, depreciated_value=41562.50, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=2406.25, remaining_value=56031.25, depreciated_value=43968.75, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=2406.25, remaining_value=53625.00, depreciated_value=46375.00, state='draft'), + + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1564.06, remaining_value=52060.94, depreciated_value=47939.06, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=1564.07, remaining_value=50496.87, depreciated_value=49503.13, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1564.06, remaining_value=48932.81, depreciated_value=51067.19, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=1564.06, remaining_value=47368.75, depreciated_value=52631.25, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1564.06, remaining_value=45804.69, depreciated_value=54195.31, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=1564.07, remaining_value=44240.62, depreciated_value=55759.38, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1564.06, remaining_value=42676.56, depreciated_value=57323.44, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1564.06, remaining_value=41112.50, depreciated_value=58887.50, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=1564.06, remaining_value=39548.44, depreciated_value=60451.56, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1564.07, remaining_value=37984.37, depreciated_value=62015.63, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=1564.06, remaining_value=36420.31, depreciated_value=63579.69, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1564.06, remaining_value=34856.25, depreciated_value=65143.75, state='draft'), + + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=1161.88, remaining_value=33694.37, depreciated_value=66305.63, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=1161.87, remaining_value=32532.50, depreciated_value=67467.50, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=1161.88, remaining_value=31370.62, depreciated_value=68629.38, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=1161.87, remaining_value=30208.75, depreciated_value=69791.25, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=1161.88, remaining_value=29046.87, depreciated_value=70953.13, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=1161.87, remaining_value=27885.00, depreciated_value=72115.00, state='draft'), + self._get_depreciation_move_values(date='2025-07-31', depreciation_value=1161.88, remaining_value=26723.12, depreciated_value=73276.88, state='draft'), + self._get_depreciation_move_values(date='2025-08-31', depreciation_value=1161.87, remaining_value=25561.25, depreciated_value=74438.75, state='draft'), + self._get_depreciation_move_values(date='2025-09-30', depreciation_value=1161.88, remaining_value=24399.37, depreciated_value=75600.63, state='draft'), + self._get_depreciation_move_values(date='2025-10-31', depreciation_value=1161.87, remaining_value=23237.50, depreciated_value=76762.50, state='draft'), + self._get_depreciation_move_values(date='2025-11-30', depreciation_value=1161.88, remaining_value=22075.62, depreciated_value=77924.38, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=1161.87, remaining_value=20913.75, depreciated_value=79086.25, state='draft'), + + self._get_depreciation_move_values(date='2026-01-31', depreciation_value=1161.88, remaining_value=19751.87, depreciated_value=80248.13, state='draft'), + self._get_depreciation_move_values(date='2026-02-28', depreciation_value=1161.87, remaining_value=18590.00, depreciated_value=81410.00, state='draft'), + self._get_depreciation_move_values(date='2026-03-31', depreciation_value=1161.88, remaining_value=17428.12, depreciated_value=82571.88, state='draft'), + self._get_depreciation_move_values(date='2026-04-30', depreciation_value=1161.87, remaining_value=16266.25, depreciated_value=83733.75, state='draft'), + self._get_depreciation_move_values(date='2026-05-31', depreciation_value=1161.88, remaining_value=15104.37, depreciated_value=84895.63, state='draft'), + self._get_depreciation_move_values(date='2026-06-30', depreciation_value=1161.87, remaining_value=13942.50, depreciated_value=86057.50, state='draft'), + self._get_depreciation_move_values(date='2026-07-31', depreciation_value=1161.88, remaining_value=12780.62, depreciated_value=87219.38, state='draft'), + self._get_depreciation_move_values(date='2026-08-31', depreciation_value=1161.87, remaining_value=11618.75, depreciated_value=88381.25, state='draft'), + self._get_depreciation_move_values(date='2026-09-30', depreciation_value=1161.88, remaining_value=10456.87, depreciated_value=89543.13, state='draft'), + self._get_depreciation_move_values(date='2026-10-31', depreciation_value=1161.87, remaining_value=9295.00, depreciated_value=90705.00, state='draft'), + self._get_depreciation_move_values(date='2026-11-30', depreciation_value=1161.88, remaining_value=8133.12, depreciated_value=91866.88, state='draft'), + self._get_depreciation_move_values(date='2026-12-31', depreciation_value=1161.87, remaining_value=6971.25, depreciated_value=93028.75, state='draft'), + + self._get_depreciation_move_values(date='2027-01-31', depreciation_value=1161.88, remaining_value=5809.37, depreciated_value=94190.63, state='draft'), + self._get_depreciation_move_values(date='2027-02-28', depreciation_value=1161.87, remaining_value=4647.50, depreciated_value=95352.50, state='draft'), + self._get_depreciation_move_values(date='2027-03-31', depreciation_value=1161.88, remaining_value=3485.62, depreciated_value=96514.38, state='draft'), + self._get_depreciation_move_values(date='2027-04-30', depreciation_value=1161.87, remaining_value=2323.75, depreciated_value=97676.25, state='draft'), + self._get_depreciation_move_values(date='2027-05-31', depreciation_value=1161.88, remaining_value=1161.87, depreciated_value=98838.13, state='draft'), + self._get_depreciation_move_values(date='2027-06-30', depreciation_value=1161.87, remaining_value=0.00, depreciated_value=100000.00, state='draft'), + ]) + + def test_degressive_60_months_from_middle_sync_with_fiscalyear(self): + company = self.env.company + company.fiscalyear_last_day = 30 + company.fiscalyear_last_month = '6' + asset = self.create_asset( + value=100000, + periodicity='monthly', + periods=60, + method='degressive', + method_progress_factor=0.35, + acquisition_date='2022-07-01', + prorata_computation_type='constant_periods' + ) + asset.compute_depreciation_board() + self.assertEqual(asset.state, 'draft') + self.assertEqual(asset.book_value, 100000) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=2916.67, remaining_value=97083.33, depreciated_value=2916.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=2916.66, remaining_value=94166.67, depreciated_value=5833.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=2916.67, remaining_value=91250.00, depreciated_value=8750.00, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=2916.67, remaining_value=88333.33, depreciated_value=11666.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=2916.66, remaining_value=85416.67, depreciated_value=14583.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=2916.67, remaining_value=82500.00, depreciated_value=17500.00, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=2916.67, remaining_value=79583.33, depreciated_value=20416.67, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=2916.66, remaining_value=76666.67, depreciated_value=23333.33, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=2916.67, remaining_value=73750.00, depreciated_value=26250.00, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=2916.67, remaining_value=70833.33, depreciated_value=29166.67, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=2916.66, remaining_value=67916.67, depreciated_value=32083.33, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=2916.67, remaining_value=65000.00, depreciated_value=35000.00, state='draft'), + + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=1895.83, remaining_value=63104.17, depreciated_value=36895.83, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=1895.84, remaining_value=61208.33, depreciated_value=38791.67, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=1895.83, remaining_value=59312.50, depreciated_value=40687.50, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=1895.83, remaining_value=57416.67, depreciated_value=42583.33, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=1895.84, remaining_value=55520.83, depreciated_value=44479.17, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=1895.83, remaining_value=53625.00, depreciated_value=46375.00, state='draft'), + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=1895.83, remaining_value=51729.17, depreciated_value=48270.83, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=1895.84, remaining_value=49833.33, depreciated_value=50166.67, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=1895.83, remaining_value=47937.50, depreciated_value=52062.50, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=1895.83, remaining_value=46041.67, depreciated_value=53958.33, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=1895.84, remaining_value=44145.83, depreciated_value=55854.17, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=1895.83, remaining_value=42250.00, depreciated_value=57750.00, state='draft'), + + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=1232.29, remaining_value=41017.71, depreciated_value=58982.29, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=1232.29, remaining_value=39785.42, depreciated_value=60214.58, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=1232.29, remaining_value=38553.13, depreciated_value=61446.87, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=1232.30, remaining_value=37320.83, depreciated_value=62679.17, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=1232.29, remaining_value=36088.54, depreciated_value=63911.46, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=1232.29, remaining_value=34856.25, depreciated_value=65143.75, state='draft'), + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=1232.29, remaining_value=33623.96, depreciated_value=66376.04, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=1232.29, remaining_value=32391.67, depreciated_value=67608.33, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=1232.29, remaining_value=31159.38, depreciated_value=68840.62, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=1232.30, remaining_value=29927.08, depreciated_value=70072.92, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=1232.29, remaining_value=28694.79, depreciated_value=71305.21, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=1232.29, remaining_value=27462.50, depreciated_value=72537.50, state='draft'), + + self._get_depreciation_move_values(date='2025-07-31', depreciation_value=1144.27, remaining_value=26318.23, depreciated_value=73681.77, state='draft'), + self._get_depreciation_move_values(date='2025-08-31', depreciation_value=1144.27, remaining_value=25173.96, depreciated_value=74826.04, state='draft'), + self._get_depreciation_move_values(date='2025-09-30', depreciation_value=1144.27, remaining_value=24029.69, depreciated_value=75970.31, state='draft'), + self._get_depreciation_move_values(date='2025-10-31', depreciation_value=1144.27, remaining_value=22885.42, depreciated_value=77114.58, state='draft'), + self._get_depreciation_move_values(date='2025-11-30', depreciation_value=1144.27, remaining_value=21741.15, depreciated_value=78258.85, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=1144.27, remaining_value=20596.88, depreciated_value=79403.12, state='draft'), + self._get_depreciation_move_values(date='2026-01-31', depreciation_value=1144.28, remaining_value=19452.60, depreciated_value=80547.40, state='draft'), + self._get_depreciation_move_values(date='2026-02-28', depreciation_value=1144.27, remaining_value=18308.33, depreciated_value=81691.67, state='draft'), + self._get_depreciation_move_values(date='2026-03-31', depreciation_value=1144.27, remaining_value=17164.06, depreciated_value=82835.94, state='draft'), + self._get_depreciation_move_values(date='2026-04-30', depreciation_value=1144.27, remaining_value=16019.79, depreciated_value=83980.21, state='draft'), + self._get_depreciation_move_values(date='2026-05-31', depreciation_value=1144.27, remaining_value=14875.52, depreciated_value=85124.48, state='draft'), + self._get_depreciation_move_values(date='2026-06-30', depreciation_value=1144.27, remaining_value=13731.25, depreciated_value=86268.75, state='draft'), + + self._get_depreciation_move_values(date='2026-07-31', depreciation_value=1144.27, remaining_value=12586.98, depreciated_value=87413.02, state='draft'), + self._get_depreciation_move_values(date='2026-08-31', depreciation_value=1144.27, remaining_value=11442.71, depreciated_value=88557.29, state='draft'), + self._get_depreciation_move_values(date='2026-09-30', depreciation_value=1144.27, remaining_value=10298.44, depreciated_value=89701.56, state='draft'), + self._get_depreciation_move_values(date='2026-10-31', depreciation_value=1144.27, remaining_value=9154.17, depreciated_value=90845.83, state='draft'), + self._get_depreciation_move_values(date='2026-11-30', depreciation_value=1144.27, remaining_value=8009.90, depreciated_value=91990.10, state='draft'), + self._get_depreciation_move_values(date='2026-12-31', depreciation_value=1144.27, remaining_value=6865.63, depreciated_value=93134.37, state='draft'), + self._get_depreciation_move_values(date='2027-01-31', depreciation_value=1144.28, remaining_value=5721.35, depreciated_value=94278.65, state='draft'), + self._get_depreciation_move_values(date='2027-02-28', depreciation_value=1144.27, remaining_value=4577.08, depreciated_value=95422.92, state='draft'), + self._get_depreciation_move_values(date='2027-03-31', depreciation_value=1144.27, remaining_value=3432.81, depreciated_value=96567.19, state='draft'), + self._get_depreciation_move_values(date='2027-04-30', depreciation_value=1144.27, remaining_value=2288.54, depreciated_value=97711.46, state='draft'), + self._get_depreciation_move_values(date='2027-05-31', depreciation_value=1144.27, remaining_value=1144.27, depreciated_value=98855.73, state='draft'), + self._get_depreciation_move_values(date='2027-06-30', depreciation_value=1144.27, remaining_value=0.00, depreciated_value=100000.00, state='draft'), + ]) + + def test_degressive_60_months_no_prorata_with_imported_amount_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'method': 'degressive', + 'method_progress_factor': 0.3, + 'already_depreciated_amount_import': 2000, + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 24500) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=1000.0, remaining_value=57000.0, depreciated_value=3000.0, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=1500.0, remaining_value=55500.0, depreciated_value=4500.0, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=1500.0, remaining_value=54000.0, depreciated_value=6000.0, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=1500.0, remaining_value=52500.0, depreciated_value=7500.0, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=1500.0, remaining_value=51000.0, depreciated_value=9000.0, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1500.0, remaining_value=49500.0, depreciated_value=10500.0, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1500.0, remaining_value=48000.0, depreciated_value=12000.0, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1500.0, remaining_value=46500.0, depreciated_value=13500.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1500.0, remaining_value=45000.0, depreciated_value=15000.0, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1500.0, remaining_value=43500.0, depreciated_value=16500.0, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1500.0, remaining_value=42000.0, depreciated_value=18000.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1050.0, remaining_value=40950.0, depreciated_value=19050.0, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1050.0, remaining_value=39900.0, depreciated_value=20100.0, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1050.0, remaining_value=38850.0, depreciated_value=21150.0, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1050.0, remaining_value=37800.0, depreciated_value=22200.0, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1050.0, remaining_value=36750.0, depreciated_value=23250.0, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1050.0, remaining_value=35700.0, depreciated_value=24300.0, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1050.0, remaining_value=34650.0, depreciated_value=25350.0, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1050.0, remaining_value=33600.0, depreciated_value=26400.0, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1050.0, remaining_value=32550.0, depreciated_value=27450.0, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1050.0, remaining_value=31500.0, depreciated_value=28500.0, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1050.0, remaining_value=30450.0, depreciated_value=29550.0, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1050.0, remaining_value=29400.0, depreciated_value=30600.0, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=816.67, remaining_value=28583.33, depreciated_value=31416.67, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=816.66, remaining_value=27766.67, depreciated_value=32233.33, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=816.67, remaining_value=26950.0, depreciated_value=33050.0, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=816.67, remaining_value=26133.33, depreciated_value=33866.67, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=816.66, remaining_value=25316.67, depreciated_value=34683.33, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=816.67, remaining_value=24500.0, depreciated_value=35500.0, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=816.67, remaining_value=23683.33, depreciated_value=36316.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=816.66, remaining_value=22866.67, depreciated_value=37133.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=816.67, remaining_value=22050.0, depreciated_value=37950.0, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=816.67, remaining_value=21233.33, depreciated_value=38766.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=816.66, remaining_value=20416.67, depreciated_value=39583.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=816.67, remaining_value=19600.0, depreciated_value=40400.0, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=816.67, remaining_value=18783.33, depreciated_value=41216.67, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=816.66, remaining_value=17966.67, depreciated_value=42033.33, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=816.67, remaining_value=17150.0, depreciated_value=42850.0, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=816.67, remaining_value=16333.33, depreciated_value=43666.67, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=816.66, remaining_value=15516.67, depreciated_value=44483.33, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=816.67, remaining_value=14700.0, depreciated_value=45300.0, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=816.67, remaining_value=13883.33, depreciated_value=46116.67, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=816.66, remaining_value=13066.67, depreciated_value=46933.33, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=816.67, remaining_value=12250.0, depreciated_value=47750.0, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=816.67, remaining_value=11433.33, depreciated_value=48566.67, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=816.66, remaining_value=10616.67, depreciated_value=49383.33, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=816.67, remaining_value=9800.0, depreciated_value=50200.0, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=816.67, remaining_value=8983.33, depreciated_value=51016.67, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=816.66, remaining_value=8166.67, depreciated_value=51833.33, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=816.67, remaining_value=7350.0, depreciated_value=52650.0, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=816.67, remaining_value=6533.33, depreciated_value=53466.67, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=816.66, remaining_value=5716.67, depreciated_value=54283.33, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=816.67, remaining_value=4900.0, depreciated_value=55100.0, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=816.67, remaining_value=4083.33, depreciated_value=55916.67, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=816.66, remaining_value=3266.67, depreciated_value=56733.33, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=816.67, remaining_value=2450.0, depreciated_value=57550.0, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=816.67, remaining_value=1633.33, depreciated_value=58366.67, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=816.66, remaining_value=816.67, depreciated_value=59183.33, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=816.67, remaining_value=0.0, depreciated_value=60000.0, state='draft'), + ]) + + def test_degressive_60_months_no_prorata_with_salvage_value_asset(self): + self.car.write({ + 'method_number': 60, + 'method_period': '1', + 'method': 'degressive', + 'method_progress_factor': 0.3, + 'salvage_value': 2000, + }) + self.car.validate() + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 25683.33) + self.assertEqual(self.car.value_residual, 23683.33) + self.assertRecordValues(self.car.depreciation_move_ids, [ + # 2020 + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=1450.0, remaining_value=56550.0, depreciated_value=1450.0, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=1450.0, remaining_value=55100.0, depreciated_value=2900.0, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=1450.0, remaining_value=53650.0, depreciated_value=4350.0, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=1450.0, remaining_value=52200.0, depreciated_value=5800.0, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=1450.0, remaining_value=50750.0, depreciated_value=7250.0, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=1450.0, remaining_value=49300.0, depreciated_value=8700.0, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=1450.0, remaining_value=47850.0, depreciated_value=10150.0, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=1450.0, remaining_value=46400.0, depreciated_value=11600.0, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=1450.0, remaining_value=44950.0, depreciated_value=13050.0, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=1450.0, remaining_value=43500.0, depreciated_value=14500.0, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=1450.0, remaining_value=42050.0, depreciated_value=15950.0, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=1450.0, remaining_value=40600.0, depreciated_value=17400.0, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=1015.0, remaining_value=39585.0, depreciated_value=18415.0, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=1015.0, remaining_value=38570.0, depreciated_value=19430.0, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=1015.0, remaining_value=37555.0, depreciated_value=20445.0, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=1015.0, remaining_value=36540.0, depreciated_value=21460.0, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=1015.0, remaining_value=35525.0, depreciated_value=22475.0, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=1015.0, remaining_value=34510.0, depreciated_value=23490.0, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=1015.0, remaining_value=33495.0, depreciated_value=24505.0, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=1015.0, remaining_value=32480.0, depreciated_value=25520.0, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=1015.0, remaining_value=31465.0, depreciated_value=26535.0, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=1015.0, remaining_value=30450.0, depreciated_value=27550.0, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=1015.0, remaining_value=29435.0, depreciated_value=28565.0, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=1015.0, remaining_value=28420.0, depreciated_value=29580.0, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=789.44, remaining_value=27630.56, depreciated_value=30369.44, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=789.45, remaining_value=26841.11, depreciated_value=31158.89, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=789.44, remaining_value=26051.67, depreciated_value=31948.33, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=789.45, remaining_value=25262.22, depreciated_value=32737.78, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=789.44, remaining_value=24472.78, depreciated_value=33527.22, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=789.45, remaining_value=23683.33, depreciated_value=34316.67, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=789.44, remaining_value=22893.89, depreciated_value=35106.11, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=789.45, remaining_value=22104.44, depreciated_value=35895.56, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=789.44, remaining_value=21315.0, depreciated_value=36685.0, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=789.44, remaining_value=20525.56, depreciated_value=37474.44, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=789.45, remaining_value=19736.11, depreciated_value=38263.89, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=789.44, remaining_value=18946.67, depreciated_value=39053.33, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=789.44, remaining_value=18157.23, depreciated_value=39842.77, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=789.45, remaining_value=17367.78, depreciated_value=40632.22, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=789.44, remaining_value=16578.34, depreciated_value=41421.66, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=789.45, remaining_value=15788.89, depreciated_value=42211.11, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=789.44, remaining_value=14999.45, depreciated_value=43000.55, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=789.45, remaining_value=14210.0, depreciated_value=43790.0, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=789.44, remaining_value=13420.56, depreciated_value=44579.44, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=789.45, remaining_value=12631.11, depreciated_value=45368.89, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=789.44, remaining_value=11841.67, depreciated_value=46158.33, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=789.45, remaining_value=11052.22, depreciated_value=46947.78, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=789.44, remaining_value=10262.78, depreciated_value=47737.22, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=789.45, remaining_value=9473.33, depreciated_value=48526.67, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=789.44, remaining_value=8683.89, depreciated_value=49316.11, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=789.45, remaining_value=7894.44, depreciated_value=50105.56, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=789.44, remaining_value=7105.0, depreciated_value=50895.0, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=789.45, remaining_value=6315.55, depreciated_value=51684.45, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=789.44, remaining_value=5526.11, depreciated_value=52473.89, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=789.45, remaining_value=4736.66, depreciated_value=53263.34, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=789.44, remaining_value=3947.22, depreciated_value=54052.78, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=789.44, remaining_value=3157.78, depreciated_value=54842.22, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=789.45, remaining_value=2368.33, depreciated_value=55631.67, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=789.44, remaining_value=1578.89, depreciated_value=56421.11, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=789.45, remaining_value=789.44, depreciated_value=57210.56, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=789.44, remaining_value=0.0, depreciated_value=58000.0, state='draft'), + ]) + + def test_degressive_5_years_from_beggining_of_year(self): + asset = self.create_asset( + value=100000, + periodicity='yearly', + periods=5, + method='degressive', + method_progress_factor=0.35, + acquisition_date='2022-01-01', + prorata_computation_type='constant_periods' + ) + asset.compute_depreciation_board() + self.assertEqual(asset.state, 'draft') + self.assertEqual(asset.book_value, 100000) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=35000.00, remaining_value=65000.00, depreciated_value=35000.00, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=22750.00, remaining_value=42250.00, depreciated_value=57750.00, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=14787.50, remaining_value=27462.50, depreciated_value=72537.50, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=13731.25, remaining_value=13731.25, depreciated_value=86268.75, state='draft'), + self._get_depreciation_move_values(date='2026-12-31', depreciation_value=13731.25, remaining_value=0.00, depreciated_value=100000.00, state='draft'), + ]) + + def test_degressive_5_years_from_middle_of_year(self): + asset = self.create_asset( + value=100000, + periodicity='yearly', + periods=5, + method='degressive', + method_progress_factor=0.35, + acquisition_date='2022-07-01', + prorata_computation_type='constant_periods' + ) + asset.compute_depreciation_board() + self.assertEqual(asset.state, 'draft') + self.assertEqual(asset.book_value, 100000) + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=17500.00, remaining_value=82500.00, depreciated_value=17500.00, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=28875.00, remaining_value=53625.00, depreciated_value=46375.00, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=18768.75, remaining_value=34856.25, depreciated_value=65143.75, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=13942.50, remaining_value=20913.75, depreciated_value=79086.25, state='draft'), + self._get_depreciation_move_values(date='2026-12-31', depreciation_value=13942.50, remaining_value=6971.25, depreciated_value=93028.75, state='draft'), + self._get_depreciation_move_values(date='2027-12-31', depreciation_value=6971.25, remaining_value=0.00, depreciated_value=100000.00, state='draft'), + ]) + + def test_compute_board_in_mass(self): + book = self.create_asset(value=35, periodicity="monthly", periods=2, method="linear", salvage_value=0) + shelf = self.create_asset(value=250, periodicity="monthly", periods=8, method="linear", salvage_value=0) + screw = self.create_asset(value=1, periodicity="monthly", periods=1, method="linear", salvage_value=0) + + (book + screw).validate() + (book + shelf + screw).compute_depreciation_board() + + self.assertRecordValues(book.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=17.5, remaining_value=17.5, depreciated_value=17.5, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=17.5, remaining_value=0, depreciated_value=35, state='posted'), + ]) + + self.assertRecordValues(shelf.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=31.25, remaining_value=218.75, depreciated_value=31.25, state='draft'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=31.25, remaining_value=187.5, depreciated_value=62.5, state='draft'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=31.25, remaining_value=156.25, depreciated_value=93.75, state='draft'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=31.25, remaining_value=125, depreciated_value=125, state='draft'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=31.25, remaining_value=93.75, depreciated_value=156.25, state='draft'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=31.25, remaining_value=62.5, depreciated_value=187.5, state='draft'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=31.25, remaining_value=31.25, depreciated_value=218.75, state='draft'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=31.25, remaining_value=0, depreciated_value=250, state='draft'), + ]) + + self.assertRecordValues(screw.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=1, remaining_value=0, depreciated_value=1, state='posted'), + ]) + def test_copy_prorata_date(self): + """ Verifies that prorata date and acquisition date are copied when duplicate an asset + For this test, the prorata computation type is set to None. + The idea is of this test is to verify that we do copy prorata date. + """ + old_car_asset = self.create_asset( + value=60000, + periodicity='yearly', + periods=5, + method='linear', + salvage_value=0, + + ) + old_car_asset.validate() + + self.assertEqual(old_car_asset.state, 'open') + self.assertEqual(old_car_asset.book_value, 36000) + self.assertEqual(old_car_asset.acquisition_date, fields.Date.from_string('2020-02-01')) + self.assertEqual(old_car_asset.prorata_date, fields.Date.from_string('2020-01-01')) + self.assertRecordValues(old_car_asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + new_car_asset = old_car_asset.copy() + new_car_asset.original_value = 60000 + new_car_asset.validate() + + self.assertEqual(new_car_asset.state, 'open') + self.assertEqual(new_car_asset.book_value, 36000) + self.assertEqual(new_car_asset.acquisition_date, fields.Date.from_string('2020-02-01')) + self.assertEqual(new_car_asset.prorata_date, fields.Date.from_string('2020-01-01')) + self.assertRecordValues(new_car_asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_change_computation_method_before_lock_date(self): + """Test that we can change the computation method when there are draft moves before the lock date. + """ + self.car.company_id.fiscalyear_lock_date = '2022-06-30' + self.car.compute_depreciation_board() + + self.assertEqual(self.car.state, 'draft') + self.assertEqual(self.car.book_value, 60000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='draft'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + # Change the computation type + self.car.prorata_computation_type = 'constant_periods' + self.car.prorata_date = '2021-01-01' + self.car.compute_depreciation_board() + + self.assertEqual(self.car.state, 'draft') + self.assertEqual(self.car.book_value, 60000) + self.assertRecordValues(self.car.depreciation_move_ids.sorted(lambda m: (m.date, m.id)), [ + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2025-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_post_moves_after_lock_date(self): + """Test that we can change the computation method when there are draft moves before the lock date. + """ + self.car.company_id.fiscalyear_lock_date = '2021-06-30' + self.car.compute_depreciation_board() + + self.assertEqual(self.car.state, 'draft') + self.assertEqual(self.car.book_value, 60000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='draft'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + self.car.validate() + + self.assertEqual(self.car.state, 'open') + self.assertEqual(self.car.book_value, 36000) + self.assertRecordValues(self.car.depreciation_move_ids, [ + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=12000, remaining_value=48000, depreciated_value=12000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=12000, remaining_value=36000, depreciated_value=24000, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=12000, remaining_value=24000, depreciated_value=36000, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=12000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=12000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_assets_one_complete_period(self): + """Test the depreciation move value in case of having just one complete period (year or month) asset.""" + datas = [ + ('monthly', '2022-01-31', 'linear'), + ('monthly', '2022-01-31', 'degressive'), + ('monthly', '2022-01-31', 'degressive_then_linear'), + ('yearly', '2022-12-31', 'linear'), + ('yearly', '2022-12-31', 'degressive'), + ('yearly', '2022-12-31', 'degressive_then_linear'), + ] + for periodicity, end_depreciation_date, method in datas: + with self.subTest(period=periodicity, method=method, end_depreciation_date=end_depreciation_date): + asset = self.create_asset( + value=1000, + periodicity=periodicity, + periods=1, + method=method, + acquisition_date='2022-01-01', + prorata_date='2022-01-01', + prorata_computation_type='constant_periods', + account_depreciation_id=self.company_data['default_account_assets'].id, + ) + + asset.compute_depreciation_board() + self.assertEqual(asset.state, 'draft') + self.assertRecordValues(asset.depreciation_move_ids, [ + self._get_depreciation_move_values(date=end_depreciation_date, depreciation_value=1000.0, remaining_value=0.0, depreciated_value=1000.0, state='draft'), + ]) + asset.validate() + self.assertEqual(asset.state, 'open') diff --git a/dev_odex30_accounting/odex30_account_asset/tests/test_reevaluation_asset.py b/dev_odex30_accounting/odex30_account_asset/tests/test_reevaluation_asset.py new file mode 100644 index 0000000..330dd77 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/tests/test_reevaluation_asset.py @@ -0,0 +1,1662 @@ +from unittest.mock import patch +from odoo.tests.common import tagged, freeze_time +from odoo.addons.odex30_account_asset.tests.common import TestAccountAssetCommon +from odoo import fields + + +@freeze_time('2022-06-30') +@tagged('post_install', '-at_install') +class TestAccountAssetReevaluation(TestAccountAssetCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account_depreciation_expense = cls.company_data['default_account_assets'].copy() + cls.asset_counterpart_account_id = cls.company_data['default_account_expense'].copy() + cls.degressive_asset = cls.create_asset( + value=7200, + periodicity="monthly", + periods=60, + method="degressive", + method_progress_factor=0.35, + acquisition_date="2020-07-01", + prorata_computation_type="constant_periods" + ) + cls.degressive_then_linear_asset = cls.create_asset( + value=7200, + periodicity="monthly", + periods=60, + method="degressive_then_linear", + method_progress_factor=0.35, + acquisition_date="2020-07-01", + prorata_computation_type="constant_periods" + ) + + def test_linear_start_beginning_month_reevaluation_beginning_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-01"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=600, remaining_value=6600, depreciated_value=600, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6000, depreciated_value=1200, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5400, depreciated_value=1800, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=4800, depreciated_value=2400, state='posted'), + # 20 because we have 1 * 600 / 30 (1 day of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-01', depreciation_value=20, remaining_value=4780, depreciated_value=2420, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=580, remaining_value=4200, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3600, depreciated_value=3600, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3000, depreciated_value=4200, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2400, depreciated_value=4800, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=1800, depreciated_value=5400, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1200, depreciated_value=6000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=600, depreciated_value=6600, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_beginning_month_reevaluation_middle_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-15") + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=600, remaining_value=6600, depreciated_value=600, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6000, depreciated_value=1200, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5400, depreciated_value=1800, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=4800, depreciated_value=2400, state='posted'), + # 300 because we have 15 * 600 / 30 (15 days of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=300, remaining_value=4500, depreciated_value=2700, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=300, remaining_value=4200, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3600, depreciated_value=3600, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3000, depreciated_value=4200, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2400, depreciated_value=4800, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=1800, depreciated_value=5400, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1200, depreciated_value=6000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=600, depreciated_value=6600, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_beginning_month_reevaluation_end_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30") + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=600, remaining_value=6600, depreciated_value=600, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6000, depreciated_value=1200, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5400, depreciated_value=1800, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=4800, depreciated_value=2400, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=600, remaining_value=4200, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3600, depreciated_value=3600, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3000, depreciated_value=4200, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2400, depreciated_value=4800, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=1800, depreciated_value=5400, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1200, depreciated_value=6000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=600, depreciated_value=6600, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_middle_month_reevaluation_beginning_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-15", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-01"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=300, remaining_value=6900, depreciated_value=300, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6300, depreciated_value=900, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5700, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5100, depreciated_value=2100, state='posted'), + # 20 because we have 1 * 600 / 30 (1 day of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-01', depreciation_value=20, remaining_value=5080, depreciated_value=2120, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=580, remaining_value=4500, depreciated_value=2700, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3900, depreciated_value=3300, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3300, depreciated_value=3900, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2700, depreciated_value=4500, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2100, depreciated_value=5100, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1500, depreciated_value=5700, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=900, depreciated_value=6300, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=300, depreciated_value=6900, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=300, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_middle_month_reevaluation_middle_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-15", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-15"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=300, remaining_value=6900, depreciated_value=300, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6300, depreciated_value=900, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5700, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5100, depreciated_value=2100, state='posted'), + # 300 because we have 15 * 600 / 30 (15 days of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=300, remaining_value=4800, depreciated_value=2400, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=300, remaining_value=4500, depreciated_value=2700, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3900, depreciated_value=3300, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3300, depreciated_value=3900, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2700, depreciated_value=4500, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2100, depreciated_value=5100, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1500, depreciated_value=5700, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=900, depreciated_value=6300, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=300, depreciated_value=6900, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=300, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_middle_month_reevaluation_end_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-15", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=300, remaining_value=6900, depreciated_value=300, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6300, depreciated_value=900, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5700, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5100, depreciated_value=2100, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=600, remaining_value=4500, depreciated_value=2700, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=3900, depreciated_value=3300, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3300, depreciated_value=3900, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2700, depreciated_value=4500, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2100, depreciated_value=5100, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1500, depreciated_value=5700, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=900, depreciated_value=6300, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=300, depreciated_value=6900, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=300, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_end_month_reevaluation_beginning_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-28", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-01"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=21.43, remaining_value=7178.57, depreciated_value=21.43, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6578.57, depreciated_value=621.43, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5978.57, depreciated_value=1221.43, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5378.57, depreciated_value=1821.43, state='posted'), + # 20 because we have 1 * 600 / 30 (1 day of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-01', depreciation_value=20, remaining_value=5358.57, depreciated_value=1841.43, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=580, remaining_value=4778.57, depreciated_value=2421.43, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=4178.57, depreciated_value=3021.43, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3578.57, depreciated_value=3621.43, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2978.57, depreciated_value=4221.43, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2378.57, depreciated_value=4821.43, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1778.57, depreciated_value=5421.43, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=1178.57, depreciated_value=6021.43, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=578.57, depreciated_value=6621.43, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=578.57, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_end_month_reevaluation_middle_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-28", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-15"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=21.43, remaining_value=7178.57, depreciated_value=21.43, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6578.57, depreciated_value=621.43, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5978.57, depreciated_value=1221.43, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5378.57, depreciated_value=1821.43, state='posted'), + # 300 because we have 15 * 600 / 30 (15 days of a month of 30 days, with 600 per month) + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=300, remaining_value=5078.57, depreciated_value=2121.43, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=300, remaining_value=4778.57, depreciated_value=2421.43, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=4178.57, depreciated_value=3021.43, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3578.57, depreciated_value=3621.43, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2978.57, depreciated_value=4221.43, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2378.57, depreciated_value=4821.43, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1778.57, depreciated_value=5421.43, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=1178.57, depreciated_value=6021.43, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=578.57, depreciated_value=6621.43, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=578.57, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_start_end_month_reevaluation_end_month(self): + asset = self.create_asset(value=7200, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-02-28", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30"), + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=21.43, remaining_value=7178.57, depreciated_value=21.43, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=600, remaining_value=6578.57, depreciated_value=621.43, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=600, remaining_value=5978.57, depreciated_value=1221.43, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=600, remaining_value=5378.57, depreciated_value=1821.43, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=600, remaining_value=4778.57, depreciated_value=2421.43, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=600, remaining_value=4178.57, depreciated_value=3021.43, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=600, remaining_value=3578.57, depreciated_value=3621.43, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=600, remaining_value=2978.57, depreciated_value=4221.43, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=600, remaining_value=2378.57, depreciated_value=4821.43, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=600, remaining_value=1778.57, depreciated_value=5421.43, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=600, remaining_value=1178.57, depreciated_value=6021.43, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=600, remaining_value=578.57, depreciated_value=6621.43, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=578.57, remaining_value=0, depreciated_value=7200, state='draft'), + ]) + + def test_linear_reevaluation_simple_decrease(self): + asset = self.create_asset(value=10000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30"), + 'value_residual': 4000, # -1000 + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=833.33, remaining_value=9166.67, depreciated_value=833.33, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=833.34, remaining_value=8333.33, depreciated_value=1666.67, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=833.33, remaining_value=7500, depreciated_value=2500, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=833.33, remaining_value=6666.67, depreciated_value=3333.33, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=833.34, remaining_value=5833.33, depreciated_value=4166.67, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=833.33, remaining_value=5000, depreciated_value=5000, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=4000, depreciated_value=6000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=666.67, remaining_value=3333.33, depreciated_value=6666.67, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=666.66, remaining_value=2666.67, depreciated_value=7333.33, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=666.67, remaining_value=2000, depreciated_value=8000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=666.67, remaining_value=1333.33, depreciated_value=8666.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=666.66, remaining_value=666.67, depreciated_value=9333.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=666.67, remaining_value=0, depreciated_value=10000, state='draft'), + ]) + + def test_linear_reevaluation_double_decrease(self): + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) - 8500, + }).modify() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30"), + 'value_residual': 18000, # -6000 + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=8500, remaining_value=34000, depreciated_value=26000, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2000, remaining_value=32000, depreciated_value=28000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=4000, remaining_value=28000, depreciated_value=32000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=4000, remaining_value=24000, depreciated_value=36000, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=6000, remaining_value=18000, depreciated_value=42000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=3000, remaining_value=15000, depreciated_value=45000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=3000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=3000, remaining_value=9000, depreciated_value=51000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=3000, remaining_value=6000, depreciated_value=54000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=3000, remaining_value=3000, depreciated_value=57000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=3000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_reevaluation_double_increase(self): + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify_1 = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_1, + 'value_residual': asset._get_residual_value_at_date(date_modify_1) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + date_modify_2 = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_2, + 'value_residual': asset._get_residual_value_at_date(date_modify_2) + 6000, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2500, remaining_value=40000, depreciated_value=20000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=5000, remaining_value=35000, depreciated_value=25000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=5000, remaining_value=30000, depreciated_value=30000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=5000, remaining_value=25000, depreciated_value=35000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=5000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=5000, remaining_value=15000, depreciated_value=45000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=5000, remaining_value=10000, depreciated_value=50000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=5000, remaining_value=5000, depreciated_value=55000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=5000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=500, remaining_value=8000, depreciated_value=500, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=7000, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=6000, depreciated_value=2500, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=3500, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=4500, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=5500, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=6500, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=7500, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=8500, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[1].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=1000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=2000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=3000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=4000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=5000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=6000, state='draft'), + ]) + + def test_linear_reevaluation_decrease_then_increase(self): + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify_1 = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_1, + 'value_residual': asset._get_residual_value_at_date(date_modify_1) - 8500, + }).modify() + + date_modify_2 = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_2, + 'value_residual': asset._get_residual_value_at_date(date_modify_2) + 6000, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=8500, remaining_value=34000, depreciated_value=26000, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2000, remaining_value=32000, depreciated_value=28000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=4000, remaining_value=28000, depreciated_value=32000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=4000, remaining_value=24000, depreciated_value=36000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=4000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=4000, remaining_value=16000, depreciated_value=44000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=4000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=4000, remaining_value=8000, depreciated_value=52000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=4000, remaining_value=4000, depreciated_value=56000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=4000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=1000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=2000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=3000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=4000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=5000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=6000, state='draft'), + ]) + + def test_linear_reevaluation_increase_then_decrease_in_future(self): + asset = self.create_asset(value=10000, periodicity="yearly", periods=5, method="linear", acquisition_date="2018-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify_1 = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_1, + 'value_residual': asset._get_residual_value_at_date(date_modify_1) + 1000, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + date_modify_2 = fields.Date.to_date("2022-09-30") # This is 3 month in the future + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_2, + 'value_residual': asset._get_residual_value_at_date(date_modify_2) - 200, + 'method_period': '1', # to reflect the change on the child, we go in monthly + 'method_number': 60, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2018-12-31', depreciation_value=2000, remaining_value=8000, depreciated_value=2000, state='posted'), + self._get_depreciation_move_values(date='2019-12-31', depreciation_value=2000, remaining_value=6000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=2000, remaining_value=4000, depreciated_value=6000, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=2000, remaining_value=2000, depreciated_value=8000, state='posted'), + # move before increase + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=1000, depreciated_value=9000, state='posted'), + # move before decrease + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=500, remaining_value=500, depreciated_value=9500, state='draft'), + # decrease move + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=200, remaining_value=300, depreciated_value=9700, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=100, remaining_value=200, depreciated_value=9800, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=100, remaining_value=100, depreciated_value=9900, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=100, remaining_value=0, depreciated_value=10000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + # move before switch to monthly + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=500, remaining_value=500, depreciated_value=500, state='draft'), + + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=166.67, remaining_value=333.33, depreciated_value=666.67, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=166.66, remaining_value=166.67, depreciated_value=833.33, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=166.67, remaining_value=0, depreciated_value=1000, state='draft'), + ]) + + def test_linear_reevaluation_decrease_then_increase_with_lock_date(self): + self.company_data['company'].fiscalyear_lock_date = fields.Date.to_date('2022-03-01') + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify_1 = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_1, + 'value_residual': asset._get_residual_value_at_date(date_modify_1) - 8500, + }).modify() + + self.company_data['company'].fiscalyear_lock_date = fields.Date.to_date('2022-05-01') + + date_modify_2 = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_2, + 'value_residual': asset._get_residual_value_at_date(date_modify_2) + 6000, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=8500, remaining_value=34000, depreciated_value=26000, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2000, remaining_value=32000, depreciated_value=28000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=4000, remaining_value=28000, depreciated_value=32000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=4000, remaining_value=24000, depreciated_value=36000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=4000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=4000, remaining_value=16000, depreciated_value=44000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=4000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=4000, remaining_value=8000, depreciated_value=52000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=4000, remaining_value=4000, depreciated_value=56000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=4000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=1000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=2000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=3000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=4000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=5000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=6000, state='draft'), + ]) + + def test_linear_reevaluation_increase_then_decrease(self): + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify_1 = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_1, + 'value_residual': asset._get_residual_value_at_date(date_modify_1) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + date_modify_2 = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify_2, + 'value_residual': asset._get_residual_value_at_date(date_modify_2) - 6000, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2500, remaining_value=40000, depreciated_value=20000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=5000, remaining_value=35000, depreciated_value=25000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=5000, remaining_value=30000, depreciated_value=30000, state='posted'), + + # decrease move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=6000, remaining_value=24000, depreciated_value=36000, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=4000, remaining_value=20000, depreciated_value=40000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=4000, remaining_value=16000, depreciated_value=44000, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=4000, remaining_value=12000, depreciated_value=48000, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=4000, remaining_value=8000, depreciated_value=52000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=4000, remaining_value=4000, depreciated_value=56000, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=4000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=500, remaining_value=8000, depreciated_value=500, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=7000, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=6000, depreciated_value=2500, state='posted'), + + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=5000, depreciated_value=3500, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=1000, remaining_value=4000, depreciated_value=4500, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=1000, remaining_value=3000, depreciated_value=5500, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=1000, remaining_value=2000, depreciated_value=6500, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=1000, remaining_value=1000, depreciated_value=7500, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=1000, remaining_value=0, depreciated_value=8500, state='draft'), + ]) + + def test_linear_reevaluation_decrease_then_disposal(self): + asset = self.create_asset(value=60000, periodicity="monthly", periods=12, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + self.loss_account_id = self.company_data['default_account_expense'].copy().id + + date_modify = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) - 8500, + }).modify() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date("2022-06-30"), + 'modify_action': 'dispose', + 'loss_account_id': self.loss_account_id, + }).sell_dispose() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=5000, remaining_value=55000, depreciated_value=5000, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=5000, remaining_value=50000, depreciated_value=10000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=5000, remaining_value=45000, depreciated_value=15000, state='posted'), + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=2500, remaining_value=42500, depreciated_value=17500, state='posted'), + # decrease move + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=8500, remaining_value=34000, depreciated_value=26000, state='posted'), + + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=2000, remaining_value=32000, depreciated_value=28000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=4000, remaining_value=28000, depreciated_value=32000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=4000, remaining_value=24000, depreciated_value=36000, state='posted'), + + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=24000, remaining_value=0, depreciated_value=60000, state='draft'), + ]) + + def test_linear_reevaluation_increase_then_disposal(self): + asset = self.create_asset(value=36000, periodicity="yearly", periods=3, method="linear", acquisition_date="2022-01-01", prorata_computation_type="constant_periods") + asset.validate() + self.loss_account_id = self.company_data['default_account_expense'].copy().id + self.asset_counterpart_account_id = self.company_data['default_account_expense'].copy().id + + date_modify = fields.Date.to_date("2022-04-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id, + }).modify() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date("2022-06-30"), + 'modify_action': 'dispose', + 'loss_account_id': self.loss_account_id, + }).sell_dispose() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-04-15', depreciation_value=3500, remaining_value=32500, depreciated_value=3500, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=2500, remaining_value=30000, depreciated_value=6000, state='posted'), + # disposal move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=30000, remaining_value=0, depreciated_value=36000, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + # 653.85 = 8500 * (2.5 months * 30) / (32.5 months * 30) + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=653.85, remaining_value=7846.15, depreciated_value=653.85, state='posted'), + # disposal move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=7846.15, remaining_value=0, depreciated_value=8500, state='draft'), + ]) + + def test_linear_reevaluation_increase_constant_periods(self): + asset = self.create_asset(value=1200, periodicity="monthly", periods=12, method="linear", acquisition_date="2021-10-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify = fields.Date.to_date("2022-01-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'modify_action': 'modify', + 'value_residual': asset._get_residual_value_at_date(date_modify) + 2100, + 'account_asset_counterpart_id': self.company_data['default_account_revenue'].copy().id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=100, remaining_value=1100, depreciated_value=100, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=100, remaining_value=1000, depreciated_value=200, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=100, remaining_value=900, depreciated_value=300, state='posted'), + self._get_depreciation_move_values(date='2022-01-15', depreciation_value=48.39, remaining_value=851.61, depreciated_value=348.39, state='posted'), + + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=51.61, remaining_value=800, depreciated_value=400, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=100, remaining_value=700, depreciated_value=500, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=100, remaining_value=600, depreciated_value=600, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=100, remaining_value=500, depreciated_value=700, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=100, remaining_value=400, depreciated_value=800, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=100, remaining_value=300, depreciated_value=900, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=100, remaining_value=200, depreciated_value=1000, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=100, remaining_value=100, depreciated_value=1100, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=100, remaining_value=0, depreciated_value=1200, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=127.27, remaining_value=1972.73, depreciated_value=127.27, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=246.59, remaining_value=1726.14, depreciated_value=373.86, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=246.59, remaining_value=1479.55, depreciated_value=620.45, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=246.60, remaining_value=1232.95, depreciated_value=867.05, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=246.59, remaining_value=986.36, depreciated_value=1113.64, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=246.59, remaining_value=739.77, depreciated_value=1360.23, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=246.59, remaining_value=493.18, depreciated_value=1606.82, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=246.59, remaining_value=246.59, depreciated_value=1853.41, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=246.59, remaining_value=0, depreciated_value=2100, state='draft'), + ]) + + def test_linear_reevaluation_increase_daily_computation(self): + asset = self.create_asset(value=1200, periodicity="monthly", periods=12, method="linear", acquisition_date="2021-10-01", prorata_computation_type="daily_computation") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-01-15"), + 'modify_action': 'modify', + 'value_residual': 2945.75, + 'account_asset_counterpart_id': self.company_data['default_account_revenue'].copy().id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=101.92, remaining_value=1098.08, depreciated_value=101.92, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=98.63, remaining_value=999.45, depreciated_value=200.55, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=101.92, remaining_value=897.53, depreciated_value=302.47, state='posted'), + self._get_depreciation_move_values(date='2022-01-15', depreciation_value=49.31, remaining_value=848.22, depreciated_value=351.78, state='posted'), + + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=52.60, remaining_value=795.62, depreciated_value=404.38, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=92.06, remaining_value=703.56, depreciated_value=496.44, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=101.92, remaining_value=601.64, depreciated_value=598.36, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=98.63, remaining_value=503.01, depreciated_value=696.99, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=101.91, remaining_value=401.10, depreciated_value=798.90, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=98.63, remaining_value=302.47, depreciated_value=897.53, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=101.92, remaining_value=200.55, depreciated_value=999.45, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=101.92, remaining_value=98.63, depreciated_value=1101.37, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=98.63, remaining_value=0, depreciated_value=1200, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=130.08, remaining_value=1967.45, depreciated_value=130.08, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=227.64, remaining_value=1739.81, depreciated_value=357.72, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=252.03, remaining_value=1487.78, depreciated_value=609.75, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=243.90, remaining_value=1243.88, depreciated_value=853.65, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=252.02, remaining_value=991.86, depreciated_value=1105.67, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=243.90, remaining_value=747.96, depreciated_value=1349.57, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=252.03, remaining_value=495.93, depreciated_value=1601.60, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=252.03, remaining_value=243.90, depreciated_value=1853.63, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=243.90, remaining_value=0, depreciated_value=2097.53, state='draft'), + ]) + + def test_linear_reevaluation_increase_amount_and_length(self): + """ After 5 months, extend the lifetime by 3 month and the amount by 200 """ + asset = self.create_asset(value=1200, periodicity="monthly", periods=10, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'method_number': 10 + 3, + 'date': date_modify, + 'modify_action': 'modify', + 'value_residual': asset._get_residual_value_at_date(date_modify) + 200, + 'account_asset_counterpart_id': self.company_data['default_account_revenue'].copy().id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=120, remaining_value=1080, depreciated_value=120, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=120, remaining_value=960, depreciated_value=240, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=120, remaining_value=840, depreciated_value=360, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=120, remaining_value=720, depreciated_value=480, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=120, remaining_value=600, depreciated_value=600, state='posted'), + # After the reeval, we divide the amount to depreciate left on the amount left + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=75, remaining_value=525, depreciated_value=675, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=75, remaining_value=450, depreciated_value=750, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=75, remaining_value=375, depreciated_value=825, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=75, remaining_value=300, depreciated_value=900, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=75, remaining_value=225, depreciated_value=975, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=75, remaining_value=150, depreciated_value=1050, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=75, remaining_value=75, depreciated_value=1125, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=75, remaining_value=0, depreciated_value=1200, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=25, remaining_value=175, depreciated_value=25, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=25, remaining_value=150, depreciated_value=50, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=25, remaining_value=125, depreciated_value=75, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=25, remaining_value=100, depreciated_value=100, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=25, remaining_value=75, depreciated_value=125, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=25, remaining_value=50, depreciated_value=150, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=25, remaining_value=25, depreciated_value=175, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=25, remaining_value=0, depreciated_value=200, state='draft'), + ]) + + def test_linear_reevaluation_decrease_amount_and_increase_length(self): + """ After 5 months, extend the lifetime by 3 month and reduce the amount by 200 """ + asset = self.create_asset(value=1200, periodicity="monthly", periods=10, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + date_modify = fields.Date.to_date("2022-06-30") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'method_number': 10 + 3, + 'date': date_modify, + 'modify_action': 'modify', + 'value_residual': asset._get_residual_value_at_date(date_modify) - 200, + 'account_asset_counterpart_id': self.company_data['default_account_revenue'].copy().id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=120, remaining_value=1080, depreciated_value=120, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=120, remaining_value=960, depreciated_value=240, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=120, remaining_value=840, depreciated_value=360, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=120, remaining_value=720, depreciated_value=480, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=120, remaining_value=600, depreciated_value=600, state='posted'), + # Decrease Move + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=200, remaining_value=400, depreciated_value=800, state='posted'), + # After the reeval, we divide the amount to depreciate left on the amount left + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=50, remaining_value=350, depreciated_value=850, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=50, remaining_value=300, depreciated_value=900, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=50, remaining_value=250, depreciated_value=950, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=50, remaining_value=200, depreciated_value=1000, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=50, remaining_value=150, depreciated_value=1050, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=50, remaining_value=100, depreciated_value=1100, state='draft'), + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=50, remaining_value=50, depreciated_value=1150, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=50, remaining_value=0, depreciated_value=1200, state='draft'), + ]) + + def test_monthly_degressive_start_beginning_month_increase_middle_month_on_degressive_part(self): + asset = self.degressive_asset + asset.validate() + + date_modify = fields.Date.to_date("2022-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=173.25, remaining_value=4900.50, depreciated_value=2299.50, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=173.25, remaining_value=4727.25, depreciated_value=2472.75, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=173.25, remaining_value=4554.00, depreciated_value=2646.00, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=173.25, remaining_value=4380.75, depreciated_value=2819.25, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=173.25, remaining_value=4207.50, depreciated_value=2992.50, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=173.25, remaining_value=4034.25, depreciated_value=3165.75, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=173.25, remaining_value=3861.00, depreciated_value=3339.00, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=112.61, remaining_value=3748.39, depreciated_value=3451.61, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=112.61, remaining_value=3635.78, depreciated_value=3564.22, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=112.62, remaining_value=3523.16, depreciated_value=3676.84, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=112.61, remaining_value=3410.55, depreciated_value=3789.45, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=112.61, remaining_value=3297.94, depreciated_value=3902.06, state='posted'), + # Increase + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=56.31, remaining_value=3241.63, depreciated_value=3958.37, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=47.27, remaining_value=3194.36, depreciated_value=4005.64, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=94.55, remaining_value=3099.81, depreciated_value=4100.19, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=94.55, remaining_value=3005.26, depreciated_value=4194.74, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=94.55, remaining_value=2910.71, depreciated_value=4289.29, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=94.54, remaining_value=2816.17, depreciated_value=4383.83, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=94.55, remaining_value=2721.62, depreciated_value=4478.38, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=94.55, remaining_value=2627.07, depreciated_value=4572.93, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=87.57, remaining_value=2539.50, depreciated_value=4660.50, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=87.57, remaining_value=2451.93, depreciated_value=4748.07, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=87.57, remaining_value=2364.36, depreciated_value=4835.64, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=87.57, remaining_value=2276.79, depreciated_value=4923.21, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=87.56, remaining_value=2189.23, depreciated_value=5010.77, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=87.57, remaining_value=2101.66, depreciated_value=5098.34, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=87.57, remaining_value=2014.09, depreciated_value=5185.91, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=87.57, remaining_value=1926.52, depreciated_value=5273.48, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=87.57, remaining_value=1838.95, depreciated_value=5361.05, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=87.57, remaining_value=1751.38, depreciated_value=5448.62, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=87.57, remaining_value=1663.81, depreciated_value=5536.19, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=87.57, remaining_value=1576.24, depreciated_value=5623.76, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=87.57, remaining_value=1488.67, depreciated_value=5711.33, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=87.57, remaining_value=1401.10, depreciated_value=5798.90, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=87.57, remaining_value=1313.53, depreciated_value=5886.47, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=87.57, remaining_value=1225.96, depreciated_value=5974.04, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=87.56, remaining_value=1138.40, depreciated_value=6061.60, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=87.57, remaining_value=1050.83, depreciated_value=6149.17, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=87.57, remaining_value=963.26, depreciated_value=6236.74, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=87.57, remaining_value=875.69, depreciated_value=6324.31, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=87.57, remaining_value=788.12, depreciated_value=6411.88, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=87.57, remaining_value=700.55, depreciated_value=6499.45, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=87.57, remaining_value=612.98, depreciated_value=6587.02, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=87.57, remaining_value=525.41, depreciated_value=6674.59, state='draft'), + # 2025 + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=87.57, remaining_value=437.84, depreciated_value=6762.16, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=87.57, remaining_value=350.27, depreciated_value=6849.73, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=87.56, remaining_value=262.71, depreciated_value=6937.29, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=87.57, remaining_value=175.14, depreciated_value=7024.86, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=87.57, remaining_value=87.57, depreciated_value=7112.43, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=87.57, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=123.96, remaining_value=8376.04, depreciated_value=123.96, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=247.92, remaining_value=8128.12, depreciated_value=371.88, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=247.91, remaining_value=7880.21, depreciated_value=619.79, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=247.92, remaining_value=7632.29, depreciated_value=867.71, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=247.92, remaining_value=7384.37, depreciated_value=1115.63, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=247.91, remaining_value=7136.46, depreciated_value=1363.54, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=247.92, remaining_value=6888.54, depreciated_value=1611.46, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=229.62, remaining_value=6658.92, depreciated_value=1841.08, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=229.62, remaining_value=6429.30, depreciated_value=2070.70, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=229.61, remaining_value=6199.69, depreciated_value=2300.31, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=229.62, remaining_value=5970.07, depreciated_value=2529.93, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=229.62, remaining_value=5740.45, depreciated_value=2759.55, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=229.62, remaining_value=5510.83, depreciated_value=2989.17, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=229.62, remaining_value=5281.21, depreciated_value=3218.79, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=229.61, remaining_value=5051.60, depreciated_value=3448.40, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=229.62, remaining_value=4821.98, depreciated_value=3678.02, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=229.62, remaining_value=4592.36, depreciated_value=3907.64, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=229.62, remaining_value=4362.74, depreciated_value=4137.26, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=229.62, remaining_value=4133.12, depreciated_value=4366.88, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=229.62, remaining_value=3903.50, depreciated_value=4596.50, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=229.62, remaining_value=3673.88, depreciated_value=4826.12, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=229.61, remaining_value=3444.27, depreciated_value=5055.73, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=229.62, remaining_value=3214.65, depreciated_value=5285.35, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=229.62, remaining_value=2985.03, depreciated_value=5514.97, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=229.62, remaining_value=2755.41, depreciated_value=5744.59, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=229.61, remaining_value=2525.80, depreciated_value=5974.20, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=229.62, remaining_value=2296.18, depreciated_value=6203.82, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=229.62, remaining_value=2066.56, depreciated_value=6433.44, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=229.62, remaining_value=1836.94, depreciated_value=6663.06, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=229.62, remaining_value=1607.32, depreciated_value=6892.68, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=229.61, remaining_value=1377.71, depreciated_value=7122.29, state='draft'), + # 2025 + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=229.62, remaining_value=1148.09, depreciated_value=7351.91, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=229.62, remaining_value=918.47, depreciated_value=7581.53, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=229.62, remaining_value=688.85, depreciated_value=7811.15, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=229.61, remaining_value=459.24, depreciated_value=8040.76, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=229.62, remaining_value=229.62, depreciated_value=8270.38, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=229.62, remaining_value=0.00, depreciated_value=8500.00, state='draft'), + ]) + + def test_monthly_degressive_start_beginning_month_increase_middle_month_on_linear_part(self): + asset = self.degressive_asset + asset.write({'acquisition_date': '2019-07-01'}) + asset.validate() + + date_modify = fields.Date.to_date("2022-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2019-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2019-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2019-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2019-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2019-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2019-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2020 + self._get_depreciation_move_values(date='2020-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2020-02-29', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2020-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2020-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2020-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + self._get_depreciation_move_values(date='2020-06-30', depreciation_value=173.25, remaining_value=4900.50, depreciated_value=2299.50, state='posted'), + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=173.25, remaining_value=4727.25, depreciated_value=2472.75, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=173.25, remaining_value=4554.00, depreciated_value=2646.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=173.25, remaining_value=4380.75, depreciated_value=2819.25, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=173.25, remaining_value=4207.50, depreciated_value=2992.50, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=173.25, remaining_value=4034.25, depreciated_value=3165.75, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=173.25, remaining_value=3861.00, depreciated_value=3339.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=112.61, remaining_value=3748.39, depreciated_value=3451.61, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=112.61, remaining_value=3635.78, depreciated_value=3564.22, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=112.62, remaining_value=3523.16, depreciated_value=3676.84, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=112.61, remaining_value=3410.55, depreciated_value=3789.45, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=112.61, remaining_value=3297.94, depreciated_value=3902.06, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=112.61, remaining_value=3185.33, depreciated_value=4014.67, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=112.62, remaining_value=3072.71, depreciated_value=4127.29, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=112.61, remaining_value=2960.10, depreciated_value=4239.90, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=112.61, remaining_value=2847.49, depreciated_value=4352.51, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=112.61, remaining_value=2734.88, depreciated_value=4465.12, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=112.62, remaining_value=2622.26, depreciated_value=4577.74, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=112.61, remaining_value=2509.65, depreciated_value=4690.35, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=83.66, remaining_value=2425.99, depreciated_value=4774.01, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=83.65, remaining_value=2342.34, depreciated_value=4857.66, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=83.65, remaining_value=2258.69, depreciated_value=4941.31, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=83.66, remaining_value=2175.03, depreciated_value=5024.97, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=83.66, remaining_value=2091.37, depreciated_value=5108.63, state='posted'), + # Increase + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=41.82, remaining_value=2049.55, depreciated_value=5150.45, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=41.83, remaining_value=2007.72, depreciated_value=5192.28, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=83.65, remaining_value=1924.07, depreciated_value=5275.93, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=83.66, remaining_value=1840.41, depreciated_value=5359.59, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=83.65, remaining_value=1756.76, depreciated_value=5443.24, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=83.66, remaining_value=1673.10, depreciated_value=5526.90, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=83.65, remaining_value=1589.45, depreciated_value=5610.55, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=83.66, remaining_value=1505.79, depreciated_value=5694.21, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=83.66, remaining_value=1422.13, depreciated_value=5777.87, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=83.65, remaining_value=1338.48, depreciated_value=5861.52, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=83.65, remaining_value=1254.83, depreciated_value=5945.17, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=83.66, remaining_value=1171.17, depreciated_value=6028.83, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=83.65, remaining_value=1087.52, depreciated_value=6112.48, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=83.66, remaining_value=1003.86, depreciated_value=6196.14, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=83.65, remaining_value=920.21, depreciated_value=6279.79, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=83.66, remaining_value=836.55, depreciated_value=6363.45, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=83.65, remaining_value=752.90, depreciated_value=6447.10, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=83.66, remaining_value=669.24, depreciated_value=6530.76, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=83.65, remaining_value=585.59, depreciated_value=6614.41, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=83.66, remaining_value=501.93, depreciated_value=6698.07, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=83.65, remaining_value=418.28, depreciated_value=6781.72, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=83.66, remaining_value=334.62, depreciated_value=6865.38, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=83.65, remaining_value=250.97, depreciated_value=6949.03, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=83.66, remaining_value=167.31, depreciated_value=7032.69, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=83.65, remaining_value=83.66, depreciated_value=7116.34, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=83.66, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=173.47, remaining_value=8326.53, depreciated_value=173.47, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=346.94, remaining_value=7979.59, depreciated_value=520.41, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=346.94, remaining_value=7632.65, depreciated_value=867.35, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=346.94, remaining_value=7285.71, depreciated_value=1214.29, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=346.93, remaining_value=6938.78, depreciated_value=1561.22, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=346.94, remaining_value=6591.84, depreciated_value=1908.16, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=346.94, remaining_value=6244.90, depreciated_value=2255.10, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=346.94, remaining_value=5897.96, depreciated_value=2602.04, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=346.94, remaining_value=5551.02, depreciated_value=2948.98, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=346.94, remaining_value=5204.08, depreciated_value=3295.92, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=346.94, remaining_value=4857.14, depreciated_value=3642.86, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=346.93, remaining_value=4510.21, depreciated_value=3989.79, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=346.94, remaining_value=4163.27, depreciated_value=4336.73, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=346.94, remaining_value=3816.33, depreciated_value=4683.67, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=346.94, remaining_value=3469.39, depreciated_value=5030.61, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=346.94, remaining_value=3122.45, depreciated_value=5377.55, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=346.94, remaining_value=2775.51, depreciated_value=5724.49, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=346.94, remaining_value=2428.57, depreciated_value=6071.43, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=346.94, remaining_value=2081.63, depreciated_value=6418.37, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=346.94, remaining_value=1734.69, depreciated_value=6765.31, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=346.94, remaining_value=1387.75, depreciated_value=7112.25, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=346.94, remaining_value=1040.81, depreciated_value=7459.19, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=346.93, remaining_value=693.88, depreciated_value=7806.12, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=346.94, remaining_value=346.94, depreciated_value=8153.06, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=346.94, remaining_value=0.00, depreciated_value=8500.00, state='draft'), + ]) + + def test_monthly_degressive_start_beginning_month_decrease_middle_month(self): + asset = self.degressive_asset + asset.validate() + + date_modify = fields.Date.to_date("2022-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) - 500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=173.25, remaining_value=4900.50, depreciated_value=2299.50, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=173.25, remaining_value=4727.25, depreciated_value=2472.75, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=173.25, remaining_value=4554.00, depreciated_value=2646.00, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=173.25, remaining_value=4380.75, depreciated_value=2819.25, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=173.25, remaining_value=4207.50, depreciated_value=2992.50, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=173.25, remaining_value=4034.25, depreciated_value=3165.75, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=173.25, remaining_value=3861.00, depreciated_value=3339.00, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=112.61, remaining_value=3748.39, depreciated_value=3451.61, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=112.61, remaining_value=3635.78, depreciated_value=3564.22, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=112.62, remaining_value=3523.16, depreciated_value=3676.84, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=112.61, remaining_value=3410.55, depreciated_value=3789.45, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=112.61, remaining_value=3297.94, depreciated_value=3902.06, state='posted'), + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=56.31, remaining_value=3241.63, depreciated_value=3958.37, state='posted'), + # Decrease + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=500.00, remaining_value=2741.63, depreciated_value=4458.37, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=39.98, remaining_value=2701.65, depreciated_value=4498.35, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=79.97, remaining_value=2621.68, depreciated_value=4578.32, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=79.96, remaining_value=2541.72, depreciated_value=4658.28, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=79.96, remaining_value=2461.76, depreciated_value=4738.24, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=79.97, remaining_value=2381.79, depreciated_value=4818.21, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=79.96, remaining_value=2301.83, depreciated_value=4898.17, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=79.97, remaining_value=2221.86, depreciated_value=4978.14, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=74.06, remaining_value=2147.80, depreciated_value=5052.20, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=74.06, remaining_value=2073.74, depreciated_value=5126.26, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=74.07, remaining_value=1999.67, depreciated_value=5200.33, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=74.06, remaining_value=1925.61, depreciated_value=5274.39, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=74.06, remaining_value=1851.55, depreciated_value=5348.45, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=74.06, remaining_value=1777.49, depreciated_value=5422.51, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=74.06, remaining_value=1703.43, depreciated_value=5496.57, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=74.07, remaining_value=1629.36, depreciated_value=5570.64, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=74.06, remaining_value=1555.30, depreciated_value=5644.70, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=74.06, remaining_value=1481.24, depreciated_value=5718.76, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=74.06, remaining_value=1407.18, depreciated_value=5792.82, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=74.06, remaining_value=1333.12, depreciated_value=5866.88, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=74.06, remaining_value=1259.06, depreciated_value=5940.94, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=74.06, remaining_value=1185.00, depreciated_value=6015.00, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=74.07, remaining_value=1110.93, depreciated_value=6089.07, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=74.06, remaining_value=1036.87, depreciated_value=6163.13, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=74.06, remaining_value=962.81, depreciated_value=6237.19, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=74.06, remaining_value=888.75, depreciated_value=6311.25, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=74.07, remaining_value=814.68, depreciated_value=6385.32, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=74.06, remaining_value=740.62, depreciated_value=6459.38, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=74.06, remaining_value=666.56, depreciated_value=6533.44, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=74.06, remaining_value=592.50, depreciated_value=6607.50, state='draft'), + self._get_depreciation_move_values(date='2024-11-30', depreciation_value=74.06, remaining_value=518.44, depreciated_value=6681.56, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=74.07, remaining_value=444.37, depreciated_value=6755.63, state='draft'), + # 2025 + self._get_depreciation_move_values(date='2025-01-31', depreciation_value=74.06, remaining_value=370.31, depreciated_value=6829.69, state='draft'), + self._get_depreciation_move_values(date='2025-02-28', depreciation_value=74.06, remaining_value=296.25, depreciated_value=6903.75, state='draft'), + self._get_depreciation_move_values(date='2025-03-31', depreciation_value=74.07, remaining_value=222.18, depreciated_value=6977.82, state='draft'), + self._get_depreciation_move_values(date='2025-04-30', depreciation_value=74.06, remaining_value=148.12, depreciated_value=7051.88, state='draft'), + self._get_depreciation_move_values(date='2025-05-31', depreciation_value=74.06, remaining_value=74.06, depreciated_value=7125.94, state='draft'), + self._get_depreciation_move_values(date='2025-06-30', depreciation_value=74.06, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + def test_monthly_degressive_then_linear_start_beginning_month_increase_middle_month_on_degressive_part(self): + asset = self.degressive_then_linear_asset + asset.validate() + + date_modify = fields.Date.to_date("2021-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + # Increase + self._get_depreciation_move_values(date='2021-06-15', depreciation_value=86.63, remaining_value=4987.12, depreciated_value=2212.88, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=72.73, remaining_value=4914.39, depreciated_value=2285.61, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=145.46, remaining_value=4768.93, depreciated_value=2431.07, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=145.45, remaining_value=4623.48, depreciated_value=2576.52, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=145.46, remaining_value=4478.02, depreciated_value=2721.98, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=145.46, remaining_value=4332.56, depreciated_value=2867.44, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=145.46, remaining_value=4187.10, depreciated_value=3012.90, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=145.45, remaining_value=4041.65, depreciated_value=3158.35, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=120.00, remaining_value=3921.65, depreciated_value=3278.35, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=120.00, remaining_value=3801.65, depreciated_value=3398.35, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=120.00, remaining_value=3681.65, depreciated_value=3518.35, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=120.00, remaining_value=3561.65, depreciated_value=3638.35, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=120.00, remaining_value=3441.65, depreciated_value=3758.35, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=120.00, remaining_value=3321.65, depreciated_value=3878.35, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=120.00, remaining_value=3201.65, depreciated_value=3998.35, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=120.00, remaining_value=3081.65, depreciated_value=4118.35, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=120.00, remaining_value=2961.65, depreciated_value=4238.35, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=120.00, remaining_value=2841.65, depreciated_value=4358.35, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=120.00, remaining_value=2721.65, depreciated_value=4478.35, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=120.00, remaining_value=2601.65, depreciated_value=4598.35, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=120.00, remaining_value=2481.65, depreciated_value=4718.35, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=120.00, remaining_value=2361.65, depreciated_value=4838.35, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=120.00, remaining_value=2241.65, depreciated_value=4958.35, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=120.00, remaining_value=2121.65, depreciated_value=5078.35, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=120.00, remaining_value=2001.65, depreciated_value=5198.35, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=120.00, remaining_value=1881.65, depreciated_value=5318.35, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=120.00, remaining_value=1761.65, depreciated_value=5438.35, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=120.00, remaining_value=1641.65, depreciated_value=5558.35, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=120.00, remaining_value=1521.65, depreciated_value=5678.35, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=120.00, remaining_value=1401.65, depreciated_value=5798.35, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=120.00, remaining_value=1281.65, depreciated_value=5918.35, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=120.00, remaining_value=1161.65, depreciated_value=6038.35, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=120.00, remaining_value=1041.65, depreciated_value=6158.35, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=120.00, remaining_value=921.65, depreciated_value=6278.35, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=120.00, remaining_value=801.65, depreciated_value=6398.35, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=120.00, remaining_value=681.65, depreciated_value=6518.35, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=120.00, remaining_value=561.65, depreciated_value=6638.35, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=120.00, remaining_value=441.65, depreciated_value=6758.35, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=120.00, remaining_value=321.65, depreciated_value=6878.35, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=120.00, remaining_value=201.65, depreciated_value=6998.35, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=120.00, remaining_value=81.65, depreciated_value=7118.35, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=81.65, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=123.96, remaining_value=8376.04, depreciated_value=123.96, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=247.92, remaining_value=8128.12, depreciated_value=371.88, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=247.91, remaining_value=7880.21, depreciated_value=619.79, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=247.92, remaining_value=7632.29, depreciated_value=867.71, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=247.92, remaining_value=7384.37, depreciated_value=1115.63, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=247.91, remaining_value=7136.46, depreciated_value=1363.54, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=247.92, remaining_value=6888.54, depreciated_value=1611.46, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=204.52, remaining_value=6684.02, depreciated_value=1815.98, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=204.52, remaining_value=6479.50, depreciated_value=2020.50, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=204.53, remaining_value=6274.97, depreciated_value=2225.03, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=204.52, remaining_value=6070.45, depreciated_value=2429.55, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=204.52, remaining_value=5865.93, depreciated_value=2634.07, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=204.53, remaining_value=5661.40, depreciated_value=2838.60, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=204.52, remaining_value=5456.88, depreciated_value=3043.12, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=204.52, remaining_value=5252.36, depreciated_value=3247.64, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=204.53, remaining_value=5047.83, depreciated_value=3452.17, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=204.52, remaining_value=4843.31, depreciated_value=3656.69, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=204.52, remaining_value=4638.79, depreciated_value=3861.21, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=204.52, remaining_value=4434.27, depreciated_value=4065.73, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=204.53, remaining_value=4229.74, depreciated_value=4270.26, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=204.52, remaining_value=4025.22, depreciated_value=4474.78, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=204.52, remaining_value=3820.70, depreciated_value=4679.30, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=204.53, remaining_value=3616.17, depreciated_value=4883.83, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=204.52, remaining_value=3411.65, depreciated_value=5088.35, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=204.52, remaining_value=3207.13, depreciated_value=5292.87, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=204.52, remaining_value=3002.61, depreciated_value=5497.39, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=204.53, remaining_value=2798.08, depreciated_value=5701.92, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=204.52, remaining_value=2593.56, depreciated_value=5906.44, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=204.52, remaining_value=2389.04, depreciated_value=6110.96, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=204.53, remaining_value=2184.51, depreciated_value=6315.49, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=204.52, remaining_value=1979.99, depreciated_value=6520.01, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=204.52, remaining_value=1775.47, depreciated_value=6724.53, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=204.52, remaining_value=1570.95, depreciated_value=6929.05, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=204.53, remaining_value=1366.42, depreciated_value=7133.58, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=204.52, remaining_value=1161.90, depreciated_value=7338.10, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=204.52, remaining_value=957.38, depreciated_value=7542.62, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=204.53, remaining_value=752.85, depreciated_value=7747.15, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=204.52, remaining_value=548.33, depreciated_value=7951.67, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=204.52, remaining_value=343.81, depreciated_value=8156.19, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=204.53, remaining_value=139.28, depreciated_value=8360.72, state='draft'), + self._get_depreciation_move_values(date='2024-10-31', depreciation_value=139.28, remaining_value=0.00, depreciated_value=8500.00, state='draft'), + ]) + + def test_monthly_degressive_then_linear_start_beginning_month_increase_middle_month_on_linear_part(self): + asset = self.degressive_then_linear_asset + asset.validate() + + date_modify = fields.Date.to_date("2022-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) + 8500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=173.25, remaining_value=4900.50, depreciated_value=2299.50, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=173.25, remaining_value=4727.25, depreciated_value=2472.75, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=173.25, remaining_value=4554.00, depreciated_value=2646.00, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=173.25, remaining_value=4380.75, depreciated_value=2819.25, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=173.25, remaining_value=4207.50, depreciated_value=2992.50, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=173.25, remaining_value=4034.25, depreciated_value=3165.75, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=173.25, remaining_value=3861.00, depreciated_value=3339.00, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=120.00, remaining_value=3741.00, depreciated_value=3459.00, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=120.00, remaining_value=3621.00, depreciated_value=3579.00, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=120.00, remaining_value=3501.00, depreciated_value=3699.00, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=120.00, remaining_value=3381.00, depreciated_value=3819.00, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=120.00, remaining_value=3261.00, depreciated_value=3939.00, state='posted'), + # Increase + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=60.00, remaining_value=3201.00, depreciated_value=3999.00, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=60.00, remaining_value=3141.00, depreciated_value=4059.00, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=120.00, remaining_value=3021.00, depreciated_value=4179.00, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=120.00, remaining_value=2901.00, depreciated_value=4299.00, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=120.00, remaining_value=2781.00, depreciated_value=4419.00, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=120.00, remaining_value=2661.00, depreciated_value=4539.00, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=120.00, remaining_value=2541.00, depreciated_value=4659.00, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=120.00, remaining_value=2421.00, depreciated_value=4779.00, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=120.00, remaining_value=2301.00, depreciated_value=4899.00, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=120.00, remaining_value=2181.00, depreciated_value=5019.00, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=120.00, remaining_value=2061.00, depreciated_value=5139.00, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=120.00, remaining_value=1941.00, depreciated_value=5259.00, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=120.00, remaining_value=1821.00, depreciated_value=5379.00, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=120.00, remaining_value=1701.00, depreciated_value=5499.00, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=120.00, remaining_value=1581.00, depreciated_value=5619.00, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=120.00, remaining_value=1461.00, depreciated_value=5739.00, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=120.00, remaining_value=1341.00, depreciated_value=5859.00, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=120.00, remaining_value=1221.00, depreciated_value=5979.00, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=120.00, remaining_value=1101.00, depreciated_value=6099.00, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=120.00, remaining_value=981.00, depreciated_value=6219.00, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=120.00, remaining_value=861.00, depreciated_value=6339.00, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=120.00, remaining_value=741.00, depreciated_value=6459.00, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=120.00, remaining_value=621.00, depreciated_value=6579.00, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=120.00, remaining_value=501.00, depreciated_value=6699.00, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=120.00, remaining_value=381.00, depreciated_value=6819.00, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=120.00, remaining_value=261.00, depreciated_value=6939.00, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=120.00, remaining_value=141.00, depreciated_value=7059.00, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=120.00, remaining_value=21.00, depreciated_value=7179.00, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=21.00, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + self.assertRecordValues(asset.children_ids[0].depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=159.32, remaining_value=8340.68, depreciated_value=159.32, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=318.65, remaining_value=8022.03, depreciated_value=477.97, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=318.65, remaining_value=7703.38, depreciated_value=796.62, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=318.65, remaining_value=7384.73, depreciated_value=1115.27, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=318.65, remaining_value=7066.08, depreciated_value=1433.92, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=318.65, remaining_value=6747.43, depreciated_value=1752.57, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=318.65, remaining_value=6428.78, depreciated_value=2071.22, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=318.65, remaining_value=6110.13, depreciated_value=2389.87, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=318.65, remaining_value=5791.48, depreciated_value=2708.52, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=318.65, remaining_value=5472.83, depreciated_value=3027.17, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=318.65, remaining_value=5154.18, depreciated_value=3345.82, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=318.65, remaining_value=4835.53, depreciated_value=3664.47, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=318.65, remaining_value=4516.88, depreciated_value=3983.12, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=318.65, remaining_value=4198.23, depreciated_value=4301.77, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=318.65, remaining_value=3879.58, depreciated_value=4620.42, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=318.65, remaining_value=3560.93, depreciated_value=4939.07, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=318.65, remaining_value=3242.28, depreciated_value=5257.72, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=318.65, remaining_value=2923.63, depreciated_value=5576.37, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=318.65, remaining_value=2604.98, depreciated_value=5895.02, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=318.65, remaining_value=2286.33, depreciated_value=6213.67, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=318.65, remaining_value=1967.68, depreciated_value=6532.32, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=318.65, remaining_value=1649.03, depreciated_value=6850.97, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=318.65, remaining_value=1330.38, depreciated_value=7169.62, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=318.65, remaining_value=1011.73, depreciated_value=7488.27, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=318.65, remaining_value=693.08, depreciated_value=7806.92, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=318.65, remaining_value=374.43, depreciated_value=8125.57, state='draft'), + self._get_depreciation_move_values(date='2024-08-31', depreciation_value=318.65, remaining_value=55.78, depreciated_value=8444.22, state='draft'), + self._get_depreciation_move_values(date='2024-09-30', depreciation_value=55.78, remaining_value=0.00, depreciated_value=8500.00, state='draft'), + ]) + + def test_monthly_degressive_then_linear_start_beginning_month_decrease_middle_month(self): + asset = self.degressive_then_linear_asset + asset.validate() + + date_modify = fields.Date.to_date("2022-06-15") + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': date_modify, + 'value_residual': asset._get_residual_value_at_date(date_modify) - 500, + "account_asset_counterpart_id": self.asset_counterpart_account_id.id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-07-31', depreciation_value=210.00, remaining_value=6990.00, depreciated_value=210.00, state='posted'), + self._get_depreciation_move_values(date='2020-08-31', depreciation_value=210.00, remaining_value=6780.00, depreciated_value=420.00, state='posted'), + self._get_depreciation_move_values(date='2020-09-30', depreciation_value=210.00, remaining_value=6570.00, depreciated_value=630.00, state='posted'), + self._get_depreciation_move_values(date='2020-10-31', depreciation_value=210.00, remaining_value=6360.00, depreciated_value=840.00, state='posted'), + self._get_depreciation_move_values(date='2020-11-30', depreciation_value=210.00, remaining_value=6150.00, depreciated_value=1050.00, state='posted'), + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=210.00, remaining_value=5940.00, depreciated_value=1260.00, state='posted'), + # 2021 + self._get_depreciation_move_values(date='2021-01-31', depreciation_value=173.25, remaining_value=5766.75, depreciated_value=1433.25, state='posted'), + self._get_depreciation_move_values(date='2021-02-28', depreciation_value=173.25, remaining_value=5593.50, depreciated_value=1606.50, state='posted'), + self._get_depreciation_move_values(date='2021-03-31', depreciation_value=173.25, remaining_value=5420.25, depreciated_value=1779.75, state='posted'), + self._get_depreciation_move_values(date='2021-04-30', depreciation_value=173.25, remaining_value=5247.00, depreciated_value=1953.00, state='posted'), + self._get_depreciation_move_values(date='2021-05-31', depreciation_value=173.25, remaining_value=5073.75, depreciated_value=2126.25, state='posted'), + self._get_depreciation_move_values(date='2021-06-30', depreciation_value=173.25, remaining_value=4900.50, depreciated_value=2299.50, state='posted'), + self._get_depreciation_move_values(date='2021-07-31', depreciation_value=173.25, remaining_value=4727.25, depreciated_value=2472.75, state='posted'), + self._get_depreciation_move_values(date='2021-08-31', depreciation_value=173.25, remaining_value=4554.00, depreciated_value=2646.00, state='posted'), + self._get_depreciation_move_values(date='2021-09-30', depreciation_value=173.25, remaining_value=4380.75, depreciated_value=2819.25, state='posted'), + self._get_depreciation_move_values(date='2021-10-31', depreciation_value=173.25, remaining_value=4207.50, depreciated_value=2992.50, state='posted'), + self._get_depreciation_move_values(date='2021-11-30', depreciation_value=173.25, remaining_value=4034.25, depreciated_value=3165.75, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=173.25, remaining_value=3861.00, depreciated_value=3339.00, state='posted'), + # 2022 + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=120.00, remaining_value=3741.00, depreciated_value=3459.00, state='posted'), + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=120.00, remaining_value=3621.00, depreciated_value=3579.00, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=120.00, remaining_value=3501.00, depreciated_value=3699.00, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=120.00, remaining_value=3381.00, depreciated_value=3819.00, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=120.00, remaining_value=3261.00, depreciated_value=3939.00, state='posted'), + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=60.00, remaining_value=3201.00, depreciated_value=3999.00, state='posted'), + # Decrease + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=500.00, remaining_value=2701.00, depreciated_value=4499.00, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=53.15, remaining_value=2647.85, depreciated_value=4552.15, state='posted'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=106.30, remaining_value=2541.55, depreciated_value=4658.45, state='draft'), + self._get_depreciation_move_values(date='2022-08-31', depreciation_value=106.30, remaining_value=2435.25, depreciated_value=4764.75, state='draft'), + self._get_depreciation_move_values(date='2022-09-30', depreciation_value=106.30, remaining_value=2328.95, depreciated_value=4871.05, state='draft'), + self._get_depreciation_move_values(date='2022-10-31', depreciation_value=106.30, remaining_value=2222.65, depreciated_value=4977.35, state='draft'), + self._get_depreciation_move_values(date='2022-11-30', depreciation_value=106.30, remaining_value=2116.35, depreciated_value=5083.65, state='draft'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=106.30, remaining_value=2010.05, depreciated_value=5189.95, state='draft'), + # 2023 + self._get_depreciation_move_values(date='2023-01-31', depreciation_value=106.30, remaining_value=1903.75, depreciated_value=5296.25, state='draft'), + self._get_depreciation_move_values(date='2023-02-28', depreciation_value=106.30, remaining_value=1797.45, depreciated_value=5402.55, state='draft'), + self._get_depreciation_move_values(date='2023-03-31', depreciation_value=106.30, remaining_value=1691.15, depreciated_value=5508.85, state='draft'), + self._get_depreciation_move_values(date='2023-04-30', depreciation_value=106.30, remaining_value=1584.85, depreciated_value=5615.15, state='draft'), + self._get_depreciation_move_values(date='2023-05-31', depreciation_value=106.30, remaining_value=1478.55, depreciated_value=5721.45, state='draft'), + self._get_depreciation_move_values(date='2023-06-30', depreciation_value=106.30, remaining_value=1372.25, depreciated_value=5827.75, state='draft'), + self._get_depreciation_move_values(date='2023-07-31', depreciation_value=106.30, remaining_value=1265.95, depreciated_value=5934.05, state='draft'), + self._get_depreciation_move_values(date='2023-08-31', depreciation_value=106.30, remaining_value=1159.65, depreciated_value=6040.35, state='draft'), + self._get_depreciation_move_values(date='2023-09-30', depreciation_value=106.30, remaining_value=1053.35, depreciated_value=6146.65, state='draft'), + self._get_depreciation_move_values(date='2023-10-31', depreciation_value=106.30, remaining_value=947.05, depreciated_value=6252.95, state='draft'), + self._get_depreciation_move_values(date='2023-11-30', depreciation_value=106.30, remaining_value=840.75, depreciated_value=6359.25, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=106.30, remaining_value=734.45, depreciated_value=6465.55, state='draft'), + # 2024 + self._get_depreciation_move_values(date='2024-01-31', depreciation_value=106.30, remaining_value=628.15, depreciated_value=6571.85, state='draft'), + self._get_depreciation_move_values(date='2024-02-29', depreciation_value=106.30, remaining_value=521.85, depreciated_value=6678.15, state='draft'), + self._get_depreciation_move_values(date='2024-03-31', depreciation_value=106.30, remaining_value=415.55, depreciated_value=6784.45, state='draft'), + self._get_depreciation_move_values(date='2024-04-30', depreciation_value=106.30, remaining_value=309.25, depreciated_value=6890.75, state='draft'), + self._get_depreciation_move_values(date='2024-05-31', depreciation_value=106.30, remaining_value=202.95, depreciated_value=6997.05, state='draft'), + self._get_depreciation_move_values(date='2024-06-30', depreciation_value=106.30, remaining_value=96.65, depreciated_value=7103.35, state='draft'), + self._get_depreciation_move_values(date='2024-07-31', depreciation_value=96.65, remaining_value=0.00, depreciated_value=7200.00, state='draft'), + ]) + + def test_linear_modify_0_value_residual(self): + """Set the value residual to 0""" + asset = self.create_asset(value=10000, periodicity="monthly", periods=10, method="linear", acquisition_date="2022-02-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'method_number': 4, + 'date': fields.Date.to_date("2022-06-24"), + 'modify_action': 'modify', + 'value_residual': 0, + 'account_asset_counterpart_id': self.company_data['default_account_revenue'].copy().id, + }).modify() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-02-28', depreciation_value=1000, remaining_value=9000, depreciated_value=1000, state='posted'), + self._get_depreciation_move_values(date='2022-03-31', depreciation_value=1000, remaining_value=8000, depreciated_value=2000, state='posted'), + self._get_depreciation_move_values(date='2022-04-30', depreciation_value=1000, remaining_value=7000, depreciated_value=3000, state='posted'), + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=6000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2022-06-24', depreciation_value=800, remaining_value=5200, depreciated_value=4800, state='posted'), + + self._get_depreciation_move_values(date='2022-06-24', depreciation_value=5200, remaining_value=0, depreciated_value=10000, state='posted'), + ]) + + def test_asset_modify_value_residual_after_reversal(self): + """ Tests the special case of residual amounts on a board with a reverse entry. + It keeps its focus on the computed residual amount in the modify asset wizard as for now, + the recomputation after a modify on a board with reverse entries is broken. This should be corrected in a later task.""" + + asset = self.create_asset(value=1000, periodicity="yearly", periods=5, method="linear", acquisition_date="2020-01-01", prorata_computation_type="constant_periods") + asset.validate() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=200, remaining_value=800, depreciated_value=200, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=200, remaining_value=600, depreciated_value=400, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=200, remaining_value=400, depreciated_value=600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=200, remaining_value=200, depreciated_value=800, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=200, remaining_value=0, depreciated_value=1000, state='draft'), + ]) + + move_to_reverse = asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id))[0] + self.env['account.move.reversal']\ + .with_context(active_model="account.move", active_ids=move_to_reverse.ids)\ + .create({ + 'journal_id': move_to_reverse.journal_id.id + }).reverse_moves() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2020-12-31', depreciation_value=200, remaining_value=800, depreciated_value=200, state='posted'), + self._get_depreciation_move_values(date='2021-12-31', depreciation_value=200, remaining_value=600, depreciated_value=400, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=-200, remaining_value=800, depreciated_value=200, state='posted'), + self._get_depreciation_move_values(date='2022-12-31', depreciation_value=400, remaining_value=400, depreciated_value=600, state='draft'), + self._get_depreciation_move_values(date='2023-12-31', depreciation_value=200, remaining_value=200, depreciated_value=800, state='draft'), + self._get_depreciation_move_values(date='2024-12-31', depreciation_value=200, remaining_value=0, depreciated_value=1000, state='draft'), + ]) + + asset_modify = self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date("2022-06-30"), + 'modify_action': 'modify', + }) + + # We want to show the actual remaining value of the asset. + self.assertEqual(asset_modify.value_residual, 500, "The computation of the value_residual in asset.modify shouldn't care about the reversal.") + + def test_asset_gain_or_loss_account(self): + asset = self.create_asset(value=1000, periodicity="yearly", periods=5, method="linear", acquisition_date="2020-01-01", prorata_computation_type="constant_periods") + asset.validate() + + invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.env['res.partner'].create({'name': 'Res Partner 12'}).id, + 'invoice_date': '2022-06-30', + 'invoice_line_ids': [(0, 0, { + 'name': 'Asset sold', + 'tax_ids': [], + 'price_unit': 500, + })], + }) + invoice.action_post() + + asset_modify = self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'name': 'Test reason', + 'date': fields.Date.to_date('2022-06-10'), + 'modify_action': 'sell', + 'invoice_ids': invoice.ids, + 'invoice_line_ids': invoice.invoice_line_ids.ids + }) + # The remaining value of the asset on 2022-06-30 is 500: if the asset is sold before that date, it will result in a loss (and a gain if sold after) + self.assertEqual(asset_modify.gain_or_loss, 'loss') + + asset_modify.date = fields.Date.from_string('2022-07-15') + self.assertEqual(asset_modify.gain_or_loss, 'gain') + + asset_modify.date = fields.Date.from_string('2022-06-30') + self.assertEqual(asset_modify.gain_or_loss, 'no') + + def test_asset_disposal_on_hashed_journal(self): + asset = self.create_asset( + value=3000, + periodicity='monthly', + periods=3, + method='linear', + acquisition_date='2022-05-01', + prorata_computation_type='constant_periods', + ) + asset.journal_id.restrict_mode_hash_table = True + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date('2022-05-15'), + 'modify_action': 'dispose', + 'loss_account_id': self.company_data['default_account_expense'].copy().id, + }).sell_dispose() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-05-15', depreciation_value=483.87, remaining_value=2516.13, depreciated_value=483.87, state='posted'), + self._get_depreciation_move_values(date='2022-05-15', depreciation_value=2516.13, remaining_value=0, depreciated_value=3000, state='draft'), + # At this point the asset is disposed, which means its 'remaining_value' is 0. + # But the next 2 depreciation moves could not be removed due to the hash on the journal. + # This results in a negative 'remaining_value' and a 'depreciated_value' that exceeds the asset's initial value. + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=-1000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=-2000, depreciated_value=5000, state='posted'), + # The next 2 depreciation moves are reverting the previous 2, + # bringing the 'remaining_value' back to 0 on the last one, as it should be. + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=-1000, remaining_value=-1000, depreciated_value=4000, state='posted'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=-1000, remaining_value=0, depreciated_value=3000, state='posted'), + ]) + + def test_asset_disposal_with_audit_trail(self): + asset = self.create_asset( + value=3000, + periodicity='monthly', + periods=3, + method='linear', + acquisition_date='2022-05-01', + prorata_computation_type='constant_periods', + ) + asset.validate() + + with patch.object(self.env.registry['account.move'], '_is_protected_by_audit_trail', lambda move: True): + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date('2022-06-15'), + 'modify_action': 'dispose', + 'loss_account_id': self.company_data['default_account_expense'].copy().id, + }).sell_dispose() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-05-31', depreciation_value=1000, remaining_value=2000, depreciated_value=1000, state='posted'), + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=500, remaining_value=1500, depreciated_value=1500, state='posted'), + self._get_depreciation_move_values(date='2022-06-15', depreciation_value=1500, remaining_value=0, depreciated_value=3000, state='draft'), + self._get_depreciation_move_values(date='2022-06-30', depreciation_value=1000, remaining_value=0, depreciated_value=3000, state='cancel'), + self._get_depreciation_move_values(date='2022-07-31', depreciation_value=1000, remaining_value=0, depreciated_value=3000, state='cancel'), + ]) + + def test_disposal_of_fully_depreciated_asset(self): + asset = self.create_asset(value=10000, periodicity="yearly", periods=2, method="degressive", acquisition_date="2020-01-01", prorata_computation_type="constant_periods") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date("2022-01-01"), + 'modify_action': 'dispose', + 'loss_account_id': self.company_data['default_account_expense'].copy().id, + }).sell_dispose() + + def test_asset_disposal_in_middle_of_fiscal_year(self): + self.company_data['company'].fiscalyear_last_month = "3" + + asset = self.create_asset(value=10000, periodicity="monthly", periods=12, method="degressive", acquisition_date="2022-01-01", prorata_computation_type="daily_computation") + asset.validate() + + self.env['asset.modify'].create({ + 'asset_id': asset.id, + 'date': fields.Date.to_date("2022-02-24"), + 'modify_action': 'dispose', + 'loss_account_id': self.company_data['default_account_expense'].copy().id, + }).sell_dispose() + + self.assertRecordValues(asset.depreciation_move_ids.sorted(lambda mv: (mv.date, mv.id)), [ + self._get_depreciation_move_values(date='2022-01-31', depreciation_value=849.32, remaining_value=9150.68, depreciated_value=849.32, state='posted'), + self._get_depreciation_move_values(date='2022-02-24', depreciation_value=657.53, remaining_value=8493.15, depreciated_value=1506.85, state='posted'), + self._get_depreciation_move_values(date='2022-02-24', depreciation_value=8493.15, remaining_value=0, depreciated_value=10000, state='draft'), + ]) diff --git a/dev_odex30_accounting/odex30_account_asset/views/account_account_views.xml b/dev_odex30_accounting/odex30_account_asset/views/account_account_views.xml new file mode 100644 index 0000000..dc3b053 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/views/account_account_views.xml @@ -0,0 +1,50 @@ + + + + account.move.line.list + account.move.line + + extension + + + +
    +
    +
    + + + + + + + + + + + + account.asset.group.list + account.asset.group + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_asset/views/account_asset_views.xml b/dev_odex30_accounting/odex30_account_asset/views/account_asset_views.xml new file mode 100644 index 0000000..38d1add --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/views/account_asset_views.xml @@ -0,0 +1,518 @@ + + + + + account.asset.form + account.asset + 1 + +
    + + + + + + + + +
    +
    + +
    + + + + +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + +
    +
    + + + account.asset.kanban + account.asset + + + + + +
    + +
    + +
    +
    +
    +
    + Original Value +
    +
    + +
    +
    +
    +
    + Acquisition Date +
    +
    + +
    +
    +
    +
    + Duration +
    +
    + +
    +
    +
    +
    + Model +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + account.asset.list + account.asset + + + + + + + + + + + + + + + + + + + + + + + + + account.asset.model.list + account.asset + + + + + + + + + + + + + + + + + account.asset.search + account.asset + + + + + + + + + + + + + + + + + + + + + + + + + account.asset.model.search + account.asset + 100 + + + + + + + + + + + + + + + + Assets + account.asset + + [('state', '!=', 'model'), ('parent_id', '=', False)] + +

    + Create new asset +

    +
    +
    + + + Asset Models + account.asset + list,kanban,form + + + [('state', '=', 'model')] + {'default_state': 'model'} + +

    + Create new asset model +

    +
    +
    + + + + + + Confirm + + + + code + +if records: + action = records.filtered(lambda asset: asset.state == 'draft').validate() + + + + + Compute Depreciation + + + + list + code + +if records: + action = records.filtered(lambda asset: asset.state == 'draft').compute_depreciation_board() + + + + + + + + + + + account.move.line.list.asset + account.move.line + primary + + + hide + hide + hide + hide + hide + show + + + +
    diff --git a/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml b/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml new file mode 100644 index 0000000..5be8e1f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/views/account_move_views.xml @@ -0,0 +1,68 @@ + + + + account.move.form + account.move + + + + + + + + + + + + + + + + + + + + + + account.move.line.form + account.move.line + + + + + + + + + + + + Create Asset + + + + code + +if records: + action = records.turn_as_asset() + + + diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py b/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py new file mode 100644 index 0000000..71973ea --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import asset_modify diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..0b7c888 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc new file mode 100644 index 0000000..35e5444 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_asset/wizard/__pycache__/asset_modify.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py new file mode 100644 index 0000000..310e1ef --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _, Command +from odoo.exceptions import UserError +from odoo.tools.misc import format_date +from odoo.tools import float_is_zero + +from dateutil.relativedelta import relativedelta + + +class AssetModify(models.TransientModel): + _name = 'asset.modify' + _description = 'Modify Asset' + + name = fields.Text(string='Note') + asset_id = fields.Many2one(string="Asset", comodel_name='account.asset', required=True, help="The asset to be modified by this wizard", ondelete="cascade") + method_number = fields.Integer(string='Duration', required=True) + method_period = fields.Selection([('1', 'Months'), ('12', 'Years')], string='Number of Months in a Period', help="The amount of time between two depreciations") + value_residual = fields.Monetary(string="Depreciable Amount", help="New residual amount for the asset", compute="_compute_value_residual", store=True, readonly=False) + salvage_value = fields.Monetary(string="Not Depreciable Amount", help="New salvage amount for the asset") + currency_id = fields.Many2one(related='asset_id.currency_id') + date = fields.Date(default=lambda self: fields.Date.today(), string='Date') + select_invoice_line_id = fields.Boolean(compute="_compute_select_invoice_line_id") + # if we should display the fields for the creation of gross increase asset + gain_value = fields.Boolean(compute="_compute_gain_value") + + account_asset_id = fields.Many2one( + 'account.account', + string="Gross Increase Account", + check_company=True, + domain="[('deprecated', '=', False)]", + ) + account_asset_counterpart_id = fields.Many2one( + 'account.account', + check_company=True, + domain="[('deprecated', '=', False)]", + string="Asset Counterpart Account", + ) + account_depreciation_id = fields.Many2one( + 'account.account', + check_company=True, + domain="[('deprecated', '=', False)]", + string="Depreciation Account", + ) + account_depreciation_expense_id = fields.Many2one( + 'account.account', + check_company=True, + domain="[('deprecated', '=', False)]", + string="Expense Account", + ) + modify_action = fields.Selection(selection="_get_selection_modify_options", string="Action") + company_id = fields.Many2one('res.company', related='asset_id.company_id') + + invoice_ids = fields.Many2many( + comodel_name='account.move', + string="Customer Invoice", + check_company=True, + domain="[('move_type', '=', 'out_invoice'), ('state', '=', 'posted')]", + help="The disposal invoice is needed in order to generate the closing journal entry.", + ) + invoice_line_ids = fields.Many2many( + comodel_name='account.move.line', + check_company=True, + domain="[('move_id', '=', invoice_id), ('display_type', '=', 'product')]", + help="There are multiple lines that could be the related to this asset", + ) + gain_account_id = fields.Many2one( + comodel_name='account.account', + check_company=True, + domain="[('deprecated', '=', False)]", + compute="_compute_accounts", inverse="_inverse_gain_account", readonly=False, compute_sudo=True, + help="Account used to write the journal item in case of gain", + ) + loss_account_id = fields.Many2one( + comodel_name='account.account', + check_company=True, + domain="[('deprecated', '=', False)]", + compute="_compute_accounts", inverse="_inverse_loss_account", readonly=False, compute_sudo=True, + help="Account used to write the journal item in case of loss", + ) + + informational_text = fields.Html(compute='_compute_informational_text') + + # Technical field to know if there was a profit or a loss in the selling of the asset + gain_or_loss = fields.Selection([('gain', 'Gain'), ('loss', 'Loss'), ('no', 'No')], compute='_compute_gain_or_loss') + + def _compute_modify_action(self): + if self.env.context.get('resume_after_pause'): + return 'resume' + else: + return 'dispose' + + @api.depends('asset_id') + def _get_selection_modify_options(self): + if self.env.context.get('resume_after_pause'): + return [('resume', _('Resume'))] + return [ + ('dispose', _("Dispose")), + ('sell', _("Sell")), + ('modify', _("Re-evaluate")), + ('pause', _("Pause")), + ] + + @api.depends('company_id') + def _compute_accounts(self): + for record in self: + record.gain_account_id = record.company_id.gain_account_id + record.loss_account_id = record.company_id.loss_account_id + + @api.depends('date') + def _compute_value_residual(self): + for record in self: + record.value_residual = record.asset_id._get_residual_value_at_date(record.date) + + def _inverse_gain_account(self): + for record in self: + record.company_id.sudo().gain_account_id = record.gain_account_id + + def _inverse_loss_account(self): + for record in self: + record.company_id.sudo().loss_account_id = record.loss_account_id + + @api.onchange('modify_action') + def _onchange_action(self): + if self.modify_action == 'sell' and self.asset_id.children_ids.filtered(lambda a: a.state in ('draft', 'open') or a.value_residual > 0): + raise UserError(_("You cannot automate the journal entry for an asset that has a running gross increase. Please use 'Dispose' on the increase(s).")) + if self.modify_action not in ('modify', 'resume'): + self.write({'value_residual': self.asset_id._get_residual_value_at_date(self.date), 'salvage_value': self.asset_id.salvage_value}) + + @api.onchange('invoice_ids') + def _onchange_invoice_ids(self): + self.invoice_line_ids = self.invoice_ids.invoice_line_ids.filtered(lambda line: line._origin.id in self.invoice_line_ids.ids) # because the domain filter doesn't apply and the invoice_line_ids remains selected + for invoice in self.invoice_ids.filtered(lambda inv: len(inv.invoice_line_ids) == 1): + self.invoice_line_ids += invoice.invoice_line_ids + + @api.depends('asset_id', 'invoice_ids', 'invoice_line_ids', 'modify_action', 'date') + def _compute_gain_or_loss(self): + for record in self: + balances = abs(sum([invoice.balance for invoice in record.invoice_line_ids])) + comparison = record.company_id.currency_id.compare_amounts(record.asset_id._get_own_book_value(record.date), balances) + if record.modify_action in ('sell', 'dispose') and comparison < 0: + record.gain_or_loss = 'gain' + elif record.modify_action in ('sell', 'dispose') and comparison > 0: + record.gain_or_loss = 'loss' + else: + record.gain_or_loss = 'no' + + @api.depends('asset_id', 'value_residual', 'salvage_value') + def _compute_gain_value(self): + for record in self: + record.gain_value = record.currency_id.compare_amounts( + record._get_own_book_value(), + record.asset_id._get_own_book_value(record.date) + ) > 0 + + @api.depends('loss_account_id', 'gain_account_id', 'gain_or_loss', 'modify_action', 'date', 'value_residual', 'salvage_value') + def _compute_informational_text(self): + for wizard in self: + if wizard.modify_action == 'dispose': + if wizard.gain_or_loss == 'gain': + account = wizard.gain_account_id.display_name or '' + gain_or_loss = _('gain') + elif wizard.gain_or_loss == 'loss': + account = wizard.loss_account_id.display_name or '' + gain_or_loss = _('loss') + else: + account = '' + gain_or_loss = _('gain/loss') + wizard.informational_text = _( + "A depreciation entry will be posted on and including the date %(date)s." + "
    A disposal entry will be posted on the %(account_type)s account %(account)s.", + date=format_date(self.env, wizard.date), account_type=gain_or_loss, account=account, + ) + elif wizard.modify_action == 'sell': + if wizard.gain_or_loss == 'gain': + account = wizard.gain_account_id.display_name or '' + elif wizard.gain_or_loss == 'loss': + account = wizard.loss_account_id.display_name or '' + else: + account = '' + wizard.informational_text = _( + "A depreciation entry will be posted on and including the date %(date)s." + "
    A second entry will neutralize the original income and post the " + "outcome of this sale on account %(account)s.", + date=format_date(self.env, wizard.date), account=account, + ) + elif wizard.modify_action == 'pause': + wizard.informational_text = _( + "A depreciation entry will be posted on and including the date %s.", + format_date(self.env, wizard.date) + ) + elif wizard.modify_action == 'modify': + if wizard.gain_value: + text = _("An asset will be created for the value increase of the asset.
    ") + else: + text = "" + wizard.informational_text = _( + "A depreciation entry will be posted on and including the date %(date)s.
    %(extra_text)s " + "Future entries will be recomputed to depreciate the asset following the changes.", + date=format_date(self.env, wizard.date), extra_text=text, + ) + + else: + if wizard.gain_value: + text = _("An asset will be created for the value increase of the asset.
    ") + else: + text = "" + wizard.informational_text = _("%s Future entries will be recomputed to depreciate the asset following the changes.", text) + + @api.depends('invoice_ids', 'modify_action') + def _compute_select_invoice_line_id(self): + for record in self: + record.select_invoice_line_id = record.modify_action == 'sell' and len(record.invoice_ids.invoice_line_ids) > 1 + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'asset_id' in vals: + asset = self.env['account.asset'].browse(vals['asset_id']) + if asset.depreciation_move_ids.filtered(lambda m: m.state == 'posted' and not m.reversal_move_ids and m.date > fields.Date.today()): + raise UserError(_('Reverse the depreciation entries posted in the future in order to modify the depreciation')) + if 'method_number' not in vals: + vals.update({'method_number': asset.method_number}) + if 'method_period' not in vals: + vals.update({'method_period': asset.method_period}) + if 'salvage_value' not in vals: + vals.update({'salvage_value': asset.salvage_value}) + if 'account_asset_id' not in vals: + vals.update({'account_asset_id': asset.account_asset_id.id}) + if 'account_depreciation_id' not in vals: + vals.update({'account_depreciation_id': asset.account_depreciation_id.id}) + if 'account_depreciation_expense_id' not in vals: + vals.update({'account_depreciation_expense_id': asset.account_depreciation_expense_id.id}) + return super().create(vals_list) + + def modify(self): + """ Modifies the duration of asset for calculating depreciation + and maintains the history of old values, in the chatter. + """ + if self.date <= self.asset_id.company_id._get_user_fiscal_lock_date(self.asset_id.journal_id): + raise UserError(_("You can't re-evaluate the asset before the lock date.")) + + old_values = { + 'method_number': self.asset_id.method_number, + 'method_period': self.asset_id.method_period, + 'value_residual': self.asset_id.value_residual, + 'salvage_value': self.asset_id.salvage_value, + } + + asset_vals = { + 'method_number': self.method_number, + 'method_period': self.method_period, + 'salvage_value': self.salvage_value, + 'account_asset_id': self.account_asset_id, + 'account_depreciation_id': self.account_depreciation_id, + 'account_depreciation_expense_id': self.account_depreciation_expense_id, + } + if self.env.context.get('resume_after_pause'): + date_before_pause = max(self.asset_id.depreciation_move_ids, key=lambda x: x.date).date if self.asset_id.depreciation_move_ids else self.asset_id.acquisition_date + # We are removing one day to number days because we don't count the current day + # i.e. If we pause and resume the same day, there isn't any gap whereas for depreciation + # purpose it would count as one full day + number_days = self.asset_id._get_delta_days(date_before_pause, self.date) - 1 + if self.currency_id.compare_amounts(number_days, 0) < 0: + raise UserError(_("You cannot resume at a date equal to or before the pause date")) + + asset_vals.update({'asset_paused_days': self.asset_id.asset_paused_days + number_days}) + asset_vals.update({'state': 'open'}) + self.asset_id.message_post(body=_("Asset unpaused. %s", self.name)) + + current_asset_book = self.asset_id._get_own_book_value(self.date) + after_asset_book = self._get_own_book_value() + increase = after_asset_book - current_asset_book + + new_residual, new_salvage = self._get_new_asset_values(current_asset_book) + residual_increase = max(0, self.value_residual - new_residual) + salvage_increase = max(0, self.salvage_value - new_salvage) + + if not self.env.context.get('resume_after_pause'): + if self.env['account.move'].search_count([('asset_id', '=', self.asset_id.id), ('state', '=', 'draft'), ('date', '<=', self.date)], limit=1): + raise UserError(_('There are unposted depreciations prior to the selected operation date, please deal with them first.')) + self.asset_id._create_move_before_date(self.date) + + asset_vals.update({ + 'salvage_value': new_salvage, + }) + computation_children_changed = ( + asset_vals['method_number'] != self.asset_id.method_number + or asset_vals['method_period'] != self.asset_id.method_period + or asset_vals.get('asset_paused_days') and not float_is_zero(asset_vals['asset_paused_days'] - self.asset_id.asset_paused_days, 8) + ) + self.asset_id.write(asset_vals) + + # Check for residual/salvage increase while rounding with the company currency precision to prevent float precision issues. + if self.currency_id.compare_amounts(residual_increase + salvage_increase, 0) > 0: + move = self.env['account.move'].create({ + 'journal_id': self.asset_id.journal_id.id, + 'date': self.date + relativedelta(days=1), + 'move_type': 'entry', + 'asset_move_type': 'positive_revaluation', + 'line_ids': [ + Command.create({ + 'account_id': self.account_asset_id.id, + 'debit': residual_increase + salvage_increase, + 'credit': 0, + 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name), + }), + Command.create({ + 'account_id': self.account_asset_counterpart_id.id, + 'debit': 0, + 'credit': residual_increase + salvage_increase, + 'name': _('Value increase for: %(asset)s', asset=self.asset_id.name), + }), + ], + }) + move._post() + asset_increase = self.env['account.asset'].create({ + 'name': self.asset_id.name + ': ' + self.name if self.name else "", + 'currency_id': self.asset_id.currency_id.id, + 'company_id': self.asset_id.company_id.id, + 'method': self.asset_id.method, + 'method_number': self.method_number, + 'method_period': self.method_period, + 'method_progress_factor': self.asset_id.method_progress_factor, + 'acquisition_date': self.date + relativedelta(days=1), + 'value_residual': residual_increase, + 'salvage_value': salvage_increase, + 'prorata_date': self.date + relativedelta(days=1), + 'prorata_computation_type': 'daily_computation' if self.asset_id.prorata_computation_type == 'daily_computation' else 'constant_periods', + 'original_value': self._get_increase_original_value(residual_increase, salvage_increase), + 'account_asset_id': self.account_asset_id.id, + 'account_depreciation_id': self.account_depreciation_id.id, + 'account_depreciation_expense_id': self.account_depreciation_expense_id.id, + 'journal_id': self.asset_id.journal_id.id, + 'parent_id': self.asset_id.id, + 'original_move_line_ids': [(6, 0, move.line_ids.filtered(lambda r: r.account_id == self.account_asset_id).ids)], + }) + asset_increase.validate() + + subject = _('A gross increase has been created: %(link)s', link=asset_increase._get_html_link()) + self.asset_id.message_post(body=subject) + + if self.currency_id.compare_amounts(increase, 0) < 0: + move = self.env['account.move'].create(self.env['account.move']._prepare_move_for_asset_depreciation({ + 'amount': -increase, + 'asset_id': self.asset_id, + 'move_ref': _('Value decrease for: %(asset)s', asset=self.asset_id.name), + 'depreciation_beginning_date': self.date, + 'depreciation_end_date': self.date, + 'date': self.date, + 'asset_number_days': 0, + 'asset_value_change': True, + 'asset_move_type': 'negative_revaluation', + }))._post() + + restart_date = self.date if self.env.context.get('resume_after_pause') else self.date + relativedelta(days=1) + if self.asset_id.depreciation_move_ids: + self.asset_id.compute_depreciation_board(restart_date) + else: + # We have no moves, we can compute it as new + self.asset_id.compute_depreciation_board() + + if computation_children_changed: + children = self.asset_id.children_ids + children.write({ + 'method_number': asset_vals['method_number'], + 'method_period': asset_vals['method_period'], + 'asset_paused_days': self.asset_id.asset_paused_days, + }) + + for child in children: + if not self.env.context.get('resume_after_pause'): + child._create_move_before_date(self.date) + if child.depreciation_move_ids: + child.compute_depreciation_board(restart_date) + else: + child.compute_depreciation_board() + child._check_depreciations() + child.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post() + tracked_fields = self.env['account.asset'].fields_get(old_values.keys()) + changes, tracking_value_ids = self.asset_id._mail_track(tracked_fields, old_values) + if changes: + self.asset_id.message_post(body=_('Depreciation board modified %s', self.name), tracking_value_ids=tracking_value_ids) + self.asset_id._check_depreciations() + self.asset_id.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post() + return {'type': 'ir.actions.act_window_close'} + + def pause(self): + for record in self: + record.asset_id.pause(pause_date=record.date, message=self.name) + + def sell_dispose(self): + self.ensure_one() + if self.gain_account_id == self.asset_id.account_depreciation_id or self.loss_account_id == self.asset_id.account_depreciation_id: + raise UserError(_("You cannot select the same account as the Depreciation Account")) + invoice_lines = self.env['account.move.line'] if self.modify_action == 'dispose' else self.invoice_line_ids + return self.asset_id.set_to_close(invoice_line_ids=invoice_lines, date=self.date, message=self.name) + + def _get_own_book_value(self): + return self.value_residual + self.salvage_value + + def _get_increase_original_value(self, residual_increase, salvage_increase): + return residual_increase + salvage_increase + + def _get_new_asset_values(self, current_asset_book): + self.ensure_one() + new_residual = min(current_asset_book - min(self.salvage_value, self.asset_id.salvage_value), self.value_residual) + new_salvage = min(current_asset_book - new_residual, self.salvage_value) + return new_residual, new_salvage diff --git a/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml new file mode 100644 index 0000000..c36d943 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_asset/wizard/asset_modify_views.xml @@ -0,0 +1,104 @@ + + + + + wizard.asset.modify.form + asset.modify + +
    + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + +
    diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py new file mode 100644 index 0000000..502c788 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import demo diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py b/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py new file mode 100644 index 0000000..71a683a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/__manifest__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Account Automatic Transfers', + 'depends': ['odex30_account_accountant'], + 'description': """ +Account Automatic Transfers +=========================== +Manage automatic transfers between your accounts. + """, + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'data': [ + 'security/account_auto_transfer_security.xml', + 'security/ir.model.access.csv', + 'data/cron.xml', + 'views/transfer_model_views.xml', + ], + 'auto_install': True, +} diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5f93de8 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml b/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml new file mode 100644 index 0000000..a6b169b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/data/cron.xml @@ -0,0 +1,10 @@ + + + Account automatic transfers: Perform transfers + + code + model.action_cron_auto_transfer() + 1 + days + + diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py new file mode 100644 index 0000000..7eaefb9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__init__.py @@ -0,0 +1 @@ +from . import account_demo diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4638962 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc new file mode 100644 index 0000000..3d62215 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/demo/__pycache__/account_demo.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py b/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py new file mode 100644 index 0000000..17893f3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/demo/account_demo.py @@ -0,0 +1,63 @@ +import time + +from odoo import api, _, models, Command + + +class AccountChartTemplate(models.AbstractModel): + _inherit = "account.chart.template" + + @api.model + def _get_demo_data(self, company=False): + demo_data = { + 'account.journal': {}, + **super()._get_demo_data(company), + } + demo_data['account.journal'].update({ + 'auto_transfer_journal': { + 'name': _("IFRS Automatic Transfers"), + 'code': "IFRSA", + 'type': 'general', + 'show_on_dashboard': False, + 'sequence': 1000, + }, + }) + demo_data['account.transfer.model'] = { + 'monthly_model': { + 'name': _("IFRS rent expense transfer"), + 'date_start': time.strftime('%Y-01-01'), + 'frequency': 'month', + 'journal_id': 'auto_transfer_journal', + 'account_ids': [self._get_demo_account('expense_rent', 'expense', company).id], + 'line_ids': [ + Command.create({ + 'account_id': self._get_demo_account('expense_rd', 'expense', company).id, + 'percent': 35.0, + }), + Command.create({ + 'account_id': self._get_demo_account('expense_sales', 'expense_direct_cost', company).id, + 'percent': 65.0, + }), + ], + }, + 'yearly_model': { + 'name': _("Yearly liabilites auto transfers"), + 'date_start': time.strftime('%Y-01-01'), + 'frequency': 'year', + 'journal_id': 'auto_transfer_journal', + 'account_ids': [Command.set([ + self._get_demo_account('current_liabilities', 'liability_current', company).id, + self._get_demo_account('payable', 'liability_payable', company).id + ])], + 'line_ids': [ + Command.create({ + 'account_id': self._get_demo_account('payable', 'liability_payable', company).id, + 'percent': 77.5, + }), + Command.create({ + 'account_id': self._get_demo_account('non_current_liabilities', 'liability_non_current', company).id, + 'percent': 22.5, + }), + ], + }, + } + return demo_data diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po b/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po new file mode 100644 index 0000000..c3d8bc6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/i18n/ar.po @@ -0,0 +1,479 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_auto_transfer +# +# Translators: +# Wil Odoo, 2024 +# Mustafa J. Kadhem , 2024 +# Malaz Abuidris , 2024 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2024\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid " to " +msgstr " إلى " + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_chart_template +msgid "Account Chart Template" +msgstr "نموذج مخطط الحساب " + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_transfer_model +msgid "Account Transfer Model" +msgstr "نموذج تحويل الحساب " + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_transfer_model_line +msgid "Account Transfer Model Line" +msgstr "بند نموذج تحويل الحساب " + +#. module: odex30_account_auto_transfer +#: model:ir.actions.server,name:odex30_account_auto_transfer.ir_cron_auto_transfer_ir_actions_server +msgid "Account automatic transfers: Perform transfers" +msgstr "تحويلات الحساب التلقائية: أداء التحويلات " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Activate" +msgstr "تفعيل" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__active +msgid "Active" +msgstr "نشط " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__analytic_account_ids +msgid "" +"Adds a condition to only transfer the sum of the lines from the origin " +"accounts that match these analytic accounts to the destination account" +msgstr "" +"يقوم بإضافة شرط لتحويل مجموع البنود من الحسابات الأصلية التي تطابق تلك " +"الحسابات التحليلية إلى الحساب الهدف فقط " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__partner_ids +msgid "" +"Adds a condition to only transfer the sum of the lines from the origin " +"accounts that match these partners to the destination account" +msgstr "" +"يقوم بإضافة شرط لتحويل مجموع البنود من الحسابات الأصلية التي تطابق هؤلاء " +"الشركاء إلى الحساب الهدف فقط " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__analytic_account_ids +msgid "Analytic Filter" +msgstr "عامل تصفية تحليلي " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_search +msgid "Archived" +msgstr "مؤرشف " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Automated Transfer" +msgstr "التحويل التلقائي " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "Automatic Transfer (%(percent)s%% from account %(origin_account)s)" +msgstr "التحويل التلقائي (%(percent)s%% من الحساب %(origin_account)s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "Automatic Transfer (-%s%%)" +msgstr "التحويل التلقائي (-%s%%)" + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"Automatic Transfer (entries with analytic account(s): %(analytic_accounts)s " +"and partner(s): %(partners)s)" +msgstr "" +"التحويل التلقائي (القيود التي بها حساب (حسابات) تحليلية: " +"%(analytic_accounts)s والشريك (الشركاء): %(partners)s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "Automatic Transfer (entries with analytic account(s): %s)" +msgstr "التحويل التلقائي (القيود التي بها حساب (حسابات) تحليلية: %s)" + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "Automatic Transfer (entries with partner(s): %s)" +msgstr "التحويل التلقائي (القيود التي بها شريك (شركاء): %s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"Automatic Transfer (from account %(origin_account)s with analytic " +"account(s): %(analytic_accounts)s and partner(s): %(partners)s)" +msgstr "" +"التحويل التلقائي (من الحساب %(origin_account)s الذي به حسابات تحليلية: " +"%(analytic_accounts)s والشريك (الشركاء): %(partners)s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"Automatic Transfer (from account %(origin_account)s with analytic " +"account(s): %(analytic_accounts)s)" +msgstr "" +"التحويل التلقائي (من الحساب %(origin_account)s الذي به حسابات تحليلية: " +"%(analytic_accounts)s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"Automatic Transfer (from account %(origin_account)s with partner(s): " +"%(partners)s)" +msgstr "" +"التحويل التلقائي (من الحساب %(origin_account)s الذي به الشركاء: " +"%(partners)s) " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "Automatic Transfer (to account %s)" +msgstr "التحويل التلقائي (للحساب %s)" + +#. module: odex30_account_auto_transfer +#: model:ir.actions.act_window,name:odex30_account_auto_transfer.transfer_model_action +msgid "Automatic Transfers" +msgstr "التحويلات التلقائية " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model__company_id +msgid "Company related to this journal" +msgstr "الشركة المتعلقة بهذه اليومية " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Compute Transfer" +msgstr "حساب التحويل " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__create_uid +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__create_date +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Description" +msgstr "الوصف" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__account_id +msgid "Destination Account" +msgstr "حساب الوجهة" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__line_ids +msgid "Destination Accounts" +msgstr "حسابات الوجهة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__journal_id +msgid "Destination Journal" +msgstr "يومية الوجهة " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Disable" +msgstr "تعطيل" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__state__disabled +msgid "Disabled" +msgstr "معطل" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__display_name +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__frequency +msgid "Frequency" +msgstr "معدل الحدوث " + +#. module: odex30_account_auto_transfer +#: model:ir.actions.act_window,name:odex30_account_auto_transfer.generated_transfers_action +msgid "Generated Entries" +msgstr "القيود المُنشأة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__move_ids +msgid "Generated Moves" +msgstr "الحركات المُنشأة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__id +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0 +msgid "IFRS Automatic Transfers" +msgstr "التحويلات التلقائية في IFRS " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0 +msgid "IFRS rent expense transfer" +msgstr "تحويل نفقات الإيجار في IFRS " + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_journal +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_move +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: odex30_account_auto_transfer +#: model:ir.model,name:odex30_account_auto_transfer.model_account_move_line +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__write_uid +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__write_date +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__month +msgid "Monthly" +msgstr "شهرياً" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__move_ids_count +msgid "Move Ids Count" +msgstr "تعداد مُعرفات الحركات " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Move Model" +msgstr "نموذج الحركة " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_tree +msgid "Move Models" +msgstr "نماذج الحركة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_auto_transfer +#: model:ir.model.constraint,message:odex30_account_auto_transfer.constraint_account_transfer_model_line_unique_account_by_transfer_model +msgid "Only one account occurrence by transfer model" +msgstr "يُسمح بوجود حساب واحد فقط لكل نموذج تحويل " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__account_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Origin Accounts" +msgstr "الحسابات الأصلية " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_bank_statement_line__transfer_model_id +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_move__transfer_model_id +msgid "Originating Model" +msgstr "النموذج المُنشئ " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__partner_ids +msgid "Partner Filter" +msgstr "عامل تصفية الشريك " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__percent +msgid "Percent" +msgstr "بالمئة " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Percent (%)" +msgstr "النسبة (%)" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__percent_is_readonly +msgid "Percent Is Readonly" +msgstr "النسبة للقراءة فقط " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,help:odex30_account_auto_transfer.field_account_transfer_model_line__percent +msgid "" +"Percentage of the sum of lines from the origin accounts will be transferred " +"to the destination account" +msgstr "" +"سوف يتم تحويل نسبة من مجموع البنود من الحسابات الأصلية إلى حساب الوجهة " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Period" +msgstr "الفترة" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__quarter +msgid "Quarterly" +msgstr "ربع سنوي" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__state__in_progress +msgid "Running" +msgstr "جاري" + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__sequence +msgid "Sequence" +msgstr "تسلسل " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__date_start +msgid "Start Date" +msgstr "تاريخ البدء " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__state +msgid "State" +msgstr "الحالة " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__date_stop +msgid "Stop Date" +msgstr "تاريخ الإيقاف " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "The analytic filter %s is duplicated" +msgstr "تم إنشاء نسخة مطابقة من عامل التصفية التحليلي %s " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"The partner filter %(partner_filter)s in combination with the analytic " +"filter %(analytic_filter)s is duplicated" +msgstr "" +"تم إنشاء نسخة مطابقة لعامل تصفية الشريك %(partner_filter)s وجمعه مع الحساب " +"التحليلي %(analytic_filter)s " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "The partner filter %s is duplicated" +msgstr "تم إنشاء نسخة مطابقة لعامل تصفية الشريك %s " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "The total percentage (%s) should be less or equal to 100!" +msgstr "يجب أن تكون النسبة الكلية (%s) أقل من أو تساوي 100! " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model__total_percent +msgid "Total Percent" +msgstr "النسبة الكلية " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields,field_description:odex30_account_auto_transfer.field_account_transfer_model_line__transfer_model_id +msgid "Transfer Model" +msgstr "نموذج التحويل " + +#. module: odex30_account_auto_transfer +#: model:ir.ui.menu,name:odex30_account_auto_transfer.menu_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "Transfers" +msgstr "التحويلات " + +#. module: odex30_account_auto_transfer +#: model:ir.model.fields.selection,name:odex30_account_auto_transfer.selection__account_transfer_model__frequency__year +msgid "Yearly" +msgstr "سنويًا" + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/demo/account_demo.py:0 +msgid "Yearly liabilites auto transfers" +msgstr "التحويلات التلقائية للالتزامات السنوية " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"You cannot delete an automatic transfer that has draft moves attached " +"('%s'). Please delete them before deleting this transfer." +msgstr "" +"لا يمكنك حذف شحنة تلقائية بها حركات مرفقة بحالة المسودة ('%s')؛ يرجى حذفها " +"قبل حذف هذه الشحنة. " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/transfer_model.py:0 +msgid "" +"You cannot delete an automatic transfer that has posted moves attached " +"('%s')." +msgstr "لا يمكنك حذف شحنة تلقائية بها حركات مرفقة قد تم ترحيلها ('%s'). " + +#. module: odex30_account_auto_transfer +#. odoo-python +#: code:addons/odex30_account_auto_transfer/models/account_move_line.py:0 +msgid "You cannot set Tax on Automatic Transfer's entries." +msgstr "لا يمكنك تعيين ضريبة في قيود التحويل التلقائي. " + +#. module: odex30_account_auto_transfer +#: model_terms:ir.ui.view,arch_db:odex30_account_auto_transfer.view_transfer_model_form +msgid "e.g. Monthly Expense Transfer" +msgstr "مثال: التحويل الشهري للنفقات " diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py new file mode 100644 index 0000000..0664ea8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import account_journal +from . import account_move +from . import account_move_line +from . import transfer_model diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..0f077a4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..cbf4914 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc new file mode 100644 index 0000000..eb76819 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc new file mode 100644 index 0000000..620c95e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/account_move_line.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc new file mode 100644 index 0000000..2d583ba Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/models/__pycache__/transfer_model.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py new file mode 100644 index 0000000..95f5402 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_journal.py @@ -0,0 +1,11 @@ +from odoo import models, api +from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + @api.ondelete(at_uninstall=True) + def _unlink_cascade_transfer_model(self): + if self.env.context.get(MODULE_UNINSTALL_FLAG): # only cascade when switching CoA + self.env['account.transfer.model'].search([('journal_id', 'in', self.ids)]).unlink() diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py new file mode 100644 index 0000000..d4dd1e5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + transfer_model_id = fields.Many2one('account.transfer.model', string="Originating Model") diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py new file mode 100644 index 0000000..623c520 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/account_move_line.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, _ +from odoo.exceptions import UserError + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + @api.constrains('tax_ids') + def _check_auto_transfer_line_ids_tax(self): + if any(line.move_id.transfer_model_id and line.tax_ids for line in self): + raise UserError(_("You cannot set Tax on Automatic Transfer's entries.")) diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py b/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py new file mode 100644 index 0000000..a4cb5f5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/models/transfer_model.py @@ -0,0 +1,531 @@ +# -*- coding: utf-8 -*- + +from datetime import date + +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, api, _ +from odoo.exceptions import UserError, ValidationError +from odoo.osv import expression +from odoo.tools.float_utils import float_compare, float_is_zero + + +class TransferModel(models.Model): + _name = "account.transfer.model" + _description = "Account Transfer Model" + + # DEFAULTS + def _get_default_date_start(self): + company = self.env.company + return company.compute_fiscalyear_dates(date.today())['date_from'] if company else None + + def _get_default_journal(self): + return self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(self.env.company), + ('type', '=', 'general'), + ], limit=1) + + name = fields.Char(required=True) + active = fields.Boolean(default=True) + journal_id = fields.Many2one('account.journal', required=True, string="Destination Journal", default=_get_default_journal) + company_id = fields.Many2one('res.company', readonly=True, related='journal_id.company_id') + date_start = fields.Date(string="Start Date", required=True, default=_get_default_date_start) + date_stop = fields.Date(string="Stop Date", required=False) + frequency = fields.Selection([('month', 'Monthly'), ('quarter', 'Quarterly'), ('year', 'Yearly')], + required=True, default='month') + account_ids = fields.Many2many('account.account', 'account_model_rel', string="Origin Accounts", domain="[('account_type', '!=', 'off_balance')]") + line_ids = fields.One2many('account.transfer.model.line', 'transfer_model_id', string="Destination Accounts") + move_ids = fields.One2many('account.move', 'transfer_model_id', string="Generated Moves") + move_ids_count = fields.Integer(compute="_compute_move_ids_count") + total_percent = fields.Float(compute="_compute_total_percent", string="Total Percent", readonly=True) + state = fields.Selection([('disabled', 'Disabled'), ('in_progress', 'Running')], default='disabled', required=True) + + def copy(self, default=None): + new_models = super().copy(default) + for old_model, new_model in zip(self, new_models): + new_model.account_ids += old_model.account_ids + old_model.line_ids.copy({'transfer_model_id': new_model.id}) + return new_models + + @api.ondelete(at_uninstall=False) + def _unlink_with_check_moves(self): + # Only unlink a transfer that has no posted/draft moves attached. + for transfer in self: + if transfer.move_ids_count > 0: + posted_moves = any(move.state == 'posted' for move in transfer.move_ids) + if posted_moves: + raise UserError(_("You cannot delete an automatic transfer that has posted moves attached ('%s').", transfer.name)) + draft_moves = any(move.state == 'draft' for move in transfer.move_ids) + if draft_moves: + raise UserError(_("You cannot delete an automatic transfer that has draft moves attached ('%s'). " + "Please delete them before deleting this transfer.", transfer.name)) + + def action_archive(self): + self.action_disable() + return super().action_archive() + + # COMPUTEDS / CONSTRAINS + @api.depends('move_ids') + def _compute_move_ids_count(self): + """ Compute the amount of move ids have been generated by this transfer model. """ + for record in self: + record.move_ids_count = len(record.move_ids) + + @api.constrains('line_ids') + def _check_line_ids_percent(self): + """ Check that the total percent is not bigger than 100.0 """ + for record in self: + if not (0 < record.total_percent <= 100.0): + raise ValidationError(_('The total percentage (%s) should be less or equal to 100!', record.total_percent)) + + @api.constrains('line_ids') + def _check_line_ids_filters(self): + """ Check that the filters on the lines make sense """ + for record in self: + combinations = [] + for line in record.line_ids: + if line.partner_ids and line.analytic_account_ids: + for p in line.partner_ids: + for a in line.analytic_account_ids: + combination = (p.id, a.id) + if combination in combinations: + raise ValidationError(_( + "The partner filter %(partner_filter)s in combination with the analytic filter %(analytic_filter)s is duplicated", + partner_filter=p.display_name, analytic_filter=a.display_name, + )) + combinations.append(combination) + elif line.partner_ids: + for p in line.partner_ids: + combination = (p.id, None) + if combination in combinations: + raise ValidationError(_("The partner filter %s is duplicated", p.display_name)) + combinations.append(combination) + elif line.analytic_account_ids: + for a in line.analytic_account_ids: + combination = (None, a.id) + if combination in combinations: + raise ValidationError(_("The analytic filter %s is duplicated", a.display_name)) + combinations.append(combination) + + @api.depends('line_ids') + def _compute_total_percent(self): + """ Compute the total percentage of all lines linked to this model. """ + for record in self: + non_filtered_lines = record.line_ids.filtered(lambda l: not l.partner_ids and not l.analytic_account_ids) + if record.line_ids and not non_filtered_lines: + # Lines are only composed of filtered ones thus percentage does not matter, make it 100 + record.total_percent = 100.0 + else: + total_percent = sum(non_filtered_lines.mapped('percent')) + if float_compare(total_percent, 100.0, precision_digits=6) == 0: + total_percent = 100.0 + record.total_percent = total_percent + + # ACTIONS + def action_activate(self): + """ Put this move model in "in progress" state. """ + return self.write({'state': 'in_progress'}) + + def action_disable(self): + """ Put this move model in "disabled" state. """ + return self.write({'state': 'disabled'}) + + @api.model + def action_cron_auto_transfer(self): + """ Perform the automatic transfer for the all active move models. """ + self.search([('state', '=', 'in_progress')]).action_perform_auto_transfer() + + def action_perform_auto_transfer(self): + """ Perform the automatic transfer for the current recordset of models """ + for record in self: + # If no account to ventilate or no account to ventilate into : nothing to do + if record.account_ids and record.line_ids: + today = date.today() + max_date = record.date_stop and min(today, record.date_stop) or today + start_date = record._determine_start_date() + next_move_date = record._get_next_move_date(start_date) + + # (Re)Generate moves in draft untill today + # Journal entries will be recomputed everyday until posted. + while next_move_date <= max_date: + record._create_or_update_move_for_period(start_date, next_move_date) + start_date = next_move_date + relativedelta(days=1) + next_move_date = record._get_next_move_date(start_date) + + # (Re)Generate move for one more period if needed + if not record.date_stop: + record._create_or_update_move_for_period(start_date, next_move_date) + elif today < record.date_stop: + record._create_or_update_move_for_period(start_date, min(next_move_date, record.date_stop)) + return False + + def _get_move_lines_base_domain(self, start_date, end_date): + """ + Determine the domain to get all account move lines posted in a given period, for an account in origin accounts + :param start_date: the start date of the period + :param end_date: the end date of the period + :return: the computed domain + :rtype: list + """ + self.ensure_one() + return [ + ('account_id', 'in', self.account_ids.ids), + ('date', '>=', start_date), + ('date', '<=', end_date), + ('parent_state', '=', 'posted') + ] + + # PROTECTEDS + + def _create_or_update_move_for_period(self, start_date, end_date): + """ + Create or update a move for a given period. This means (re)generates all the needed moves to execute the + transfers + :param start_date: the start date of the targeted period + :param end_date: the end date of the targeted period + :return: the created (or updated) move + """ + self.ensure_one() + current_move = self._get_move_for_period(end_date) + line_values = self._get_auto_transfer_move_line_values(start_date, end_date) + if line_values: + if current_move is None: + current_move = self.env['account.move'].create({ + 'ref': '%s: %s --> %s' % (self.name, str(start_date), str(end_date)), + 'date': end_date, + 'journal_id': self.journal_id.id, + 'transfer_model_id': self.id, + }) + + line_ids_values = [(0, 0, value) for value in line_values] + # unlink all old line ids + current_move.line_ids.unlink() + # recreate line ids + current_move.write({'line_ids': line_ids_values}) + return current_move + + def _get_move_for_period(self, end_date): + """ Get the generated move for a given period + :param end_date: the end date of the wished period, do not need the start date as the move will always be + generated with end date of a period as date + :return: a recordset containing the move found if any, else None + """ + self.ensure_one() + # Move will always be generated with end_date of a period as date + domain = [ + ('date', '=', end_date), + ('state', '=', 'draft'), + ('transfer_model_id', '=', self.id) + ] + current_moves = self.env['account.move'].search(domain, limit=1, order="date desc") + return current_moves[0] if current_moves else None + + def _determine_start_date(self): + """ Determine the automatic transfer start date which is the last created move if any or the start date of the model """ + self.ensure_one() + # Get last generated move date if any (to know when to start) + last_move_domain = [('transfer_model_id', '=', self.id), ('state', '=', 'posted'), ('company_id', '=', self.company_id.id)] + move_ids = self.env['account.move'].search(last_move_domain, order='date desc', limit=1) + return (move_ids[0].date + relativedelta(days=1)) if move_ids else self.date_start + + def _get_next_move_date(self, date): + """ Compute the following date of automated transfer move, based on a date and the frequency """ + self.ensure_one() + if self.frequency == 'month': + delta = relativedelta(months=1) + elif self.frequency == 'quarter': + delta = relativedelta(months=3) + else: + delta = relativedelta(years=1) + return date + delta - relativedelta(days=1) + + def _get_auto_transfer_move_line_values(self, start_date, end_date): + """ Get all the transfer move lines values for a given period + :param start_date: the start date of the period + :param end_date: the end date of the period + :return: a list of dict representing the values of lines to create + :rtype: list + """ + self.ensure_one() + values = [] + # Get the balance of all moves from all selected accounts, grouped by accounts + filtered_lines = self.line_ids.filtered(lambda x: x.analytic_account_ids or x.partner_ids) + if filtered_lines: + values += filtered_lines._get_transfer_move_lines_values(start_date, end_date) + + non_filtered_lines = self.line_ids - filtered_lines + if non_filtered_lines: + values += self._get_non_filtered_auto_transfer_move_line_values(non_filtered_lines, start_date, end_date) + + return values + + def _get_non_filtered_auto_transfer_move_line_values(self, lines, start_date, end_date): + """ + Get all values to create move lines corresponding to the transfers needed by all lines without analytic + account or partner for a given period. It contains the move lines concerning destination accounts and + the ones concerning the origin accounts. This process all the origin accounts one after one. + :param lines: the move model lines to handle + :param start_date: the start date of the period + :param end_date: the end date of the period + :return: a list of dict representing the values to use to create the needed move lines + :rtype: list + """ + self.ensure_one() + domain = expression.AND([ + self._get_move_lines_base_domain(start_date, end_date), + [('partner_id', 'not in', self.line_ids.partner_ids.ids)], + [('analytic_distribution', 'not in', self.line_ids.analytic_account_ids.ids)], + ]) + total_balance_account = self.env['account.move.line']._read_group( + domain, + ['account_id'], + ['balance:sum'], + ) + # balance = debit - credit + # --> balance > 0 means a debit so it should be credited on the source account + # --> balance < 0 means a credit so it should be debited on the source account + values_list = [] + for account, balance in total_balance_account: + initial_amount = abs(balance) + source_account_is_debit = balance >= 0 + if not float_is_zero(initial_amount, precision_digits=9): + move_lines_values, amount_left = self._get_non_analytic_transfer_values(account, lines, end_date, + initial_amount, + source_account_is_debit) + + # the line which credit/debit the source account + substracted_amount = initial_amount - amount_left + source_move_line = { + 'name': _('Automatic Transfer (-%s%%)', self.total_percent), + 'account_id': account.id, + 'date_maturity': end_date, + 'credit' if source_account_is_debit else 'debit': substracted_amount + } + values_list += move_lines_values + values_list.append(source_move_line) + return values_list + + def _get_non_analytic_transfer_values(self, account, lines, write_date, amount, is_debit): + """ + Get all values to create destination account move lines corresponding to the transfers needed by all lines + without analytic account for a given account. + :param account: the origin account to handle + :param write_date: the write date of the move lines + :param amount: the total amount to take care on the origin account + :type amount: float + :param is_debit: True if origin account has a debit balance, False if it's a credit + :type is_debit: bool + :return: a tuple containing the move lines values in a list and the amount left on the origin account after + processing as a float + :rtype: tuple + """ + # if total ventilated is 100% + # then the last line should not compute in % but take the rest + # else + # it should compute in % (as the rest will stay on the source account) + self.ensure_one() + amount_left = amount + + take_the_rest = self.total_percent == 100.0 + amount_of_lines = len(lines) + values_list = [] + + for i, line in enumerate(lines): + if take_the_rest and i == amount_of_lines - 1: + line_amount = amount_left + amount_left = 0 + else: + currency = self.journal_id.currency_id or self.company_id.currency_id + line_amount = currency.round((line.percent / 100.0) * amount) + amount_left -= line_amount + + move_line = line._get_destination_account_transfer_move_line_values(account, line_amount, is_debit, + write_date) + values_list.append(move_line) + + return values_list, amount_left + + +class TransferModelLine(models.Model): + _name = "account.transfer.model.line" + _description = "Account Transfer Model Line" + _order = "sequence, id" + + transfer_model_id = fields.Many2one('account.transfer.model', string="Transfer Model", required=True, ondelete='cascade') + account_id = fields.Many2one('account.account', string="Destination Account", required=True, + domain="[('account_type', '!=', 'off_balance')]") + percent = fields.Float(string="Percent", required=True, default=100, help="Percentage of the sum of lines from the origin accounts will be transferred to the destination account") + analytic_account_ids = fields.Many2many('account.analytic.account', string='Analytic Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these analytic accounts to the destination account") + partner_ids = fields.Many2many('res.partner', string='Partner Filter', help="Adds a condition to only transfer the sum of the lines from the origin accounts that match these partners to the destination account") + percent_is_readonly = fields.Boolean(compute="_compute_percent_is_readonly") + sequence = fields.Integer("Sequence") + + _sql_constraints = [ + ( + 'unique_account_by_transfer_model', 'UNIQUE(transfer_model_id, account_id)', + 'Only one account occurrence by transfer model') + ] + + @api.onchange('analytic_account_ids', 'partner_ids') + def set_percent_if_analytic_account_ids(self): + """ + Set percent to 100 if at least analytic account id is set. + """ + for record in self: + if record.analytic_account_ids or record.partner_ids: + record.percent = 100 + + def _get_transfer_move_lines_values(self, start_date, end_date): + """ + Get values to create the move lines to perform all needed transfers between accounts linked to current recordset + for a given period + :param start_date: the start date of the targeted period + :param end_date: the end date of the targeted period + :return: a list containing all the values needed to create the needed transfers + :rtype: list + """ + transfer_values = [] + # Avoid to transfer two times the same entry + already_handled_move_line_ids = [] + for transfer_model_line in self: + domain = transfer_model_line._get_move_lines_domain(start_date, end_date, already_handled_move_line_ids) + + if transfer_model_line.analytic_account_ids: + domain = expression.AND([ + domain, + [('analytic_distribution', 'in', transfer_model_line.analytic_account_ids.ids)], + ]) + + total_balances = self.env['account.move.line']._read_group( + domain, + ['account_id'], + ['id:array_agg', 'balance:sum'], + ) + for account, ids, balance in total_balances: + already_handled_move_line_ids += ids + if not float_is_zero(balance, precision_digits=9): + amount = abs(balance) + source_account_is_debit = balance > 0 + transfer_values += transfer_model_line._get_transfer_values(account, amount, source_account_is_debit, + end_date) + return transfer_values + + def _get_move_lines_domain(self, start_date, end_date, avoid_move_line_ids=None): + """ + Determine the domain to get all account move lines posted in a given period corresponding to self move model + line. + :param start_date: the start date of the targeted period + :param end_date: the end date of the targeted period + :param avoid_move_line_ids: the account.move.line ids that should be excluded from the domain + :return: the computed domain + :rtype: list + """ + self.ensure_one() + move_lines_domain = self.transfer_model_id._get_move_lines_base_domain(start_date, end_date) + if avoid_move_line_ids: + move_lines_domain.append(('id', 'not in', avoid_move_line_ids)) + if self.partner_ids: + move_lines_domain.append(('partner_id', 'in', self.partner_ids.ids)) + return move_lines_domain + + def _get_transfer_values(self, account, amount, is_debit, write_date): + """ + Get values to create the move lines to perform a transfer between self account and given account + :param account: the account + :param amount: the amount that is being transferred + :type amount: float + :param is_debit: True if the transferred amount is a debit, False if credit + :type is_debit: bool + :param write_date: the date to use for the move line writing + :return: a list containing the values to create the needed move lines + :rtype: list + """ + self.ensure_one() + return [ + self._get_destination_account_transfer_move_line_values(account, amount, is_debit, write_date), + self._get_origin_account_transfer_move_line_values(account, amount, is_debit, write_date) + ] + + def _get_origin_account_transfer_move_line_values(self, origin_account, amount, is_debit, + write_date): + """ + Get values to create the move line in the origin account side for a given transfer of a given amount from origin + account to a given destination account. + :param origin_account: the origin account + :param amount: the amount that is being transferred + :type amount: float + :param is_debit: True if the transferred amount is a debit, False if credit + :type is_debit: bool + :param write_date: the date to use for the move line writing + :return: a dict containing the values to create the move line + :rtype: dict + """ + anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name')) + partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name')) + if anal_accounts and partners: + name = _("Automatic Transfer (entries with analytic account(s): %(analytic_accounts)s and partner(s): %(partners)s)", analytic_accounts=anal_accounts, partners=partners) + elif anal_accounts: + name = _("Automatic Transfer (entries with analytic account(s): %s)", anal_accounts) + elif partners: + name = _("Automatic Transfer (entries with partner(s): %s)", partners) + else: + name = _("Automatic Transfer (to account %s)", self.account_id.code) + return { + 'name': name, + 'account_id': origin_account.id, + 'date_maturity': write_date, + 'credit' if is_debit else 'debit': amount + } + + def _get_destination_account_transfer_move_line_values(self, origin_account, amount, is_debit, + write_date): + """ + Get values to create the move line in the destination account side for a given transfer of a given amount from + given origin account to destination account. + :param origin_account: the origin account + :param amount: the amount that is being transferred + :type amount: float + :param is_debit: True if the transferred amount is a debit, False if credit + :type is_debit: bool + :param write_date: the date to use for the move line writing + :return: a dict containing the values to create the move line + :rtype dict: + """ + anal_accounts = self.analytic_account_ids and ', '.join(self.analytic_account_ids.mapped('name')) + partners = self.partner_ids and ', '.join(self.partner_ids.mapped('name')) + if anal_accounts and partners: + name = _( + "Automatic Transfer (from account %(origin_account)s with analytic account(s): %(analytic_accounts)s and partner(s): %(partners)s)", + origin_account=origin_account.code, + analytic_accounts=anal_accounts, + partners=partners, + ) + elif anal_accounts: + name = _( + "Automatic Transfer (from account %(origin_account)s with analytic account(s): %(analytic_accounts)s)", + origin_account=origin_account.code, + analytic_accounts=anal_accounts + ) + elif partners: + name = _( + "Automatic Transfer (from account %(origin_account)s with partner(s): %(partners)s)", + origin_account=origin_account.code, + partners=partners, + ) + else: + name = _( + "Automatic Transfer (%(percent)s%% from account %(origin_account)s)", + percent=self.percent, + origin_account=origin_account.code, + ) + return { + 'name': name, + 'account_id': self.account_id.id, + 'date_maturity': write_date, + 'debit' if is_debit else 'credit': amount + } + + @api.depends('analytic_account_ids', 'partner_ids') + def _compute_percent_is_readonly(self): + for record in self: + record.percent_is_readonly = record.analytic_account_ids or record.partner_ids diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml b/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml new file mode 100644 index 0000000..21ec454 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/security/account_auto_transfer_security.xml @@ -0,0 +1,11 @@ + + + + + Account Automatic Transfer + + + [('company_id', 'in', company_ids)] + + + diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv new file mode 100644 index 0000000..f214369 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_transfer_model,access_account_transfer_model,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_readonly,1,0,0,0 +access_account_transfer_model_manager,access_account_transfer_model_manager,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_manager,1,1,1,1 +access_account_transfer_model_invoicing_payment,access_account_transfer_model_invoicing_payment,odex30_account_auto_transfer.model_account_transfer_model,account.group_account_invoice,1,0,1,0 +access_account_transfer_model_line,access_account_transfer_model_line,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_readonly,1,0,0,0 +access_account_transfer_model_line_manager,access_account_transfer_model_line_manager,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_manager,1,1,1,1 +access_account_transfer_model_line_invoicing_payment,access_account_transfer_model_line_invoicing_payment,odex30_account_auto_transfer.model_account_transfer_model_line,account.group_account_invoice,1,0,1,0 diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png new file mode 100644 index 0000000..a058ecb Binary files /dev/null and b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.png differ diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg new file mode 100644 index 0000000..92b815f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py new file mode 100644 index 0000000..7bb7698 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import test_transfer_model +from . import test_transfer_model_line diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py new file mode 100644 index 0000000..c6d94fd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/account_auto_transfer_test_classes.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from uuid import uuid4 +from odoo.tests import common + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +class AccountAutoTransferTestCase(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.journal = cls.env['account.journal'].create({'type': 'bank', 'name': 'bank', 'code': 'BANK'}) + cls.transfer_model = cls.env['account.transfer.model'].create({ + 'name': 'Test Transfer', + 'date_start': '2019-06-01', + 'frequency': 'month', + 'journal_id': cls.journal.id + }) + cls.analytic_plan = cls.env['account.analytic.plan'].create({ + 'name': 'A', + }) + + cls.master_account_index = 0 + cls.slave_account_index = 1 + cls.origin_accounts, cls.destination_accounts = cls._create_accounts(cls) + + def _assign_origin_accounts(self): + self.transfer_model.write({ + 'account_ids': [(6, 0, self.origin_accounts.ids)] + }) + + def _create_accounts(self, amount_of_master_accounts=2, amount_of_slave_accounts=4): + master_ids = self.env['account.account'] + + for i in range(amount_of_master_accounts): + self.master_account_index += 1 + master_ids += self.env['account.account'].create({ + 'name': 'MASTER %s' % self.master_account_index, + 'code': 'MA00%s' % self.master_account_index, + 'account_type': 'asset_receivable', + 'reconcile': True + }) + + slave_ids = self.env['account.account'] + for i in range(amount_of_slave_accounts): + self.slave_account_index += 1 + slave_ids += self.env['account.account'].create({ + 'name': 'SLAVE %s' % self.slave_account_index, + 'code': 'SL000%s' % self.slave_account_index, + 'account_type': 'asset_receivable', + 'reconcile': True + }) + return master_ids, slave_ids + + def _create_analytic_account(self, code='ANAL01'): + return self.env['account.analytic.account'].create({'name': code, 'code': code, 'plan_id': self.analytic_plan.id}) + + def _create_partner(self, name="partner01"): + return self.env['res.partner'].create({'name': name}) + + def _create_basic_move(self, cred_account=None, deb_account=None, amount=0, date_str='2019-02-01', + partner_id=False, name=False, cred_analytic=False, deb_analytic=False, + transfer_model_id=False, journal_id=False, posted=True): + move_vals = { + 'date': date_str, + 'transfer_model_id': transfer_model_id, + 'line_ids': [ + (0, 0, { + 'account_id': cred_account or self.origin_accounts[0].id, + 'credit': amount, + 'analytic_distribution': {cred_analytic: 100} if cred_analytic else {}, + 'partner_id': partner_id, + }), + (0, 0, { + 'account_id': deb_account or self.origin_accounts[1].id, + 'analytic_distribution': {deb_analytic: 100} if deb_analytic else {}, + 'debit': amount, + 'partner_id': partner_id, + }), + ] + } + if journal_id: + move_vals['journal_id'] = journal_id + move = self.env['account.move'].create(move_vals) + if posted: + move.action_post() + return move + + def _add_transfer_model_line(self, account_id: int = False, percent: float = 100.0, analytic_account_ids: list = False, partner_ids: list = False): + account_id = account_id or self.destination_accounts[0].id + return self.env['account.transfer.model.line'].create({ + 'percent': percent, + 'account_id': account_id, + 'transfer_model_id': self.transfer_model.id, + 'analytic_account_ids': analytic_account_ids and [(4, aa) for aa in analytic_account_ids], + 'partner_ids': partner_ids and [(4, p) for p in partner_ids], + }) diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py new file mode 100644 index 0000000..69645bc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model.py @@ -0,0 +1,537 @@ +# -*- coding: utf-8 -*- +from datetime import datetime, timedelta +from unittest.mock import patch, call +from functools import reduce +from itertools import chain +from freezegun import freeze_time + +from dateutil.relativedelta import relativedelta +from odoo.addons.odex30_account_auto_transfer.tests.account_auto_transfer_test_classes import AccountAutoTransferTestCase + +from odoo import Command, fields +from odoo.models import UserError, ValidationError +from odoo.tests import tagged + +# ############################################################################ # +# FUNCTIONAL TESTS # +# ############################################################################ # +@tagged('post_install', '-at_install') +class TransferModelTestFunctionalCase(AccountAutoTransferTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Model with 4 lines of 20%, 20% is left in origin accounts + cls.functional_transfer = cls.env['account.transfer.model'].create({ + 'name': 'Test Functional Model', + 'date_start': '2019-01-01', + 'date_stop': datetime.today() + relativedelta(months=1), + 'journal_id': cls.journal.id, + 'account_ids': [(6, 0, cls.origin_accounts.ids)], + 'line_ids': [(0, 0, { + 'account_id': account.id, + 'percent': 20, + }) for account in cls.destination_accounts], + }) + neutral_account = cls.env['account.account'].create({ + 'name': 'Neutral Account', + 'code': 'NEUT', + 'account_type': 'income', + }) + cls.analytic_accounts = reduce(lambda x, y: x + y, (cls._create_analytic_account(cls, name) for name in ('ANA1', 'ANA2', 'ANA3'))) + cls.dates = ('2019-01-15', '2019-02-15') + # Create one line for each date... + for date in cls.dates: + # ...with each analytic account, and with no analytic account... + for an_account in chain(cls.analytic_accounts, [cls.env['account.analytic.account']]): + # ...in each origin account with a balance of 1000. + for account in cls.origin_accounts: + cls._create_basic_move( + cls, + deb_account=account.id, + deb_analytic=an_account.id, + cred_account=neutral_account.id, + amount=1000, + date_str=date, + ) + + def test_no_analytics(self): + # Balance is +8000 in each origin account + # 80% is transfered in 4 destination accounts in equal proprotions + self.functional_transfer.action_perform_auto_transfer() + # 1600 is left in each origin account + for account in self.origin_accounts: + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 1600) + # 3200 has been transfered in each destination account + for account in self.destination_accounts: + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 3200) + for date in self.dates: + # 2 move lines have been created in each account for each date + self.assertEqual(len(self.env['account.move.line'].search([('account_id', '=', account.id), ('date', '=', fields.Date.to_date(date) + relativedelta(day=31))])), 2) + + def test_analytics(self): + # Each line with analytic accounts is set to 100% + self.functional_transfer.line_ids[0].analytic_account_ids = self.analytic_accounts[0:2] + self.functional_transfer.line_ids[1].analytic_account_ids = self.analytic_accounts[2] + + self.functional_transfer.action_perform_auto_transfer() + # 1200 is left in each origin account (60% of 2 lines) + for account in self.origin_accounts: + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', account.id)]).mapped('balance')), 1200) + # 8000 has been transfered the first destination account (100% of 8 lines) + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[0].id)]).mapped('balance')), 8000) + # 4000 has been transfered the first destination account (100% of 4 lines) + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[1].id)]).mapped('balance')), 4000) + # 800 has been transfered in each of the last two destination account (20% of 4 lines) + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[2].id)]).mapped('balance')), 800) + self.assertEqual(sum(self.env['account.move.line'].search([('account_id', '=', self.destination_accounts[3].id)]).mapped('balance')), 800) + + +# ############################################################################ # +# UNIT TESTS # +# ############################################################################ # +@tagged('post_install', '-at_install') +class TransferModelTestCase(AccountAutoTransferTestCase): + @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel.action_perform_auto_transfer') + def test_action_cron_auto_transfer(self, patched): + TransferModel = self.env['account.transfer.model'] + TransferModel.create({ + 'name': 'Test Cron Model', + 'date_start': '2019-01-01', + 'date_stop': datetime.today() + relativedelta(months=1), + 'journal_id': self.journal.id + }) + TransferModel.action_cron_auto_transfer() + patched.assert_called_once() + + @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._create_or_update_move_for_period') + @freeze_time('2022-01-01') + def test_action_perform_auto_transfer(self, patched): + self.transfer_model.date_start = datetime.strftime(datetime.today() + relativedelta(day=1), "%Y-%m-%d") + # - CASE 1 : normal case, acting on current period + self.transfer_model.action_perform_auto_transfer() + patched.assert_not_called() # create_or_update method should not be called for self.transfer_model as no account_ids and no line_ids + + master_ids, slave_ids = self._create_accounts(1, 2) + self.transfer_model.write({'account_ids': [(6, 0, [master_ids.id])]}) + + self.transfer_model.action_perform_auto_transfer() + patched.assert_not_called() # create_or_update method should not be called for self.transfer_model as no line_ids + + self.transfer_model.write({'line_ids': [ + (0, 0, { + 'percent': 50.0, + 'account_id': slave_ids[0].id + }), + (0, 0, { + 'percent': 50.0, + 'account_id': slave_ids[1].id + }) + ]}) + + self.transfer_model.action_perform_auto_transfer() + patched.assert_called_once() # create_or_update method should be called for self.transfer_model + + # - CASE 2 : "old" case, acting on everything before now as nothing has been done yet + transfer_model = self.transfer_model.copy() + transfer_model.write({ + 'date_start': transfer_model.date_start + relativedelta(months=-12) + }) + initial_call_count = patched.call_count + transfer_model.action_perform_auto_transfer() + self.assertEqual(initial_call_count + 13, patched.call_count, '13 more calls should have been done') + + @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_auto_transfer_move_line_values') + def test__create_or_update_move_for_period(self, patched_get_auto_transfer_move_line_values): + # PREPARATION + master_ids, slave_ids = self._create_accounts(2, 0) + next_move_date = self.transfer_model._get_next_move_date(self.transfer_model.date_start) + patched_get_auto_transfer_move_line_values.return_value = [ + { + 'account_id': master_ids[0].id, + 'date_maturity': next_move_date, + 'credit': 250.0, + }, + { + 'account_id': master_ids[1].id, + 'date_maturity': next_move_date, + 'debit': 250.0, + } + ] + + # There is no existing move, this is a brand new one + created_move = self.transfer_model._create_or_update_move_for_period(self.transfer_model.date_start, next_move_date) + self.assertEqual(len(created_move.line_ids), 2) + self.assertRecordValues(created_move, [{ + 'date': next_move_date, + 'journal_id': self.transfer_model.journal_id.id, + 'transfer_model_id': self.transfer_model.id, + }]) + self.assertRecordValues(created_move.line_ids.filtered(lambda l: l.credit), [{ + 'account_id': master_ids[0].id, + 'date_maturity': next_move_date, + 'credit': 250.0, + }]) + self.assertRecordValues(created_move.line_ids.filtered(lambda l: l.debit), [{ + 'account_id': master_ids[1].id, + 'date_maturity': next_move_date, + 'debit': 250.0, + }]) + + patched_get_auto_transfer_move_line_values.return_value = [ + { + 'account_id': master_ids[0].id, + 'date_maturity': next_move_date, + 'credit': 78520.0, + }, + { + 'account_id': master_ids[1].id, + 'date_maturity': next_move_date, + 'debit': 78520.0, + } + ] + + # Update the existing move but don't create a new one + amount_of_moves = self.env['account.move'].search_count([]) + amount_of_move_lines = self.env['account.move.line'].search_count([]) + updated_move = self.transfer_model._create_or_update_move_for_period(self.transfer_model.date_start, next_move_date) + self.assertEqual(amount_of_moves, self.env['account.move'].search_count([]), 'No move have been created') + self.assertEqual(amount_of_move_lines, self.env['account.move.line'].search_count([]), + 'No move line have been created (in fact yes but the old ones have been deleted)') + self.assertEqual(updated_move, created_move, 'Existing move has been updated') + self.assertRecordValues(updated_move.line_ids.filtered(lambda l: l.credit), [{ + 'account_id': master_ids[0].id, + 'date_maturity': next_move_date, + 'credit': 78520.0, + }]) + self.assertRecordValues(updated_move.line_ids.filtered(lambda l: l.debit), [{ + 'account_id': master_ids[1].id, + 'date_maturity': next_move_date, + 'debit': 78520.0, + }]) + + def test__get_move_for_period(self): + # 2019-06-30 --> None as no move generated + date_to_test = datetime.strptime('2019-06-30', '%Y-%m-%d').date() + move_for_period = self.transfer_model._get_move_for_period(date_to_test) + self.assertIsNone(move_for_period, 'No move is generated yet') + + # Generate a move + move_date = self.transfer_model._get_next_move_date(self.transfer_model.date_start) + already_generated_move = self.env['account.move'].create({ + 'date': move_date, + 'journal_id': self.journal.id, + 'transfer_model_id': self.transfer_model.id + }) + # 2019-06-30 --> None as generated move is generated for 01/07 + move_for_period = self.transfer_model._get_move_for_period(date_to_test) + self.assertEqual(move_for_period, already_generated_move, 'Should be equal to the already generated move') + + # 2019-07-01 --> The generated move + date_to_test += relativedelta(days=1) + move_for_period = self.transfer_model._get_move_for_period(date_to_test) + self.assertIsNone(move_for_period, 'The generated move is for the next period') + + # 2019-07-02 --> None as generated move is generated for 01/07 + date_to_test += relativedelta(days=1) + move_for_period = self.transfer_model._get_move_for_period(date_to_test) + self.assertIsNone(move_for_period, 'No move is generated yet for the next period') + + def test__determine_start_date(self): + start_date = self.transfer_model._determine_start_date() + self.assertEqual(start_date, self.transfer_model.date_start, 'No moves generated yet, start date should be the start date of the transfer model') + + move = self._create_basic_move(date_str='2019-07-01', journal_id=self.journal.id, transfer_model_id=self.transfer_model.id, posted=False) + start_date = self.transfer_model._determine_start_date() + self.assertEqual(start_date, self.transfer_model.date_start, 'A move generated but not posted, start date should be the start date of the transfer model') + + move.action_post() + start_date = self.transfer_model._determine_start_date() + self.assertEqual(start_date, move.date + relativedelta(days=1), 'A move posted, start date should be the day after that move') + + second_move = self._create_basic_move(date_str='2019-08-01', journal_id=self.journal.id, transfer_model_id=self.transfer_model.id, posted=False) + start_date = self.transfer_model._determine_start_date() + self.assertEqual(start_date, move.date + relativedelta(days=1), 'Two moves generated, start date should be the day after the last posted one') + + second_move.action_post() + random_move = self._create_basic_move(date_str='2019-08-01', journal_id=self.journal.id) + start_date = self.transfer_model._determine_start_date() + self.assertEqual(start_date, second_move.date + relativedelta(days=1), 'Random move generated not linked to transfer model, start date should be the day after the last one linked to it') + + def test__get_next_move_date(self): + experimentations = { + 'month': [ + # date, expected date + (self.transfer_model.date_start, '2019-06-30'), + (fields.Date.to_date('2019-01-29'), '2019-02-27'), + (fields.Date.to_date('2019-01-30'), '2019-02-27'), + (fields.Date.to_date('2019-01-31'), '2019-02-27'), + (fields.Date.to_date('2019-02-28'), '2019-03-27'), + (fields.Date.to_date('2019-12-31'), '2020-01-30'), + ], + 'quarter': [ + (self.transfer_model.date_start, '2019-08-31'), + (fields.Date.to_date('2019-01-31'), '2019-04-29'), + (fields.Date.to_date('2019-02-28'), '2019-05-27'), + (fields.Date.to_date('2019-12-31'), '2020-03-30'), + ], + 'year': [ + (self.transfer_model.date_start, '2020-05-31'), + (fields.Date.to_date('2019-01-31'), '2020-01-30'), + (fields.Date.to_date('2019-02-28'), '2020-02-27'), + (fields.Date.to_date('2019-12-31'), '2020-12-30'), + ] + } + + for frequency in experimentations: + self.transfer_model.write({'frequency': frequency}) + for start_date, expected_date_str in experimentations[frequency]: + next_date = self.transfer_model._get_next_move_date(start_date) + self.assertEqual(next_date, fields.Date.to_date(expected_date_str), + 'Next date from %s should be %s' % (str(next_date), expected_date_str)) + + @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_non_analytic_transfer_values') + def test__get_non_filtered_auto_transfer_move_line_values(self, patched_get_values): + start_date = fields.Date.to_date('2019-01-01') + self.transfer_model.write({'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])], }) + end_date = fields.Date.to_date('2019-12-31') + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2019-12-01', + 'journal_id': self.company_data['default_journal_misc'].id, + 'line_ids': [ + (0, 0, { + 'debit': 4242.42, + 'credit': 0, + 'account_id': self.origin_accounts[0].id, + }), + (0, 0, { + 'debit': 8342.58, + 'credit': 0, + 'account_id': self.company_data.get('default_account_revenue').id, + }), + (0, 0, { + 'debit': 0, + 'credit': 0, + 'account_id': self.destination_accounts[0].id, + }), + (0, 0, { + 'debit': 0, + 'credit': 12585.0, + 'account_id': self.origin_accounts[1].id, + }), + ] + }) + move.action_post() + amount_left = 10.0 + patched_get_values.return_value = [{ + 'name': "YO", + 'account_id': 1, + 'date_maturity': start_date, + 'debit': 123.45 + }], amount_left + + exp = [{ + 'name': 'YO', + 'account_id': 1, + 'date_maturity': start_date, + 'debit': 123.45 + }, { + 'name': 'Automatic Transfer (-%s%%)' % self.transfer_model.total_percent, + 'account_id': self.origin_accounts[0].id, + 'date_maturity': end_date, + 'credit': 4242.42 - amount_left + }, { + 'name': 'YO', + 'account_id': 1, + 'date_maturity': start_date, + 'debit': 123.45 + }, { + 'name': 'Automatic Transfer (-%s%%)' % self.transfer_model.total_percent, + 'account_id': self.origin_accounts[1].id, + 'date_maturity': end_date, + 'debit': 12585.0 - amount_left + }] + res = self.transfer_model._get_non_filtered_auto_transfer_move_line_values([], start_date, end_date) + self.assertEqual(len(res), 4) + self.assertListEqual(exp, res) + + @patch( + 'odoo.addons.odex30_account_auto_transfer' + '.models.transfer_model.TransferModelLine._get_destination_account_transfer_move_line_values') + def test__get_non_analytic_transfer_values(self, patched): + # Just need a transfer model line + percents = [45, 45] + self.transfer_model.write({ + 'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])], + 'line_ids': [ + (0, 0, { + 'percent': percents[0], + 'account_id': self.destination_accounts[0].id + }), + (0, 0, { + 'percent': percents[1], + 'account_id': self.destination_accounts[1].id + }) + ] + }) + account = self.origin_accounts[0] + write_date = fields.Date.to_date('2019-01-01') + lines = self.transfer_model.line_ids + amount_of_line = len(lines) + amount = 4242.0 + is_debit = False + patched.return_value = { + 'name': "YO", + 'account_id': account.id, + 'date_maturity': write_date, + 'debit' if is_debit else 'credit': amount + } + expected_result_list = [patched.return_value] * 2 + expected_result_amount = amount * ((100.0 - sum(percents)) / 100.0) + + res = self.transfer_model._get_non_analytic_transfer_values(account, lines, write_date, amount, is_debit) + self.assertListEqual(res[0], expected_result_list) + self.assertAlmostEqual(res[1], expected_result_amount) + self.assertEqual(patched.call_count, amount_of_line) + + # need to round amount to avoid failing float comparison (as magic mock uses "==" to compare args) + exp_calls = [call(account, round(amount * (line.percent / 100.0), 1), is_debit, write_date) for line in lines] + patched.assert_has_calls(exp_calls) + + # Try now with 100% repartition + lines[0].write({'percent': 55.0}) + res = self.transfer_model._get_non_analytic_transfer_values(account, lines, write_date, amount, is_debit) + self.assertAlmostEqual(res[1], 0.0) + + # TEST CONSTRAINTS + def test__check_line_ids_percents(self): + with self.assertRaises(ValidationError): + transfer_model_lines = [] + for i, percent in enumerate((50.0, 50.01)): + transfer_model_lines.append((0, 0, { + 'percent': percent, + 'account_id': self.destination_accounts[i].id + })) + self.transfer_model.write({ + 'account_ids': [(6, 0, [ma.id for ma in self.origin_accounts])], + 'line_ids': transfer_model_lines + }) + + def test_unlink_of_transfer_with_no_moves(self): + """ Deletion of an automatic transfer that has no move should not raise an error. """ + + self.transfer_model.write({ + 'account_ids': [Command.link(self.origin_accounts[0].id)], + 'line_ids': [ + Command.create({ + 'percent': 100, + 'account_id': self.destination_accounts[0].id + }) + ] + }) + self.transfer_model.action_activate() + + self.assertEqual(self.transfer_model.move_ids_count, 0) + self.transfer_model.unlink() + + def test_error_unlink_of_transfer_with_moves(self): + """ Deletion of an automatic transfer that has posted/draft moves should raise an error. """ + + self.transfer_model.write({ + 'date_start': datetime.today() - relativedelta(day=1), + 'frequency': 'year', + 'account_ids': [Command.link(self.company_data['default_account_revenue'].id)], + 'line_ids': [ + Command.create({ + 'percent': 100, + 'account_id': self.destination_accounts[0].id + }) + ] + }) + self.transfer_model.action_activate() + + # Add a transaction on the journal so that the move is not empty + self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': datetime.today(), + 'invoice_line_ids': [ + Command.create({ + 'name': 'line1', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 1000.0, + }), + ] + }).action_post() + + # Generate draft moves + self.transfer_model.action_perform_auto_transfer() + + error_message = "You cannot delete an automatic transfer that has draft moves*" + with self.assertRaisesRegex(UserError, error_message): + self.transfer_model.unlink() + + # Post one of the moves + self.transfer_model.move_ids[0].action_post() + + error_message = "You cannot delete an automatic transfer that has posted moves*" + with self.assertRaisesRegex(UserError, error_message): + self.transfer_model.unlink() + + def test_disable_transfer_when_archived(self): + """ An automatic transfer in progress should be disabled when archived. """ + + self.transfer_model.action_activate() + self.assertEqual(self.transfer_model.state, 'in_progress') + + self.transfer_model.action_archive() + self.assertEqual(self.transfer_model.state, 'disabled') + + @freeze_time('2022-01-01') + def test_compute_transfer_lines_100_percent_transfer(self): + """ Transfer 100% of the source account in separate destinations. """ + self.transfer_model.date_start = datetime.strftime(datetime.today() + relativedelta(day=1), "%Y-%m-%d") + + _, slave_ids = self._create_accounts(0, 3) + self.transfer_model.write({ + 'account_ids': [Command.link(self.company_data['default_account_revenue'].id)], + 'line_ids': [ + Command.create({ + 'percent': 15, + 'account_id': slave_ids[0].id + }), + Command.create({ + 'percent': 42.50, + 'account_id': slave_ids[1].id + }), + Command.create({ + 'percent': 42.50, + 'account_id': slave_ids[2].id + }), + ] + }) + self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'invoice_date': datetime.today(), + 'invoice_line_ids': [ + Command.create({ + 'name': 'line_xyz', + 'account_id': self.company_data['default_account_revenue'].id, + 'price_unit': 410.34, + }), + ] + }).action_post() + self.transfer_model.action_activate() + self.transfer_model.action_perform_auto_transfer() + lines = self.transfer_model.move_ids.line_ids + # 100% of the total amount + self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == self.company_data['default_account_revenue']).debit, 410.34) + # 15% of the total amount + self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[0]).credit, 61.55) + # 42.50% of the total amount + self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[1]).credit, 174.39) + # the remaining amount of the total amount (42.50%) + self.assertAlmostEqual(lines.filtered(lambda l: l.account_id == slave_ids[2]).credit, 174.4) diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py new file mode 100644 index 0000000..d4a340b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/tests/test_transfer_model_line.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- + +from unittest.mock import patch + +from odoo.addons.odex30_account_auto_transfer.tests.account_auto_transfer_test_classes import AccountAutoTransferTestCase + +from odoo import fields +from odoo.tests import tagged + +# ############################################################################ # +# UNIT TESTS # +# ############################################################################ # +@tagged('post_install', '-at_install') +class MoveModelLineTestCase(AccountAutoTransferTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._assign_origin_accounts(cls) + + def test__get_transfer_move_lines_values_same_aaccounts(self): + amounts = [4242.42, 1234.56] + aaccounts = [self._create_analytic_account('ANAL0' + str(i)) for i in range(2)] + self._create_basic_move( + cred_account=self.destination_accounts[0].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[0], + deb_analytic=aaccounts[0].id + ) + self._create_basic_move( + cred_account=self.destination_accounts[1].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[1], + deb_analytic=aaccounts[1].id + ) + transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id, + analytic_account_ids=[aaccounts[0].id, aaccounts[1].id]) + transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id, + analytic_account_ids=[aaccounts[0].id]) + + transfer_models_lines = transfer_model_line_1 + transfer_model_line_2 + args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')] + res = transfer_models_lines._get_transfer_move_lines_values(*args) + exp = [{ + 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00, ANAL01)', + 'account_id': self.destination_accounts[0].id, + 'date_maturity': args[1], + 'debit': sum(amounts), + }, { + 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00, ANAL01)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': sum(amounts), + }] + self.assertListEqual(exp, res, + 'Only first transfer model line should be handled, second should get 0 and thus not be added') + + def test__get_transfer_move_lines_values(self): + amounts = [4242.0, 1234.56] + aaccounts = [self._create_analytic_account('ANAL0' + str(i)) for i in range(3)] + self._create_basic_move( + cred_account=self.destination_accounts[0].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[0], + deb_analytic=aaccounts[0].id + ) + self._create_basic_move( + cred_account=self.destination_accounts[1].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[1], + deb_analytic=aaccounts[2].id + ) + transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id, + analytic_account_ids=[aaccounts[0].id, aaccounts[1].id]) + transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id, + analytic_account_ids=[aaccounts[2].id]) + + transfer_models_lines = transfer_model_line_1 + transfer_model_line_2 + args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')] + res = transfer_models_lines._get_transfer_move_lines_values(*args) + exp = [ + { + 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00, ANAL01)', + 'account_id': self.destination_accounts[0].id, + 'date_maturity': args[1], + 'debit': amounts[0], + }, + { + 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00, ANAL01)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': amounts[0], + }, + { + 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL02)', + 'account_id': self.destination_accounts[1].id, + 'date_maturity': args[1], + 'debit': amounts[1], + }, + { + 'name': 'Automatic Transfer (entries with analytic account(s): ANAL02)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': amounts[1], + } + ] + self.assertListEqual(exp, res) + + @patch('odoo.addons.odex30_account_auto_transfer.models.transfer_model.TransferModel._get_move_lines_base_domain') + def test__get_move_lines_domain(self, patched): + return_val = [('bla', '=', 42)] + # we need to copy return val as there are edge effects due to mocking + # return_value is modified by the function call) + patched.return_value = return_val[:] + args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')] + aaccount_1 = self._create_analytic_account('ANAL01') + aaccount_2 = self._create_analytic_account('ANAL02') + percent = 42.42 + analytic_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id, + analytic_account_ids=[aaccount_1.id, aaccount_2.id]) + percent_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[1].id, percent=percent) + + anal_res = analytic_transfer_model_line._get_move_lines_domain(*args) + anal_expected = return_val + patched.assert_called_once_with(*args) + self.assertListEqual(anal_res, anal_expected) + patched.reset_mock() + + perc_res = percent_transfer_model_line._get_move_lines_domain(*args) + patched.assert_called_once_with(*args) + self.assertListEqual(perc_res, patched.return_value) + + def test__get_origin_account_transfer_move_line_values(self): + percent = 92.42 + transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id, percent=percent) + origin_account = self.origin_accounts[0] + amount = 4200.42 + is_debit = True + write_date = fields.Date.to_date('2019-12-19') + params = [origin_account, amount, is_debit, write_date] + result = transfer_model_line._get_origin_account_transfer_move_line_values(*params) + expected = { + 'name': 'Automatic Transfer (to account %s)' % self.destination_accounts[0].code, + 'account_id': origin_account.id, + 'date_maturity': write_date, + 'credit' if is_debit else 'debit': amount + } + self.assertDictEqual(result, expected) + + def test__get_destination_account_transfer_move_line_values(self): + aaccount_1 = self._create_analytic_account('ANAL01') + aaccount_2 = self._create_analytic_account('ANAL02') + percent = 42.42 + analytic_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[0].id, + analytic_account_ids=[aaccount_1.id, aaccount_2.id]) + percent_transfer_model_line = self._add_transfer_model_line(self.destination_accounts[1].id, percent=percent) + origin_account = self.origin_accounts[0] + amount = 4200 + is_debit = True + write_date = fields.Date.to_date('2019-12-19') + params = [origin_account, amount, is_debit, write_date] + anal_result = analytic_transfer_model_line._get_destination_account_transfer_move_line_values(*params) + aaccount_names = ', '.join([aac.name for aac in [aaccount_1, aaccount_2]]) + anal_expected_result = { + 'name': 'Automatic Transfer (from account %s with analytic account(s): %s)' % ( + origin_account.code, aaccount_names), + 'account_id': self.destination_accounts[0].id, + 'date_maturity': write_date, + 'debit' if is_debit else 'credit': amount + } + self.assertDictEqual(anal_result, anal_expected_result) + percent_result = percent_transfer_model_line._get_destination_account_transfer_move_line_values(*params) + percent_expected_result = { + 'name': 'Automatic Transfer (%s%% from account %s)' % (percent, self.origin_accounts[0].code), + 'account_id': self.destination_accounts[1].id, + 'date_maturity': write_date, + 'debit' if is_debit else 'credit': amount + } + self.assertDictEqual(percent_result, percent_expected_result) + + def test__get_transfer_move_lines_values_same_partner_ids(self): + """ + Make sure we only process the account moves once. + Here the second line references a partner already handled in the first one. + The second transfer should thus not be apply on the account lines already handled by the first transfer. + """ + amounts = [4242.42, 1234.56] + partner_ids = [self._create_partner('partner' + str(i))for i in range(2)] + self._create_basic_move( + cred_account=self.destination_accounts[0].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[0], + partner_id=partner_ids[0].id, + date_str='2019-02-01' + ) + self._create_basic_move( + cred_account=self.destination_accounts[1].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[1], + partner_id=partner_ids[1].id, + date_str='2019-02-01' + ) + self._create_basic_move( + cred_account=self.destination_accounts[0].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[0], + date_str='2019-02-01' + ) + transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[0].id, + partner_ids=[partner_ids[0].id, partner_ids[1].id]) + transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[1].id, + partner_ids=[partner_ids[0].id]) + + transfer_models_lines = transfer_model_line_1 + transfer_model_line_2 + args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')] + res = transfer_models_lines._get_transfer_move_lines_values(*args) + exp = [{ + 'name': 'Automatic Transfer (from account MA001 with partner(s): partner0, partner1)', + 'account_id': self.destination_accounts[0].id, + 'date_maturity': args[1], + 'debit': sum(amounts), + }, { + 'name': 'Automatic Transfer (entries with partner(s): partner0, partner1)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': sum(amounts), + }] + self.assertListEqual(exp, res, + 'Only first transfer model line should be handled, second should get 0 and thus not be added') + + def test__get_transfer_move_lines_values_partner(self): + """ + Create account moves and transfer, verify that the result of the auto transfer is correct. + """ + amounts = [4242.0, 1234.56] + aaccounts = [self._create_analytic_account('ANAL00')] + partner_ids = [self._create_partner('partner' + str(i))for i in range(2)] + self._create_basic_move( + cred_account=self.destination_accounts[2].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[0], + partner_id=partner_ids[0].id, + date_str='2019-02-01' + ) + self._create_basic_move( + cred_account=self.destination_accounts[3].id, + deb_account=self.origin_accounts[0].id, + amount=amounts[1], + deb_analytic=aaccounts[0].id, + partner_id=partner_ids[1].id, + date_str='2019-02-01' + ) + transfer_model_line_1 = self._add_transfer_model_line(self.destination_accounts[3].id, + analytic_account_ids=[aaccounts[0].id], + partner_ids=[partner_ids[1].id]) + transfer_model_line_2 = self._add_transfer_model_line(self.destination_accounts[2].id, + partner_ids=[partner_ids[0].id]) + + transfer_models_lines = transfer_model_line_1 + transfer_model_line_2 + args = [fields.Date.to_date('2019-01-01'), fields.Date.to_date('2019-12-19')] + res = transfer_models_lines._get_transfer_move_lines_values(*args) + exp = [ + { + 'name': 'Automatic Transfer (from account MA001 with analytic account(s): ANAL00 and partner(s): partner1)', + 'account_id': self.destination_accounts[3].id, + 'date_maturity': args[1], + 'debit': amounts[1], + }, + { + 'name': 'Automatic Transfer (entries with analytic account(s): ANAL00 and partner(s): partner1)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': amounts[1], + }, + { + 'name': 'Automatic Transfer (from account MA001 with partner(s): partner0)', + 'account_id': self.destination_accounts[2].id, + 'date_maturity': args[1], + 'debit': amounts[0], + }, + { + 'name': 'Automatic Transfer (entries with partner(s): partner0)', + 'account_id': self.origin_accounts[0].id, + 'date_maturity': args[1], + 'credit': amounts[0], + }, + ] + self.assertListEqual(exp, res) diff --git a/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml b/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml new file mode 100644 index 0000000..de695e1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_auto_transfer/views/transfer_model_views.xml @@ -0,0 +1,142 @@ + + + + + account.auto.transfer.search + account.move + + + + + + + + + + + + Automatic Transfers + account.transfer.model + list,form + + + + + Generated Entries + account.move + list,form + + + + + + + + + + + account.auto.transfer.model.list + account.transfer.model + + + + + + + + + + + + + account.auto.transfer.model.form + account.transfer.model + +
    +
    +
    + +
    + +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    + + account.auto.transfer.model.search + account.transfer.model + + + + + + + + +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py new file mode 100644 index 0000000..7749576 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- + +from . import models +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py new file mode 100644 index 0000000..f524d31 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/__manifest__.py @@ -0,0 +1,26 @@ +# -*- encoding: utf-8 -*- +{ + 'name': 'Account Bank Statement Import', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'version': '1.0', + 'depends': ['odex30_account_accountant', 'base_import'], + 'description': """Generic Wizard to Import Bank Statements. + +(This module does not include any type of import format.) + +OFX and QIF imports are available in Enterprise version.""", + 'data': [ + 'views/account_bank_statement_import_view.xml', + ], + 'demo': [ + 'demo/partner_bank.xml', + ], + 'installable': True, + 'auto_install': True, + 'assets': { + 'web.assets_backend': [ + 'odex30_account_bank_statement_import/static/src/**/*', + ], + }, + +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..580c2ae Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml new file mode 100644 index 0000000..ab15c79 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/demo/partner_bank.xml @@ -0,0 +1,30 @@ + + + + + + BE68539007547034 + + + + + + 00987654322 + + + + + + 10987654320 + + + + + + 10987654322 + + + + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po new file mode 100644 index 0000000..65b1fa4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/i18n/ar.po @@ -0,0 +1,201 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "%d transactions had already been imported and were ignored." +msgstr "تم استيراد %d معاملات بالفعل وتجاهلها. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "1 transaction had already been imported and was ignored." +msgstr "تم تجاهل 1 معاملة سبق استيرادها بالفعل." + +#. module: odex30_account_bank_statement_import +#: model:ir.model.constraint,message:odex30_account_bank_statement_import.constraint_account_bank_statement_line_unique_import_id +msgid "A bank account transactions can be imported only once!" +msgstr "يمكن استيراد معاملات الحساب البنكي مرة واحدة فقط! " + +#. module: odex30_account_bank_statement_import +#: model:ir.model,name:odex30_account_bank_statement_import.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "بند كشف الحساب البنكي" + +#. module: odex30_account_bank_statement_import +#: model:ir.model,name:odex30_account_bank_statement_import.model_account_setup_bank_manual_config +msgid "Bank setup manual config" +msgstr "التهيئة اليدوية لإعدادات البنك " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"Cannot find in which journal import this statement. Please manually select a" +" journal." +msgstr "" +"لقد تعذر إيجاد دفتر اليومية الذي يتم توريد كشف الحساب المحدد إليه. الرجاء " +"اختيار دفتر اليومية يدوياً. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0 +msgid "Click \"New\" or upload a %s." +msgstr "اضغط على\"جديد\" أو قم برفع %s. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"Could not make sense of the given file.\n" +"Did you install the module to support this type of file?" +msgstr "" +" تعذر فهم طبيعة الملف المحدد.\n" +"هل قمت بتثبيت التطبيق المناسب لدعم هذا النوع من الملفات؟ " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "Go to Apps" +msgstr "الذهاب إلى التطبيقات " + +#. module: odex30_account_bank_statement_import +#: model_terms:ir.ui.view,arch_db:odex30_account_bank_statement_import.journal_dashboard_view_inherit +msgid "Import File" +msgstr "استيراد ملف" + +#. module: odex30_account_bank_statement_import +#: model:ir.model.fields,field_description:odex30_account_bank_statement_import.field_account_bank_statement_line__unique_import_id +msgid "Import ID" +msgstr "معرف الاستيراد" + +#. module: odex30_account_bank_statement_import +#. odoo-javascript +#: code:addons/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js:0 +msgid "Import Template for Bank Statements" +msgstr "استيراد قالب لكشوفات الحسابات البنكية " + +#. module: odex30_account_bank_statement_import +#: model:ir.model,name:odex30_account_bank_statement_import.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "Manual (or import %(import_formats)s)" +msgstr "يدوي (أو استيراد %(import_formats)s) " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "No attachment was provided" +msgstr "لم يتم توفير مرفق" + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "No currency found matching '%s'." +msgstr "لم يمكن العثور على أي عملة تطابق '%s'." + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0 +msgid "No transactions matching your filters were found." +msgstr "لم يتم العثور على أي معاملات تطابق عوامل التصفية الخاصة بك. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_bank_statement.py:0 +msgid "Nothing to do here!" +msgstr "لا شيء لتفعله هنا! " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"The account of this statement (%(account)s) is not the same as the journal " +"(%(journal)s)." +msgstr "" +"الحساب في كشف الحساب البنكي (%(account)s) مختلف عن حساب دفتر اليومية " +"(%(journal)s). " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"The currency of the bank statement (%(code)s) is not the same as the " +"currency of the journal (%(journal)s)." +msgstr "" +"العملة في كشف الحساب البنكي (%(code)s) مختلفة عن العملة في دفتر اليومية " +"(%(journal)s). " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "The following files could not be imported:\n" +msgstr "تعذر استيراد الملفات التالية: \n" + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"This file doesn't contain any statement for account %s.\n" +"If it contains transactions for more than one account, it must be imported on each of them." +msgstr "" +"لا يحتوي هذا الملف على أي كشف للحساب %s.\n" +"إذا كان يحتوي على معاملات لأكثر من حساب، فيجب استيراده في كل منها. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "" +"This file doesn't contain any transaction for account %s.\n" +"If it contains transactions for more than one account, it must be imported on each of them." +msgstr "" +"لا يحتوي هذا الملف على أي معاملات للحساب %s.\n" +"إذا كان يحتوي على معاملات لأكثر من حساب، فيجب استيراده في كل منها. " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "View successfully imported statements" +msgstr "عرض كشوفات الحساب البنكية التي تم استيرادها بنجاح " + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "You already have imported that file." +msgstr "لقد قمت باستيراد هذا الملف بالفعل." + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "You have to set a Default Account for the journal: %s" +msgstr "عليك تعيين حساب افتراضي لليومية: %s" + +#. module: odex30_account_bank_statement_import +#. odoo-python +#: code:addons/odex30_account_bank_statement_import/models/account_journal.py:0 +msgid "You uploaded an invalid or empty file." +msgstr "لقد قمت برفع ملف فارغ أو غير صالح. " diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py new file mode 100644 index 0000000..5b36696 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_bank_statement +from . import account_journal diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c591a80 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc new file mode 100644 index 0000000..f6cb64f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_bank_statement.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..d98c0e6 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py new file mode 100644 index 0000000..6391252 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_bank_statement.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models, _ + +from markupsafe import Markup + +import logging +_logger = logging.getLogger(__name__) + + +class AccountBankStatementLine(models.Model): + _inherit = "account.bank.statement.line" + + # Ensure transactions can be imported only once (if the import format provides unique transaction ids) + unique_import_id = fields.Char(string='Import ID', readonly=True, copy=False) + + _sql_constraints = [ + ('unique_import_id', 'unique (unique_import_id)', 'A bank account transactions can be imported only once!') + ] + + def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True): + res = super()._action_open_bank_reconciliation_widget(extra_domain, default_context, name, kanban_first) + res['help'] = Markup("

    {}

    {}
    {}

    ").format( + _('Nothing to do here!'), + _('No transactions matching your filters were found.'), + _('Click "New" or upload a %s.', ", ".join(self.env['account.journal']._get_bank_statements_available_import_formats())), + ) + return res diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py new file mode 100644 index 0000000..114ea1d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/models/account_journal.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +from odoo import models, tools, _ +from odoo.addons.base.models.res_bank import sanitize_account_number +from odoo.exceptions import UserError, RedirectWarning + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + def _get_bank_statements_available_import_formats(self): + """ Returns a list of strings representing the supported import formats. + """ + return [] + + def __get_bank_statements_available_sources(self): + rslt = super(AccountJournal, self).__get_bank_statements_available_sources() + formats_list = self._get_bank_statements_available_import_formats() + if formats_list: + formats_list.sort() + import_formats_str = ', '.join(formats_list) + rslt.append(("file_import", _("Manual (or import %(import_formats)s)", import_formats=import_formats_str))) + return rslt + + def create_document_from_attachment(self, attachment_ids=None): + journal = self or self.browse(self.env.context.get('default_journal_id')) + if journal.type in ('bank', 'credit', 'cash'): + attachments = self.env['ir.attachment'].browse(attachment_ids) + if not attachments: + raise UserError(_("No attachment was provided")) + return journal._import_bank_statement(attachments) + return super().create_document_from_attachment(attachment_ids) + + def _import_bank_statement(self, attachments): + """ Process the file chosen in the wizard, create bank statement(s) and go to reconciliation. """ + if any(not a.raw for a in attachments): + raise UserError(_("You uploaded an invalid or empty file.")) + + statement_ids_all = [] + notifications_all = {} + errors = {} + # Let the appropriate implementation module parse the file and return the required data + # The active_id is passed in context in case an implementation module requires information about the wizard state (see QIF) + for attachment in attachments: + try: + currency_code, account_number, stmts_vals = self._parse_bank_statement_file(attachment) + # Check raw data + self._check_parsed_data(stmts_vals, account_number) + # Try to find the currency and journal in odoo + journal = self._find_additional_data(currency_code, account_number) + # If no journal found, ask the user about creating one + if not journal.default_account_id: + raise UserError(_('You have to set a Default Account for the journal: %s', journal.name)) + # Prepare statement data to be used for bank statements creation + stmts_vals = self._complete_bank_statement_vals(stmts_vals, journal, account_number, attachment) + # Create the bank statements + statement_ids, dummy, notifications = self._create_bank_statements(stmts_vals) + statement_ids_all.extend(statement_ids) + + # Now that the import worked out, set it as the bank_statements_source of the journal + if journal.bank_statements_source != 'file_import': + # Use sudo() because only 'account.group_account_manager' + # has write access on 'account.journal', but 'account.group_account_user' + # must be able to import bank statement files + journal.sudo().bank_statements_source = 'file_import' + + msg = "" + for notif in notifications: + msg += ( + f"{notif['message']}" + ) + if notifications: + notifications_all[attachment.name] = msg + except (UserError, RedirectWarning) as e: + errors[attachment.name] = e.args[0] + + statements = self.env['account.bank.statement'].browse(statement_ids_all) + line_to_reconcile = statements.line_ids + if line_to_reconcile: + # 'limit_time_real_cron' defaults to -1. + # Manual fallback applied for non-POSIX systems where this key is disabled (set to None). + cron_limit_time = tools.config['limit_time_real_cron'] or -1 + limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180 + line_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time) + + result = self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=[('statement_id', 'in', statements.ids)], + default_context={ + 'search_default_not_matched': True, + 'default_journal_id': statements[:1].journal_id.id, + 'notifications': notifications_all, + }, + ) + + if errors: + error_msg = _("The following files could not be imported:\n") + error_msg += "\n".join([f"- {attachment_name}: {msg}" for attachment_name, msg in errors.items()]) + if statements: + self.env.cr.commit() # save the correctly uploaded statements to the db before raising the errors + raise RedirectWarning(error_msg, result, _('View successfully imported statements')) + else: + raise UserError(error_msg) + return result + + def _parse_bank_statement_file(self, attachment) -> tuple: + """ Each module adding a file support must extends this method. It processes the file if it can, returns super otherwise, resulting in a chain of responsability. + This method parses the given file and returns the data required by the bank statement import process, as specified below. + rtype: triplet (if a value can't be retrieved, use None) + - currency code: string (e.g: 'EUR') + The ISO 4217 currency code, case insensitive + - account number: string (e.g: 'BE1234567890') + The number of the bank account which the statement belongs to + - bank statements data: list of dict containing (optional items marked by o) : + - 'name': string (e.g: '000000123') + - 'date': date (e.g: 2013-06-26) + -o 'balance_start': float (e.g: 8368.56) + -o 'balance_end_real': float (e.g: 8888.88) + - 'transactions': list of dict containing : + - 'name': string (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01') + - 'date': date + - 'amount': float + - 'unique_import_id': string + -o 'account_number': string + Will be used to find/create the res.partner.bank in odoo + -o 'note': string + -o 'partner_name': string + -o 'ref': string + """ + raise RedirectWarning( + message=_("Could not make sense of the given file.\nDid you install the module to support this type of file?"), + action=self.env.ref('base.open_module_tree').id, + button_text=_("Go to Apps"), + additional_context={ + 'search_default_name': 'account_bank_statement_import', + 'search_default_extra': True, + }, + ) + + def _check_parsed_data(self, stmts_vals, account_number): + """ Basic and structural verifications """ + if len(stmts_vals) == 0: + raise UserError(_( + 'This file doesn\'t contain any statement for account %s.\nIf it contains transactions for more than one account, it must be imported on each of them.', + account_number, + )) + + no_st_line = True + for vals in stmts_vals: + if vals['transactions'] and len(vals['transactions']) > 0: + no_st_line = False + break + if no_st_line: + raise UserError(_( + 'This file doesn\'t contain any transaction for account %s.\nIf it contains transactions for more than one account, it must be imported on each of them.', + account_number, + )) + + def _statement_import_check_bank_account(self, account_number): + # Needed for CH to accommodate for non-unique account numbers + sanitized_acc_number = self.bank_account_id.sanitized_acc_number.split(" ")[0] + # Needed for BNP France + if len(sanitized_acc_number) == 27 and len(account_number) == 11 and sanitized_acc_number[:2].upper() == "FR": + return sanitized_acc_number[14:-2] == account_number + + # Needed for Credit Lyonnais (LCL) + if len(sanitized_acc_number) == 27 and len(account_number) == 7 and sanitized_acc_number[:2].upper() == "FR": + return sanitized_acc_number[18:-2] == account_number + + return sanitized_acc_number == account_number + + def _find_additional_data(self, currency_code, account_number): + """ Look for the account.journal using values extracted from the + statement and make sure it's consistent. + """ + company_currency = self.env.company.currency_id + currency = None + sanitized_account_number = sanitize_account_number(account_number) + + if currency_code: + currency = self.env['res.currency'].search([('name', '=ilike', currency_code)], limit=1) + if not currency: + raise UserError(_("No currency found matching '%s'.", currency_code)) + if currency == company_currency: + currency = False + + journal = self + if account_number: + # No bank account on the journal : create one from the account number of the statement + if journal and not journal.bank_account_id: + journal.set_bank_account(account_number) + # No journal passed to the wizard : try to find one using the account number of the statement + elif not journal: + journal = self.search([('bank_account_id.sanitized_acc_number', '=', sanitized_account_number)]) + if not journal: + # Sometimes the bank returns only part of the full account number (e.g. local account number instead of full IBAN) + partial_match = self.search([('bank_account_id.sanitized_acc_number', 'ilike', sanitized_account_number)]) + if len(partial_match) == 1: + journal = partial_match + # Already a bank account on the journal : check it's the same as on the statement + else: + if not self._statement_import_check_bank_account(sanitized_account_number): + raise UserError(_('The account of this statement (%(account)s) is not the same as the journal (%(journal)s).', account=account_number, journal=journal.bank_account_id.acc_number)) + + # If importing into an existing journal, its currency must be the same as the bank statement + if journal: + journal_currency = journal.currency_id or journal.company_id.currency_id + if currency is None: + currency = journal_currency + if currency and currency != journal_currency: + statement_cur_code = not currency and company_currency.name or currency.name + journal_cur_code = not journal_currency and company_currency.name or journal_currency.name + raise UserError(_('The currency of the bank statement (%(code)s) is not the same as the currency of the journal (%(journal)s).', code=statement_cur_code, journal=journal_cur_code)) + + if not journal: + raise UserError(_('Cannot find in which journal import this statement. Please manually select a journal.')) + return journal + + def _complete_bank_statement_vals(self, stmts_vals, journal, account_number, attachment): + for st_vals in stmts_vals: + if not st_vals.get('reference'): + st_vals['reference'] = attachment.name + for line_vals in st_vals['transactions']: + line_vals['journal_id'] = journal.id + unique_import_id = line_vals.get('unique_import_id') + if unique_import_id: + sanitized_account_number = sanitize_account_number(account_number) + line_vals['unique_import_id'] = (sanitized_account_number and sanitized_account_number + '-' or '') + str(journal.id) + '-' + unique_import_id + + if not line_vals.get('partner_bank_id'): + # Find the partner and his bank account or create the bank account. The partner selected during the + # reconciliation process will be linked to the bank when the statement is closed. + identifying_string = line_vals.get('account_number') + if identifying_string: + if line_vals.get('partner_id'): + partner_bank = self.env['res.partner.bank'].search([ + ('acc_number', '=', identifying_string), + ('partner_id', '=', line_vals['partner_id']) + ]) + else: + partner_bank = self.env['res.partner.bank'].search([ + ('acc_number', '=', identifying_string), + ('company_id', 'in', (False, journal.company_id.id)) + ]) + # If multiple partners share the same account number, do not try to guess and just avoid setting it + if partner_bank and len(partner_bank) == 1: + line_vals['partner_bank_id'] = partner_bank.id + line_vals['partner_id'] = partner_bank.partner_id.id + return stmts_vals + + def _create_bank_statements(self, stmts_vals, raise_no_imported_file=True): + """ Create new bank statements from imported values, filtering out already imported transactions, and returns data used by the reconciliation widget """ + BankStatement = self.env['account.bank.statement'] + BankStatementLine = self.env['account.bank.statement.line'] + + # Filter out already imported transactions and create statements + statement_ids = [] + statement_line_ids = [] + ignored_statement_lines_import_ids = [] + for st_vals in stmts_vals: + filtered_st_lines = [] + for line_vals in st_vals['transactions']: + if (line_vals['amount'] != 0 + and ('unique_import_id' not in line_vals + or not line_vals['unique_import_id'] + or not bool(BankStatementLine.sudo().search([('unique_import_id', '=', line_vals['unique_import_id'])], limit=1)))): + filtered_st_lines.append(line_vals) + else: + ignored_statement_lines_import_ids.append(line_vals) + if st_vals.get('balance_start') is not None: + st_vals['balance_start'] += float(line_vals['amount']) + + if len(filtered_st_lines) > 0: + # Remove values that won't be used to create records + st_vals.pop('transactions', None) + # Create the statement + st_vals['line_ids'] = [[0, False, line] for line in filtered_st_lines] + statement = BankStatement.with_context(default_journal_id=self.id).create(st_vals) + if not statement.name: + statement.name = st_vals['reference'] + statement_ids.append(statement.id) + statement_line_ids.extend(statement.line_ids.ids) + + # Create the report. + if statement.is_complete and not self._context.get('skip_pdf_attachment_generation'): + statement.action_generate_attachment() + + if len(statement_line_ids) == 0 and raise_no_imported_file: + raise UserError(_('You already have imported that file.')) + + # Prepare import feedback + notifications = [] + num_ignored = len(ignored_statement_lines_import_ids) + if num_ignored > 0: + notifications += [{ + 'type': 'warning', + 'message': _("%d transactions had already been imported and were ignored.", num_ignored) + if num_ignored > 1 + else _("1 transaction had already been imported and was ignored."), + }] + return statement_ids, statement_line_ids, notifications diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv b/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv new file mode 100644 index 0000000..7be40fd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv @@ -0,0 +1,6 @@ +Journal,Name,Date,Starting Balance,Ending Balance,Statement lines / Date,Statement lines / Label,Statement lines / Partner,Statement lines / Reference,Statement lines / Amount,Statement lines / Amount Currency,Statement lines / Currency +Bank,Statement May 01,2017-05-15,100,5124.5,2017-05-10,INV/2017/0001,,#01,4610,, +,,,,,2017-05-11,Payment bill 20170521,,#02,-100,, +,,,,,2017-05-15,INV/2017/0003 discount 2% early payment,,#03,514.5,, +Bank,Statement May 02,2017-05-30,5124.5,9847.35,2017-05-30,INV/2017/0002 + INV/2017/0004,,#01,5260,, +,,,,,2017-05-31,Payment bill EUR 001234565,,#02,-537.15,-500,EUR diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg b/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg new file mode 100644 index 0000000..664381e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/description/icon_src.svg @@ -0,0 +1,178 @@ + +image/svg+xml08/12/13 1000.00 Delta PC08/15/13 75.46 Walts Drugs03/03/13 379.00 Epic Technologies03/04/13 20.28 YOUR LOCAL SU03/03/13 421.35 SPRINGFIELD WA03/03/13 379.00 Epic Technologies03/04/13 20.28 YOUR LOCAL SUP08/15/13 75.46 Walts Drugs08/12/13 1000.00 Delta PC03/03/13 421.35 SPRINGFIELD WA03/04/13 20.28 YOUR LOCAL SU03/03/13 379.00 Epic Technologies08/12/13 1000.00 De a PC03/03/13 379.00 E Technologies08/15/13 75.46 Walts Drugs03/04/13 20.28 YOUR LOCAL SU03/03/13 379.00 Epic Technologies08/12/13 1000.00 Delta PC08/15/13 75.46 Walts Drugs + + \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js new file mode 100644 index 0000000..99b6246 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/account_bank_statement_import_model.js @@ -0,0 +1,18 @@ +/** @odoo-module **/ + +import { BaseImportModel } from "@base_import/import_model"; +import { patch } from "@web/core/utils/patch"; +import { _t } from "@web/core/l10n/translation"; + +patch(BaseImportModel.prototype, { + async init() { + await super.init(...arguments); + + if (this.resModel === "account.bank.statement") { + this.importTemplates.push({ + label: _t("Import Template for Bank Statements"), + template: "/odex30_account_bank_statement_import/static/csv/account.bank.statement.csv", + }); + } + } +}); diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js new file mode 100644 index 0000000..72c1af2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.js @@ -0,0 +1,10 @@ +/** @odoo-module **/ +import { patch } from "@web/core/utils/patch"; +import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader"; +import { BankRecFinishButtons } from "@odex30_account_accountant/components/bank_reconciliation/finish_buttons"; + +patch(BankRecFinishButtons, { + components: { + AccountFileUploader, + } +}) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml new file mode 100644 index 0000000..f9a6334 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/finish_buttons.xml @@ -0,0 +1,8 @@ + + + + +
    + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js new file mode 100644 index 0000000..61fece5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.js @@ -0,0 +1,42 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; +import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader"; +import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone"; +import { BankRecKanbanView, BankRecKanbanController, BankRecKanbanRenderer } from "@odex30_account_accountant/components/bank_reconciliation/kanban"; +import { useState } from "@odoo/owl"; + +export class BankRecKanbanUploadController extends BankRecKanbanController { + static components = { + ...BankRecKanbanController.components, + AccountFileUploader, + } +} + +export class BankRecUploadKanbanRenderer extends BankRecKanbanRenderer { + static template = "account.BankRecKanbanUploadRenderer"; + static components = { + ...BankRecKanbanRenderer.components, + UploadDropZone, + }; + setup() { + super.setup(); + this.dropzoneState = useState({ + visible: false, + }); + } + + onDragStart(ev) { + if (ev.dataTransfer.types.includes("Files")) { + this.dropzoneState.visible = true + } + } +} + +export const BankRecKanbanUploadView = { + ...BankRecKanbanView, + Controller: BankRecKanbanUploadController, + Renderer: BankRecUploadKanbanRenderer, + buttonTemplate: "account.BankRecKanbanButtons", +}; + +registry.category("views").add('bank_rec_widget_kanban', BankRecKanbanUploadView, { force: true }); diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml new file mode 100644 index 0000000..9252348 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/kanban.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + onDragStart + + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js new file mode 100644 index 0000000..8668138 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.js @@ -0,0 +1,43 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { AccountFileUploader } from "@account/components/account_file_uploader/account_file_uploader"; +import { UploadDropZone } from "@account/components/upload_drop_zone/upload_drop_zone"; +import { bankRecListView, BankRecListController } from "@odex30_account_accountant/components/bank_reconciliation/list"; +import { useState } from "@odoo/owl"; + +export class BankRecListUploadController extends BankRecListController { + static components = { + ...BankRecListController.components, + AccountFileUploader, + } +} + +export class BankRecListUploadRenderer extends ListRenderer { + static template = "account.BankRecListUploadRenderer"; + static components = { + ...ListRenderer.components, + UploadDropZone, + } + + setup() { + super.setup(); + this.dropzoneState = useState({ visible: false }); + } + + onDragStart(ev) { + if (ev.dataTransfer.types.includes("Files")) { + this.dropzoneState.visible = true + } + } +} + +export const bankRecListUploadView = { + ...bankRecListView, + Controller: BankRecListUploadController, + Renderer: BankRecListUploadRenderer, + buttonTemplate: "account.BankRecListUploadButtons", +} + +registry.category("views").add("bank_rec_list", bankRecListUploadView, { force: true }); diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml new file mode 100644 index 0000000..0db8ef5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/static/src/bank_reconciliation/list.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + onDragStart + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml b/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml new file mode 100644 index 0000000..2fb4234 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/views/account_bank_statement_import_view.xml @@ -0,0 +1,27 @@ + + + + + account.journal.dashboard.kanban.inherit + account.journal + + + +
    + +
    +
    +
    +
    + + + account.bank.statement.list + account.bank.statement + + + + account_tree + + + +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__init__.py new file mode 100644 index 0000000..a7e4f0c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__init__.py @@ -0,0 +1 @@ +from . import setup_wizards diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..74fe634 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/setup_wizards.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/setup_wizards.cpython-311.pyc new file mode 100644 index 0000000..f0ab6b4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/__pycache__/setup_wizards.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/setup_wizards.py b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/setup_wizards.py new file mode 100644 index 0000000..2a6ab18 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import/wizard/setup_wizards.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api + + +class SetupBarBankConfigWizard(models.TransientModel): + _inherit = 'account.setup.bank.manual.config' + + def validate(self): + """ Default the bank statement source of new bank journals as 'file_import' + """ + res = super(SetupBarBankConfigWizard, self).validate() + if (self.num_journals_without_account == 0 or self.linked_journal_id.bank_statements_source == 'undefined') \ + and self.env['account.journal']._get_bank_statements_available_import_formats(): + self.linked_journal_id.bank_statements_source = 'file_import' + return res diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__manifest__.py new file mode 100644 index 0000000..d7f5f92 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__manifest__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Import CAMT Bank Statement', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'depends': ['odex30_account_bank_statement_import'], + 'description': """ +Module to import CAMT bank statements. +====================================== + +Improve the import of bank statement feature to support the SEPA recommended Cash Management format (CAMT.053). + """, + 'auto_install': True, +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7221b97 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/doc/standard-camt053-statement-v2-en_1.pdf b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/doc/standard-camt053-statement-v2-en_1.pdf new file mode 100644 index 0000000..36420b4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/doc/standard-camt053-statement-v2-en_1.pdf differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/account_bank_statement_import_camt.pot b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/account_bank_statement_import_camt.pot new file mode 100644 index 0000000..9bcaad7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/account_bank_statement_import_camt.pot @@ -0,0 +1,1920 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_camt +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:26+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: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Concentration" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Corporate Trade" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Credit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Pre-Authorised" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Return" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Reversal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Settlement" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Transaction" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ARP Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Balancing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Closing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Management" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Opening" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Additional Info: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Additional Miscellaneous Credit Operations" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Additional Miscellaneous Debit Operations" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Address:\n" +"%s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Adjustments" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Automatic Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Back Value" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bank Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bank Fees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Blocked Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bonus Issue/Capitalisation Issue" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Borrowing fee" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Withdrawal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Brokerage fee" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Buy Sell Back" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "CSD Blocked Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Call on intermediate securities" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Capital Gains Distribution" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Dividend" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Letter" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Letter Adjustment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Management" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Pooling" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Withdrawal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash in lieu" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Certified Customer Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Charge/fees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Charges" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Check Number: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Reversal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Under Reserve" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Circular Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Clean Collection" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Client Owned Collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Collateral Management" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission excluding taxes" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission including taxes" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commodities" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Compensation/Claims" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Consumer Loans" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Controlled Disbursement" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Conversion" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Action" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Own Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Rebate" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate mark broker owned" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate mark client owned" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Counter Party: %(partner)s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Counter Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Adjustment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Adjustments" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Card Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Transfer with agreed Commercial Information" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross Trade" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Cash Withdrawal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Credit Card Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Intra Company Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Payroll/Salary Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Standing Order" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Crossed Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Custody" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Custody Collection" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Customer Card Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Debit Adjustments" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Decrease in Value" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Delivery" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Deposit/Contribution" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Depositary Receipt Issue" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Derivatives" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit under reserve" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Discounted Draft" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dishonoured/Unpaid Draft" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dividend Option" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dividend Reinvestment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Documentary Collection" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Documentary Credit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Domestic Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Draft Maturity Change" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drafts/BillOfOrders" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drawdown" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drawing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dutch Auction" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "End to end ID: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Entry Info: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity Premium Reserve" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity mark broker owned" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity mark client owned" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Rate Adjustment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded CCP" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded Non-CCP" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Extended Domain" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "External Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Factor Update" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fees, Commission , Taxes, Charges and Interest" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Final Maturity" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Final Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Direct Debit Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Own Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Deposit Interest Amount" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Term Deposits" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Term Loans" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Float adjustment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Cheque Under Reserve" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Currency Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Currency Withdrawal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Exchange" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards broker owned collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards client owned collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Freeze of funds" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Full Call / Early Redemption" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Future Variation Margin" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures Commission" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures Residual Amount" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Guarantees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Inspeci/Share Exchange" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Instruction ID: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest Payment with Principle" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Internal Account Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Internal Book Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Intra Company Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Invoice Accepted with Differed Due Date" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Cash Concentration Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Cheques" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Credit Transfers" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Direct Debits" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Real Time Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#: model:ir.model,name:odex30_account_bank_statement_import_camt.model_account_journal +msgid "Journal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lack" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending Broker Owned Cash Collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending Client Owned Cash Collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending income" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Liquidation Dividend / Liquidation Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Listed Derivatives – Futures" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Listed Derivatives – Options" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Loans, Deposits & Syndications" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lockbox Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Management Fees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Mandate ID: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Margin Payments" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Margin client owned cash collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Merchant Card Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Merger" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Credit Operations" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Debit Operations" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Securities Operations" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Mixed Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Mortgage Loans" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Netting" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "" +"No exchange rate was found to convert an amount into the currency of the " +"journal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Deliverable" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Settled" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Syndicated" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Taxable commissions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non-Presented Circular Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Not available" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Notice Deposits" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Notice Loans" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC CCP" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Bonds" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Credit Derivatives" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Equity" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Interest Rates" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Structured Exotic Derivatives" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Swaps" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Non-CCP" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Odd Lot Sale/Purchase" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "One-Off Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Open Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Opening & Closing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Option broker owned collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Option client owned collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Options" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Order Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Other" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Overdraft" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Overdraft Charge" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Pair-Off" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Redemption Without Reduction of Nominal Value" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Redemption with reduction of nominal value" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Payments" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Payroll/Salary Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Placement" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Please check the currency on your bank journal.\n" +"No statements in currency %s were found in this CAMT file." +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Please set the IBAN account on your bank journal.\n" +"\n" +"This CAMT file is targeting several IBAN accounts but none match the current journal." +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Point-of-Sale (POS) Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Point-of-Sale (POS) Payment - Debit Card" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Portfolio Move" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Posting Error" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Pre-Authorised Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Precious Metal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Principal Pay-down/pay-up" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Principal Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Priority Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Priority Issue" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Put Redemption" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Cash Concentration Transactions" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Cheques" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Credit Transfers" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Direct Debits" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Real Time Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption Asset Allocation" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption Withdrawing Plan" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reimbursements" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Renewal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repayment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repo" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repurchase offer/Issuer Bid/Reverse Rights." +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reset Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Cancellation Request" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Return/reimbursement of a Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Reversal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Return/Unpaid Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to a Payment Cancellation Request" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reverse Repo" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Rights Issue/Subscription Rights/Rights Offer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA B2B Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA Core Direct Debit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Same Day Value Credit Transfer" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities Borrowing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities Lending" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sell Buy Back" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement after collection" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement against bank guarantee" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement at Maturity" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement of Sight Export document" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement of Sight Import document" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement under reserve" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Smart-Card Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Spots" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Stamp duty" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Stand-By Letter Of Credit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Standing Order" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription Asset Allocation" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription Savings Plan" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swap Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swap broker owned collateral" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swaps" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sweep" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sweeping" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Switch" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Syndicated" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Syndications" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "TBA closing" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Tax Reclaim" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Taxes" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Tender" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Topping" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade Services" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade, Clearing and Settlement" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transaction Fees" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Transaction ID: %s" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transfer In" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transfer Out" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Travellers Cheques Deposit" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Travellers Cheques Withdrawal" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Treasury Tax And Loan Service" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Triparty Repo" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Triparty Reverse Repo" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Turnaround" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Underwriting Commission" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Card Transaction" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Foreign Cheque" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Upfront Payment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Value Date" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Warrant Exercise/Warrant Conversion" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Withdrawal/distribution" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Withholding Tax" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "YTD Adjustment" +msgstr "" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Zero Balancing" +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/ar.po new file mode 100644 index 0000000..05dd499 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/i18n/ar.po @@ -0,0 +1,1932 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_camt +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Concentration" +msgstr "تركيز غرفة المقاصة الآلية (ACH) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Corporate Trade" +msgstr "تجارة الشركات عن طريق غرفة المقاصة الآلية (ACH) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Credit" +msgstr "رصيد غرفة المقاصة الآلية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Debit" +msgstr "خصم غرفة المقاصة الآلية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Pre-Authorised" +msgstr "تفويض مسبق لغرفة المقاصة الآلية (ACH)" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Return" +msgstr "عائد غرفة المقاصة الآلية (ACH) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Reversal" +msgstr "عكس معاملة في غرفة المقاصة الآلية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Settlement" +msgstr "تسوية غرفة المقاصة الآلية (ACH) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ACH Transaction" +msgstr "معاملة غرفة المقاصة الآلية (ACH) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "ARP Debit" +msgstr "خصم النسبة المئوية السنوية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Balancing" +msgstr "موازنة الحساب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Closing" +msgstr "إغلاق الحساب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Management" +msgstr "إدارة الحساب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Opening" +msgstr "فتح الحساب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Account Transfer" +msgstr "تحويل لحساب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Additional Info: %s" +msgstr "معلومات إضافية:%s" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Additional Miscellaneous Credit Operations" +msgstr "عمليات الائتمان المتنوعة الإضافية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Additional Miscellaneous Debit Operations" +msgstr "عمليات الخصم المتنوعة الإضافية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Address:\n" +"%s" +msgstr "" +"العنوان:\n" +"%s " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Adjustments" +msgstr "التعديلات" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Automatic Transfer" +msgstr "التحويل التلقائي" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Back Value" +msgstr "استحقاق سابق " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bank Cheque" +msgstr "شيك بنكي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bank Fees" +msgstr "رسوم بنكية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Blocked Transactions" +msgstr "المعاملات المحظورة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Bonus Issue/Capitalisation Issue" +msgstr "إصدار المكافأة / قضية الرسملة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Borrowing fee" +msgstr "رسوم الاقتراض" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Account Transfer" +msgstr "تحويل الحساب إلى الفرع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Deposit" +msgstr "إيداع في الفرع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Branch Withdrawal" +msgstr "السحب من الفرع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Brokerage fee" +msgstr "رسوم سمسرة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Buy Sell Back" +msgstr "إعادة الشراء/ البيع" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "CSD Blocked Transactions" +msgstr "المعاملات المحظورة للإيداع المركزي للأوراق المالية (CSD) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Call on intermediate securities" +msgstr "استدعاء الأوراق المالية الوسيطة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Capital Gains Distribution" +msgstr "توزيع أرباح رأس المال" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Deposit" +msgstr "الإيداع النقدي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Dividend" +msgstr "توزيعات الأرباح المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Letter" +msgstr "خطاب نقدي" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Letter Adjustment" +msgstr "تعديل خطاب نقدي" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Management" +msgstr "إدارة النقد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Pooling" +msgstr "تجميع النقد" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash Withdrawal" +msgstr "السحب النقدي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cash in lieu" +msgstr "نقد بالمقابل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Certified Customer Cheque" +msgstr "شيك عميل معتمَد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Charge/fees" +msgstr "الرسوم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Charges" +msgstr "الرسوم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Check Number: %s" +msgstr "رقم الشيك: %s" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque" +msgstr "الشيك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Deposit" +msgstr "إيداع الشيك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Reversal" +msgstr "شيك معكوس" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cheque Under Reserve" +msgstr "شيك قيد الحجز" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Circular Cheque" +msgstr "شيك مدوّر" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Clean Collection" +msgstr "مجموعة نظيفة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Client Owned Collateral" +msgstr "الضمانات التي يمتلكها العميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Collateral Management" +msgstr "إدارة الضمانات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission" +msgstr "العمولة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission excluding taxes" +msgstr "العمولة غير الشاملة للضريبة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commission including taxes" +msgstr "العمولة الشاملة للضريبة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Commodities" +msgstr "السلع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Compensation/Claims" +msgstr "التعويضات/المطالبات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Consumer Loans" +msgstr "قروض المستهلك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Controlled Disbursement" +msgstr "الصرف الخاضع للرقابة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Conversion" +msgstr "التحويل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Action" +msgstr "إجراء مؤسسي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Own Account Transfer" +msgstr "تحويل الحساب الخاص بالمؤسسة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate Rebate" +msgstr "خصم المؤسسة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate mark broker owned" +msgstr "علامة تجارية مملوكة للسمسار " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Corporate mark client owned" +msgstr "علامة تجارية مملوكة للعميل" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Counter Party: %(partner)s" +msgstr "الطرف المقابل: %(partner)s" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Counter Transactions" +msgstr "المعاملات المقابلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Adjustment" +msgstr "تسوية الائتمان " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Adjustments" +msgstr "تسويات الائتمان " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Card Payment" +msgstr "الدفع عن طريق بطاقة الائتمان " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Credit Transfer with agreed Commercial Information" +msgstr "تحويل الرصيد مع معلومات تجارية متفق عليها " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross Trade" +msgstr "التجارة العابرة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border" +msgstr "عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Cash Withdrawal" +msgstr "السحب النقدي عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Credit Card Payment" +msgstr "الدفع عن طريق البطاقة الائتمانية عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Credit Transfer" +msgstr "تحويل الرصيد عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Direct Debit" +msgstr "الخصم المباشر عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Intra Company Transfer" +msgstr "التحويل ما بين الشركات عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Payroll/Salary Payment" +msgstr "كشوفات المرتبات/دفع الراتب عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Cross-Border Standing Order" +msgstr "أمر دائم عبر الحدود " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Crossed Cheque" +msgstr "شيك مشطوب " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Custody" +msgstr "عهدة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Custody Collection" +msgstr "تحصيل العهدة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Customer Card Transactions" +msgstr "معاملات بطاقة العميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Debit" +msgstr "المدين" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Debit Adjustments" +msgstr "تعديلات الخصم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Decrease in Value" +msgstr "انخفاض القيمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Delivery" +msgstr "التوصيل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Deposit" +msgstr "إيداع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Deposit/Contribution" +msgstr "إيداع/مساهمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Depositary Receipt Issue" +msgstr "إصدار إيصال إيداع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Derivatives" +msgstr "المشتقات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit" +msgstr "الخصم المباشر " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit Payment" +msgstr "دفع الخصم المباشر " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Direct Debit under reserve" +msgstr "الخصم المباشر تحت الحجز " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Discounted Draft" +msgstr "مسودة الخصم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dishonoured/Unpaid Draft" +msgstr "كمبيالة مرفوضة / غير مدفوعة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dividend Option" +msgstr "خيار الأرباح " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dividend Reinvestment" +msgstr "إعادة استثمار الأرباح " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Documentary Collection" +msgstr "المجموعة المستندية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Documentary Credit" +msgstr "الاعتمادات المستندية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Domestic Credit Transfer" +msgstr "تحويل الرصيد المحلي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Draft Maturity Change" +msgstr "مسودة تغيير النضج" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drafts/BillOfOrders" +msgstr "مسودات/فواتير الطلبات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drawdown" +msgstr "السحب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Drawing" +msgstr "سحب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Dutch Auction" +msgstr "Dutch Auction" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "End to end ID: %s" +msgstr "معرف طرف إلى طرف: %s " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Entry Info: %s" +msgstr "معلومات القيد: %s " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity Premium Reserve" +msgstr "احتياطي أقساط حقوق الملكية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity mark broker owned" +msgstr "علامة سهم مملوكة للسمسار " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Equity mark client owned" +msgstr "علامة سهم مملوكة للعميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange" +msgstr "تبادل" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Rate Adjustment" +msgstr "تعديل سعر الصرف" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded" +msgstr "متداولة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded CCP" +msgstr "CCP المؤشرات المتداولة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Exchange Traded Non-CCP" +msgstr "المؤشرات المتداولة لغير CCP " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Extended Domain" +msgstr "نطاق موسّع" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "External Account Transfer" +msgstr "تحويل حساب خارجي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Factor Update" +msgstr "تحديث العامل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fees" +msgstr "رسوم" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fees, Commission , Taxes, Charges and Interest" +msgstr "الرسوم والعمولات والضرائب والتكاليف والفوائد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Final Maturity" +msgstr "تاريخ الاستحقاق النهائي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Final Payment" +msgstr "الدفع النهائي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Credit Transfer" +msgstr "تحويل الرصيد للمنشأة المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Direct Debit Payment" +msgstr "دفع الخصم المباشر للمنشأة المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Financial Institution Own Account Transfer" +msgstr "التحويل للحساب الشخصي للمنشأة المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Deposit Interest Amount" +msgstr "إجمالي نسبة الفائدة الثابتة من الإيداع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Term Deposits" +msgstr "الإيداعات بسعر فائدة ثابت " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Fixed Term Loans" +msgstr "قروض بسعر فائدة ثابت " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Float adjustment" +msgstr "تعديل الفاصلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Cheque" +msgstr "شيك أجنبي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Cheque Under Reserve" +msgstr "شيك أجنبي تحت الحجز " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Currency Deposit" +msgstr "إيداع العملة الأجنبية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Currency Withdrawal" +msgstr "سحب العملة الأجنبية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Foreign Exchange" +msgstr "تحويل العملة الأجنبية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards" +msgstr "العقود الآجلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards broker owned collateral" +msgstr "ضمانات العقود الآجلة المملوكة للسمسار " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Forwards client owned collateral" +msgstr "ضمانات العقود الآجلة المملوكة للعميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Freeze of funds" +msgstr "تجميد الأموال" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Full Call / Early Redemption" +msgstr "الدفع قبل تاريخ الاستحقاق / التحصيل قبل تاريخ الاستحقاق " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Future Variation Margin" +msgstr "هامش الاختلاف المستقبلي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures" +msgstr "العقود الآجلة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures Commission" +msgstr "عمولة العقود الآجلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Futures Residual Amount" +msgstr "المبالغ المتبقية من العقود الآجلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Guarantees" +msgstr "ضمانات" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Inspeci/Share Exchange" +msgstr "Inspeci/Share Exchange" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Instruction ID: %s" +msgstr "معرف التعليمات: %s" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest" +msgstr "الفائدة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest Payment" +msgstr "دفع الفوائد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Interest Payment with Principle" +msgstr "دفع الفوائد مع المبلغ المتفق عليه " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Internal Account Transfer" +msgstr "تحويل الحساب الداخلي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Internal Book Transfer" +msgstr "تحويل داخلي من حساب إلى آخر " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Intra Company Transfer" +msgstr "التحويل داخل الشركة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Invoice Accepted with Differed Due Date" +msgstr "تم قبول الفاتورة بتاريخ استحقاق مختلف " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Cash Concentration Transactions" +msgstr "معاملات التركيز النقدي التي تم إصدارها " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Cheques" +msgstr "الشيكات المصدرة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Credit Transfers" +msgstr "تحويلات الرصيد المصدرة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Direct Debits" +msgstr "الخصومات المباشرة المصدرة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Issued Real Time Credit Transfer" +msgstr "عملية تحويل الرصيد في الوقت الفعلي المصدرة " + +#. module: odex30_account_bank_statement_import_camt +#: model:ir.model,name:odex30_account_bank_statement_import_camt.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lack" +msgstr "قلة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending Broker Owned Cash Collateral" +msgstr "إقراض الضمانات النقدية المملوكة للسمسار " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending Client Owned Cash Collateral" +msgstr "إقراض الضمانات النقدية المملوكة للعميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lending income" +msgstr "دخل الإقراض " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Liquidation Dividend / Liquidation Payment" +msgstr "توزيعات أرباح التصفية / مدفوعات التصفية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Listed Derivatives – Futures" +msgstr "المشتقات المسجلة – العقود الآجلة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Listed Derivatives – Options" +msgstr "المشتقات المسجلة – الخيارات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Loans, Deposits & Syndications" +msgstr "القروض، الإيداعات والقروض المشتركة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Lockbox Transactions" +msgstr "معاملات صندوق الأمانات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Management Fees" +msgstr "رسوم إدارية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Mandate ID: %s" +msgstr "معرف التوكيل: %s " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Margin Payments" +msgstr "مدفوعات الهوامش " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Margin client owned cash collateral" +msgstr "الضمانات النقدية للهامش المملوكة للعميل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Merchant Card Transactions" +msgstr "معاملات بطاقة التاجر " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Merger" +msgstr "اندماج " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Credit Operations" +msgstr "العمليات الائتمانية المتنوعة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Debit Operations" +msgstr "عمليات الخصم المتنوعة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Deposit" +msgstr "إيداع متنوعات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Miscellaneous Securities Operations" +msgstr "عمليات الأوراق المالية المتنوعة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Mixed Deposit" +msgstr "إيداع مختلط " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Mortgage Loans" +msgstr "قروض التمويل العقاري " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Netting" +msgstr "حساب الصافي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "" +"No exchange rate was found to convert an amount into the currency of the " +"journal" +msgstr "لم يتم العثور على سعر صرف لتحويل مبلغ إلى عملة دفتر اليومية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Deliverable" +msgstr "غير قابل للتوصيل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Settled" +msgstr "غير مسوى " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Syndicated" +msgstr "غير مشتركة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non Taxable commissions" +msgstr "العمولات غير الخاضعة للضريبة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Non-Presented Circular Cheque" +msgstr "شيك دائري " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Not available" +msgstr "غير متاح" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Notice Deposits" +msgstr "إيداعات الإخطار" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Notice Loans" +msgstr "القروض التي تتطلب إخطاراً " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC" +msgstr "التداول خارج البورصة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC CCP" +msgstr "OTC CCP" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Bonds" +msgstr "مشتقات التداول خارج البورصة - الصكوك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Credit Derivatives" +msgstr "الصكوك الاشتقاقية الخارجة عن البورصة – المشتقات الائتمانية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Equity" +msgstr "مشتقات التداول خارج البورصة - رأس المال " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Interest Rates" +msgstr "الصكوك الاشتقاقية الخارجة عن البورصة – نسبة الفوائد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Structured Exotic Derivatives" +msgstr "الصكوك الاشتقاقية الخارجة عن البورصة – المشتقات الغريبة المنظمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Derivatives – Swaps" +msgstr "مشتقات التداول خارج البورصة - السواب " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "OTC Non-CCP" +msgstr "التداول خارج البورصة للطرف المقابل غير المركزي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Odd Lot Sale/Purchase" +msgstr "مجموعة بيع/شراء شاذة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "One-Off Direct Debit" +msgstr "الخصم المباشر على دفعة واحدة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Open Cheque" +msgstr "شيك مفتوح " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Opening & Closing" +msgstr "الفتح والإقفال " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Option broker owned collateral" +msgstr "ضمانات العقود الآجلة المملوكة لوسيط الخيارات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Option client owned collateral" +msgstr "ضمانات العقود الآجلة المملوكة لعميل الخيارات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Options" +msgstr "الخيارات" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Order Cheque" +msgstr "شيك الطلب " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Other" +msgstr "غير ذلك" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Overdraft" +msgstr "السحب الزائد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Overdraft Charge" +msgstr "رسوم السحب الزائد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Pair-Off" +msgstr "إقران " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Payment" +msgstr "الدفع الجزئي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Redemption Without Reduction of Nominal Value" +msgstr "الاسترداد الجزئي دون تخفيض القيمة الاسمية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Partial Redemption with reduction of nominal value" +msgstr "الاسترداد الجزئي مع تخفيض القيمة الاسمية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Payments" +msgstr "الدفعات" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Payroll/Salary Payment" +msgstr "كشوف المرتبات/دفع المرتبات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Placement" +msgstr "تحديد مستوى" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Please check the currency on your bank journal.\n" +"No statements in currency %s were found in this CAMT file." +msgstr "" +"يرجى التحقق من العملة في دفتر يومية بنكك. \n" +"لم يتم العثور على كشوفات حساب بالعملة %s في ملف CAMT هذا. " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "" +"Please set the IBAN account on your bank journal.\n" +"\n" +"This CAMT file is targeting several IBAN accounts but none match the current journal." +msgstr "" +"يرجى تعيين حساب IBAN في دفتر يومية بنكك. \n" +"\n" +"يستهدف ملف CAMT عدة حسابات IBAN ولكن لا يطابق أي منها دفتر اليومية الحالي. " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Point-of-Sale (POS) Payment" +msgstr "مدفوعات نقطة البيع (POS) " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Point-of-Sale (POS) Payment - Debit Card" +msgstr "مدفوعات نقطة البيع (POS) - بطاقة الخصم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Portfolio Move" +msgstr "حركة المحفظة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Posting Error" +msgstr "خطأ في الترحيل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Pre-Authorised Direct Debit" +msgstr "الخصم المباشر المصرح له مسبقاً " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Precious Metal" +msgstr "المعادن الثمينة" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Principal Pay-down/pay-up" +msgstr "Principal Pay-down/pay-up" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Principal Payment" +msgstr "مدفوعات أصل الدين " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Priority Credit Transfer" +msgstr "تحويل رصيد ذو أولوية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Priority Issue" +msgstr "مشكلة في الأولوية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Put Redemption" +msgstr "الاستبدال " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Cash Concentration Transactions" +msgstr "معاملات التركيز النقدي التي تم استلامها " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Cheques" +msgstr "الشيكات المستلمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Credit Transfers" +msgstr "تحويلات الرصيد المستلمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Direct Debits" +msgstr "الخصم المباشر الذي تم استلامه " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Received Real Time Credit Transfer" +msgstr "عملية تحويل الرصيد في الوقت الفعلي التي تم استلامها " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption" +msgstr "استبدال " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption Asset Allocation" +msgstr "Redemption Asset Allocation" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Redemption Withdrawing Plan" +msgstr "Redemption Withdrawing Plan" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reimbursements" +msgstr "إعادة الأموال " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Renewal" +msgstr "التجديد" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repayment" +msgstr "إعادة الدفع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repo" +msgstr "تقرير " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Repurchase offer/Issuer Bid/Reverse Rights." +msgstr "عرض إعادة الشراء/عطاء المُصدِر/ الحقوق المعكوسة. " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reset Payment" +msgstr "إعادة الدفع" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Cancellation Request" +msgstr "العكس نظراً لطلب إلغاء الدفع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Return/reimbursement of a Credit Transfer" +msgstr "العكس بسبب إرجاع المدفوعات/تعويض الأموال لتحويل الرصيد " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Payment Reversal" +msgstr "العكس بسبب عكس الدفع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to Return/Unpaid Direct Debit" +msgstr "العكس بسبب الإرجاع/الخصم المباشر غير المدفوع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reversal due to a Payment Cancellation Request" +msgstr "العكس نظراً لطلب إلغاء الدفع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Reverse Repo" +msgstr "عكس التقرير " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Rights Issue/Subscription Rights/Rights Offer" +msgstr "إصدار الحقوق/حقوق الاشتراك/عرض الحقوق " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA B2B Direct Debit" +msgstr "خصم SEPA المباشر بين الشركات " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA Core Direct Debit" +msgstr "خصم SEPA المباشر الأساسي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "SEPA Credit Transfer" +msgstr "تحويل الرصيد من خلال SEPA " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Same Day Value Credit Transfer" +msgstr "تحويل الرصيد بقيمة نفس اليوم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities" +msgstr "ضمانات" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities Borrowing" +msgstr "اقتراض الأوراق المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Securities Lending" +msgstr "إقراض الأوراق المالية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sell Buy Back" +msgstr "بيع الشراء مجدداً " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement" +msgstr "التسوية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement after collection" +msgstr "التسوية بعد التحصيل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement against bank guarantee" +msgstr "التسوية مقابل ضمان البنك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement at Maturity" +msgstr "التسوية عند الوصول لتاريخ الاستحقاق " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement of Sight Export document" +msgstr "تسوية وثيقة التصدير فورياً " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement of Sight Import document" +msgstr "تسوية وثيقة الاستيراد فورياً " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Settlement under reserve" +msgstr "التسوية تحت الحجز " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Smart-Card Payment" +msgstr "الدفع عن طريق البطاقة الذكية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Spots" +msgstr "Spots" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Stamp duty" +msgstr "رسوم الطابع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Stand-By Letter Of Credit" +msgstr "رسالة الاعتماد الاحتياطية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Standing Order" +msgstr "أمر دائم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription" +msgstr "الاشتراك" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription Asset Allocation" +msgstr "تخصيص أصول الاشتراك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Subscription Savings Plan" +msgstr "خطة التوفير في الاشتراك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swap Payment" +msgstr "الدفع عن طريق Swap " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swap broker owned collateral" +msgstr "ضمانات Swap المملوكة للسمسار " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Swaps" +msgstr "Swaps" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sweep" +msgstr "مسح " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Sweeping" +msgstr "المسح " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Switch" +msgstr "تبديل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Syndicated" +msgstr "قرض مشترك " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Syndications" +msgstr "القروض المشتركة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "TBA closing" +msgstr "إغلاق TBA " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Tax Reclaim" +msgstr "استرداد الضريبة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Taxes" +msgstr "الضرائب" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Tender" +msgstr "مناقصة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Topping" +msgstr "Topping" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade" +msgstr "تداول " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade Services" +msgstr "الخدمات التجارية " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Trade, Clearing and Settlement" +msgstr "التجارة والمقاصة والتسوية" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transaction Fees" +msgstr "رسوم المعاملة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/models/account_journal.py:0 +msgid "Transaction ID: %s" +msgstr "معرف المعاملة: %s " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transfer In" +msgstr "التحويل إلى الداخل " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Transfer Out" +msgstr "التحويل إلى الخارج " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Travellers Cheques Deposit" +msgstr "إيداع شيكات المسافرين " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Travellers Cheques Withdrawal" +msgstr "سحب شيكات المسافرين " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Treasury Tax And Loan Service" +msgstr "ضريبة الخزينة وخدمات القروض " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Triparty Repo" +msgstr "تقرير ثلاثي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Triparty Reverse Repo" +msgstr "تقرير ثلاثي عكسي " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Turnaround" +msgstr "تحول " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Underwriting Commission" +msgstr "عمولة الاكتتاب " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Card Transaction" +msgstr "معاملة البطاقة غير المدفوعة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Cheque" +msgstr "شيك غير مدفوع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Unpaid Foreign Cheque" +msgstr "شيك أجنبي غير مدفوع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Upfront Payment" +msgstr "دفع مقدم " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Value Date" +msgstr "تاريخ القيمة " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Warrant Exercise/Warrant Conversion" +msgstr "Warrant Exercise/Warrant Conversion" + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Withdrawal/distribution" +msgstr "السحب/التوزيع " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Withholding Tax" +msgstr "ضريبة الاحتجاز " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "YTD Adjustment" +msgstr "تعديل العام حتى تاريخه " + +#. module: odex30_account_bank_statement_import_camt +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_camt/lib/camt.py:0 +msgid "Zero Balancing" +msgstr "Zero Balancing" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..451ebc9 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/camt.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/camt.cpython-311.pyc new file mode 100644 index 0000000..b204f0a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/__pycache__/camt.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/camt.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/camt.py new file mode 100644 index 0000000..a829cd4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/lib/camt.py @@ -0,0 +1,806 @@ + +import math +import re +from functools import partial + +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + +# keep code as-is but do not translate, +# the language is not known in this context +_lt = lambda x: x # noqa: E731 + + +# Codes from the updated document of 30 june 2017 +# pylint: disable=duplicate-key +codes = { + # ExternalBankTransactionDomain1Code ####################################### + 'PMNT': _lt('Payments'), + 'CAMT': _lt('Cash Management'), + 'DERV': _lt('Derivatives'), + 'LDAS': _lt('Loans, Deposits & Syndications'), + 'FORX': _lt('Foreign Exchange'), + 'PMET': _lt('Precious Metal'), + 'CMDT': _lt('Commodities'), + 'TRAD': _lt('Trade Services'), + 'SECU': _lt('Securities'), + 'ACMT': _lt('Account Management'), + 'XTND': _lt('Extended Domain'), + # ExternalBankTransactionFamily1Code ####################################### + 'RCDT': _lt('Received Credit Transfers'), # Payments + 'ICDT': _lt('Issued Credit Transfers'), + 'RCCN': _lt('Received Cash Concentration Transactions'), + 'ICCN': _lt('Issued Cash Concentration Transactions'), + 'RDDT': _lt('Received Direct Debits'), + 'IDDT': _lt('Issued Direct Debits'), + 'RCHQ': _lt('Received Cheques'), + 'ICHQ': _lt('Issued Cheques'), + 'CCRD': _lt('Customer Card Transactions'), + 'MCRD': _lt('Merchant Card Transactions'), + 'LBOX': _lt('Lockbox Transactions'), + 'CNTR': _lt('Counter Transactions'), + 'DRFT': _lt('Drafts/BillOfOrders'), + 'RRCT': _lt('Received Real Time Credit Transfer'), + 'IRCT': _lt('Issued Real Time Credit Transfer'), + 'CAPL': _lt('Cash Pooling'), # Cash Management + 'ACCB': _lt('Account Balancing'), + 'OCRD': _lt('OTC Derivatives – Credit Derivatives'), # Derivatives + 'OIRT': _lt('OTC Derivatives – Interest Rates'), + 'OEQT': _lt('OTC Derivatives – Equity'), + 'OBND': _lt('OTC Derivatives – Bonds'), + 'OSED': _lt('OTC Derivatives – Structured Exotic Derivatives'), + 'OSWP': _lt('OTC Derivatives – Swaps'), + 'LFUT': _lt('Listed Derivatives – Futures'), + 'LOPT': _lt('Listed Derivatives – Options'), + 'FTLN': _lt('Fixed Term Loans'), # Loans, Deposits & Syndications + 'NTLN': _lt('Notice Loans'), + 'FTDP': _lt('Fixed Term Deposits'), + 'NTDP': _lt('Notice Deposits'), + 'MGLN': _lt('Mortgage Loans'), + 'CSLN': _lt('Consumer Loans'), + 'SYDN': _lt('Syndications'), + 'SPOT': _lt('Spots'), # Foreign Exchange + 'FWRD': _lt('Forwards'), + 'SWAP': _lt('Swaps'), + 'FTUR': _lt('Futures'), + 'NDFX': _lt('Non Deliverable'), + 'SPOT': _lt('Spots'), # Precious Metal + 'FTUR': _lt('Futures'), + 'OPTN': _lt('Options'), + 'DLVR': _lt('Delivery'), + 'SPOT': _lt('Spots'), # Commodities + 'FTUR': _lt('Futures'), + 'OPTN': _lt('Options'), + 'DLVR': _lt('Delivery'), + 'LOCT': _lt('Stand-By Letter Of Credit'), # Trade Services + 'DCCT': _lt('Documentary Credit'), + 'CLNC': _lt('Clean Collection'), + 'DOCC': _lt('Documentary Collection'), + 'GUAR': _lt('Guarantees'), + 'SETT': _lt('Trade, Clearing and Settlement'), # Securities + 'NSET': _lt('Non Settled'), + 'BLOC': _lt('Blocked Transactions'), + 'OTHB': _lt('CSD Blocked Transactions'), + 'COLL': _lt('Collateral Management'), + 'CORP': _lt('Corporate Action'), + 'CUST': _lt('Custody'), + 'COLC': _lt('Custody Collection'), + 'LACK': _lt('Lack'), + 'CASH': _lt('Miscellaneous Securities Operations'), + 'OPCL': _lt('Opening & Closing'), # Account Management + 'ACOP': _lt('Additional Miscellaneous Credit Operations'), + 'ADOP': _lt('Additional Miscellaneous Debit Operations'), + # ExternalBankTransactionSubFamily1Code #################################### + # Generic Sub-Families + 'FEES': _lt('Fees'), # Miscellaneous Credit Operations + 'COMM': _lt('Commission'), + 'COME': _lt('Commission excluding taxes'), + 'COMI': _lt('Commission including taxes'), + 'COMT': _lt('Non Taxable commissions'), + 'TAXE': _lt('Taxes'), + 'CHRG': _lt('Charges'), + 'INTR': _lt('Interest'), + 'RIMB': _lt('Reimbursements'), + 'ADJT': _lt('Adjustments'), + 'FEES': _lt('Fees'), # Miscellaneous Debit Operations + 'COMM': _lt('Commission'), + 'COME': _lt('Commission excluding taxes'), + 'COMI': _lt('Commission including taxes'), + 'COMT': _lt('Non Taxable commissions'), + 'TAXE': _lt('Taxes'), + 'CHRG': _lt('Charges'), + 'INTR': _lt('Interest'), + 'RIMB': _lt('Reimbursements'), + 'ADJT': _lt('Adjustments'), + 'IADD': _lt('Invoice Accepted with Differed Due Date'), + 'FEES': _lt('Fees'), # Generic Sub-Families + 'COMM': _lt('Commission'), + 'COME': _lt('Commission excluding taxes'), + 'COMI': _lt('Commission including taxes'), + 'COMT': _lt('Non Taxable commissions'), + 'TAXE': _lt('Taxes'), + 'CHRG': _lt('Charges'), + 'INTR': _lt('Interest'), + 'RIMB': _lt('Reimbursements'), + 'DAJT': _lt('Credit Adjustments'), + 'CAJT': _lt('Debit Adjustments'), + # Payments Sub-Families + 'BOOK': _lt('Internal Book Transfer'), # Received Credit Transfer + 'STDO': _lt('Standing Order'), + 'XBST': _lt('Cross-Border Standing Order'), + 'ESCT': _lt('SEPA Credit Transfer'), + 'DMCT': _lt('Domestic Credit Transfer'), + 'XBCT': _lt('Cross-Border Credit Transfer'), + 'VCOM': _lt('Credit Transfer with agreed Commercial Information'), + 'FICT': _lt('Financial Institution Credit Transfer'), + 'PRCT': _lt('Priority Credit Transfer'), + 'SALA': _lt('Payroll/Salary Payment'), + 'XBSA': _lt('Cross-Border Payroll/Salary Payment'), + 'SDVA': _lt('Same Day Value Credit Transfer'), + 'RPCR': _lt('Reversal due to Payment Cancellation Request'), + 'RRTN': _lt('Reversal due to Payment Return/reimbursement of a Credit Transfer'), + 'AUTT': _lt('Automatic Transfer'), + 'ATXN': _lt('ACH Transaction'), + 'ACOR': _lt('ACH Corporate Trade'), + 'APAC': _lt('ACH Pre-Authorised'), + 'ASET': _lt('ACH Settlement'), + 'ARET': _lt('ACH Return'), + 'AREV': _lt('ACH Reversal'), + 'ACDT': _lt('ACH Credit'), + 'ADBT': _lt('ACH Debit'), + 'TTLS': _lt('Treasury Tax And Loan Service'), + 'BOOK': _lt('Internal Book Transfer'), # Issued Credit Transfer + 'STDO': _lt('Standing Order'), + 'XBST': _lt('Cross-Border Standing Order'), + 'ESCT': _lt('SEPA Credit Transfer'), + 'DMCT': _lt('Domestic Credit Transfer'), + 'XBCT': _lt('Cross-Border Credit Transfer'), + 'FICT': _lt('Financial Institution Credit Transfer'), + 'PRCT': _lt('Priority Credit Transfer'), + 'VCOM': _lt('Credit Transfer with agreed Commercial Information'), + 'SALA': _lt('Payroll/Salary Payment'), + 'XBSA': _lt('Cross-Border Payroll/Salary Payment'), + 'RPCR': _lt('Reversal due to Payment Cancellation Request'), + 'RRTN': _lt('Reversal due to Payment Return/reimbursement of a Credit Transfer'), + 'SDVA': _lt('Same Day Value Credit Transfer'), + 'AUTT': _lt('Automatic Transfer'), + 'ATXN': _lt('ACH Transaction'), + 'ACOR': _lt('ACH Corporate Trade'), + 'APAC': _lt('ACH Pre-Authorised'), + 'ASET': _lt('ACH Settlement'), + 'ARET': _lt('ACH Return'), + 'AREV': _lt('ACH Reversal'), + 'ACDT': _lt('ACH Credit'), + 'ADBT': _lt('ACH Debit'), + 'TTLS': _lt('Treasury Tax And Loan Service'), + 'COAT': _lt('Corporate Own Account Transfer'), # Received Cash Concentration + 'ICCT': _lt('Intra Company Transfer'), + 'XICT': _lt('Cross-Border Intra Company Transfer'), + 'FIOA': _lt('Financial Institution Own Account Transfer'), + 'BACT': _lt('Branch Account Transfer'), + 'ACON': _lt('ACH Concentration'), + 'COAT': _lt('Corporate Own Account Transfer'), # Issued Cash Concentration + 'ICCT': _lt('Intra Company Transfer'), + 'XICT': _lt('Cross-Border Intra Company Transfer'), + 'FIOA': _lt('Financial Institution Own Account Transfer'), + 'BACT': _lt('Branch Account Transfer'), + 'ACON': _lt('ACH Concentration'), + 'PMDD': _lt('Direct Debit'), # Received Direct Debit + 'URDD': _lt('Direct Debit under reserve'), + 'ESDD': _lt('SEPA Core Direct Debit'), + 'BBDD': _lt('SEPA B2B Direct Debit'), + 'XBDD': _lt('Cross-Border Direct Debit'), + 'OODD': _lt('One-Off Direct Debit'), + 'PADD': _lt('Pre-Authorised Direct Debit'), + 'FIDD': _lt('Financial Institution Direct Debit Payment'), + 'RCDD': _lt('Reversal due to a Payment Cancellation Request'), + 'UPDD': _lt('Reversal due to Return/Unpaid Direct Debit'), + 'PRDD': _lt('Reversal due to Payment Reversal'), + 'PMDD': _lt('Direct Debit Payment'), # Issued Direct Debit + 'URDD': _lt('Direct Debit under reserve'), + 'ESDD': _lt('SEPA Core Direct Debit'), + 'BBDD': _lt('SEPA B2B Direct Debit'), + 'OODD': _lt('One-Off Direct Debit'), + 'XBDD': _lt('Cross-Border Direct Debit'), + 'PADD': _lt('Pre-Authorised Direct Debit'), + 'FIDD': _lt('Financial Institution Direct Debit Payment'), + 'RCDD': _lt('Reversal due to a Payment Cancellation Request'), + 'UPDD': _lt('Reversal due to Return/Unpaid Direct Debit'), + 'PRDD': _lt('Reversal due to Payment Reversal'), + 'CCHQ': _lt('Cheque'), # Received Cheque + 'URCQ': _lt('Cheque Under Reserve'), + 'UPCQ': _lt('Unpaid Cheque'), + 'CQRV': _lt('Cheque Reversal'), + 'CCCH': _lt('Certified Customer Cheque'), + 'CLCQ': _lt('Circular Cheque'), + 'NPCC': _lt('Non-Presented Circular Cheque'), + 'CRCQ': _lt('Crossed Cheque'), + 'ORCQ': _lt('Order Cheque'), + 'OPCQ': _lt('Open Cheque'), + 'BCHQ': _lt('Bank Cheque'), + 'XBCQ': _lt('Foreign Cheque'), + 'XRCQ': _lt('Foreign Cheque Under Reserve'), + 'XPCQ': _lt('Unpaid Foreign Cheque'), + 'CDIS': _lt('Controlled Disbursement'), + 'ARPD': _lt('ARP Debit'), + 'CASH': _lt('Cash Letter'), + 'CSHA': _lt('Cash Letter Adjustment'), + 'CCHQ': _lt('Cheque'), # Issued Cheque + 'URCQ': _lt('Cheque Under Reserve'), + 'UPCQ': _lt('Unpaid Cheque'), + 'CQRV': _lt('Cheque Reversal'), + 'CCCH': _lt('Certified Customer Cheque'), + 'CLCQ': _lt('Circular Cheque'), + 'NPCC': _lt('Non-Presented Circular Cheque'), + 'CRCQ': _lt('Crossed Cheque'), + 'ORCQ': _lt('Order Cheque'), + 'OPCQ': _lt('Open Cheque'), + 'BCHQ': _lt('Bank Cheque'), + 'XBCQ': _lt('Foreign Cheque'), + 'XRCQ': _lt('Foreign Cheque Under Reserve'), + 'XPCQ': _lt('Unpaid Foreign Cheque'), + 'CDIS': _lt('Controlled Disbursement'), + 'ARPD': _lt('ARP Debit'), + 'CASH': _lt('Cash Letter'), + 'CSHA': _lt('Cash Letter Adjustment'), + 'CWDL': _lt('Cash Withdrawal'), # Customer Card Transaction + 'CDPT': _lt('Cash Deposit'), + 'XBCW': _lt('Cross-Border Cash Withdrawal'), + 'POSD': _lt('Point-of-Sale (POS) Payment - Debit Card'), + 'POSC': _lt('Credit Card Payment'), + 'XBCP': _lt('Cross-Border Credit Card Payment'), + 'SMRT': _lt('Smart-Card Payment'), + 'POSP': _lt('Point-of-Sale (POS) Payment'), # Merchant Card Transaction + 'POSC': _lt('Credit Card Payment'), + 'SMCD': _lt('Smart-Card Payment'), + 'UPCT': _lt('Unpaid Card Transaction'), + 'CDPT': _lt('Cash Deposit'), # Counter Transaction + 'CWDL': _lt('Cash Withdrawal'), + 'BCDP': _lt('Branch Deposit'), + 'BCWD': _lt('Branch Withdrawal'), + 'CHKD': _lt('Cheque Deposit'), + 'MIXD': _lt('Mixed Deposit'), + 'MSCD': _lt('Miscellaneous Deposit'), + 'FCDP': _lt('Foreign Currency Deposit'), + 'FCWD': _lt('Foreign Currency Withdrawal'), + 'TCDP': _lt('Travellers Cheques Deposit'), + 'TCWD': _lt('Travellers Cheques Withdrawal'), + 'LBCA': _lt('Credit Adjustment'), # Lockbox + 'LBDB': _lt('Debit'), + 'LBDP': _lt('Deposit'), + 'STAM': _lt('Settlement at Maturity'), # Drafts / Bill to Order + 'STLR': _lt('Settlement under reserve'), + 'DDFT': _lt('Discounted Draft'), + 'UDFT': _lt('Dishonoured/Unpaid Draft'), + 'DMCG': _lt('Draft Maturity Change'), + 'BOOK': _lt('Internal Book Transfer'), # Received Real-Time Credit Transfer + 'STDO': _lt('Standing Order'), + 'XBST': _lt('Cross-Border Standing Order'), + 'ESCT': _lt('SEPA Credit Transfer'), + 'DMCT': _lt('Domestic Credit Transfer'), + 'XBCT': _lt('Cross-Border Credit Transfer'), + 'VCOM': _lt('Credit Transfer with agreed Commercial Information'), + 'FICT': _lt('Financial Institution Credit Transfer'), + 'PRCT': _lt('Priority Credit Transfer'), + 'SALA': _lt('Payroll/Salary Payment'), + 'XBSA': _lt('Cross-Border Payroll/Salary Payment'), + 'SDVA': _lt('Same Day Value Credit Transfer'), + 'RPCR': _lt('Reversal due to Payment Cancellation Request'), + 'RRTN': _lt('Reversal due to Payment Return/reimbursement of a Credit Transfer'), + 'AUTT': _lt('Automatic Transfer'), + 'ATXN': _lt('ACH Transaction'), + 'ACOR': _lt('ACH Corporate Trade'), + 'APAC': _lt('ACH Pre-Authorised'), + 'ASET': _lt('ACH Settlement'), + 'ARET': _lt('ACH Return'), + 'AREV': _lt('ACH Reversal'), + 'ACDT': _lt('ACH Credit'), + 'ADBT': _lt('ACH Debit'), + 'TTLS': _lt('Treasury Tax And Loan Service'), + 'BOOK': _lt('Internal Book Transfer'), # Issued Real-Time Credit Transfer + 'STDO': _lt('Standing Order'), + 'XBST': _lt('Cross-Border Standing Order'), + 'ESCT': _lt('SEPA Credit Transfer'), + 'DMCT': _lt('Domestic Credit Transfer'), + 'XBCT': _lt('Cross-Border Credit Transfer'), + 'FICT': _lt('Financial Institution Credit Transfer'), + 'PRCT': _lt('Priority Credit Transfer'), + 'VCOM': _lt('Credit Transfer with agreed Commercial Information'), + 'SALA': _lt('Payroll/Salary Payment'), + 'XBSA': _lt('Cross-Border Payroll/Salary Payment'), + 'RPCR': _lt('Reversal due to Payment Cancellation Request'), + 'RRTN': _lt('Reversal due to Payment Return/reimbursement of a Credit Transfer'), + 'SDVA': _lt('Same Day Value Credit Transfer'), + 'AUTT': _lt('Automatic Transfer'), + 'ATXN': _lt('ACH Transaction'), + 'ACOR': _lt('ACH Corporate Trade'), + 'APAC': _lt('ACH Pre-Authorised'), + 'ASET': _lt('ACH Settlement'), + 'ARET': _lt('ACH Return'), + 'AREV': _lt('ACH Reversal'), + 'ACDT': _lt('ACH Credit'), + 'ADBT': _lt('ACH Debit'), + 'TTLS': _lt('Treasury Tax And Loan Service'), + # Cash Management Sub-Families + 'XBRD': _lt('Cross-Border'), # Cash Pooling + 'ZABA': _lt('Zero Balancing'), # Account Balancing + 'SWEP': _lt('Sweeping'), + 'TOPG': _lt('Topping'), + 'DSBR': _lt('Controlled Disbursement'), + 'ODFT': _lt('Overdraft'), + 'XBRD': _lt('Cross-Border'), + # Derivatives Sub-Families + 'SWUF': _lt('Upfront Payment'), + 'SWRS': _lt('Reset Payment'), + 'SWPP': _lt('Partial Payment'), + 'SWFP': _lt('Final Payment'), + 'SWCC': _lt('Client Owned Collateral'), + # Loans, Deposits & Syndications Sub-Families + 'DDWN': _lt('Drawdown'), + 'RNEW': _lt('Renewal'), + 'PPAY': _lt('Principal Payment'), + 'DPST': _lt('Deposit'), + 'RPMT': _lt('Repayment'), + # Trade Services Sub-Families + 'FRZF': _lt('Freeze of funds'), + 'SOSI': _lt('Settlement of Sight Import document'), + 'SOSE': _lt('Settlement of Sight Export document'), + 'SABG': _lt('Settlement against bank guarantee'), + 'STLR': _lt('Settlement under reserve'), + 'STLR': _lt('Settlement under reserve'), + 'STAC': _lt('Settlement after collection'), + 'STLM': _lt('Settlement'), + # Securities Sub-Families + 'PAIR': _lt('Pair-Off'), # Trade, Clearing and Settlement & Non Settled + 'TRAD': _lt('Trade'), + 'NETT': _lt('Netting'), + 'TRPO': _lt('Triparty Repo'), + 'TRVO': _lt('Triparty Reverse Repo'), + 'RVPO': _lt('Reverse Repo'), + 'REPU': _lt('Repo'), + 'SECB': _lt('Securities Borrowing'), + 'SECL': _lt('Securities Lending'), + 'BSBO': _lt('Buy Sell Back'), + 'BSBC': _lt('Sell Buy Back'), + 'FCTA': _lt('Factor Update'), + 'ISSU': _lt('Depositary Receipt Issue'), + 'INSP': _lt('Inspeci/Share Exchange'), + 'OWNE': _lt('External Account Transfer'), + 'OWNI': _lt('Internal Account Transfer'), + 'NSYN': _lt('Non Syndicated'), + 'PLAC': _lt('Placement'), + 'PORT': _lt('Portfolio Move'), + 'SYND': _lt('Syndicated'), + 'TBAC': _lt('TBA closing'), + 'TURN': _lt('Turnaround'), + 'REDM': _lt('Redemption'), + 'SUBS': _lt('Subscription'), + 'CROS': _lt('Cross Trade'), + 'SWIC': _lt('Switch'), + 'REAA': _lt('Redemption Asset Allocation'), + 'SUAA': _lt('Subscription Asset Allocation'), + 'PRUD': _lt('Principal Pay-down/pay-up'), + 'TOUT': _lt('Transfer Out'), + 'TRIN': _lt('Transfer In'), + 'XCHC': _lt('Exchange Traded CCP'), + 'XCHG': _lt('Exchange Traded'), + 'XCHN': _lt('Exchange Traded Non-CCP'), + 'OTCC': _lt('OTC CCP'), + 'OTCG': _lt('OTC'), + 'OTCN': _lt('OTC Non-CCP'), + 'XCHC': _lt('Exchange Traded CCP'), # Blocked Transactions & CSD Blocked Transactions + 'XCHG': _lt('Exchange Traded'), + 'XCHN': _lt('Exchange Traded Non-CCP'), + 'OTCC': _lt('OTC CCP'), + 'OTCG': _lt('OTC'), + 'OTCN': _lt('OTC Non-CCP'), + 'MARG': _lt('Margin Payments'), # Collateral Management + 'TRPO': _lt('Triparty Repo'), + 'REPU': _lt('Repo'), + 'SECB': _lt('Securities Borrowing'), + 'SECL': _lt('Securities Lending'), + 'OPBC': _lt('Option broker owned collateral'), + 'OPCC': _lt('Option client owned collateral'), + 'FWBC': _lt('Forwards broker owned collateral'), + 'FWCC': _lt('Forwards client owned collateral'), + 'MGCC': _lt('Margin client owned cash collateral'), + 'SWBC': _lt('Swap broker owned collateral'), + 'EQCO': _lt('Equity mark client owned'), + 'EQBO': _lt('Equity mark broker owned'), + 'CMCO': _lt('Corporate mark client owned'), + 'CMBO': _lt('Corporate mark broker owned'), + 'SLBC': _lt('Lending Broker Owned Cash Collateral'), + 'SLCC': _lt('Lending Client Owned Cash Collateral'), + 'CPRB': _lt('Corporate Rebate'), + 'BIDS': _lt('Repurchase offer/Issuer Bid/Reverse Rights.'), # Corporate Action & Custody + 'BONU': _lt('Bonus Issue/Capitalisation Issue'), + 'BPUT': _lt('Put Redemption'), + 'CAPG': _lt('Capital Gains Distribution'), + 'CONV': _lt('Conversion'), + 'DECR': _lt('Decrease in Value'), + 'DRAW': _lt('Drawing'), + 'DRIP': _lt('Dividend Reinvestment'), + 'DTCH': _lt('Dutch Auction'), + 'DVCA': _lt('Cash Dividend'), + 'DVOP': _lt('Dividend Option'), + 'EXOF': _lt('Exchange'), + 'EXRI': _lt('Call on intermediate securities'), + 'EXWA': _lt('Warrant Exercise/Warrant Conversion'), + 'INTR': _lt('Interest Payment'), + 'LIQU': _lt('Liquidation Dividend / Liquidation Payment'), + 'MCAL': _lt('Full Call / Early Redemption'), + 'MRGR': _lt('Merger'), + 'ODLT': _lt('Odd Lot Sale/Purchase'), + 'PCAL': _lt('Partial Redemption with reduction of nominal value'), + 'PRED': _lt('Partial Redemption Without Reduction of Nominal Value'), + 'PRII': _lt('Interest Payment with Principle'), + 'PRIO': _lt('Priority Issue'), + 'REDM': _lt('Final Maturity'), + 'RHTS': _lt('Rights Issue/Subscription Rights/Rights Offer'), + 'SHPR': _lt('Equity Premium Reserve'), + 'TEND': _lt('Tender'), + 'TREC': _lt('Tax Reclaim'), + 'RWPL': _lt('Redemption Withdrawing Plan'), + 'SSPL': _lt('Subscription Savings Plan'), + 'CSLI': _lt('Cash in lieu'), + 'CHAR': _lt('Charge/fees'), # Miscellaneous Securities Operations + 'BKFE': _lt('Bank Fees'), + 'CLAI': _lt('Compensation/Claims'), + 'MNFE': _lt('Management Fees'), + 'OVCH': _lt('Overdraft Charge'), + 'TRFE': _lt('Transaction Fees'), + 'UNCO': _lt('Underwriting Commission'), + 'STAM': _lt('Stamp duty'), + 'WITH': _lt('Withholding Tax'), + 'BROK': _lt('Brokerage fee'), + 'PRIN': _lt('Interest Payment with Principle'), + 'TREC': _lt('Tax Reclaim'), + 'GEN1': _lt('Withdrawal/distribution'), + 'GEN2': _lt('Deposit/Contribution'), + 'ERWI': _lt('Borrowing fee'), + 'ERWA': _lt('Lending income'), + 'SWEP': _lt('Sweep'), + 'SWAP': _lt('Swap Payment'), + 'FUTU': _lt('Future Variation Margin'), + 'RESI': _lt('Futures Residual Amount'), + 'FUCO': _lt('Futures Commission'), + 'INFD': _lt('Fixed Deposit Interest Amount'), + # Account Management Sub-Families + 'ACCO': _lt('Account Opening'), + 'ACCC': _lt('Account Closing'), + 'ACCT': _lt('Account Transfer'), + 'VALD': _lt('Value Date'), + 'BCKV': _lt('Back Value'), + 'YTDA': _lt('YTD Adjustment'), + 'FLTA': _lt('Float adjustment'), + 'ERTA': _lt('Exchange Rate Adjustment'), + 'PSTE': _lt('Posting Error'), + # General + 'NTAV': _lt('Not available'), + 'OTHR': _lt('Other'), + 'MCOP': _lt('Miscellaneous Credit Operations'), + 'MDOP': _lt('Miscellaneous Debit Operations'), + 'FCTI': _lt('Fees, Commission , Taxes, Charges and Interest'), +} + + +def _generic_get(*nodes, xpath, namespaces, placeholder=None): + if placeholder is not None: + xpath = xpath.format(placeholder=placeholder) + for node in nodes: + item = node.xpath(xpath, namespaces=namespaces) + if item: + return item[0] + return False + +class CAMT: + # These are pair of getters: (getter for the amount, getter for the amount's currency) + _amount_getters = [ + (partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:Amt/text()'), partial(_generic_get, xpath='ns:Amt/@Ccy')), + ] + + _charges_getters = [ + (partial(_generic_get, xpath='ns:Chrgs/ns:Rcrd/ns:Amt/text()'), partial(_generic_get, xpath='ns:Chrgs/ns:Rcrd/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:Chrgs/ns:Amt/text()'), partial(_generic_get, xpath='ns:Chrgs/ns:Amt/@Ccy')), + ] + + _amount_charges_getters = [ + (partial(_generic_get, xpath='ns:Amt/text()'), partial(_generic_get, xpath='ns:Amt/@Ccy')), + ] + + # These are pair of getters: (getter for the exchange rate, getter for the target currency) + _target_rate_getters = [ + (partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:CcyXchg/ns:TrgtCcy/text()')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:CntrValAmt/ns:CcyXchg/ns:SrcCcy/text()')), + ] + + # These are pair of getters: (getter for the exchange rate, getter for the source currency) + _source_rate_getters = [ + (partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:CcyXchg/ns:SrcCcy/text()')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:CcyXchg/ns:SrcCcy/text()')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:CcyXchg/ns:TrgtCcy/text()')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:CcyXchg/ns:XchgRate/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:CcyXchg/ns:TrgtCcy/text()')), + ] + + # These are pair of getters: (getter for the amount, getter for the amount's currency) + _currency_amount_getters = [ + (partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:InstdAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:NtryDtls/ns:TxDtls/ns:AmtDtls/ns:InstdAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:NtryDtls/ns:TxDtls/ns:AmtDtls/ns:InstdAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:AmtDtls/ns:TxAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:NtryDtls/ns:TxDtls/ns:AmtDtls/ns:TxAmt/ns:Amt/text()'), partial(_generic_get, xpath='ns:NtryDtls/ns:TxDtls/ns:AmtDtls/ns:TxAmt/ns:Amt/@Ccy')), + (partial(_generic_get, xpath='ns:Amt/text()'), partial(_generic_get, xpath='ns:Amt/@Ccy')), + ] + _total_amount_getters = [ + (partial(_generic_get, xpath='ns:NtryDtls/ns:Btch/ns:TtlAmt/text()'), partial(_generic_get, xpath='ns:NtryDtls/ns:Btch/ns:TtlAmt/@Ccy')) + ] + + # Start Balance + # OPBD : Opening Booked + # PRCD : Previous Closing Balance + # OPAV : Opening Available + # ITBD : Interim Booked (in the case of preceeding pagination) + # These are pair of getters: (getter for the amount, getter for the sign) + _start_balance_getters = [ + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='OPBD']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='OPBD']/../../ns:CdtDbtInd/text()")), + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='PRCD']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='PRCD']/../../ns:CdtDbtInd/text()")), + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='OPAV']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='OPAV']/../../ns:CdtDbtInd/text()")), + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:CdtDbtInd/text()")), + ] + + # Ending Balance + # CLBD : Closing Booked + # CLAV : Closing Available + # ITBD : Interim Booked + # These are pair of getters: (getter for the amount, getter for the sign) + _end_balance_getters = [ + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLBD']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLBD']/../../ns:CdtDbtInd/text()")), + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLAV']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLAV']/../../ns:CdtDbtInd/text()")), + (partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:Amt/text()"), + partial(_generic_get, xpath="ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:CdtDbtInd/text()")), + ] + + _get_credit_debit_indicator = partial(_generic_get, + xpath='ns:CdtDbtInd/text()') + + _get_charges_credit_debit_indicator = partial(_generic_get, + xpath='ns:Chrgs/ns:Rcrd/ns:CdtDbtInd/text()') + + _get_transaction_date = partial(_generic_get, + xpath=('ns:ValDt/ns:Dt/text()' + '| ns:BookgDt/ns:Dt/text()' + '| ns:BookgDt/ns:DtTm/text()')) + + _get_statement_date = partial(_generic_get, + xpath=("ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLBD']/../../ns:Dt/ns:Dt/text()" + " | ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLBD']/../../ns:Dt/ns:DtTm/text()" + " | ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLAV']/../../ns:Dt/ns:Dt/text()" + " | ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='CLAV']/../../ns:Dt/ns:DtTm/text()" + " | ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:Dt/ns:Dt/text()" + " | ns:Bal/ns:Tp/ns:CdOrPrtry[ns:Cd='ITBD']/../../ns:Dt/ns:DtTm/text()" + )) + + _get_partner_name = partial(_generic_get, + xpath=('.//ns:RltdPties/ns:Ultmt{placeholder}/ns:Nm/text()' + ' | .//ns:RltdPties/ns:Ultmt{placeholder}/ns:Pty/ns:Nm/text()' + ' | .//ns:RltdPties/ns:{placeholder}/ns:Nm/text()' + ' | .//ns:RltdPties/ns:{placeholder}/ns:Pty/ns:Nm/text()' + )) + + _get_account_number = partial(_generic_get, + xpath=('.//ns:RltdPties/ns:{placeholder}Acct/ns:Id/ns:IBAN/text()' + '| (.//ns:{placeholder}Acct/ns:Id/ns:Othr/ns:Id)[1]/text()')) + + _get_main_ref = partial(_generic_get, + xpath='.//ns:RmtInf/ns:Strd/ns:{placeholder}RefInf/ns:Ref/text()') + + _get_other_ref = partial(_generic_get, + xpath=('ns:AcctSvcrRef/text()' + '| {placeholder}ns:Refs/ns:TxId/text()' + '| {placeholder}ns:Refs/ns:InstrId/text()' + '| {placeholder}ns:Refs/ns:EndToEndId/text()' + '| {placeholder}ns:Refs/ns:MndtId/text()' + '| {placeholder}ns:Refs/ns:ChqNb/text()')) + + _get_additional_entry_info = partial(_generic_get, xpath='ns:AddtlNtryInf/text()') + _get_additional_text_info = partial(_generic_get, xpath='ns:AddtlTxInf/text()') + _get_transaction_id = partial(_generic_get, xpath='ns:Refs/ns:TxId/text()') + _get_instruction_id = partial(_generic_get, xpath='ns:Refs/ns:InstrId/text()') + _get_end_to_end_id = partial(_generic_get, xpath='ns:Refs/ns:EndToEndId/text()') + _get_mandate_id = partial(_generic_get, xpath='ns:Refs/ns:MndtId/text()') + _get_check_number = partial(_generic_get, xpath='ns:Refs/ns:ChqNb/text()') + + @staticmethod + def _get_signed_balance(node, namespaces, getters): + for balance_getter, sign_getter in getters: + balance = balance_getter(node, namespaces=namespaces) + sign = sign_getter(node, namespaces=namespaces) + if balance and sign: + return -float(balance) if sign == 'DBIT' else float(balance) + return None + + @staticmethod + def _get_signed_amount(*nodes, namespaces, journal_currency=None): + def get_value_and_currency_name(node, getters, target_currency=None): + for value_getter, currency_getter in getters: + value = value_getter(node, namespaces=namespaces) + currency_name = currency_getter(node, namespaces=namespaces) + if value and (target_currency is None or currency_name == target_currency): + return float(value), currency_name + return None, None + + def get_rate(*entries, target_currency, source_currency=None): + for entry in entries: + source_rate = get_value_and_currency_name(entry, CAMT._source_rate_getters)[0] + target_rate = get_value_and_currency_name(entry, CAMT._target_rate_getters)[0] + + rate = source_rate or target_rate + if rate: + # According to the camt.053 Swiss Payment Standards, the exchange rate should be divided by 100 if the + # currency is in YEN, SEK, DKK or NOK. + if target_currency == 'CHF' and source_currency in ('SEK', 'DKK', 'YEN', 'NOK'): + rate /= 100 + elif not source_rate: + rate = 1 / rate + return rate + return None + + def get_charges(*entries, target_currency=None): + for entry in entries: + charges = get_value_and_currency_name(entry, CAMT._charges_getters, target_currency=target_currency)[0] + if charges: + sign = -1 if CAMT._get_charges_credit_debit_indicator(entry, namespaces=namespaces) == "DBIT" else 1 + return sign * charges + return None + + entry_details = nodes[0] + entry = nodes[1] if len(nodes) > 1 else nodes[0] + journal_currency_name = journal_currency.name if journal_currency else None + entry_amount = get_value_and_currency_name(entry, CAMT._amount_charges_getters, target_currency=journal_currency_name)[0] + entry_details_amount = get_value_and_currency_name(entry_details, CAMT._amount_charges_getters, target_currency=journal_currency_name)[0] + + charges = get_charges(entry_details, entry) + getters = CAMT._amount_charges_getters if charges else CAMT._amount_getters + amount, amount_currency_name = get_value_and_currency_name(entry_details, getters) + + if not amount or (charges and journal_currency and journal_currency.compare_amounts(amount + charges, entry_amount) == 0): + amount, amount_currency_name = get_value_and_currency_name(entry, getters) + + entry_amount_in_currency = get_value_and_currency_name(entry, getters, target_currency=amount_currency_name)[0] + entry_details_amount_in_currency = get_value_and_currency_name(entry_details, getters, target_currency=amount_currency_name)[0] + + if not journal_currency or amount_currency_name == journal_currency_name: + rate = 1.0 + else: + rate = get_rate(entry_details, entry, target_currency=journal_currency_name, source_currency=amount_currency_name) + entry_amount = entry_details_amount or entry_amount + if entry_details_amount: + entry_amount_in_currency = entry_details_amount_in_currency + elif not entry_amount_in_currency: + entry_amount_in_currency = amount + computed_rate = entry_amount / entry_amount_in_currency + if rate: + if float_compare(rate, computed_rate, precision_digits=4) == 0: + rate = computed_rate + elif float_compare(rate, 1 / computed_rate, precision_digits=4) == 0: + rate = 1 / computed_rate + else: + amount, amount_currency_name = get_value_and_currency_name(entry_details, CAMT._amount_getters, target_currency=journal_currency_name) + if not amount: + amount, amount_currency_name = get_value_and_currency_name(entry, CAMT._amount_getters, target_currency=journal_currency_name) + if amount_currency_name == journal_currency_name: + rate = 1.0 + if not rate: + raise ValidationError(_lt("No exchange rate was found to convert an amount into the currency of the journal")) + + sign = 1 if CAMT._get_credit_debit_indicator(*nodes, namespaces=namespaces) == "CRDT" else -1 + total_amount, total_amount_currency = get_value_and_currency_name(entry, CAMT._total_amount_getters) + result_amount = sign * amount * rate + if not total_amount or total_amount_currency != journal_currency_name and journal_currency: + entry_amount = entry_details_amount or entry_amount + total_amount = total_amount or amount + if journal_currency.compare_amounts(total_amount * rate, entry_amount) == 0: + result_amount = sign * amount * rate + elif journal_currency.compare_amounts(total_amount / rate, entry_amount) == 0: + result_amount = sign * amount / rate + + if journal_currency: + result_amount = journal_currency.round(result_amount) + return result_amount + + @staticmethod + def _get_counter_party(*nodes, namespaces): + ind = CAMT._get_credit_debit_indicator(*nodes, namespaces=namespaces) + return 'Dbtr' if ind == 'CRDT' else 'Cdtr' + + @staticmethod + def _set_amount_in_currency(node, getters, entry_vals, currency, curr_cache, has_multi_currency, namespaces): + for value_getter, currency_getter in getters: + instruc_amount = value_getter(node, namespaces=namespaces) + instruc_curr = currency_getter(node, namespaces=namespaces) + if (has_multi_currency and instruc_amount and instruc_curr and + instruc_curr != currency and instruc_curr in curr_cache): + entry_vals['amount_currency'] = math.copysign(abs(float(instruc_amount)), entry_vals['amount']) + entry_vals['foreign_currency_id'] = curr_cache[instruc_curr] + break + + @staticmethod + def _get_transaction_name(node, namespaces, entry=None): + xpaths = ( + './/ns:RmtInf/ns:Ustrd/text()', + './/ns:RmtInf/ns:Strd/ns:CdtrRefInf/ns:Ref/text()', + './/ns:AddtlNtryInf/text()', + './/ns:RmtInf/ns:Strd/ns:AddtlRmtInf/text()', + ) + for xpath in xpaths: + if entry is not None and 'AddtlNtryInf' in xpath: + transaction_name = entry.xpath(xpath, namespaces=namespaces) + else: + transaction_name = node.xpath(xpath, namespaces=namespaces) + if transaction_name: + return ' '.join(transaction_name) + return '/' + + @staticmethod + def _get_ref(node, counter_party, prefix, namespaces): + ref = CAMT._get_main_ref(node, placeholder=counter_party, namespaces=namespaces) + if ref is False: # Explicitely match False, not a falsy value + ref = CAMT._get_other_ref(node, placeholder=prefix, namespaces=namespaces) + return ref + + @staticmethod + def _get_unique_import_id(entry, sequence, name, date, unique_import_set, namespaces): + unique_import_ref = entry.xpath('ns:AcctSvcrRef/text()', namespaces=namespaces) + if unique_import_ref and not CAMT._is_full_of_zeros(unique_import_ref[0]) and unique_import_ref[0] != 'NOTPROVIDED': + entry_ref = entry.xpath('ns:NtryRef/text()', namespaces=namespaces) + if entry_ref: + return '{}-{}-{}'.format(name, unique_import_ref[0], entry_ref[0]) + elif not entry_ref and unique_import_ref[0] not in unique_import_set: + return unique_import_ref[0] + else: + return '{}-{}-{}'.format(name, unique_import_ref[0], sequence) + else: + return '{}-{}-{}'.format(name, date, sequence) + + @staticmethod + def _get_transaction_type(node, namespaces): + code = node.xpath('ns:Domn/ns:Cd/text()', namespaces=namespaces) + family = node.xpath('ns:Domn/ns:Fmly/ns:Cd/text()', namespaces=namespaces) + subfamily = node.xpath('ns:Domn/ns:Fmly/ns:SubFmlyCd/text()', namespaces=namespaces) + if code: + return {'transaction_type': "{code}: {family} ({subfamily})".format( + code=codes.get(code[0].upper(), code[0]), + family=family and codes.get(family[0].upper(), family[0]) or '', + subfamily=subfamily and codes.get(subfamily[0].upper(), subfamily[0]) or '', + )} + return {} + + @staticmethod + def _get_partner_address(node, ns, ph): + StrtNm = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:StrtNm/text()'.format(ph), namespaces=ns) + BldgNb = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:BldgNb/text()'.format(ph), namespaces=ns) + PstCd = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:PstCd/text()'.format(ph), namespaces=ns) + TwnNm = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:TwnNm/text()'.format(ph), namespaces=ns) + Ctry = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:Ctry/text()'.format(ph), namespaces=ns) + AdrLine = node.xpath('ns:RltdPties/ns:{}/ns:PstlAdr/ns:AdrLine/text()'.format(ph), namespaces=ns) + address = "\n".join(AdrLine) + if StrtNm: + address = "\n".join([address, ", ".join(StrtNm + BldgNb)]) + if PstCd or TwnNm: + address = "\n".join([address, " ".join(PstCd + TwnNm)]) + if Ctry: + address = "\n".join([address, Ctry[0]]) + return address + + @staticmethod + def _is_full_of_zeros(strg): + pattern_zero = re.compile('^0+$') + return bool(pattern_zero.match(strg)) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__init__.py new file mode 100644 index 0000000..dfddd98 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_journal \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5c26b18 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..4c3e40a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/account_journal.py new file mode 100644 index 0000000..b437d3d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/models/account_journal.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import io +import logging + +from lxml import etree + +from odoo import models, _ +from odoo.exceptions import UserError +from odoo.addons.base.models.res_bank import sanitize_account_number +from odoo.addons.odex30_account_bank_statement_import_camt.lib.camt import CAMT + +_logger = logging.getLogger(__name__) + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + def _get_bank_statements_available_import_formats(self): + rslt = super()._get_bank_statements_available_import_formats() + rslt.append('CAMT') + return rslt + + def _check_camt(self, attachment): + try: + root = etree.parse(io.BytesIO(attachment.raw)).getroot() + except Exception: + return None + if root.tag.find('camt.053') != -1: + return root + return None + + def _parse_bank_statement_file(self, attachment): + root = self._check_camt(attachment) + if root is not None: + return self._parse_bank_statement_file_camt(root) + return super()._parse_bank_statement_file(attachment) + + def _parse_bank_statement_file_camt(self, root): + ns = {'ns': root.xpath('namespace-uri(.)')} + + curr_cache = {c['name']: c['id'] for c in self.env['res.currency'].search_read([], ['id', 'name'])} + statements_per_iban = {} + currency_per_iban = {} + unique_import_set = set([]) + currency = account_no = False + has_multi_currency = self.env.user.has_group('base.group_multi_currency') + journal_currency = self.currency_id or self.company_id.currency_id + for statement in root[0].findall('ns:Stmt', ns): + statement_vals = {} + statement_vals['name'] = (statement.xpath('ns:LglSeqNb/text()', namespaces=ns) or statement.xpath('ns:Id/text()', namespaces=ns))[0] + statement_date = CAMT._get_statement_date(statement, namespaces=ns) + + # Transaction Entries 0..n + transactions = [] + sequence = 0 + + # Account Number 1..1 + # if not IBAN value then... would have. + account_no = sanitize_account_number(statement.xpath('ns:Acct/ns:Id/ns:IBAN/text() | ns:Acct/ns:Id/ns:Othr/ns:Id/text()', + namespaces=ns)[0]) + + # Currency 0..1 + currency = statement.xpath('ns:Acct/ns:Ccy/text() | ns:Bal/ns:Amt/@Ccy', namespaces=ns)[0] + + if currency and journal_currency and currency != journal_currency.name: + continue + + for entry in statement.findall('ns:Ntry', ns): + # Date 0..1 + date = CAMT._get_transaction_date(entry, namespaces=ns) or statement_date + + transaction_details = entry.xpath('.//ns:TxDtls', namespaces=ns) + entry_details_sum = 0 + largest_entry_vals = {'amount': 0} + for entry_details in transaction_details or [entry]: + sequence += 1 + counter_party = CAMT._get_counter_party(entry_details, entry, namespaces=ns) + partner_name = CAMT._get_partner_name(entry_details, placeholder=counter_party, namespaces=ns) + entry_vals = { + 'sequence': sequence, + 'date': date, + 'amount': CAMT._get_signed_amount(entry_details, entry, namespaces=ns, journal_currency=journal_currency), + 'payment_ref': CAMT._get_transaction_name(entry_details, namespaces=ns, entry=entry), + 'partner_name': partner_name, + 'account_number': CAMT._get_account_number(entry_details, placeholder=counter_party, namespaces=ns), + 'ref': CAMT._get_ref(entry_details, counter_party=counter_party, prefix='', namespaces=ns), + } + + entry_vals['unique_import_id'] = CAMT._get_unique_import_id( + entry=entry_details, + sequence=sequence, + name=statement_vals['name'], + date=entry_vals['date'], + unique_import_set=unique_import_set, + namespaces=ns) + + CAMT._set_amount_in_currency( + node=entry_details, + getters=CAMT._currency_amount_getters, + entry_vals=entry_vals, + currency=currency, + curr_cache=curr_cache, + has_multi_currency=has_multi_currency, + namespaces=ns) + + BkTxCd = entry.xpath('ns:BkTxCd', namespaces=ns)[0] + entry_vals.update(CAMT._get_transaction_type(BkTxCd, namespaces=ns)) + notes = [] + entry_info = CAMT._get_additional_entry_info(entry, namespaces=ns) + if entry_info: + notes.append(_('Entry Info: %s', entry_info)) + text_info = CAMT._get_additional_text_info(entry_details, namespaces=ns) + if text_info: + notes.append(_('Additional Info: %s', text_info)) + if partner_name: + notes.append(_('Counter Party: %(partner)s', partner=partner_name)) + partner_address = CAMT._get_partner_address(entry_details, ns, counter_party) + if partner_address: + notes.append(_('Address:\n%s', partner_address)) + transaction_id = CAMT._get_transaction_id(entry_details, namespaces=ns) + if transaction_id: + notes.append(_('Transaction ID: %s', transaction_id)) + instruction_id = CAMT._get_instruction_id(entry_details, namespaces=ns) + if instruction_id: + notes.append(_('Instruction ID: %s', instruction_id)) + end_to_end_id = CAMT._get_end_to_end_id(entry_details, namespaces=ns) + if end_to_end_id: + notes.append(_('End to end ID: %s', end_to_end_id)) + mandate_id = CAMT._get_mandate_id(entry_details, namespaces=ns) + if mandate_id: + notes.append(_('Mandate ID: %s', mandate_id)) + check_number = CAMT._get_check_number(entry_details, namespaces=ns) + if check_number: + notes.append(_('Check Number: %s', check_number)) + entry_vals['narration'] = "\n".join(notes) + + unique_import_set.add(entry_vals['unique_import_id']) + transactions.append(entry_vals) + + entry_details_sum += entry_vals['amount'] + if abs(entry_vals['amount']) >= abs(largest_entry_vals['amount']): + largest_entry_vals = entry_vals + + # In a multi-currency entry (Ntry) with multiple entry details, we might have some rounding differences when applying the currency rate. + # We add this difference back on the largest amount. + transaction_amount = float(entry.find('ns:Amt', namespaces=ns).text) + transaction_amount = -transaction_amount if entry.find('ns:CdtDbtInd', namespaces=ns).text == 'DBIT' else transaction_amount + largest_entry_vals['amount'] += transaction_amount - entry_details_sum + + statement_vals['transactions'] = transactions + statement_vals['balance_start'] = CAMT._get_signed_balance(node=statement, namespaces=ns, getters=CAMT._start_balance_getters) + statement_vals['balance_end_real'] = CAMT._get_signed_balance(node=statement, namespaces=ns, getters=CAMT._end_balance_getters) + + # Save statements and currency + statements_per_iban.setdefault(account_no, []).append(statement_vals) + currency_per_iban[account_no] = currency + + # If statements target multiple journals, returns thoses targeting the current journal + if len(statements_per_iban) > 1: + account_no = sanitize_account_number(self.bank_acc_number) + _logger.warning("The following statements will not be imported because they are targeting another journal (current journal id: %s):\n- %s", + account_no, "\n- ".join("{}: {} statement(s)".format(iban, len(statements)) for iban, statements in statements_per_iban.items() if iban != account_no)) + if not account_no: + raise UserError(_("Please set the IBAN account on your bank journal.\n\nThis CAMT file is targeting several IBAN accounts but none match the current journal.")) + + # Otherwise, returns those from only account_no + statement_list = statements_per_iban.get(account_no, []) + currency = currency_per_iban.get(account_no) + + if not currency and not statement_list: + raise UserError(_("Please check the currency on your bank journal.\n" + "No statements in currency %s were found in this CAMT file.", journal_currency.name)) + return currency, account_no, statement_list diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_additional_entry_info.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_additional_entry_info.xml new file mode 100644 index 0000000..8dbdd8a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_additional_entry_info.xml @@ -0,0 +1,72 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + MSGSALA0001 + CustRefForSalaBatch + 1 + 500.0 + CRDT + + + + + 500 + + + + + entry info + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_custom_codes.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_custom_codes.xml new file mode 100644 index 0000000..23d9f29 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_custom_codes.xml @@ -0,0 +1,62 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + custom_code + + custom_family + custom_subfamily + + + + ABCD + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_exchange_fees.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_exchange_fees.xml new file mode 100644 index 0000000..3343fc1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_exchange_fees.xml @@ -0,0 +1,71 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 2672.98 + CRDT +
    +
    2019-02-13
    + +
    + + 1672.98 + CRDT + BOOK + + + ABCD + + + + + + + 1672.98 + + CAD + 1.4595 + + + + 1639.98 + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal.xml new file mode 100644 index 0000000..7e4a51c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal.xml @@ -0,0 +1,55 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_EUR.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_EUR.xml new file mode 100644 index 0000000..564ed87 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_EUR.xml @@ -0,0 +1,55 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency.xml new file mode 100644 index 0000000..4d96a15 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency.xml @@ -0,0 +1,78 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + PMNT + + RCDT + ESCT + + + + + + 250.00 + + + 500.00000 + + EUR + USD + 0.5 + + + + + + 250.00000 + CRDT + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_02.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_02.xml new file mode 100644 index 0000000..ed8304c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_02.xml @@ -0,0 +1,71 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + CRDT + + + 250 + + USD + EUR + 2 + + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_03.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_03.xml new file mode 100644 index 0000000..46307d5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_03.xml @@ -0,0 +1,73 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + PMNT + + RCDT + ESCT + + + + + + 250.00 + + + 500.00000 + + + + + 250.00000 + CRDT + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_04.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_04.xml new file mode 100644 index 0000000..c410bea --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_04.xml @@ -0,0 +1,71 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + CRDT + + + 250 + + EUR + USD + 2 + + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_05.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_05.xml new file mode 100644 index 0000000..b7e88e9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_05.xml @@ -0,0 +1,71 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + CRDT + + + 250 + + EUR + USD + 0.5 + + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_06.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_06.xml new file mode 100644 index 0000000..45ee941 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_06.xml @@ -0,0 +1,71 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + CRDT + + + 245 + + USD + EUR + 2.040817 + + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_07.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_07.xml new file mode 100644 index 0000000..5b9369a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_and_multicurrency_07.xml @@ -0,0 +1,78 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + PMNT + + RCDT + ESCT + + + + + + 252.00 + + + 500.00 + + EUR + USD + 1.9841 + + + + + + 252.00 + CRDT + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges.xml new file mode 100644 index 0000000..6345a00 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges.xml @@ -0,0 +1,77 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + 4 + + 4 + CRDT + true + + + + + 500 + CRDT + + + 496 + + + 496 + + + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_02.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_02.xml new file mode 100644 index 0000000..a11851a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_02.xml @@ -0,0 +1,69 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 496 + CRDT + + + 496 + + + + 4 + + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_03.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_03.xml new file mode 100644 index 0000000..22b14a7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_charges_03.xml @@ -0,0 +1,73 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 505.00 + CRDT + + + 505.00 + + + + + 5.00 + DBIT + true + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_datetime.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_datetime.xml new file mode 100644 index 0000000..bed9bb8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_datetime.xml @@ -0,0 +1,55 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    + 2019-02-13T21:00:00Z +
    +
    + + + + CLBD + + + 1500.00 + CRDT +
    + 2019-02-13T21:00:00Z +
    +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_intraday.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_intraday.xml new file mode 100644 index 0000000..cf5828f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_minimal_intraday.xml @@ -0,0 +1,79 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988304.2019-02-13 + 2019-02-13T13:31:05.668+02:00 + + + + + 445566 + + + + + + + ITBD + + + 0 + CRDT +
    +
    2019-02-13
    + +
    +
    + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-13
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_namespace.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_namespace.xml new file mode 100644 index 0000000..b14cc0c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_namespace.xml @@ -0,0 +1,55 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT + + 2019-02-12 + + + + + + CLBD + + + 1500.00 + CRDT + + 2019-02-13 + + + + 500.00 + CRDT + BOOK + + + ABCD + + + + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_sample.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_sample.xml new file mode 100644 index 0000000..be4fe9c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_sample.xml @@ -0,0 +1,948 @@ + + + + + + + BANKFILEID00001 + 2009-10-30T03:30:47+02:00 + + + + + BANKSTMTID0972009 + + 120 + + + + 97 + 2009-10-30T02:00:00+02:00 + + + + + 2009-10-29T02:00:00+02:00 + 2009-10-29T21:00:00+02:00 + + + + + + + + FI7433010001222090 + + + + CACC + + + EUR + + + BANK ACCOUNT OWNER + + HELSINGINKATU + 31 + 00100 + HELSINKI + FI + + + + + + 12345678901 + + + BANK + + + + + + + + + ESSEFIHX + + + + + + + + FI1533010001911270 + + EUR + + + + + + + INDY + + + + Business agreement + + + + + + 0.00 + true + + + 1000000.00 + true + + + + CRDT + EUR + + + + + + + + + + + OPBD + + + + + false + 10000.00 + + 10000.00 + CRDT + +
    +
    2009-10-29
    + +
    + + + + + PRCD + + + + false + 10000.00 + + 10000.00 + CRDT + +
    +
    2009-10-28
    + +
    + + + + + CLBD + + + + + false + 10000.00 + + 23644.83 + CRDT + +
    +
    2009-10-29
    + +
    + + + + + + CLAV + + + 33644.83 + CRDT +
    +
    2009-10-29
    + +
    + + + + + 10 + + + + 6 + 23075.00 + + + + 4 + 9430.17 + + + + + + + + + 1000.12 + DBIT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + + 091029ACCTSTMTARCH01 + + + + PMNT + + ICDT + SALA + + + + + NTRF+701TransactionCodeText + + + + + + + MSGSALA0001 + + CustRefForSalaBatch + + 4 + + + + + SALA + + + +
    + + + + 4000.00 + DBIT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + + 091029ACCTSTMTARCH02 + + + + PMNT + + ICDT + DMCT + + + + + NTRF+702TransactionCodeText + + + + + + + MSGSCT0099 + CustRefForPmtBatch + + 3 + + +
    + + + 4230.05 + DBIT + BOOK + + +
    2009-10-29
    +
    + + +
    2009-10-29
    +
    + 091029ACCTSTMTARCH03 + + + PMNT + + ICDT + DMCT + + + + + NTRF+702TransactionCodeText + + + + + MSGSCT0100 + CustRefForPmtBatch9 + 3 + + + + + + + + 091029ARCH03001 + + TITOT1106ID01 + + + + 2000.02 + + + + + Creditor Company + + + FI + Mannerheimintie 123 + 00100 Helsinki + + FI + + + + + + 29501800020582 + + BBAN + + + + + + + + + + + SCOR + + + 3900 + + + + + + + + 091029ARCH03002 + TITOT1106ID02 + + + + 1000.01 + + + + + Creditor Company + + FI + Mannerheimintie 123 + 00100 Helsinki + + FI + + + + + 29501800020582 + + BBAN + + + + + + ACWC + + + + + Invoices 123 and 321 + + + + + + + 091029ARCH03003 + TITOT1106ID03 + + + + 1230.02 + + + + + Creditor Company + + FI + Mannerheimintie 123 + 00100 Helsinki + + FI + + + + + 29501800020574 + + BBAN + + + + + + + + + + + CINV + + + 217827182718 + + + + + + 9102910 + + + + + + + + +
    + + + + 2000.00 + CRDT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + + 091029ACCTSTMTARCH04 + + + PMNT + + RCDT + DMCT + + + + NTRF+705TransactionCodeText + + + + + BANKFILEID998765 + + 2 + + + + + 2000.00 + + + + +
    + + + 500.00 + CRDT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + + 091029ACCTSTMTARCH21 + + + PMNT + + RDDT + PMDD + + + + NTRF+705TransactionCodeText + + + + + BANKFILEID998799 + + 2 + + + + + 500.00 + + + + +
    + + + 3000.00 + CRDT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + 091029ACCTSTMTARCH05 + + + PMNT + + RCDT + ESCT + + + + + + BANKFILEID998765 + + 2 + + + + + 3000.00 + + + + +
    + + + 2120.00 + CRDT + BOOK + +
    2009-10-29
    +
    + + +
    2009-10-30
    +
    + 091029ACCTSTMTARCH06 + + + PMNT + + RCDT + XBCT + + + + + + + + ISSRBKREF12345678 + ISSRBKREF12345678 + + + + + + 3200.00 + + USD + EUR + + EUR + 0.666667 + FX12345 + 2009-10-29T10:00:00+02:00 + + + + 2120.00 + + + + 2120.00 + + USD + EUR + EUR + 0.666667 + FX12345 + 2009-10-29T10:00:00+02:00 + + + + + + 20.00 + + COMM + + + + + DEBTOR NAME + + + + + 123456789 + + DUNS + + + + + + + + + + BOFAUS6H + + + + + INVOICE US20291092 + + + +
    + + + + 5455.00 + CRDT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + 091029ACCTSTMTARCH07 + + + PMNT + + RCDT + + PRCT + + + + + + + + BKREFDBT0101010 + BKREFDBT0101010 + + + + + 5500.00 + + + 5455.00 + + + + 45.00 + + COMM + + + + + PAYERS NAME2 + + GOVERN STREET + 22 + 291092 + LONDON + UK + + + + + 123456789 + + EANG + + + + + + + + + + BOFSGB22 + + + + + + + 5500.00 + + + + + SCOR + + ISO + + RF98123456789012 + + + + + +
    + + + 10000.00 + CRDT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + 091029ACCTSTMTARCH08 + + + PMNT + + RCDT + ESCT + + + + + + + EndToEndIdSCT01 + + + + + 10000.00 + + + + + DEBTOR + + + ULTIMATE DEBTOR + + + + 987654321 + + TXID + + + + + + + + + + NDEAFIHH + + + + + TREA + + + + + + + SCOR + + ISO + + RF98123456789012 + + + + + 2009-10-28T03:00:00+02:00 + + + +
    + + + + 200.00 + DBIT + BOOK + +
    2009-10-29
    +
    + +
    2009-10-29
    +
    + 091029ACCTSTMTARCH09 + + + + PMNT + + RCDT + CHRG + + + + + + + + + 091029ARCH09001 + + + + 100.00 + + + + + PMNT + + RCDT + DMCT + + + + + + SEB Merchant Banking Finland + + + + + 10000 + + + + + + + 091029ARCH09001 + + + + 100.00 + + + + + PMNT + + RCDT + ESCT + + + + + + SEB Merchant Banking Finland + + + + + 150000 + + + + +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_ibans.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_ibans.xml new file mode 100644 index 0000000..a2ce288 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_ibans.xml @@ -0,0 +1,96 @@ + + + + + 2514988305.2019-05-23 + 2019-05-23T15:27:15.66+02:00 + + + 2514988305.2019-05-23 + 2019-05-23T15:27:15.66+02:00 + + + BE86 6635 9439 7150 + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-05-22
    + +
    + + + + CLBD + + + 1600.00 + CRDT +
    +
    2019-05-23
    + +
    + + 600.00 + CRDT + BOOK + + + ABCD + + + +
    + + + 2514988305.2019-05-23 + 2019-05-23T15:27:15.66+02:00 + + + BE79 5390 0754 6933 + + + + + + OPBD + + + 100.00 + CRDT +
    +
    2019-05-22
    + +
    + + + + CLBD + + + 150.00 + CRDT +
    +
    2019-05-23
    + +
    + + 50.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_minimal_stmt_different_currency.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_minimal_stmt_different_currency.xml new file mode 100644 index 0000000..6628923 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_minimal_stmt_different_currency.xml @@ -0,0 +1,103 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + USD + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + EUR + + + + + OPBD + + + 2000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 3000.00 + CRDT +
    +
    2019-02-13
    + +
    + + 1000.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details.xml new file mode 100644 index 0000000..9f732b2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details.xml @@ -0,0 +1,94 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 500.0 + CRDT + + + + + 100 + + + + label01 + + + + + + 150 + + + + label02 + + + + + + 250 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_charges.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_charges.xml new file mode 100644 index 0000000..b45cdb0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_charges.xml @@ -0,0 +1,108 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + 15 + + 15 + CRDT + false + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 500.0 + CRDT + + + 100 + CRDT + + + 6 + + + + label01 + + + + 150 + CRDT + + + 5 + + + + label02 + + + + 250 + CRDT + + + 4 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount.xml new file mode 100644 index 0000000..a3e87ee --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount.xml @@ -0,0 +1,94 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 500.0 + CRDT + + + + + 100 + + + + label01 + + + + + + 150 + + + + label02 + + + + + + 250 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount_02.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount_02.xml new file mode 100644 index 0000000..266c1cb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_instructed_amount_02.xml @@ -0,0 +1,90 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 3 + + + + + 100 + + + + label01 + + + + + + 150 + + + + label02 + + + + + + 250 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_01.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_01.xml new file mode 100644 index 0000000..f53a131 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_01.xml @@ -0,0 +1,104 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + + EUR + USD + 0.5 + + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 500.0 + CRDT + + + + + 50 + + + + label01 + + + + + + 75 + + + + label02 + + + + + + 125 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_02.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_02.xml new file mode 100644 index 0000000..9fc8fa9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_02.xml @@ -0,0 +1,104 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + + EUR + USD + 0.5 + + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 250.0 + CRDT + + + + + 50 + + + + label01 + + + + + + 75 + + + + label02 + + + + + + 125 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_03.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_03.xml new file mode 100644 index 0000000..5959dd0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_03.xml @@ -0,0 +1,97 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + 500 + + EUR + USD + 0.5 + + + + + + + + 50 + + + + label01 + + + + + + 75 + + + + label02 + + + + + + 125 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_04.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_04.xml new file mode 100644 index 0000000..0293518 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_04.xml @@ -0,0 +1,100 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 100000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 150000.00 + CRDT +
    +
    2019-02-13
    + +
    + + 50000.00 + CRDT + BOOK + + + ABCD + + + + + 47824.94 + + USD + EUR + 1.0455 + + + + + + 3 + + + + + 9564.99 + + + + label01 + + + + + + 14347.48 + + + + label02 + + + + + + 23912.47 + + + + label03 + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_05.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_05.xml new file mode 100644 index 0000000..987b845 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_and_multicurrency_05.xml @@ -0,0 +1,103 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988306.2019-02-13 + 2019-02-12T21:17:26+01:00 + + + + + 112233 + + + + + + + OPBD + + + 100000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 150000.00 + CRDT +
    +
    2019-02-13
    + +
    + + 50000.00 + DBIT + BOOK + +
    2024-11-25
    +
    + +
    2024-11-25
    +
    + + + ABCD + + + + + 52759.31 + + + 52759.31 + + EUR + 0.9477 + + + + + + 4 + + + 19962.03 + DBIT + + label01 + + + + 24806 + DBIT + + label02 + + + + + A6Z0001086376914 + 8052-2410138 + + 7991.28 + DBIT + + label03 + + + +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_nordic.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_nordic.xml new file mode 100644 index 0000000..15a189e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_tx_details_nordic.xml @@ -0,0 +1,104 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + OPBD + + + 1000.00 + CRDT +
    +
    2019-02-12
    + +
    + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + + + MSGSALA0001 + CustRefForSalaBatch + 3 + 500.0 + CRDT + + + + + 100 + + + + label01 + + + + + + 150 + + + + label02 + + + + + + 250 + + + + + Ultimate Debtor Name + + + DEBTOR NAME + + + + + Transaction 03 name + + + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt.xml new file mode 100644 index 0000000..45074eb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt.xml @@ -0,0 +1,249 @@ + + + + + 0574908765.2015-12-05 + 2015-12-05T10:55:08.66+02:00 + + 1 + true + + + + 0574908765.2015-12-05 + 0070 + 2015-12-05T10:55:08.66+02:00 + + + + + 123456 + + + Norbert Brant + + + ABNANL2A + + + + + + + OPBD + + + 8998.20 + CRDT +
    +
    2015-12-05
    + +
    + + + + CLAV + + + 2661.49 + CRDT +
    +
    2015-12-05
    + +
    + + 7379.54 + DBIT + BOOK + +
    2015-12-01
    +
    + +
    2015-12-01
    +
    + + + PMNT + + RDDT + ESDD + + + + EI + + + + + + INNDNL2U20141231000142300002844 + 435005714488-ABNO33052620 + 1880000341866 + + + + 7379.54 + + + + + ASUSTeK + + TEST STREET 20 + 1234 AB TESTCITY + NL + + + + + NL46ABNA0499998748 + + + + + + + ABNANL2A + + + + + BILL 2015 0002 + + MKB 859239PERIOD 31.12.2015 + + +
    + + 564.05 + DBIT + true + BOOK + +
    2015-11-29
    +
    + +
    2015-11-29
    +
    + + + PMNT + + IDDT + UPDD + + + + EIST + + + + + + TESTBANK/NL/20151129/01206408 + TESTBANK/NL/20151129/01206408 + NL22ZZZ524885430000-C0125.1 + + + + 564.05 + + + + + China Export + + NL + + + + + + 10987654323 + + + + + + + + ABNANL2A + + + + + Direct Debit S14 0410 + + + + AC06 + + + Direct debit S14 0410 AC07 Rek.nummer blokkade TESTBANK/NL/20141229/01206408 + + +
    + + 1636.88 + CRDT + BOOK + +
    2015-01-05
    +
    + +
    2015-01-05
    +
    + + + PMNT + + RCDT + ESCT + + + + ET + + + + + + INNDNL2U20150105000217200000708 + 115 + + + + 1636.88 + + + + + Norbert Brant + + SOMESTREET 570-A + 1276 ML HOUSCITY + NL + + + + + + BE93999574162167 + + + + + + + + ABNANL2A + + + + #RD INV/2015/0002 INV/2015/0003 + + +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt_no_opening_balance.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt_no_opening_balance.xml new file mode 100644 index 0000000..d7baa7b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/test_camt_file/test_camt_no_opening_balance.xml @@ -0,0 +1,43 @@ + + + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + 2514988305.2019-02-13 + 2019-02-13T15:27:15.66+02:00 + + + + + 112233 + + + + + + + CLBD + + + 1500.00 + CRDT +
    +
    2019-02-13
    + +
    + + 500.00 + CRDT + BOOK + + + ABCD + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/__init__.py new file mode 100644 index 0000000..5102391 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_account_bank_statement_import_camt diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/test_account_bank_statement_import_camt.py b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/test_account_bank_statement_import_camt.py new file mode 100644 index 0000000..2e1deb3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_camt/tests/test_account_bank_statement_import_camt.py @@ -0,0 +1,473 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.tools import file_open +from odoo.exceptions import UserError +from odoo.addons.odex30_account_bank_statement_import_camt.models.account_journal import _logger as camt_wizard_logger + +NORMAL_AMOUNTS = [100, 150, 250] +LARGE_AMOUNTS = [10000, 15000, 25000] + +@tagged('post_install', '-at_install') +class TestAccountBankStatementImportCamt(AccountTestInvoicingCommon): + + def test_camt_file_import(self): + bank_journal = self.env['account.journal'].create({ + 'name': 'Bank 123456', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '123456', + 'currency_id': self.env.ref('base.USD').id, + }) + + partner_norbert = self.env['res.partner'].create({ + 'name': 'Norbert Brant', + 'is_company': True, + }) + bank_norbert = self.env['res.bank'].create({'name': 'test'}) + + self.env['res.partner.bank'].create({ + 'acc_number': 'BE93999574162167', + 'partner_id': partner_norbert.id, + 'bank_id': bank_norbert.id, + }) + self.env['res.partner.bank'].create({ + 'acc_number': '10987654323', + 'partner_id': self.partner_a.id, + 'bank_id': bank_norbert.id, + }) + + # Get CAMT file content + camt_file_path = 'odex30_account_bank_statement_import_camt/test_camt_file/test_camt.xml' + with file_open(camt_file_path, 'rb') as camt_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_camt.xml', + 'raw': camt_file.read(), + }).ids) + + # Check the imported bank statement + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + self.assertRecordValues(imported_statement, [{ + 'name': '0574908765.2015-12-05', + 'balance_start': 8998.20, + 'balance_end_real': 2661.49, + }]) + self.assertRecordValues(imported_statement.line_ids.sorted('ref'), [ + { + 'ref': 'INNDNL2U20141231000142300002844', + 'partner_name': 'ASUSTeK', + 'amount': -7379.54, + 'partner_id': False, + }, + { + 'ref': 'INNDNL2U20150105000217200000708', + 'partner_name': partner_norbert.name, + 'amount': 1636.88, + 'partner_id': partner_norbert.id, + }, + { + 'ref': 'TESTBANK/NL/20151129/01206408', + 'partner_name': 'China Export', + 'amount': -564.05, + 'partner_id': self.partner_a.id, + }, + ]) + + def test_minimal_camt_file_import(self): + """ + This basic test aims at importing a file with amounts expressed in USD + while the company's currency is USD too and the journal has not any currency + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_02(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + The exchange rate is provided and the company's currency is set in the source currency. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_02.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_03(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD but with no rate provided. + The company's currency is USD. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_03.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_04(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + This is the same test than test_minimal_and_multicurrency_camt_file_import_02, + except that the company's currency is set in the target currency. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_04.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_05(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + This is the same test than test_minimal_and_multicurrency_camt_file_import_04, + except that the exchange rate is inverted. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_05.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_06(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + This is the same test than test_minimal_and_multicurrency_camt_file_import_02, + except that the exchange rate is leading to a rounding difference. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_06.xml', usd_currency) + + def test_minimal_and_multicurrency_camt_file_import_07(self): + """ + This test aims at importing a file with amounts expressed in EUR and USD. + The company's currency is USD. + This is the same test than test_minimal_and_multicurrency_camt_file_import, + except that the exchange rate is rounded to 4 digits. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_and_multicurrency_07.xml', usd_currency) + + def test_several_minimal_stmt_different_currency(self): + """ + Two different journals with the same bank account. The first one is in USD, the second one in EUR + Test to import a CAMT file with two statements: one in USD, another in EUR + """ + usd_currency = self.env.ref('base.USD') + eur_currency = self.env.ref('base.EUR') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + # USD Statement + self._test_minimal_camt_file_import('camt_053_several_minimal_stmt_different_currency.xml', usd_currency) + # EUR Statement + eur_currency.active = True + self._test_minimal_camt_file_import('camt_053_several_minimal_stmt_different_currency.xml', eur_currency, + start_balance=2000, end_balance=3000) + + def test_journal_with_other_currency(self): + """ + This test aims at importing a file with amounts expressed in EUR into a journal + that also uses EUR while the company's currency is USD. + """ + self.assertEqual(self.env.company.currency_id.id, self.env.ref('base.USD').id) + self.env.ref('base.EUR').active = True + self._test_minimal_camt_file_import('camt_053_minimal_EUR.xml', self.env.ref('base.EUR')) + + def _import_camt_file(self, camt_file_name, currency): + # Create a bank account and journal corresponding to the CAMT + # file (same currency and account number) + BankAccount = self.env['res.partner.bank'] + partner = self.env.user.company_id.partner_id + bank_account = BankAccount.search([('acc_number', '=', '112233'), ('partner_id', '=', partner.id)]) \ + or BankAccount.create({'acc_number': '112233', 'partner_id': partner.id}) + bank_journal = self.env['account.journal'].create( + { + 'name': "Bank 112233 %s" % currency.name, + 'code': "B-%s" % currency.name, + 'type': 'bank', + 'bank_account_id': bank_account.id, + 'currency_id': currency.id, + } + ) + + # Use an import wizard to process the file + camt_file_path = f'odex30_account_bank_statement_import_camt/test_camt_file/{camt_file_name}' + with file_open(camt_file_path, 'rb') as camt_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_camt.xml', + 'raw': camt_file.read(), + }).ids) + + def _test_minimal_camt_file_import(self, camt_file_name, currency, start_balance=1000, end_balance=1500): + # Create a bank account and journal corresponding to the CAMT + # file (same currency and account number) + self._import_camt_file(camt_file_name, currency) + # Check the imported bank statement + bank_st_record = self.env['account.bank.statement'].search( + [('name', '=', '2514988305.2019-02-13')] + ).filtered(lambda bk_stmt: bk_stmt.currency_id == currency).ensure_one() + self.assertEqual( + bank_st_record.balance_start, start_balance, "Start balance not matched" + ) + self.assertEqual( + bank_st_record.balance_end_real, end_balance, "End balance not matched" + ) + + # Check the imported bank statement line + line = bank_st_record.line_ids.ensure_one() + self.assertEqual(line.amount, end_balance - start_balance, "Transaction not matched") + + def _test_camt_with_several_tx_details(self, filename, expected_amounts=None): + if expected_amounts is None: + expected_amounts = NORMAL_AMOUNTS + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._import_camt_file(filename, usd_currency) + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)], order='id desc', limit=1) + self.assertEqual(len(imported_statement.line_ids), 3) + self.assertEqual(imported_statement.line_ids[0].payment_ref, 'label01') + self.assertEqual(usd_currency.round(imported_statement.line_ids[0].amount), expected_amounts[0]) + self.assertEqual(imported_statement.line_ids[1].payment_ref, 'label02') + self.assertEqual(usd_currency.round(imported_statement.line_ids[1].amount), expected_amounts[1]) + self.assertEqual(imported_statement.line_ids[2].payment_ref, 'label03') + self.assertEqual(usd_currency.round(imported_statement.line_ids[2].amount), expected_amounts[2]) + + def test_camt_with_several_tx_details(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details.xml') + + def test_camt_with_several_tx_details_and_instructed_amount(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_instructed_amount.xml') + + def test_camt_with_several_tx_details_and_instructed_amount_02(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_instructed_amount_02.xml') + + def test_camt_with_several_tx_details_and_multicurrency_01(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_multicurrency_01.xml') + + def test_camt_with_several_tx_details_and_multicurrency_02(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_multicurrency_02.xml') + + def test_camt_with_several_tx_details_and_multicurrency_03(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_multicurrency_03.xml') + + def test_camt_with_several_tx_details_and_multicurrency_04(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_multicurrency_04.xml', + expected_amounts=LARGE_AMOUNTS) + + def test_camt_with_several_tx_details_and_multicurrency_05(self): + # Tests when the rounded sum is different by one cent from the sum of the rounded amounts after applying the currency rate. + # The difference is put on the largest amount (positive or negative) + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_multicurrency_05.xml', + expected_amounts=[-18918.02, -23508.64, -7573.34]) + + def test_camt_with_several_tx_details_and_charges(self): + self._test_camt_with_several_tx_details('camt_053_several_tx_details_and_charges.xml') + + def test_several_ibans_match_journal_camt_file_import(self): + # Create a bank account and journal corresponding to the CAMT + # file (same currency and account number) + bank_journal = self.env['account.journal'].create({ + 'name': "Bank BE86 6635 9439 7150", + 'code': 'BNK69', + 'type': 'bank', + 'bank_acc_number': 'BE86 6635 9439 7150', + 'currency_id': self.env.ref('base.USD').id, + }) + + # Use an import wizard to process the file + camt_file_path = 'odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_ibans.xml' + with file_open(camt_file_path, 'rb') as camt_file: + with self.assertLogs(level="WARNING") as log_catcher: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_camt.xml', + 'raw': camt_file.read(), + }).ids) + self.assertEqual(len(log_catcher.output), 1, "Exactly one warning should be logged") + self.assertIn( + "The following statements will not be imported", + log_catcher.output[0], + "The logged warning warns about non-imported statements", + ) + + # Check the imported bank statement + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + self.assertRecordValues(imported_statement, [{ + 'name': '2514988305.2019-05-23', + 'balance_start': 1000.00, + 'balance_end_real': 1600.00, + }]) + self.assertRecordValues(imported_statement.line_ids.sorted('ref'), [{'amount': 600.00}]) + + def test_several_ibans_missing_journal_id_camt_file_import(self): + # Create a bank account and journal corresponding to the CAMT + # file (same currency and account number) + bank_journal = self.env['account.journal'].create({ + 'name': "Bank BE43 9787 8497 9701", + 'code': 'BNK69', + 'type': 'bank', + 'currency_id': self.env.ref('base.USD').id, + # missing bank account number + }) + + # Use an import wizard to process the file + camt_file_path = 'odex30_account_bank_statement_import_camt/test_camt_file/camt_053_several_ibans.xml' + with file_open(camt_file_path, 'rb') as camt_file: + with self.assertLogs(camt_wizard_logger, level="WARNING") as log_catcher: + with self.assertRaises(UserError) as error_catcher: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_camt.xml', + 'raw': camt_file.read(), + }).ids) + + self.assertEqual(len(log_catcher.output), 1, "Exactly one warning should be logged") + self.assertIn( + "The following statements will not be imported", + log_catcher.output[0], + "The logged warning warns about non-imported statements", + ) + + self.assertEqual(error_catcher.exception.args[0], ( + "The following files could not be imported:\n" + "- test_camt.xml: Please set the IBAN account on your bank journal.\n\n" + "This CAMT file is targeting several IBAN accounts but none match the current journal." + )) + + def test_date_and_time_format_camt_file_import(self): + """ + This test aims to import a statement having dates specified in datetime format. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_datetime.xml', usd_currency) + + def test_intraday_camt_file_import(self): + """ + This test aims to import a statement having only an ITBD balance, where we have + only one date, corresponding to the same opening and closing amount. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_intraday.xml', usd_currency) + + def test_charges_camt_file_import(self): + """ + This test aims to import a statement having transactions including charges in their + total amount. In that case, we need to check that the retrieved amount is correct. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_charges.xml', usd_currency) + + def test_charges_camt_file_import_02(self): + """ + This test aims to import a statement having transactions including charges in their + total amount. In that case, we need to check that the retrieved amount is correct. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_charges_02.xml', usd_currency) + + def test_charges_camt_file_import_03(self): + """ + This test aims to import a statement having transactions including charges in their + total amount. In that case, we need to check that the retrieved amount is correct. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_minimal_charges_03.xml', usd_currency) + + def test_import_already_fully_imported_catm_without_opening_balance(self): + """ + Test the scenario when you have a CAMT file where one statement does not + have an opening balance, and you try to import it twice + """ + bank_journal = self.env['account.journal'].create({ + 'name': 'Bank 123456', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '112233', + 'currency_id': self.env.ref('base.USD').id, + }) + + camt_file_path = 'odex30_account_bank_statement_import_camt/test_camt_file/test_camt_no_opening_balance.xml' + + def import_file(): + with file_open(camt_file_path, 'rb') as camt_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_camt_no_opening_balance.xml', + 'raw': camt_file.read(), + }).ids) + + import_file() + with self.assertRaises(UserError, msg='You already have imported that file.'): + import_file() + + def test_import_camt_with_nordic_tags(self): + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._import_camt_file('camt_053_several_tx_details_nordic.xml', usd_currency) + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)], order='id desc', limit=1) + self.assertEqual(len(imported_statement.line_ids), 3) + third_line = imported_statement.line_ids[2] + self.assertEqual(third_line.payment_ref, 'Transaction 03 name') + self.assertEqual(third_line.partner_name, 'Ultimate Debtor Name') + + def test_import_camt_additional_entry_info(self): + """ + Ensures that '' is used as a fallback for the payment reference + """ + usd_currency = self.env.ref("base.USD") + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._import_camt_file("camt_053_additional_entry_info.xml", usd_currency) + imported_statement = self.env["account.bank.statement"].search( + [("company_id", "=", self.env.company.id)], order="id desc", limit=1 + ) + self.assertEqual(len(imported_statement.line_ids), 1) + self.assertEqual(imported_statement.line_ids.payment_ref, "entry info") + + def test_import_camt_amounts_with_fees(self): + """ + Ensures that '' is used as a fallback for the payment reference + """ + usd_currency = self.env.ref("base.USD") + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._import_camt_file("camt_053_exchange_fees.xml", usd_currency) + imported_statement = self.env["account.bank.statement"].search( + [("company_id", "=", self.env.company.id)], order="id desc", limit=1 + ) + self.assertEqual(len(imported_statement.line_ids), 1) + self.assertRecordValues(imported_statement.line_ids, [{ + 'amount': 1672.98, + }]) + + def test_camt_file_import_namespace(self): + """ + Ensures that CAMT files using namespaces can be imported. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_namespace.xml', usd_currency) + + def test_camt_file_import_custom_code(self): + """ + Ensures that CAMT files with custom codes can be imported. + """ + usd_currency = self.env.ref('base.USD') + self.assertEqual(self.env.company.currency_id.id, usd_currency.id) + self._test_minimal_camt_file_import('camt_053_custom_codes.xml', usd_currency) + bank_st_record = self.env['account.bank.statement'].search( + [('name', '=', '2514988305.2019-02-13')] + ).filtered(lambda bk_stmt: bk_stmt.currency_id == usd_currency).ensure_one() + line = bank_st_record.line_ids.ensure_one() + self.assertEqual(line.transaction_type, "custom_code: custom_family (custom_subfamily)") diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__init__.py new file mode 100644 index 0000000..35e7c96 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__manifest__.py new file mode 100644 index 0000000..1808e41 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Import CSV Bank Statement', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'version': '1.0', + 'description': r''' +Module to import CSV bank statements. +====================================== + +This module allows you to import CSV Files in Odoo: they are parsed and stored in human readable format in +Accounting \ Bank and Cash \ Bank Statements. + +Important Note +--------------------------------------------- +Because of the CSV format limitation, we cannot ensure the same transactions aren't imported several times or handle multicurrency. +Whenever possible, you should use a more appropriate file format like OFX. +''', + 'depends': ['odex30_account_bank_statement_import', 'base_import'], + 'installable': True, + 'auto_install': True, + 'assets': { + 'web.assets_backend': [ + 'odex30_account_bank_statement_import_csv/static/src/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2952708 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/account_bank_statement_import_csv.pot b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/account_bank_statement_import_csv.pot new file mode 100644 index 0000000..4582db7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/account_bank_statement_import_csv.pot @@ -0,0 +1,56 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_csv +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-19 09:52+0000\n" +"PO-Revision-Date: 2024-12-19 09:52+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: odex30_account_bank_statement_import_csv +#: model:ir.model,name:odex30_account_bank_statement_import_csv.model_base_import_import +msgid "Base Import" +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-javascript +#: code:addons/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_action.js:0 +msgid "Import Bank Statement" +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#: model:ir.model,name:odex30_account_bank_statement_import_csv.model_account_journal +msgid "Journal" +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py:0 +msgid "Make sure that an Amount or Debit and Credit is in the file." +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/models/account_journal.py:0 +msgid "Mixing CSV files with other file types is not allowed." +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/models/account_journal.py:0 +msgid "Only one CSV file can be selected." +msgstr "" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py:0 +msgid "Rows must be sorted by date." +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/ar.po new file mode 100644 index 0000000..8158116 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/i18n/ar.po @@ -0,0 +1,62 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_csv +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-12-19 09:52+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_bank_statement_import_csv +#: model:ir.model,name:odex30_account_bank_statement_import_csv.model_base_import_import +msgid "Base Import" +msgstr "الاستيراد الأساسي" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-javascript +#: code:addons/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_action.js:0 +msgid "Import Bank Statement" +msgstr "استيراد كشف حساب بنكي" + +#. module: odex30_account_bank_statement_import_csv +#: model:ir.model,name:odex30_account_bank_statement_import_csv.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py:0 +msgid "Make sure that an Amount or Debit and Credit is in the file." +msgstr "تأكد من وجود مبلغ أو رصيد مدين ومخصوم في الملف. " + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/models/account_journal.py:0 +msgid "Mixing CSV files with other file types is not allowed." +msgstr "" +"لا يسمح بخلط ملفات القيم المفصولة بفواصل (CSV) مع أنواع الملفات الأخرى. " + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/models/account_journal.py:0 +msgid "Only one CSV file can be selected." +msgstr "يمكنك تحديد ملف قيم مفصولة بفواصل (CSV) واحد فقط. " + +#. module: odex30_account_bank_statement_import_csv +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py:0 +msgid "Rows must be sorted by date." +msgstr "يجب أن يتم فرز الصفوف حسب التاريخ. " diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__init__.py new file mode 100644 index 0000000..5cd061d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_journal \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..5459258 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..9e3ed62 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/account_journal.py new file mode 100644 index 0000000..35b52e3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/models/account_journal.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +from odoo import _, models +from odoo.exceptions import UserError + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + def _get_bank_statements_available_import_formats(self): + rslt = super()._get_bank_statements_available_import_formats() + rslt.extend(['CSV', 'XLS', 'XLSX']) + return rslt + + def _check_file_format(self, filename): + return filename and filename.lower().strip().endswith(('.csv', '.xls', '.xlsx')) + + def _import_bank_statement(self, attachments): + # In case of CSV files, only one file can be imported at a time. + if len(attachments) > 1: + csv = [bool(self._check_file_format(att.name)) for att in attachments] + if True in csv and False in csv: + raise UserError(_('Mixing CSV files with other file types is not allowed.')) + if csv.count(True) > 1: + raise UserError(_('Only one CSV file can be selected.')) + return super()._import_bank_statement(attachments) + + if not self._check_file_format(attachments.name): + return super()._import_bank_statement(attachments) + ctx = dict(self.env.context) + import_wizard = self.env['base_import.import'].create({ + 'res_model': 'account.bank.statement.line', + 'file': attachments.raw, + 'file_name': attachments.name, + 'file_type': attachments.mimetype, + }) + ctx['wizard_id'] = import_wizard.id + ctx['default_journal_id'] = self.id + return { + 'type': 'ir.actions.client', + 'tag': 'import_bank_stmt', + 'params': { + 'model': 'account.bank.statement.line', + 'context': ctx, + 'filename': attachments.name, + } + } diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/csv/account.bank.statement.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/csv/account.bank.statement.csv new file mode 100644 index 0000000..b27f721 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/csv/account.bank.statement.csv @@ -0,0 +1,6 @@ +Date,Reference,Partner,Label,Amount,Amount Currency,Currency,Cumulative Balance +2017-05-10,INV/2017/0001,,#01,4610,,,4710 +2017-05-11,Payment bill 20170521,,#02,-100,,,4610 +2017-05-15,INV/2017/0003 discount 2% early payment,,#03,514.5,,,5124.5 +2017-05-30,INV/2017/0002 + INV/2017/0004,,#04,5260,,,10384.5 +2017-05-31,Payment bill EUR 001234565,,#05,-537.15,-500,EUR,9847.35 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_action.js b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_action.js new file mode 100644 index 0000000..e2dd13b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_action.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { onWillStart } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; +import { ImportAction } from "@base_import/import_action/import_action"; +import { useBankStatementCSVImportModel } from "./bank_statement_csv_import_model"; + +export class BankStatementImportAction extends ImportAction { + setup() { + super.setup(); + + this.action = useService("action"); + + this.model = useBankStatementCSVImportModel({ + env: this.env, + resModel: this.resModel, + context: this.props.action.params.context || {}, + orm: this.orm, + }); + + this.env.config.setDisplayName(_t("Import Bank Statement")); // Displayed in the breadcrumbs + this.state.filename = this.props.action.params.filename || undefined; + + onWillStart(async () => { + if (this.props.action.params.context) { + this.model.id = this.props.action.params.context.wizard_id; + await super.handleFilesUpload([{ name: this.state.filename }]) + } + }); + } + + async exit() { + if (this.model.statement_id) { + const res = await this.orm.call( + "account.bank.statement", + "action_open_bank_reconcile_widget", + [this.model.statement_id] + ); + return this.action.doAction(res); + } + super.exit(); + } +} + +registry.category("actions").add("import_bank_stmt", BankStatementImportAction); diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_model.js b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_model.js new file mode 100644 index 0000000..d990721 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/static/src/bank_statement_csv_import_model.js @@ -0,0 +1,37 @@ +/** @odoo-module **/ + +import { useState } from "@odoo/owl"; +import { BaseImportModel } from "@base_import/import_model"; + +class BankStatementCSVImportModel extends BaseImportModel { + async init() { + this.importOptionsValues.bank_stmt_import = { + value: true, + }; + return Promise.resolve(); + } + + async _onLoadSuccess(res) { + super._onLoadSuccess(res); + + if (!res.messages || res.messages.length === 0 || res.messages.length > 1) { + return; + } + + const message = res.messages[0]; + if (message.ids) { + this.statement_line_ids = message.ids + } + + if (message.messages && message.messages.length > 0) { + this.statement_id = message.messages[0].statement_id + } + } +} + +/** + * @returns {BankStatementCSVImportModel} + */ +export function useBankStatementCSVImportModel({ env, resModel, context, orm }) { + return useState(new BankStatementCSVImportModel({ env, resModel, context, orm })); +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv.csv new file mode 100644 index 0000000..8e8bfea --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv.csv @@ -0,0 +1,20 @@ +02 01 15;;LAST STATEMENT;; $21,699.55 +02 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 MAILCHIMP MAILCHIMP.COMGA";($240.00); $21,459.55 +02 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 INDEED 203-564-2400 CT";($500.08); $20,959.46 +02 02 15;;"ACH CREDIT""AMERICAN EXPRESS-SETTLEMENT";$3,728.87 ; $24,688.34 +02 02 15;;"DEBIT CARD 6906""BAYSIDE MARKET/1 SAN FRANCISCO CA";($41.64); $24,646.70 +02 02 15;;"DEBIT CARD 6906""02/02 COMFORT INNS SAN FRANCISCOCA";($2,064.82); $22,581.88 +02 03 15;;"ACH CREDIT""CHECKFLUID INC -013015";$2,500.00 ; $25,081.88 +02 03 15;;"DEBIT CARD 6906""02/02 DISTRICT SF SAN FRANCISCOCA";($45.86); $25,036.02 +02 03 15;;"DEPOSIT-WIRED FUNDS""TVET OPERATING PLLC";$8,366.00 ; $33,402.02 +02 03 15;;"DEBIT CARD 6906""02/03 IBM USED PC 888S 188-874-6742 NY";($4,344.66); $29,057.36 +02 03 15;;"DEBIT CARD 6906""02/02 VIR ATL 9327 180-08628621 CT";($1,284.33); $27,773.03 +02 03 15;;"DEBIT CARD 6906""02/02 VIR ATL 9327 180-08628621 CT";($1,284.33); $26,488.70 +02 03 15;;"DEBIT CARD 6906""02/02 VIR ATL 9327 180-08628621 CT";($1,284.33); $25,204.37 +02 03 15;;"DEBIT CARD 6906""02/02 VIR ATL 9327 180-08628621 CT";($1,123.33); $24,081.04 +02 03 15;;"DEBIT CARD 6906""02/02 VIR ATL 9327 180-08628621 CT";($1,123.33); $22,957.71 +02 03 15;;"ACH DEBIT""AUTHNET GATEWAY -BILLING";($25.00); $22,932.71 +02 03 15;;"ACH DEBIT""WW 222 BROADWAY -ACH";($7,500.00); $15,432.71 +02 04 15;;"DEBIT CARD 6906""02/03 VIR ATL 9327 180-08628621 CT";($1,284.33); $14,148.38 +02 04 15;;"DEBIT CARD 6906""02/04 GROUPON INC 877-788-7858 IL";($204.23); $13,944.15 +02 05 15;;"ACH CREDIT""MERCHE-SOLUTIONS-MERCH DEP";$9,518.40 ; $23,462.55 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_empty_date.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_empty_date.csv new file mode 100644 index 0000000..e1769a4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_empty_date.csv @@ -0,0 +1,5 @@ +02 01 15;;LAST STATEMENT;; $21,699.55 +02 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 MAILCHIMP MAILCHIMP.COMGA"; ($240.00); $21,459.55 +04 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 INDEED 203-564-2400 CT"; ($500.08); $20,959.46 +02 02 15;;"ACH CREDIT""AMERICAN EXPRESS-SETTLEMENT"; $3,728.87; $24,688.34 +;;"DEBIT CARD 6906""BAYSIDE MARKET/1 SAN FRANCISCO CA"; ($41.64); $24,646.70 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_missing_values.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_missing_values.csv new file mode 100644 index 0000000..b930490 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_missing_values.csv @@ -0,0 +1,2 @@ +TRANSFER;bank_ref_1;bank_statement_line_1;;1000 +TRANSFER;;bank_statement_line_2;;3500 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_non_sorted.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_non_sorted.csv new file mode 100644 index 0000000..98d6a30 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_non_sorted.csv @@ -0,0 +1,4 @@ +02 01 15;;LAST STATEMENT;; $21,699.55 +02 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 MAILCHIMP MAILCHIMP.COMGA";($240.00); $21,459.55 +04 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 INDEED 203-564-2400 CT";($500.08); $20,959.46 +02 02 15;;"ACH CREDIT""AMERICAN EXPRESS-SETTLEMENT";$3,728.87 ; $24,688.34 diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_without_amount.csv b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_without_amount.csv new file mode 100644 index 0000000..3d1973e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/test_csv_file/test_csv_without_amount.csv @@ -0,0 +1,3 @@ +02 01 15;;LAST STATEMENT; +02 02 15;;"DEBIT CARD 6906 EFF 02-01""01/31 MAILCHIMP MAILCHIMP.COMGA"; + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/__init__.py new file mode 100644 index 0000000..3eb2e34 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_import_bank_statement diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/test_import_bank_statement.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/test_import_bank_statement.py new file mode 100644 index 0000000..57e3386 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/tests/test_import_bank_statement.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo import fields +from odoo.exceptions import UserError, ValidationError +from odoo.tests import tagged +from odoo.tools import file_open + + +@tagged('post_install', '-at_install') +class TestAccountBankStatementImportCSV(AccountTestInvoicingCommon): + + def _import_file(self, csv_file_path, csv_fields=False): + # Create a bank account and journal corresponding to the CSV file (same currency and account number) + bank_journal = self.env['account.journal'].create({ + 'name': 'Bank 123456', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '123456', + 'currency_id': self.env.ref("base.USD").id, + }) + + # Use an import wizard to process the file + with file_open(csv_file_path, 'rb') as csv_file: + action = bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'text/csv', + 'name': 'test_csv.csv', + 'raw': csv_file.read(), + }).ids) + import_wizard = self.env['base_import.import'].browse( + action['params']['context']['wizard_id'] + ).with_context(action['params']['context']) + + import_wizard_options = { + 'date_format': '%m %d %y', + 'keep_matches': False, + 'encoding': 'utf-8', + 'fields': [], + 'quoting': '"', + 'bank_stmt_import': True, + 'headers': True, + 'limit': 10, + 'skip': 0, + 'separator': ';', + 'float_thousand_separator': ',', + 'float_decimal_separator': '.', + 'advanced': False, + } + import_wizard_fields = csv_fields or ['date', False, 'payment_ref', 'amount', 'balance'] + res = import_wizard.execute_import(import_wizard_fields, [], import_wizard_options, dryrun=False) + while not res['messages'] and res['nextrow'] != 0: # no error and there is a next row + import_wizard_options['skip'] = res['nextrow'] + 1 + res = import_wizard.execute_import(import_wizard_fields, [], import_wizard_options, dryrun=False) + + def test_csv_file_import(self): + self._import_file('odex30_account_bank_statement_import_csv/test_csv_file/test_csv.csv') + + # Check the imported bank statement + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + self.assertRecordValues(imported_statement, [ + { + 'reference': 'test_csv.csv', + 'balance_start': 27773.03, + 'balance_end_real': 23462.55, + }, + { + 'reference': 'test_csv.csv', + 'balance_start': 21699.55, + 'balance_end_real': 23462.55, + } + ]) + self.assertRecordValues(imported_statement.line_ids.sorted(lambda line: (line.date, line.payment_ref)), [ + {'date': fields.Date.from_string('2015-02-02'), 'amount': 3728.87, 'payment_ref': 'ACH CREDIT"AMERICAN EXPRESS-SETTLEMENT'}, + {'date': fields.Date.from_string('2015-02-02'), 'amount': -500.08, 'payment_ref': 'DEBIT CARD 6906 EFF 02-01"01/31 INDEED 203-564-2400 CT'}, + {'date': fields.Date.from_string('2015-02-02'), 'amount': -240.00, 'payment_ref': 'DEBIT CARD 6906 EFF 02-01"01/31 MAILCHIMP MAILCHIMP.COMGA'}, + {'date': fields.Date.from_string('2015-02-02'), 'amount': -2064.82, 'payment_ref': 'DEBIT CARD 6906"02/02 COMFORT INNS SAN FRANCISCOCA'}, + {'date': fields.Date.from_string('2015-02-02'), 'amount': -41.64, 'payment_ref': 'DEBIT CARD 6906"BAYSIDE MARKET/1 SAN FRANCISCO CA'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': 2500.00, 'payment_ref': 'ACH CREDIT"CHECKFLUID INC -013015'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -25.00, 'payment_ref': 'ACH DEBIT"AUTHNET GATEWAY -BILLING'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -7500.00, 'payment_ref': 'ACH DEBIT"WW 222 BROADWAY -ACH'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -45.86, 'payment_ref': 'DEBIT CARD 6906"02/02 DISTRICT SF SAN FRANCISCOCA'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -1284.33, 'payment_ref': 'DEBIT CARD 6906"02/02 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -1284.33, 'payment_ref': 'DEBIT CARD 6906"02/02 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -1123.33, 'payment_ref': 'DEBIT CARD 6906"02/02 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -1123.33, 'payment_ref': 'DEBIT CARD 6906"02/02 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -1284.33, 'payment_ref': 'DEBIT CARD 6906"02/02 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': -4344.66, 'payment_ref': 'DEBIT CARD 6906"02/03 IBM USED PC 888S 188-874-6742 NY'}, + {'date': fields.Date.from_string('2015-02-03'), 'amount': 8366.00, 'payment_ref': 'DEPOSIT-WIRED FUNDS"TVET OPERATING PLLC'}, + {'date': fields.Date.from_string('2015-02-04'), 'amount': -1284.33, 'payment_ref': 'DEBIT CARD 6906"02/03 VIR ATL 9327 180-08628621 CT'}, + {'date': fields.Date.from_string('2015-02-04'), 'amount': -204.23, 'payment_ref': 'DEBIT CARD 6906"02/04 GROUPON INC 877-788-7858 IL'}, + {'date': fields.Date.from_string('2015-02-05'), 'amount': 9518.40, 'payment_ref': 'ACH CREDIT"MERCHE-SOLUTIONS-MERCH DEP'}, + ]) + + def test_csv_file_import_with_missing_values(self): + self._import_file('odex30_account_bank_statement_import_csv/test_csv_file/test_csv_missing_values.csv', ['transaction_type', 'ref', 'payment_ref', 'debit', 'credit']) + + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + + self.assertEqual(len(imported_statement.line_ids), 2) + + self.assertRecordValues(imported_statement.line_ids.sorted(lambda line: line.amount), [ + {'transaction_type': 'TRANSFER', 'ref': 'bank_ref_1', 'payment_ref': 'bank_statement_line_1', 'sequence': 0, 'amount': 1000.0}, + {'transaction_type': 'TRANSFER', 'ref': False, 'payment_ref': 'bank_statement_line_2', 'sequence': 1, 'amount': 3500.0}, + ]) + + def test_csv_file_import_non_ordered(self): + with self.assertRaises(UserError): + self._import_file('odex30_account_bank_statement_import_csv/test_csv_file/test_csv_non_sorted.csv') + + def test_csv_file_empty_date(self): + with self.assertRaises(UserError): + self._import_file('odex30_account_bank_statement_import_csv/test_csv_file/test_csv_empty_date.csv') + + def test_csv_file_import_without_amount(self): + csv_fields = ['date', False, 'payment_ref', 'balance'] + with self.assertRaisesRegex(ValidationError, "Make sure that an Amount or Debit and Credit is in the file."): + self._import_file('odex30_account_bank_statement_import_csv/test_csv_file/test_csv_without_amount.csv', csv_fields) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__init__.py new file mode 100644 index 0000000..da76434 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_bank_statement_import_csv diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f8809bf Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/account_bank_statement_import_csv.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/account_bank_statement_import_csv.cpython-311.pyc new file mode 100644 index 0000000..15d20b7 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/__pycache__/account_bank_statement_import_csv.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py new file mode 100644 index 0000000..713d19a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_csv/wizard/account_bank_statement_import_csv.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import contextlib + +import psycopg2 + +from odoo import _, api, fields, models, Command +from odoo.exceptions import UserError, ValidationError +from odoo.addons.base_import.models.base_import import FIELDS_RECURSION_LIMIT + + +class AccountBankStmtImportCSV(models.TransientModel): + + _inherit = 'base_import.import' + + @api.model + def get_fields_tree(self, model, depth=FIELDS_RECURSION_LIMIT): + fields_list = super(AccountBankStmtImportCSV, self).get_fields_tree(model, depth=depth) + if self._context.get('bank_stmt_import', False): + add_fields = [{ + 'id': 'balance', + 'name': 'balance', + 'string': 'Cumulative Balance', + 'required': False, + 'fields': [], + 'type': 'monetary', + 'model_name': model, + }, { + 'id': 'debit', + 'name': 'debit', + 'string': 'Debit', + 'required': False, + 'fields': [], + 'type': 'monetary', + 'model_name': model, + }, { + 'id': 'credit', + 'name': 'credit', + 'string': 'Credit', + 'required': False, + 'fields': [], + 'type': 'monetary', + 'model_name': model, + }] + fields_list.extend(add_fields) + return fields_list + + def _convert_to_float(self, value): + return float(value) if value else 0.0 + + def _parse_import_data(self, data, import_fields, options): + # EXTENDS base + data = super()._parse_import_data(data, import_fields, options) + journal_id = self._context.get('default_journal_id') + bank_stmt_import = options.get('bank_stmt_import') + if not journal_id or not bank_stmt_import: + return data + + has_amount = 'amount' in import_fields + has_credit = 'credit' in import_fields + has_debit = 'debit' in import_fields + if (has_debit ^ has_credit) or not (has_amount ^ has_debit): + raise ValidationError(_("Make sure that an Amount or Debit and Credit is in the file.")) + statement_vals = options['statement_vals'] = {} + ret_data = [] + + import_fields.append('sequence') + index_balance = False + convert_to_amount = False + + # check that the rows are sorted by date (ascending or descending) as we assume they are in the following code + # we can't order the rows for the user as two rows could have the same date + # and we don't have a way to know which one should be first + if 'date' in import_fields: + index_date = import_fields.index('date') + dates = [fields.Date.from_string(line[index_date]) for line in data if line[index_date]] + sorted_dates_asc = sorted(dates) + if dates != sorted_dates_asc: # If dates are not in ascending order, check if they are in descending order + sorted_dates_desc = sorted_dates_asc[::-1] + if dates == sorted_dates_desc: + data = data[::-1] # reverse data if dates are in descending order to make them ascending + else: # If dates are not sorted at all, we throw an error + raise UserError(_('Rows must be sorted by date.')) + + if 'debit' in import_fields and 'credit' in import_fields: + index_debit = import_fields.index('debit') + index_credit = import_fields.index('credit') + self._parse_float_from_data(data, index_debit, 'debit', options) + self._parse_float_from_data(data, index_credit, 'credit', options) + import_fields.append('amount') + convert_to_amount = True + + # add starting balance and ending balance to context + if 'balance' in import_fields: + index_balance = import_fields.index('balance') + self._parse_float_from_data(data, index_balance, 'balance', options) + statement_vals['balance_start'] = self._convert_to_float(data[0][index_balance]) + statement_vals['balance_start'] -= self._convert_to_float(data[0][import_fields.index('amount')]) \ + if not convert_to_amount \ + else abs(self._convert_to_float(data[0][index_credit]))-abs(self._convert_to_float(data[0][index_debit])) + statement_vals['balance_end_real'] = data[len(data)-1][index_balance] + import_fields.remove('balance') + + if convert_to_amount: + import_fields.remove('debit') + import_fields.remove('credit') + + for index, line in enumerate(data): + line.append(index) + remove_index = [] + if convert_to_amount: + line.append( + abs(self._convert_to_float(line[index_credit])) + - abs(self._convert_to_float(line[index_debit])) + ) + remove_index.extend([index_debit, index_credit]) + if index_balance: + remove_index.append(index_balance) + # Remove added field debit/credit/balance + for index in sorted(remove_index, reverse=True): + del line[index] + if line[import_fields.index('amount')]: + ret_data.append(line) + + return ret_data + + def parse_preview(self, options, count=10): + if options.get('bank_stmt_import', False): + self = self.with_context(bank_stmt_import=True) + return super(AccountBankStmtImportCSV, self).parse_preview(options, count=count) + + def execute_import(self, fields, columns, options, dryrun=False): + if options.get('bank_stmt_import'): + with self.env.cr.savepoint(flush=False) as sp: + res = super().execute_import(fields, columns, options, dryrun=dryrun) + + if not 'statement_id' in fields: + statement = self.env['account.bank.statement'].create({ + 'reference': self.file_name, + 'line_ids': [Command.set(res.get('ids', []))], + **options.get('statement_vals', {}), + }) + + with contextlib.suppress(psycopg2.InternalError): + sp.close(rollback=dryrun) + + return res + else: + return super(AccountBankStmtImportCSV, self).execute_import(fields, columns, options, dryrun=dryrun) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__manifest__.py new file mode 100644 index 0000000..28f0b07 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Import OFX Bank Statement', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'version': '1.0', + 'depends': ['odex30_account_bank_statement_import'], + 'description': r""" +Module to import OFX bank statements. +====================================== + +This module allows you to import the machine readable OFX Files in Odoo: they are parsed and stored in human readable format in +Accounting \ Bank and Cash \ Bank Statements. + +Bank Statements may be generated containing a subset of the OFX information (only those transaction lines that are required for the +creation of the Financial Accounting records). + """, + 'installable': True, + 'auto_install': True, + 'license': 'OEEL-1', +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..4308148 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/account_bank_statement_import_ofx.pot b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/account_bank_statement_import_ofx.pot new file mode 100644 index 0000000..82ad174 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/account_bank_statement_import_ofx.pot @@ -0,0 +1,33 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_ofx +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-07 20:46+0000\n" +"PO-Revision-Date: 2025-05-07 20:46+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: odex30_account_bank_statement_import_ofx +#: model:ir.model,name:odex30_account_bank_statement_import_ofx.model_account_journal +msgid "Journal" +msgstr "" + +#. module: odex30_account_bank_statement_import_ofx +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_ofx/models/account_journal.py:0 +msgid "The library 'ofxparse' is missing, OFX import cannot proceed." +msgstr "" + +#. module: odex30_account_bank_statement_import_ofx +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_ofx/models/account_journal.py:0 +msgid "There was an issue decoding the file. Please check the file encoding." +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/ar.po new file mode 100644 index 0000000..8e021c2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/i18n/ar.po @@ -0,0 +1,38 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_ofx +# +# Translators: +# Wil Odoo, 2024 +# Malaz Abuidris , 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-05-07 20:46+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Malaz Abuidris , 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_bank_statement_import_ofx +#: model:ir.model,name:odex30_account_bank_statement_import_ofx.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_bank_statement_import_ofx +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_ofx/models/account_journal.py:0 +msgid "The library 'ofxparse' is missing, OFX import cannot proceed." +msgstr "المكتبة 'ofxparse' غير مثبتة، لن يمكن استيراد ملفات OFX بدونها." + +#. module: odex30_account_bank_statement_import_ofx +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_ofx/models/account_journal.py:0 +msgid "There was an issue decoding the file. Please check the file encoding." +msgstr "حدثت مشكلة أثناء فك تشفير الملف. يرجى التحقق من ترميز الملف. " diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__init__.py new file mode 100644 index 0000000..5cd061d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_journal \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..354cec5 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..040cbdc Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/account_journal.py new file mode 100644 index 0000000..dd8c842 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/models/account_journal.py @@ -0,0 +1,182 @@ + +# -*- coding: utf-8 -*- + +import datetime +import io +import logging +import re +import unicodedata +from xml.etree import ElementTree + +try: + from ofxparse import OfxParser + OfxParserClass = OfxParser +except ImportError: + logging.getLogger(__name__).warning("The ofxparse python library is not installed, ofx import will not work.") + OfxParser = None + OfxParserClass = object + +from odoo import models, _ +from odoo.exceptions import UserError + + +class PatchedOfxParser(OfxParserClass): + """ This class monkey-patches the ofxparse library in order to fix the following known bug: ',' is a valid + decimal separator for amounts, as we can encounter in ofx files made by european banks. + """ + + @classmethod + def decimal_separator_cleanup(cls, tag): + if hasattr(tag, "contents"): + tag.string = tag.contents[0].replace(',', '.') + + @classmethod + def parseStatement(cls, stmt_ofx): + ledger_bal_tag = stmt_ofx.find('ledgerbal') + if hasattr(ledger_bal_tag, "contents"): + balamt_tag = ledger_bal_tag.find('balamt') + cls.decimal_separator_cleanup(balamt_tag) + avail_bal_tag = stmt_ofx.find('availbal') + if hasattr(avail_bal_tag, "contents"): + balamt_tag = avail_bal_tag.find('balamt') + cls.decimal_separator_cleanup(balamt_tag) + return super().parseStatement(stmt_ofx) + + @classmethod + def parseTransaction(cls, txn_ofx): + amt_tag = txn_ofx.find('trnamt') + cls.decimal_separator_cleanup(amt_tag) + return super().parseTransaction(txn_ofx) + + @classmethod + def parseInvestmentPosition(cls, ofx): + tag = ofx.find('units') + cls.decimal_separator_cleanup(tag) + tag = ofx.find('unitprice') + cls.decimal_separator_cleanup(tag) + return super().parseInvestmentPosition(ofx) + + @classmethod + def parseInvestmentTransaction(cls, ofx): + tag = ofx.find('units') + cls.decimal_separator_cleanup(tag) + tag = ofx.find('unitprice') + cls.decimal_separator_cleanup(tag) + return super().parseInvestmentTransaction(ofx) + + @classmethod + def parseOfxDateTime(cls, ofxDateTime): + res = re.search(r"^[0-9]*\.([0-9]{0,5})", ofxDateTime) + if res: + msec = datetime.timedelta(seconds=float("0." + res.group(1))) + else: + msec = datetime.timedelta(seconds=0) + + # Some banks seem to return some OFX dates as YYYY-MM-DD; so we remove + # the '-' characters to support them as well + ofxDateTime = ofxDateTime.replace('-', '') + + try: + local_date = datetime.datetime.strptime( + ofxDateTime[:14], '%Y%m%d%H%M%S' + ) + return local_date + msec + except Exception: + if not ofxDateTime or ofxDateTime[:8] == "00000000": + return None + + return datetime.datetime.strptime( + ofxDateTime[:8], '%Y%m%d') + msec + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + def _get_bank_statements_available_import_formats(self): + rslt = super(AccountJournal, self)._get_bank_statements_available_import_formats() + rslt.append('OFX') + return rslt + + + def _check_ofx(self, attachment): + if (attachment.raw or b'').startswith(b"OFXHEADER"): + #v1 OFX + return True + try: + #v2 OFX + return b"" in (attachment.raw or b'').lower() + except ElementTree.ParseError: + return False + + def _fill_transaction_vals_line_ofx(self, transaction, length_transactions, partner_bank): + return { + 'date': transaction.date, + 'payment_ref': transaction.payee + (transaction.memo and ': ' + transaction.memo or ''), + 'ref': transaction.id, + 'amount': float(transaction.amount), + 'unique_import_id': transaction.id, + 'account_number': partner_bank.acc_number, + 'partner_id': partner_bank.partner_id.id, + 'sequence': length_transactions + 1, + } + + def _parse_bank_statement_file(self, attachment): + if not self._check_ofx(attachment): + return super()._parse_bank_statement_file(attachment) + if OfxParser is None: + raise UserError(_("The library 'ofxparse' is missing, OFX import cannot proceed.")) + + try: + ofx = PatchedOfxParser.parse(io.BytesIO(attachment.raw)) + except UnicodeDecodeError: + # Replacing utf-8 chars with ascii equivalent + encoding = re.findall(b'encoding="(.*?)"', attachment.raw) + encoding = encoding[0] if len(encoding) > 1 else 'utf-8' + try: + attachment = unicodedata.normalize('NFKD', attachment.raw.decode(encoding)).encode('ascii', 'ignore') + ofx = PatchedOfxParser.parse(io.BytesIO(attachment)) + except UnicodeDecodeError: + raise UserError(_("There was an issue decoding the file. Please check the file encoding.")) + vals_bank_statement = [] + account_lst = set() + currency_lst = set() + # Since ofxparse doesn't provide account numbers, we'll have to find res.partner and res.partner.bank here + # (normal behaviour is to provide 'account_number', which the generic module uses to find partner/bank) + transaction_payees = [ + transaction.payee + for account in ofx.accounts + for transaction in account.statement.transactions + ] + partner_banks_dict = { + partner_bank.partner_id.name: partner_bank + for partner_bank in self.env['res.partner.bank'].search([ + ('partner_id.name', 'in', transaction_payees) + ]) + } + for account in ofx.accounts: + account_lst.add(account.number) + currency_lst.add(account.statement.currency) + transactions = [] + total_amt = 0.00 + for transaction in account.statement.transactions: + partner_bank = partner_banks_dict.get(transaction.payee, self.env['res.partner.bank']) + vals_line = self._fill_transaction_vals_line_ofx(transaction, len(transactions), partner_bank) + total_amt += float(transaction.amount) + transactions.append(vals_line) + + vals_bank_statement.append({ + 'transactions': transactions, + # WARNING: the provided ledger balance is not necessarily the ending balance of the statement + # see https://github.com/odoo/odoo/issues/3003 + 'balance_start': float(account.statement.balance) - total_amt, + 'balance_end_real': account.statement.balance, + }) + + if account_lst and len(account_lst) == 1: + account_lst = account_lst.pop() + currency_lst = currency_lst.pop() + else: + account_lst = None + currency_lst = None + + return currency_lst, account_lst, vals_bank_statement diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.png b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.png new file mode 100644 index 0000000..2002d99 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.png differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.svg b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.svg new file mode 100644 index 0000000..07c6969 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx.ofx b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx.ofx new file mode 100644 index 0000000..e23d0fe --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx.ofx @@ -0,0 +1,100 @@ + + + + + + + 0 + INFO + + 20130831165153.000[-8:PST] + ENG + + + + + 0 + + 0 + INFO + + + USD + + 000000123 + 123456 + CHECKING + + + 20130801 + 20130831165153.000[-8:PST] + + POS + 20130824080000 + -80 + 219378 + Norbert Brant + + + + 20130801 + 20130831165153.000[-8:PST] + + POS + 20130824080000 + -90 + 219379 + China Export + + + + 20130801 + 20130831165153.000[-8:PST] + + POS + 20130824080000 + -100 + 219380 + Axelor Scuba + + + + 20130801 + 20130831165153.000[-8:PST] + + POS + 20130824080000 + -90 + 219381 + China Scuba + + + + 2156.56 + 20130831165153 + + + + + + + 0 + + 0 + INFO + + + USD + + 123456 + + + + + -562.00 + 20130831165153 + + + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx_unicode_error.ofx b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx_unicode_error.ofx new file mode 100644 index 0000000..9744131 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/static/ofx/test_ofx_unicode_error.ofx @@ -0,0 +1,56 @@ + +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + + 00000000000000 + POR + + + + + 0 + + 0< + INFO + + + BRL + + 000000123123456 + CHECKING + + + 20250116120000 + 20250116120000 + + DEBIT + 20241205120000 + -630,00 + 219378 + 123456 + All les dab! + + + + 630,00 + 00000000 + + + + + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/__init__.py new file mode 100644 index 0000000..f68c22d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_import_bank_statement diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/test_import_bank_statement.py b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/test_import_bank_statement.py new file mode 100644 index 0000000..2d6233a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_ofx/tests/test_import_bank_statement.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.exceptions import UserError +from odoo.tests import tagged +from odoo.tools import file_open + + +@tagged('post_install', '-at_install') +class TestAccountBankStatementImportOFX(AccountTestInvoicingCommon): + + def test_ofx_file_import(self): + bank_journal = self.env['account.journal'].create({ + 'name': 'Bank 123456', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '123456', + 'currency_id': self.env.ref('base.USD').id, + }) + + partner_norbert = self.env['res.partner'].create({ + 'name': 'Norbert Brant', + 'is_company': True, + }) + bank_norbert = self.env['res.bank'].create({'name': 'test'}) + partner_bank_norbert = self.env['res.partner.bank'].create({ + 'acc_number': 'BE93999574162167', + 'partner_id': partner_norbert.id, + 'bank_id': bank_norbert.id, + }) + + # Get OFX file content + ofx_file_path = 'account_bank_statement_import_ofx/static/ofx/test_ofx.ofx' + with file_open(ofx_file_path, 'rb') as ofx_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_ofx.ofx', + 'raw': ofx_file.read(), + }).ids) + + # Check the imported bank statement + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + self.assertRecordValues(imported_statement, [{ + 'reference': 'test_ofx.ofx', + 'balance_start': 2516.56, + 'balance_end_real': 2156.56, + }]) + self.assertRecordValues(imported_statement.line_ids.sorted('payment_ref'), [ + { + 'payment_ref': 'Axelor Scuba', + 'amount': -100.0, + 'partner_id': False, + 'account_number': False, + }, + { + 'payment_ref': 'China Export', + 'amount': -90.0, + 'partner_id': False, + 'account_number': False, + }, + { + 'payment_ref': 'China Scuba', + 'amount': -90.0, + 'partner_id': False, + 'account_number': False, + }, + { + 'payment_ref': partner_norbert.name, + 'amount': -80.0, + 'partner_id': partner_norbert.id, + 'account_number': partner_bank_norbert.acc_number, + }, + ]) + + def test_ofx_file_import_error(self): + """ + Check if a UserError is triggered when importing a file that contains characters that cannot be decoded with the default encoding + """ + bank_journal = self.env['account.journal'].create({ + 'name': 'Bank 123456', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '123456', + 'currency_id': self.env.ref('base.USD').id, + }) + + # Get OFX file content + # This file contains characters that cannot be decoded with the default encoding - PLEASE DO NOT UPDATE THIS FILE + ofx_file_path = 'account_bank_statement_import_ofx/static/ofx/test_ofx_unicode_error.ofx' + with self.assertRaises(UserError, msg="There was an issue decoding the file. Please check the file encoding."): + with file_open(ofx_file_path, 'rb') as ofx_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/xml', + 'name': 'test_ofx.ofx', + 'raw': ofx_file.read(), + }).ids) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__manifest__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__manifest__.py new file mode 100644 index 0000000..288188e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +{ + 'name': 'Import QIF Bank Statement', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'version': '1.0', + 'description': r''' +Module to import QIF bank statements. +====================================== + +This module allows you to import the machine readable QIF Files in Odoo: they are parsed and stored in human readable format in +Accounting \ Bank and Cash \ Bank Statements. + +Important Note +--------------------------------------------- +Because of the QIF format limitation, we cannot ensure the same transactions aren't imported several times or handle multicurrency. +Whenever possible, you should use a more appropriate file format like OFX. +''', + 'depends': ['odex30_account_bank_statement_import'], + 'data': [ + 'views/account_journal_views.xml', + ], + 'installable': True, + 'license': 'OEEL-1', +} diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..07e5fdb Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/account_bank_statement_import_qif.pot b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/account_bank_statement_import_qif.pot new file mode 100644 index 0000000..df6d53b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/account_bank_statement_import_qif.pot @@ -0,0 +1,66 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_qif +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:26+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: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,help:odex30_account_bank_statement_import_qif.field_account_journal__qif_date_format +msgid "" +"Although the historic QIF date format is month-first (mm/dd/yy), many " +"financial institutions use the local format.Therefore, it is frequent " +"outside the US to have QIF date formatted day-first (dd/mm/yy)." +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_qif/models/account_journal.py:0 +msgid "Could not decipher the QIF file." +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,help:odex30_account_bank_statement_import_qif.field_account_journal__qif_decimal_point +msgid "Field used to avoid conversion issues." +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model,name:odex30_account_bank_statement_import_qif.model_account_journal +msgid "Journal" +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,field_description:odex30_account_bank_statement_import_qif.field_account_journal__qif_date_format +msgid "QIF Dates format" +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,field_description:odex30_account_bank_statement_import_qif.field_account_journal__qif_decimal_point +msgid "QIF Decimal Separator" +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_qif/models/account_journal.py:0 +msgid "This file is either not a bank statement or is not correctly formed." +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields.selection,name:odex30_account_bank_statement_import_qif.selection__account_journal__qif_date_format__day_first +msgid "dd/mm/yy" +msgstr "" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields.selection,name:odex30_account_bank_statement_import_qif.selection__account_journal__qif_date_format__month_first +msgid "mm/dd/yy" +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/ar.po b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/ar.po new file mode 100644 index 0000000..9f99879 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/i18n/ar.po @@ -0,0 +1,74 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_bank_statement_import_qif +# +# Translators: +# Wil Odoo, 2024 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-09-25 09:26+0000\n" +"PO-Revision-Date: 2024-09-25 09:43+0000\n" +"Last-Translator: Wil Odoo, 2024\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,help:odex30_account_bank_statement_import_qif.field_account_journal__qif_date_format +msgid "" +"Although the historic QIF date format is month-first (mm/dd/yy), many " +"financial institutions use the local format.Therefore, it is frequent " +"outside the US to have QIF date formatted day-first (dd/mm/yy)." +msgstr "" +"بالرغم من أن نظام تنسيق التواريخ المعتاد بملفات QIF هو البدء بالشهر " +"(شهر/يوم/سنة)، إلا أن مؤسسات مالية عديدة تستخدم التنسيق المتعارف عليه " +"محلياً. لذا، كثيرًا ما يُضبط تنسيق التواريخ بملفات QIF خارج الولايات المتحدة" +" الأمريكية ليكون بنظام البدء باليوم (يوم/شهر/سنة)." + +#. module: odex30_account_bank_statement_import_qif +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_qif/models/account_journal.py:0 +msgid "Could not decipher the QIF file." +msgstr "تعذّر فهم محتويات ملف QIF." + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,help:odex30_account_bank_statement_import_qif.field_account_journal__qif_decimal_point +msgid "Field used to avoid conversion issues." +msgstr "حقل يُستَخدم لتجنب مشاكل التحويل " + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model,name:odex30_account_bank_statement_import_qif.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,field_description:odex30_account_bank_statement_import_qif.field_account_journal__qif_date_format +msgid "QIF Dates format" +msgstr "تنسيق التواريخ بملفات QIF " + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields,field_description:odex30_account_bank_statement_import_qif.field_account_journal__qif_decimal_point +msgid "QIF Decimal Separator" +msgstr "الفاصلة العشرية لـ QIF " + +#. module: odex30_account_bank_statement_import_qif +#. odoo-python +#: code:addons/odex30_account_bank_statement_import_qif/models/account_journal.py:0 +msgid "This file is either not a bank statement or is not correctly formed." +msgstr "إما أن هذا الملف ليس كشف حساب أو لم يتم تكوينه بشكل صحيح." + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields.selection,name:odex30_account_bank_statement_import_qif.selection__account_journal__qif_date_format__day_first +msgid "dd/mm/yy" +msgstr "يوم/شهر/سنة" + +#. module: odex30_account_bank_statement_import_qif +#: model:ir.model.fields.selection,name:odex30_account_bank_statement_import_qif.selection__account_journal__qif_date_format__month_first +msgid "mm/dd/yy" +msgstr "شهر/يوم/ سنة" diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__init__.py new file mode 100644 index 0000000..5cd061d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_journal \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..d33f0e5 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..7967884 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/account_journal.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/account_journal.py new file mode 100644 index 0000000..9c78038 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/models/account_journal.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- + +import io +import logging + +import dateutil.parser + +from odoo import fields, models, _ +from odoo.exceptions import UserError + + +logger = logging.getLogger(__name__) + +DATE_OF_TRANSACTION = b'D' +TOTAL_AMOUNT = b'T' +CHECK_NUMBER = b'N' +PAYEE = b'P' +MEMO = b'M' +END_OF_ITEM = b'^' + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + qif_decimal_point = fields.Char( + string="QIF Decimal Separator", + default='.', + help="Field used to avoid conversion issues.", + ) + qif_date_format = fields.Selection( + selection=[ + ('month_first', "mm/dd/yy"), + ('day_first', "dd/mm/yy"), + ], + default='day_first', + string='QIF Dates format', + help="Although the historic QIF date format is month-first (mm/dd/yy), many financial institutions use the local format." + "Therefore, it is frequent outside the US to have QIF date formatted day-first (dd/mm/yy).", + ) + + def _get_bank_statements_available_import_formats(self): + rslt = super(AccountJournal, self)._get_bank_statements_available_import_formats() + rslt.append('QIF') + return rslt + + def _check_qif(self, attachment): + return (attachment.raw or b'').strip().startswith(b'!Type:') + + def _parse_bank_statement_file(self, attachment): + if not self._check_qif(attachment): + return super()._parse_bank_statement_file(attachment) + + data_list = [ + line.rstrip(b'\r\n') + for line in io.BytesIO(attachment.raw.strip()) + ] + try: + header = data_list[0].strip().split(b':')[1] + except: + raise UserError(_('Could not decipher the QIF file.')) + + transactions = [] + vals_line = {'payment_ref': []} + total = 0.0 + # Identified header types of the QIF format that we support. + # Other types might need to be added. Here are the possible values + # according to the QIF spec: Cash, Bank, CCard, Invst, Oth A, Oth L, Invoice. + if header in [b'Bank', b'Cash', b'CCard']: + vals_bank_statement = {} + for line in data_list: + line = line.strip() + if not line: + continue + vals_line['sequence'] = len(transactions) + 1 + data = line[1:] + if line[:1] == DATE_OF_TRANSACTION: + dayfirst = self.qif_date_format == 'day_first' + vals_line['date'] = dateutil.parser.parse(data, fuzzy=True, dayfirst=dayfirst).date() + elif line[:1] == TOTAL_AMOUNT: + amount = float(data.replace(b',', b'.' if self.qif_decimal_point == ',' else b'')) + total += amount + vals_line['amount'] = amount + elif line[:1] == CHECK_NUMBER: + vals_line['ref'] = data.decode() + elif line[:1] == PAYEE: + name = data.decode() + vals_line['payment_ref'].append(name) + # Since QIF doesn't provide account numbers, we'll have to find res.partner and res.partner.bank here + # (normal behavious is to provide 'account_number', which the generic module uses to find partner/bank) + partner_bank = self.env['res.partner.bank'].search([('partner_id.name', '=', name)], limit=1) + if partner_bank: + vals_line['partner_bank_id'] = partner_bank.id + vals_line['partner_id'] = partner_bank.partner_id.id + elif line[:1] == MEMO: + vals_line['payment_ref'].append(data.decode()) + elif line[:1] == END_OF_ITEM: + if vals_line['payment_ref']: + vals_line['payment_ref'] = u': '.join(vals_line['payment_ref']) + else: + del vals_line['payment_ref'] + transactions.append(vals_line) + vals_line = {'payment_ref': []} + elif line[:1] == b'\n': + transactions = [] + else: + raise UserError(_('This file is either not a bank statement or is not correctly formed.')) + + vals_bank_statement.update({ + 'balance_end_real': total, + 'transactions': transactions + }) + return None, None, [vals_bank_statement] diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.png b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.png new file mode 100644 index 0000000..5a05c28 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.png differ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.svg b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.svg new file mode 100644 index 0000000..a262898 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/qif/test_qif.qif b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/qif/test_qif.qif new file mode 100644 index 0000000..c18c9ac --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/static/qif/test_qif.qif @@ -0,0 +1,25 @@ +!Type:Bank +D8/12/13 +T-1,000.00 +PDelta PC +^ +D8/15/13 +T-75.46 +PWalts Drugs +^ +D3/3/13 +T-379.00 +PEpic Technologies +^ +D3/4/13 +T-20.28 +PYOUR LOCAL SUPERMARKET +^ +D3/3/13 +T-421.35 +PSPRINGFIELD WATER UTILITY +^ +D27/02/25 +T0.00 +PZero amount transaction +^ diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/__init__.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/__init__.py new file mode 100644 index 0000000..f68c22d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_import_bank_statement diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/test_import_bank_statement.py b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/test_import_bank_statement.py new file mode 100644 index 0000000..df52bcf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/tests/test_import_bank_statement.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.tools import file_open + +import base64 + + +@tagged('post_install', '-at_install') +class TestAccountBankStatementImportQIF(AccountTestInvoicingCommon): + + def test_qif_file_import(self): + bank_journal = self.env['account.journal'].create({ + 'name': 'bank QIF', + 'code': 'BNK67', + 'type': 'bank', + 'bank_acc_number': '123456', + 'currency_id': self.env.ref('base.USD').id, + }) + + qif_file_path = 'odex30_account_bank_statement_import_qif/static/qif/test_qif.qif' + with file_open(qif_file_path, 'rb') as qif_file: + bank_journal.create_document_from_attachment(self.env['ir.attachment'].create({ + 'mimetype': 'application/text', + 'name': 'test_qif.qif', + 'raw': qif_file.read(), + }).ids) + + imported_statement = self.env['account.bank.statement'].search([('company_id', '=', self.env.company.id)]) + self.assertRecordValues(imported_statement, [{ + 'balance_start': 0.0, + 'balance_end_real': -1896.09, + }]) + self.assertRecordValues(imported_statement.line_ids.sorted('payment_ref'), [ + {'amount': -1000.00, 'payment_ref': 'Delta PC'}, + {'amount': -379.00, 'payment_ref': 'Epic Technologies'}, + {'amount': -421.35, 'payment_ref': 'SPRINGFIELD WATER UTILITY'}, + {'amount': -75.46, 'payment_ref': 'Walts Drugs'}, + {'amount': -20.28, 'payment_ref': 'YOUR LOCAL SUPERMARKET'}, + ]) diff --git a/dev_odex30_accounting/odex30_account_bank_statement_import_qif/views/account_journal_views.xml b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/views/account_journal_views.xml new file mode 100644 index 0000000..befd69b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_bank_statement_import_qif/views/account_journal_views.xml @@ -0,0 +1,16 @@ + + + + account.journal.form.inherited + account.journal + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/__init__.py b/dev_odex30_accounting/odex30_account_batch_payment/__init__.py new file mode 100644 index 0000000..de9509a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import report +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_batch_payment/__manifest__.py b/dev_odex30_accounting/odex30_account_batch_payment/__manifest__.py new file mode 100644 index 0000000..0f9c490 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/__manifest__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Batch Payment', + 'version': '1.0', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'description': """ +Batch Payments +======================================= +Batch payments allow grouping payments. + +They are used namely, but not only, to group several cheques before depositing them in a single batch to the bank. +The total amount deposited will then appear as a single transaction on your bank statement. +When you reconcile, simply select the corresponding batch payment to reconcile all the payments in the batch. + """, + 'depends': ['account'], + 'data': [ + 'security/account_batch_payment_security.xml', + 'security/ir.model.access.csv', + 'data/account_batch_payment_data.xml', + 'report/account_batch_payment_reports.xml', + 'report/account_batch_payment_report_templates.xml', + 'views/account_batch_payment_views.xml', + 'views/account_payment_views.xml', + 'views/account_journal_views.xml', + 'wizard/batch_error_views.xml', + ], + 'installable': True, + 'assets': { + 'web.report_assets_common': [ + 'odex30_account_batch_payment/static/src/scss/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_batch_payment/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..eed6141 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/data/account_batch_payment_data.xml b/dev_odex30_accounting/odex30_account_batch_payment/data/account_batch_payment_data.xml new file mode 100644 index 0000000..058b59b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/data/account_batch_payment_data.xml @@ -0,0 +1,18 @@ + + + + + + + + Batch Deposit + batch_payment + inbound + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/i18n/account_batch_payment.pot b/dev_odex30_accounting/odex30_account_batch_payment/i18n/account_batch_payment.pot new file mode 100644 index 0000000..c4d0bdb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/i18n/account_batch_payment.pot @@ -0,0 +1,922 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_batch_payment +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-31 18:47+0000\n" +"PO-Revision-Date: 2025-10-31 18:47+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: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "$1000.0" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "2023-08-14" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "2023-08-15" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "3956012345678" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_move_kanban +msgid "" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_form_inherit_account_batch_payment +msgid "Batch Payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "" +"The following warnings were also " +"raised; they do not impeach validation" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Please first consider the following warnings" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The following errors occurred" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "ABC Holder Name" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "ABC Suppliers" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Account Holder Name" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_ids +msgid "Activities" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_state +msgid "Activity State" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "All payments in the batch must share the same payment method." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Amount" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount_residual +msgid "Amount Residual" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount_residual_currency +msgid "Amount Residual Currency" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__amount_signed +msgid "Amount Signed" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__available_payment_method_ids +msgid "Available Payment Method" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__journal_id +msgid "Bank" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Bank Journal" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Bank Transfer" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Batch Content" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:account.payment.method,name:odex30_account_batch_payment.account_payment_method_batch_deposit +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.account_journal_dashboard_kanban_view_inherited +msgid "Batch Deposit" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_report_account_batch_payment_print_batch_payment +msgid "Batch Deposit Report" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__batch_payment_id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__batch_payment_id +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Batch Payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.ui.menu,name:odex30_account_batch_payment.menu_batch_payment_purchases +#: model:ir.ui.menu,name:odex30_account_batch_payment.menu_batch_payment_sales +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Batch Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__batch_type +msgid "Batch Type" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_in +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_out +msgid "" +"Batch payments allow you grouping different payments to ease\n" +" reconciliation. They are also useful when depositing checks\n" +" to the bank." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_error_wizard +msgid "Batch payments error reporting wizard" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_error_wizard_line +msgid "Batch payments error reporting wizard line" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Cannot validate an empty batch. Please add some payments to it first." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Check Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Close" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_method_code +msgid "Code" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__company_id +msgid "Company" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__company_currency_id +msgid "Company Currency" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__company_id +msgid "Company related to this journal" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_tree_inherit_account_batch_payment +msgid "Create Batch" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Create Batch Payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_in +msgid "Create a new customer batch payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_out +msgid "Create a new vendor batch payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.actions.server,name:odex30_account_batch_payment.action_account_create_batch_payment +msgid "Create batch payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__create_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__create_uid +msgid "Created by" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__create_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__create_date +msgid "Created on" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_file_create_date +msgid "Creation date of the related export file." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__currency_id +msgid "Currency" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Customer" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.actions.act_window,name:odex30_account_batch_payment.action_batch_payment_in +msgid "Customer Batch Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__date +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Date" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Demo Ref" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__description +msgid "Description" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__display_name +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__display_name +msgid "Display Name" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__error_line_ids +msgid "Error Line" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__error_wizard_id +msgid "Error Wizard" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_line_tree +msgid "Exclude Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_file +msgid "Export file related to this batch" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_file +msgid "File" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__file_generation_enabled +msgid "File Generation Enabled" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_filename +msgid "File Name" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_file_create_date +msgid "Generation Date" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Group By" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__has_message +msgid "Has Message" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__help_message +msgid "Help" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__id +msgid "ID" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_error +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__batch_type__inbound +msgid "Inbound" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Inbound Batch Payments Sequence" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Invalid Partners" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__invalid_sct_partners_ids +msgid "Invalid Sct Partners" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Issuing bank account :" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_journal +msgid "Journal" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__write_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__write_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__write_date +msgid "Last Updated on" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Leave empty to generate automatically..." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Memo" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_ids +msgid "Messages" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Monthly Payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__payment_method_name +msgid "Name" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_filename +msgid "Name of the export file generated for this batch" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_payment__amount_signed +msgid "Negative value of amount field if payment_type is outbound" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__draft +msgid "New" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Not Batch Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Not reconciled" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Odoo Payments LLC" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__batch_type__outbound +msgid "Outbound" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Outbound Batch Payments Sequence" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_tree_inherit_account_batch_payment +msgid "Partner" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_ids_domain +msgid "Payment Ids Domain" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_method_id +msgid "Payment Method" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_payment_method +msgid "Payment Methods" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +msgid "Payment added in batch %s" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Payment method" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +msgid "Payment removed from batch %s" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__payment_ids +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +#: code:addons/odex30_account_batch_payment/wizard/batch_error.py:0 +msgid "Payments in Error" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Print" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.actions.report,name:odex30_account_batch_payment.action_print_batch_payment +msgid "Print Batch Payment" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Proceed with validation" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__rating_ids +msgid "Ratings" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Re-generate Export File" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Recipient Bank Account" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__reconciled +msgid "Reconciled" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__name +msgid "Reference" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Remove the payments from the batch or change their state." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__sent +msgid "Sent" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_line_tree +msgid "Show" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__show_remove_button +msgid "Show Remove Button" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__show_remove_options +msgid "Show Remove Options" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Some payments have already been sent." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Some recipient accounts do not allow out payments." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__state +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "State" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "TOTAL" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"Target another recipient account or allow sending money to the current one." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The batch cannot be validated." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The batch could not be validated" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"The batch must have the same payment method as the payments it contains." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "The batch must have the same type as the payments it contains." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_error_wizard__batch_payment_id +msgid "" +"The batch payment generating the errors and warnings displayed in this " +"wizard." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "The country or city of" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"The journal of the batch payment and of the payments it contains must be the" +" same." +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__payment_method_id +msgid "The payment method used by the payments in this batch." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"To validate the batch, payments must be in process. But some are already " +"matched with a bank statement." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Total" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_error_wizard__show_remove_options +msgid "" +"True if and only if the options to remove the payments causing the errors or" +" warnings from the batch should be shown" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Unreconciled" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Validate" +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Vendor" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.actions.act_window,name:odex30_account_batch_payment.action_batch_payment_out +msgid "Vendor Batch Payments" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__warning_line_ids +msgid "Warning Line" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__warning_wizard_id +msgid "Warning Wizard" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__file_generation_enabled +msgid "" +"Whether or not this batch payment should display the 'Generate File' button " +"instead of 'Print' in form view." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot add payments with zero amount in a Batch Payment." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot add the same payment to multiple batches." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"You cannot create a batch with payments that are already in another batch." +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot create batches with overlapping payments." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "is not set, which could cause the file to be refused by the bank." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "these partners" +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_batch_payment/i18n/ar.po b/dev_odex30_accounting/odex30_account_batch_payment/i18n/ar.po new file mode 100644 index 0000000..fc6e21b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/i18n/ar.po @@ -0,0 +1,953 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_batch_payment +# +# Translators: +# Wil Odoo, 2024 +# Mustafa J. Kadhem , 2024 +# Malaz Abuidris , 2025 +# Weblate , 2025. +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-31 18:47+0000\n" +"PO-Revision-Date: 2025-11-17 14:06+0000\n" +"Last-Translator: Weblate \n" +"Language-Team: Arabic \n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" +"X-Generator: Weblate 5.12.2\n" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "$1000.0" +msgstr "$1000.0" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "2023-08-14" +msgstr "2023-08-14" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "2023-08-15" +msgstr "2023-08-15" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "3956012345678" +msgstr "3956012345678" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_move_kanban +msgid "" +"" +msgstr "" +"" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_form_inherit_account_batch_payment +msgid "Batch Payment" +msgstr "دفعة مجمعة" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "" +"The following warnings were also " +"raised; they do not impeach validation" +msgstr "" +"تم أيضاً رفع التحذيرات التالية؛ " +"إنهم لا يشككون في التحقق من الصحة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Please first consider the following warnings" +msgstr "الرجاء اعتبار التحذيرات التالية أولاً" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The following errors occurred" +msgstr "لقد حدثت الأخطاء التالية" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "ABC Holder Name" +msgstr "اسم المالك ABC " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "ABC Suppliers" +msgstr "مزودوا ABC " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Account Holder Name" +msgstr "اسم مالك الحساب" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "All payments in the batch must share the same payment method." +msgstr "يجب أن يكون لكافة عمليات السداد في هذه الدفعة طريقة السداد ذاتها. " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Amount" +msgstr "مبلغ" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount_residual +msgid "Amount Residual" +msgstr "المبلغ المتبقي" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__amount_residual_currency +msgid "Amount Residual Currency" +msgstr "عملة المبلغ المتبقي " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__amount_signed +msgid "Amount Signed" +msgstr "المبلغ الموقع عليه" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__available_payment_method_ids +msgid "Available Payment Method" +msgstr "طريقة الدفع المتاحة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__journal_id +msgid "Bank" +msgstr "البنك" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Bank Journal" +msgstr "دفتر يومية البنك" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Bank Transfer" +msgstr "تحويل بنكي " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Batch Content" +msgstr "محتويات الدفعة" + +#. module: odex30_account_batch_payment +#: model:account.payment.method,name:odex30_account_batch_payment.account_payment_method_batch_deposit +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.account_journal_dashboard_kanban_view_inherited +msgid "Batch Deposit" +msgstr "إيداع مجمع " + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_report_account_batch_payment_print_batch_payment +msgid "Batch Deposit Report" +msgstr "تقرير الإيداع المجمع " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__batch_payment_id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__batch_payment_id +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Batch Payment" +msgstr "دفعة مجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.ui.menu,name:odex30_account_batch_payment.menu_batch_payment_purchases +#: model:ir.ui.menu,name:odex30_account_batch_payment.menu_batch_payment_sales +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Batch Payments" +msgstr "الدفعات المجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__batch_type +msgid "Batch Type" +msgstr "نوع الدفعة" + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_in +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_out +msgid "" +"Batch payments allow you grouping different payments to ease\n" +" reconciliation. They are also useful when depositing " +"checks\n" +" to the bank." +msgstr "" +"تسمح لك الدفعات المجمعة بتجميع مدفوعات مختلفة مما \n" +" يُسهل من عملية التسوية. وهي مفيدة أيضاً عند إيداع شيكات\n" +" في البنك. " + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_error_wizard +msgid "Batch payments error reporting wizard" +msgstr "مُعالج الإبلاغ عن وقوع خطأ في الدفعات المجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_batch_error_wizard_line +msgid "Batch payments error reporting wizard line" +msgstr "بند مُعالج الإبلاغ عن وقوع خطأ في الدفعات المجمعة " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Cannot validate an empty batch. Please add some payments to it first." +msgstr "لا يمكن تصديق دفعة فارغة. الرجاء إضافة بعض المدفوعات لها أولاً. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Check Payments" +msgstr "التحقق من المدفوعات " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Close" +msgstr "إغلاق" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_method_code +msgid "Code" +msgstr "رمز " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__company_currency_id +msgid "Company Currency" +msgstr "عملة الشركة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__company_id +msgid "Company related to this journal" +msgstr "الشركة المتعلقة بهذه اليومية " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_tree_inherit_account_batch_payment +msgid "Create Batch" +msgstr "إنشاء الدفعة" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Create Batch Payment" +msgstr "إنشاء دفعة مجمعة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_in +msgid "Create a new customer batch payment" +msgstr "إنشاء دفعة مجمعة جديدة لعميل " + +#. module: odex30_account_batch_payment +#: model_terms:ir.actions.act_window,help:odex30_account_batch_payment.action_batch_payment_out +msgid "Create a new vendor batch payment" +msgstr "إنشاء دفعة مجمعة جديدة لمورد " + +#. module: odex30_account_batch_payment +#: model:ir.actions.server,name:odex30_account_batch_payment.action_account_create_batch_payment +msgid "Create batch payment" +msgstr "إنشاء دفعة مجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__create_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__create_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_file_create_date +msgid "Creation date of the related export file." +msgstr "تاريخ الإنشاء لملف التصدير ذي الصلة. " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__currency_id +msgid "Currency" +msgstr "العملة" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Customer" +msgstr "العميل" + +#. module: odex30_account_batch_payment +#: model:ir.actions.act_window,name:odex30_account_batch_payment.action_batch_payment_in +msgid "Customer Batch Payments" +msgstr "مدفوعات العميل المجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__date +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Date" +msgstr "التاريخ" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Demo Ref" +msgstr "المرجع التجريبي " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__description +msgid "Description" +msgstr "الوصف" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__display_name +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__error_line_ids +msgid "Error Line" +msgstr "بند الخطأ " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__error_wizard_id +msgid "Error Wizard" +msgstr "مُعالج الخطأ " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_line_tree +msgid "Exclude Payments" +msgstr "استثناء الدفعات " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_file +msgid "Export file related to this batch" +msgstr "تصدير الملف المتعلق بهذه الدفعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_file +msgid "File" +msgstr "الملف" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__file_generation_enabled +msgid "File Generation Enabled" +msgstr "تم تفعيل خيار إنشاء الملفات " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_filename +msgid "File Name" +msgstr "اسم الملف" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "أيقونة من Font awesome مثال: fa-tasks " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__export_file_create_date +msgid "Generation Date" +msgstr "تاريخ الإنشاء" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Group By" +msgstr "تجميع حسب" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__help_message +msgid "Help" +msgstr "المساعدة" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__id +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى النشاط المستثنى. " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_error +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__batch_type__inbound +msgid "Inbound" +msgstr "واردة" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Inbound Batch Payments Sequence" +msgstr "تسلسل الدفعات المجمعة الواردة " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Invalid Partners" +msgstr "الشركاء غير صالحين" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__invalid_sct_partners_ids +msgid "Invalid Sct Partners" +msgstr "" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Issuing bank account :" +msgstr "إصدار حساب بنكي:" + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_journal +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__write_uid +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__write_date +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Leave empty to generate automatically..." +msgstr "اتركه فارغًا ليتم إنشاؤه تلقائيًا ..." + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Memo" +msgstr "بيان" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Monthly Payment" +msgstr "دفع شهري " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "الموعد النهائي لنشاطاتي " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_payment__payment_method_name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__export_filename +msgid "Name of the export file generated for this batch" +msgstr "اسم الملف المُصدر الذي تم إنشاؤه لهذه الدفعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_payment__amount_signed +msgid "Negative value of amount field if payment_type is outbound" +msgstr "القيمة السالبة لحقل المبلغ إذا كان payment_type صادراً " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__draft +msgid "New" +msgstr "جديد" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "الفعالية التالية في تقويم الأنشطة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Not Batch Payments" +msgstr "مدفوعات غير مجمعة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_search_inherit_account_batch_payment +msgid "Not reconciled" +msgstr "غير مسوى " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Odoo Payments LLC" +msgstr "مدفوعات أودو المحدودة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__batch_type__outbound +msgid "Outbound" +msgstr "صادرة" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_journal.py:0 +msgid "Outbound Batch Payments Sequence" +msgstr "تسلسل الدفعات المجمعة الصادرة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_account_payment_tree_inherit_account_batch_payment +msgid "Partner" +msgstr "الشريك" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_ids_domain +msgid "Payment Ids Domain" +msgstr "نطاق معرّفات الدفع " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_method_id +msgid "Payment Method" +msgstr "طريقة الدفع " + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_payment_method +msgid "Payment Methods" +msgstr "طرق الدفع " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +msgid "Payment added in batch %s" +msgstr "تمت إضافة المدفوعات إلى الدفعة %s " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Payment method" +msgstr "طريقة الدفع " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +#: code:addons/odex30_account_batch_payment/models/account_payment.py:0 +msgid "Payment removed from batch %s" +msgstr "تمت إزالة المدفوعات من الدفعة %s " + +#. module: odex30_account_batch_payment +#: model:ir.model,name:odex30_account_batch_payment.model_account_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__payment_ids +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__payment_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Payments" +msgstr "الدفعات" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +#: code:addons/odex30_account_batch_payment/wizard/batch_error.py:0 +msgid "Payments in Error" +msgstr "المدفوعات التي بها خطأ " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Print" +msgstr "طباعة" + +#. module: odex30_account_batch_payment +#: model:ir.actions.report,name:odex30_account_batch_payment.action_print_batch_payment +msgid "Print Batch Payment" +msgstr "طباعة الدفعة المجمعة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "Proceed with validation" +msgstr "الاستمرار بالتصديق " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__rating_ids +msgid "Ratings" +msgstr "التقييمات " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Re-generate Export File" +msgstr "إعادة إنشاء ملف تصدير " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Recipient Bank Account" +msgstr "الحساب البنكي المستلم" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__reconciled +msgid "Reconciled" +msgstr "تمت التسوية" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__name +msgid "Reference" +msgstr "الرقم المرجعي " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Remove the payments from the batch or change their state." +msgstr "قم بإزالة المدفوعات من الدفعة أو تغيير حالتها." + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__activity_user_id +msgid "Responsible User" +msgstr "المستخدم المسؤول" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل النصية القصيرة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields.selection,name:odex30_account_batch_payment.selection__account_batch_payment__state__sent +msgid "Sent" +msgstr "تم الإرسال" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_line_tree +msgid "Show" +msgstr "إظهار " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__show_remove_button +msgid "Show Remove Button" +msgstr "إظهار زر الإزالة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__show_remove_options +msgid "Show Remove Options" +msgstr "إظهار خيارات الإزالة " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Some payments have already been sent." +msgstr "لقد تم إرسال بعض المدفوعات بالفعل. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "Some recipient accounts do not allow out payments." +msgstr "لا تسمح بعض حسابات المستلمين بالدفع." + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__state +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "State" +msgstr "الحالة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"الأنشطة المعتمدة على الحالة\n" +"المتأخرة: تاريخ الاستحقاق مر\n" +"اليوم: تاريخ النشاط هو اليوم\n" +"المخطط: الأنشطة المستقبلية." + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "TOTAL" +msgstr "الإجمالي" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"Target another recipient account or allow sending money to the current one." +msgstr "استهدف حساب مستلم آخر أو اسمح بإرسال الأموال إلى الحساب الحالي." + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The batch cannot be validated." +msgstr "لا يمكن تصديق الدفعة " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.batch_error_wizard_form +msgid "The batch could not be validated" +msgstr "تعذر تصديق الدفعة " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"The batch must have the same payment method as the payments it contains." +msgstr "يجب أن يكون للدفعة نفس طريقة السداد ككافة عمليات السداد التي تحتويها. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "The batch must have the same type as the payments it contains." +msgstr "يجب أن يكون للدفعة نفس نوع عمليات السداد التي تحتويها." + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_error_wizard__batch_payment_id +msgid "" +"The batch payment generating the errors and warnings displayed in this " +"wizard." +msgstr "" +"الدفعة المجمعة التي تتسبب في الأخطاء والتحذيرات المعروضة على هذا المُعالج. " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "The country or city of" +msgstr "" + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"The journal of the batch payment and of the payments it contains must be the " +"same." +msgstr "" +"يجب أن يكون دفتر اليومية للدفعة المجمعة مطابقاً لدفتر اليومية لكافة عمليات " +"السداد التي يحتويها. " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__payment_method_id +msgid "The payment method used by the payments in this batch." +msgstr "طريقة السداد المستخدمة في عمليات السداد في هذه الدفعة." + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"To validate the batch, payments must be in process. But some are already " +"matched with a bank statement." +msgstr "" +"للتحقق من صحة الدفعة، يجب أن تكون المدفوعات قيد المعالجة. ولكن بعضها مطابق " +"بالفعل مع كشف حساب بنكي. " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Total" +msgstr "الإجمالي" + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_error_wizard__show_remove_options +msgid "" +"True if and only if the options to remove the payments causing the errors or " +"warnings from the batch should be shown" +msgstr "" +"تكون القيمة صحيحة فقط عندما يكون من الواجب إظهار الخيارات لإزالة المدفوعات " +"المتسببة بالأخطاء أو التحذيرات " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط المستثنى في السجل. " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_search +msgid "Unreconciled" +msgstr "غير المسواة" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "Validate" +msgstr "تصديق " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.print_batch_payment +msgid "Vendor" +msgstr "المورّد " + +#. module: odex30_account_batch_payment +#: model:ir.actions.act_window,name:odex30_account_batch_payment.action_batch_payment_out +msgid "Vendor Batch Payments" +msgstr "مدفوعات المورّد المجمعة " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard__warning_line_ids +msgid "Warning Line" +msgstr "بند التحذير " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_error_wizard_line__warning_wizard_id +msgid "Warning Wizard" +msgstr "مُعالج التحذير " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,field_description:odex30_account_batch_payment.field_account_batch_payment__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع الإلكتروني " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع الإلكتروني " + +#. module: odex30_account_batch_payment +#: model:ir.model.fields,help:odex30_account_batch_payment.field_account_batch_payment__file_generation_enabled +msgid "" +"Whether or not this batch payment should display the 'Generate File' button " +"instead of 'Print' in form view." +msgstr "" +"إذا ما كان ينبغي عرض زر 'إنشاء ملف' في واجهة الدفعة المجمعة عوضاً عن زر " +"'طباعة' أم لا. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot add payments with zero amount in a Batch Payment." +msgstr "لا يمكنك إضافة مدفوعات قيمتها صفر في دفعة مجمعة " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot add the same payment to multiple batches." +msgstr "لا يمكنك إضافة عدة مدفوعات إلى عدة دفعات. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "" +"You cannot create a batch with payments that are already in another batch." +msgstr "لا يمكنك إنشاء دفعة بها مدفوعات موجودة بالفعل في دفعة أخرى. " + +#. module: odex30_account_batch_payment +#. odoo-python +#: code:addons/odex30_account_batch_payment/models/account_batch_payment.py:0 +msgid "You cannot create batches with overlapping payments." +msgstr "لا يمكنك إنشاء دفعات بها مدفوعات متداخلة. " + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "is not set, which could cause the file to be refused by the bank." +msgstr "" + +#. module: odex30_account_batch_payment +#: model_terms:ir.ui.view,arch_db:odex30_account_batch_payment.view_batch_payment_form +msgid "these partners" +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__init__.py b/dev_odex30_accounting/odex30_account_batch_payment/models/__init__.py new file mode 100644 index 0000000..593ad7f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import account_batch_payment +from . import account_journal +from . import account_payment +from . import account_payment_method diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..bf70b63 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_batch_payment.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_batch_payment.cpython-311.pyc new file mode 100644 index 0000000..188b138 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_batch_payment.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..91df6d6 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment.cpython-311.pyc new file mode 100644 index 0000000..0f1aa05 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment_method.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment_method.cpython-311.pyc new file mode 100644 index 0000000..91fa581 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/models/__pycache__/account_payment_method.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/account_batch_payment.py b/dev_odex30_accounting/odex30_account_batch_payment/models/account_batch_payment.py new file mode 100644 index 0000000..805f770 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/account_batch_payment.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +import base64 + +from odoo import models, fields, api, _ +from odoo.exceptions import RedirectWarning, ValidationError, UserError + + +class AccountBatchPayment(models.Model): + _name = "account.batch.payment" + _description = "Batch Payment" + _order = "date desc, id desc" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(required=True, copy=False, string='Reference') + date = fields.Date(required=True, copy=False, default=fields.Date.context_today, tracking=True) + state = fields.Selection([ + ('draft', 'New'), + ('sent', 'Sent'), + ('reconciled', 'Reconciled'), + ], store=True, compute='_compute_state', default='draft', tracking=True) + journal_id = fields.Many2one( + 'account.journal', + string='Bank', + check_company=True, + domain=[('type', '=', 'bank')], + tracking=True, + ) + company_id = fields.Many2one('res.company', related='journal_id.company_id', readonly=True) + payment_ids = fields.One2many('account.payment', 'batch_payment_id', string="Payments", required=True) + payment_ids_domain = fields.Char(compute='_compute_payment_ids_domain') + currency_id = fields.Many2one('res.currency', compute='_compute_currency', store=True, readonly=True) + company_currency_id = fields.Many2one( + string="Company Currency", + related='journal_id.company_id.currency_id', + store=True, + ) + amount_residual = fields.Monetary( + currency_field='company_currency_id', + compute='_compute_from_payment_ids', + store=True, + ) + amount_residual_currency = fields.Monetary( + currency_field='currency_id', + compute='_compute_from_payment_ids', + store=True, + ) + amount = fields.Monetary( + currency_field='currency_id', + compute='_compute_from_payment_ids', + store=True, + ) + batch_type = fields.Selection(selection=[('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True, default='inbound', tracking=True) + payment_method_id = fields.Many2one( + comodel_name='account.payment.method', + string='Payment Method', store=True, readonly=False, + compute='_compute_payment_method_id', + domain="[('id', 'in', available_payment_method_ids)]", + help="The payment method used by the payments in this batch.", tracking=True) + available_payment_method_ids = fields.Many2many( + comodel_name='account.payment.method', + compute='_compute_available_payment_method_ids') + payment_method_code = fields.Char(related='payment_method_id.code', tracking=True) + export_file_create_date = fields.Date(string='Generation Date', default=fields.Date.today, readonly=True, help="Creation date of the related export file.", copy=False) + export_file = fields.Binary(string='File', readonly=True, help="Export file related to this batch", copy=False) + export_filename = fields.Char(string='File Name', help="Name of the export file generated for this batch", store=True, copy=False) + + file_generation_enabled = fields.Boolean(help="Whether or not this batch payment should display the 'Generate File' button instead of 'Print' in form view.", compute='_compute_file_generation_enabled') + invalid_sct_partners_ids = fields.Many2many('res.partner', compute='_compute_invalid_sct_partners_ids') + + @api.depends('batch_type', 'journal_id', 'payment_method_id') + def _compute_payment_ids_domain(self): + for batch in self: + batch.payment_ids_domain = str([ + ('batch_payment_id', '=', False), + ('state', 'in', self._valid_payment_states()), + ('is_sent', '=', False), + ('payment_method_id', '=', batch.payment_method_id.id), + ('journal_id', '=', batch.journal_id.id), + ('payment_type', '=', batch.batch_type), + ('amount', '!=', 0), + ]) + + def _valid_payment_states(self): + return ['in_process', 'paid'] if self.env['account.move']._get_invoice_in_payment_state() == 'paid' else ['in_process'] + + @api.depends('batch_type', 'journal_id', 'payment_ids') + def _compute_payment_method_id(self): + ''' Compute the 'payment_method_id' field. + This field is not computed in '_compute_available_payment_method_ids' because it's a stored editable one. + ''' + for batch in self: + if batch.payment_ids: + batch.payment_method_id = batch.payment_ids.payment_method_line_id[0].payment_method_id + continue + + if not batch.journal_id: + batch.available_payment_method_ids = False + batch.payment_method_id = False + continue + + available_payment_method_lines = batch.journal_id._get_available_payment_method_lines(batch.batch_type) + + batch.available_payment_method_ids = available_payment_method_lines.mapped('payment_method_id') + + # Select the first available one by default. + if batch.available_payment_method_ids: + batch.payment_method_id = batch.available_payment_method_ids[0]._origin + else: + batch.payment_method_id = False + + @api.depends('batch_type', 'journal_id') + def _compute_available_payment_method_ids(self): + for batch in self: + available_payment_method_lines = batch.journal_id._get_available_payment_method_lines(batch.batch_type) + batch.available_payment_method_ids = available_payment_method_lines.mapped('payment_method_id') + + @api.depends('payment_ids.is_sent', 'payment_ids.is_matched') + def _compute_state(self): + for batch in self: + if batch.payment_ids and all(pay.is_matched and pay.is_sent for pay in batch.payment_ids.filtered(lambda p: p.state not in ('canceled', 'rejected'))): + batch.state = 'reconciled' + elif batch.payment_ids and all(pay.is_sent for pay in batch.payment_ids.filtered(lambda p: p.state not in ('canceled', 'rejected'))): + batch.state = 'sent' + else: + batch.state = 'draft' + + @api.depends('payment_method_id') + def _compute_file_generation_enabled(self): + for record in self: + record.file_generation_enabled = record.payment_method_id.code in record._get_methods_generating_files() + + def _get_methods_generating_files(self): + """ Hook for extension. Any payment method whose code stands in the list + returned by this function will see the "print" button disappear on batch + payments form when it gets selected and an 'Export file' appear instead. + """ + return [] + + @api.depends('journal_id') + def _compute_currency(self): + for batch in self: + batch.currency_id = batch.journal_id.currency_id or batch.company_currency_id or self.env.company.currency_id + + @api.depends('currency_id', 'payment_ids.amount', 'payment_ids.is_matched', 'payment_ids.state') + def _compute_from_payment_ids(self): + valid_payment_states = self._valid_payment_states() + for batch in self: + amount = 0.0 + amount_residual = 0.0 + amount_residual_currency = 0.0 + for payment in batch.payment_ids: + if payment.move_id: + liquidity_lines, _counterpart_lines, _writeoff_lines = payment._seek_for_lines() + for line in liquidity_lines: + if line.currency_id == batch.currency_id: + amount += line.amount_currency + elif batch.currency_id == line.company_currency_id: + amount += line.balance + else: + amount += line.company_currency_id._convert( + from_amount=line.balance, + to_currency=batch.currency_id, + company=line.company_id, + date=line.date, + ) + + if payment.state in valid_payment_states: + amount_residual += line.amount_residual + if line.currency_id == batch.currency_id: + amount_residual_currency += line.amount_residual_currency + elif batch.currency_id == line.company_currency_id: + amount_residual_currency += line.amount_residual + else: + amount_residual_currency += line.company_currency_id._convert( + from_amount=line.amount_residual, + to_currency=batch.currency_id, + company=line.company_id, + date=line.date, + ) + else: + if payment.currency_id == batch.currency_id: + payment_amount = payment.amount_signed + elif batch.currency_id == payment.company_currency_id: + payment_amount = payment.amount_company_currency_signed + else: + payment_amount = payment.company_currency_id._convert( + from_amount=payment.amount_company_currency_signed, + to_currency=batch.currency_id, + company=payment.company_id, + date=payment.date, + ) + amount += payment_amount + if payment.state in valid_payment_states: + amount_residual_currency += payment_amount + if payment.currency_id == batch.company_id.currency_id: + amount_residual += payment.amount_signed + elif batch.currency_id == payment.company_currency_id: + amount_residual += payment.amount_company_currency_signed + else: + amount_residual += payment.company_currency_id._convert( + from_amount=payment.amount_company_currency_signed, + to_currency=batch.company_id.currency_id, + company=payment.company_id, + date=payment.date, + ) + + batch.amount_residual = amount_residual + batch.amount = amount + batch.amount_residual_currency = amount_residual_currency + + @api.constrains('batch_type', 'journal_id', 'payment_ids', 'payment_method_id') + def _check_payments_constrains(self): + for record in self: + if record.payment_ids and record.journal_id != record.payment_ids.journal_id: + raise ValidationError(_("The journal of the batch payment and of the payments it contains must be the same.")) + all_types = set(record.payment_ids.mapped('payment_type')) + if all_types and record.batch_type not in all_types: + raise ValidationError(_("The batch must have the same type as the payments it contains.")) + all_payment_methods = record.payment_ids.payment_method_id + if len(all_payment_methods) > 1: + raise ValidationError(_("All payments in the batch must share the same payment method.")) + if all_payment_methods and record.payment_method_id not in all_payment_methods: + raise ValidationError(_("The batch must have the same payment method as the payments it contains.")) + payment_null = record.payment_ids.filtered(lambda p: p.amount == 0) + if payment_null: + raise ValidationError(_('You cannot add payments with zero amount in a Batch Payment.')) + + @api.model_create_multi + def create(self, vals_list): + today = fields.Date.context_today(self) + all_payment_ids = [] + for vals in vals_list: + vals['name'] = self._get_batch_name( + vals.get('batch_type'), + vals.get('date', today), + vals) + if 'payment_ids' in vals: + payments = self.new({'payment_ids': vals['payment_ids']}).payment_ids + if payments._origin.batch_payment_id: + raise ValidationError(_('You cannot create a batch with payments that are already in another batch.')) + # Collect all payment IDs + all_payment_ids.extend(payments.ids) + + if len(all_payment_ids) != len(set(all_payment_ids)): + raise ValidationError(_('You cannot create batches with overlapping payments.')) + return super().create(vals_list) + + def write(self, vals): + if 'batch_type' in vals: + vals['name'] = self.with_context(default_journal_id=self.journal_id.id)._get_batch_name(vals['batch_type'], self.date, vals) + if 'payment_ids' in vals: + if len(self) > 1: + raise ValidationError(_('You cannot add the same payment to multiple batches.')) + original_payments = self.new({'payment_ids': vals['payment_ids']}).payment_ids._origin + if original_payments.batch_payment_id - self: + raise ValidationError(_('You cannot create a batch with payments that are already in another batch.')) + + rslt = super(AccountBatchPayment, self).write(vals) + + return rslt + + def unlink(self): + for batch in self: + for payment in batch.payment_ids: + payment.message_post( + body=_('Payment removed from batch %s', batch._get_html_link(title=batch.name)), + message_type='comment', + ) + return super().unlink() + + @api.model + def _get_batch_name(self, batch_type, sequence_date, vals): + if not vals.get('name'): + sequence_code = 'account.inbound.batch.payment' + if batch_type == 'outbound': + sequence_code = 'account.outbound.batch.payment' + return self.env['ir.sequence'].with_context(sequence_date=sequence_date).next_by_code(sequence_code) + return vals['name'] + + @api.depends('state') + def _compute_display_name(self): + state_values = dict(self._fields['state'].selection) + for batch in self: + batch.display_name = f'{batch.name} ({state_values.get(batch.state)})' + + @api.depends('payment_method_id', 'payment_ids.partner_id.country_id', 'payment_ids.partner_id.city') + def _compute_invalid_sct_partners_ids(self): + sepa_batches = self.filtered(lambda b: b.payment_method_id.code == 'sepa_ct') + for batch in sepa_batches: + batch.invalid_sct_partners_ids = batch.payment_ids.partner_id.filtered( + lambda partner: not (partner.city and partner.country_id) + ) + (self - sepa_batches).invalid_sct_partners_ids = self.env['res.partner'] + + def action_invalid_partners_from_sct(self): + return self.invalid_sct_partners_ids._get_records_action(name=_("Invalid Partners")) + + def validate_batch(self): + """ Verifies the content of a batch and proceeds to its sending if possible. + If not, opens a wizard listing the errors and/or warnings encountered. + """ + validate_action = self._check_batch_validity() + if validate_action: + return validate_action + return self._send_after_validation() + + def _check_batch_validity(self): + self.ensure_one() + if not self.payment_ids: + raise UserError(_("Cannot validate an empty batch. Please add some payments to it first.")) + + errors = not self.export_file and self.check_payments_for_errors() or [] # We don't re-check for errors if we are regenerating the file (we know there aren't any) + warnings = self.check_payments_for_warnings() + if errors or warnings: + if len(errors) == 1 and not warnings: + raise RedirectWarning( + message=errors[0]['title'] + '\n' + errors[0].get('help', ''), + action=errors[0].get('records', self.env['account.payment'])._get_records_action(name=_('Payments in Error')), + button_text=_('Check Payments') + ) + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'account.batch.error.wizard', + 'target': 'new', + 'res_id': self.env['account.batch.error.wizard'].create_from_errors_list(self, errors, warnings).id, + } + + def validate_batch_button(self): + return self.validate_batch() + + def _send_after_validation(self): + """ Sends the payments of a batch (possibly generating an export file) + once the batch has been validated. + """ + + self.ensure_one() + if self.payment_ids: + self.payment_ids.mark_as_sent() + + if self.file_generation_enabled: + return self.export_batch_payment() + + def check_payments_for_warnings(self): + """ Checks the payments of this batch and returns (if relevant) some + warnings about them. These warnings are not to be confused with errors, + they are only messgaes displayed to make sure the user is aware of some + specificities in the payments he's put in the batch. He will be able to + ignore them. + + :return: A list of dictionaries, each one corresponding to a distinct + warning and containing the following keys: + - 'title': A short name for the warning (mandatory) + - 'records': The recordset of payments concerned by this warning (mandatory) + - 'help': A help text to give the user further information + on the reason this warning exists (optional) + """ + return [] + + def check_payments_for_errors(self): + """ Goes through all the payments of the batches contained in this + record set, and returns the ones that would impeach batch validation, + in such a way that the payments impeaching validation for the same reason + are grouped under a common error message. This function is a hook for + extension for modules making a specific use of batch payments, such as SEPA + ones. + + :return: A list of dictionaries, each one corresponding to a distinct + error and containing the following keys: + - 'title': A short name for the error (mandatory) + - 'records': The recordset of payments facing this error (mandatory) + - 'help': A help text to give the user further information + on how to solve the error (optional) + """ + self.ensure_one() + #We first try to post all the draft batch payments + rslt = self._check_and_post_draft_payments(self.payment_ids.filtered(lambda x: x.state == 'draft')) + + valid_payment_states = self._valid_payment_states() + wrong_state_payments = self.payment_ids.filtered(lambda x: x.state not in valid_payment_states) + + if wrong_state_payments: + rslt.append({ + 'title': _("To validate the batch, payments must be in process. But some are already matched with a bank statement."), + 'records': wrong_state_payments, + 'help': _("Remove the payments from the batch or change their state.") + }) + + if self.batch_type == 'outbound': + not_allowed_payments = self.payment_ids.filtered(lambda x: x.partner_bank_id and not x.partner_bank_id.allow_out_payment) + if not_allowed_payments: + rslt.append({ + 'code': 'out_payment_not_allowed', + 'title': _("Some recipient accounts do not allow out payments."), + 'records': not_allowed_payments, + 'help': _("Target another recipient account or allow sending money to the current one.") + }) + + sent_payments = self.payment_ids.filtered(lambda x: x.is_sent) + if sent_payments: + rslt.append({ + 'title': _("Some payments have already been sent."), + 'records': sent_payments, + }) + + return rslt + + def _check_and_post_draft_payments(self, draft_payments): + """ Tries posting each of the draft payments contained in this batch. + If it fails and raise a UserError, it is catched and the process continues + on the following payments. All the encountered errors are then returned + withing a dictionary, in the same fashion as check_payments_for_errors. + """ + exceptions_mapping = {} + for payment in draft_payments: + try: + payment.action_post() + except UserError as e: + name = e.args[0] + if name in exceptions_mapping: + exceptions_mapping[name] += payment + else: + exceptions_mapping[name] = payment + + return [{'title': error, 'records': pmts} for error, pmts in exceptions_mapping.items()] + + def export_batch_payment(self): + #export and save the file for each batch payment + self.check_access('write') + for record in self.sudo(): + record = record.with_company(record.journal_id.company_id) + export_file_data = record._generate_export_file() + record.export_file = export_file_data['file'] + record.export_filename = export_file_data['filename'] + record.export_file_create_date = fields.Date.today() + record.message_post( + attachments=[ + (record.export_filename, base64.decodebytes(record.export_file)), + ] + ) + + def print_batch_payment(self): + return self.env.ref('odex30_account_batch_payment.action_print_batch_payment').report_action(self, config=False) + + def _generate_export_file(self): + """ To be overridden by modules adding support for different export format. + This function returns False if no export file could be generated + for this batch. Otherwise, it returns a dictionary containing the following keys: + - file: the content of the generated export file, in base 64. + - filename: the name of the generated file + - warning: (optional) the warning message to display + + """ + self.ensure_one() + return False diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/account_journal.py b/dev_odex30_accounting/odex30_account_batch_payment/models/account_journal.py new file mode 100644 index 0000000..49afcdd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/account_journal.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +from odoo import models, api, _ + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + def _default_inbound_payment_methods(self): + res = super()._default_inbound_payment_methods() + if self._is_payment_method_available('batch_payment'): + res |= self.env.ref('odex30_account_batch_payment.account_payment_method_batch_deposit') + return res + + @api.model + def _create_batch_payment_outbound_sequence(self): + IrSequence = self.env['ir.sequence'] + if IrSequence.search_count([('code', '=', 'account.outbound.batch.payment')], limit=1): + return + return IrSequence.sudo().create({ + 'name': _("Outbound Batch Payments Sequence"), + 'padding': 4, + 'code': 'account.outbound.batch.payment', + 'number_next': 1, + 'number_increment': 1, + 'use_date_range': True, + 'prefix': 'BATCH/OUT/%(year)s/', + #by default, share the sequence for all companies + 'company_id': False, + }) + + @api.model + def _create_batch_payment_inbound_sequence(self): + IrSequence = self.env['ir.sequence'] + if IrSequence.search_count([('code', '=', 'account.inbound.batch.payment')], limit=1): + return + return IrSequence.sudo().create({ + 'name': _("Inbound Batch Payments Sequence"), + 'padding': 4, + 'code': 'account.inbound.batch.payment', + 'number_next': 1, + 'number_increment': 1, + 'use_date_range': True, + 'prefix': 'BATCH/IN/%(year)s/', + #by default, share the sequence for all companies + 'company_id': False, + }) + + def open_action_batch_payment(self): + ctx = self._context.copy() + ctx.update({'journal_id': self.id, 'default_journal_id': self.id}) + return { + 'name': _('Create Batch Payment'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'account.batch.payment', + 'context': ctx, + } diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment.py b/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment.py new file mode 100644 index 0000000..5a24563 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + + +class AccountPayment(models.Model): + _inherit = "account.payment" + + batch_payment_id = fields.Many2one('account.batch.payment', ondelete='set null', copy=False) + amount_signed = fields.Monetary( + currency_field='currency_id', compute='_compute_amount_signed', + help='Negative value of amount field if payment_type is outbound') + payment_method_name = fields.Char(related='payment_method_line_id.name') + + @api.depends('amount', 'payment_type') + def _compute_amount_signed(self): + for payment in self: + if payment.payment_type == 'outbound': + payment.amount_signed = -payment.amount + else: + payment.amount_signed = payment.amount + + @api.model + def create_batch_payment(self): + # We use self[0] to create the batch; the constrains on the model ensure + # the consistency of the generated data (same journal, same payment method, ...) + batch = self.env['account.batch.payment'].create({ + 'journal_id': self[0].journal_id.id, + 'payment_ids': [(4, payment.id, None) for payment in self], + 'payment_method_id': self[0].payment_method_id.id, + 'batch_type': self[0].payment_type, + }) + + return { + "type": "ir.actions.act_window", + "res_model": "account.batch.payment", + "views": [[False, "form"]], + "res_id": batch.id, + } + + def button_open_batch_payment(self): + ''' Redirect the user to the batch payments containing this payment. + :return: An action on account.batch.payment. + ''' + self.ensure_one() + + return { + 'name': _("Batch Payment"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.batch.payment', + 'context': {'create': False}, + 'view_mode': 'form', + 'res_id': self.batch_payment_id.id, + } + + def write(self, vals): + old_batch_payments = {payment: payment.batch_payment_id for payment in self} + result = super().write(vals) + if 'batch_payment_id' not in vals: + return result + batch_payment_id = vals.get('batch_payment_id') + batch_payment = self.env['account.batch.payment'].browse(batch_payment_id) if batch_payment_id else None + for payment in self: + if batch_payment: + payment.message_post( + body=_('Payment added in batch %s', batch_payment._get_html_link(title=batch_payment.name)), + message_type='comment', + ) + elif old_batch_payments.get(payment): + payment.message_post( + body=_('Payment removed from batch %s', old_batch_payments[payment]._get_html_link(title=old_batch_payments[payment].name)), + message_type='comment', + ) + return result diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment_method.py b/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment_method.py new file mode 100644 index 0000000..9ea5a88 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/account_payment_method.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models + + +class AccountPaymentMethod(models.Model): + _inherit = 'account.payment.method' + + @api.model + def _get_payment_method_information(self): + res = super()._get_payment_method_information() + res['batch_payment'] = {'mode': 'multi', 'type': ('bank',)} + return res diff --git a/dev_odex30_accounting/odex30_account_batch_payment/models/sepa_mapping.py b/dev_odex30_accounting/odex30_account_batch_payment/models/sepa_mapping.py new file mode 100644 index 0000000..a49d3c5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/models/sepa_mapping.py @@ -0,0 +1,175 @@ +import re +from xml.sax.saxutils import escape + + +def sanitize_communication(communication, size=140): + """ Returns a sanitized version of the communication given in parameter, + so that: + - it contains only latin characters + - it does not contain any // + - it does not start or end with / + - it is maximum 140 characters long + (these are the SEPA compliance criteria) + """ + # This function must be called before replacing the '/', as the + # character replacement could result in a non-compliant string. + communication = _replace_characters_SEPA(communication, size) + while '//' in communication: + communication = communication.replace('//', '/') + if communication.startswith('/'): + communication = communication[1:] + if communication.endswith('/'): + communication = communication[:-1] + return communication + + +def _replace_characters_SEPA(string, size=None): + """ + Replace non-latin characters according to the official SEPA mapping. + See https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/sepa-requirements-extended-character-set-unicode-subset-best + """ + string_array = [character for character in string] + for match in re.finditer('[^-A-Za-z0-9/?:().,\'&<>+ ]', string): + match_index = match.start() + string_array[match_index] = sepa_mapping.get(string_array[match_index], '') + string = ''.join(string_array) + + if size: + string = string[:size] + i = 0 + while len(escape(string)) > size: + i += 1 + string = string[:size - i] + + return string + +sepa_mapping = { + "\u0021": "\u002E", "\u0023": "\u002E", "\u0024": "\u002E", "\u0025": "\u002E", "\u002A": "\u002E", "\u003B": "\u002C", "\u003C": "\u002E", "\u003D": "\u002E", + "\u003E": "\u002E", "\u0040": "\u002E", "\u005B": "\u0028", "\u005C": "\u002F", "\u005D": "\u0029", "\u005E": "\u002E", "\u005F": "\u002D", "\u0060": "\u0027", + "\u007B": "\u0028", "\u007C": "\u002F", "\u007D": "\u0029", "\u007E": "\u002D", "\u007F": "\u002E", "\u0080": "\u002E", "\u0081": "\u002E", "\u0082": "\u002E", + "\u0083": "\u002E", "\u0084": "\u002E", "\u0085": "\u002E", "\u0086": "\u002E", "\u0087": "\u002E", "\u0088": "\u002E", "\u0089": "\u002E", "\u008A": "\u002E", + "\u008B": "\u002E", "\u008C": "\u002E", "\u008D": "\u002E", "\u008E": "\u002E", "\u008F": "\u002E", "\u0090": "\u002E", "\u0091": "\u002E", "\u0092": "\u002E", + "\u0093": "\u002E", "\u0094": "\u002E", "\u0095": "\u002E", "\u0096": "\u002E", "\u0097": "\u002E", "\u0098": "\u002E", "\u0099": "\u002E", "\u009A": "\u002E", + "\u009B": "\u002E", "\u009C": "\u002E", "\u009D": "\u002E", "\u009E": "\u002E", "\u009F": "\u002E", "\u00A0": "\u0020", "\u00A1": "\u002E", "\u00A2": "\u002E", + "\u00A3": "\u002E", "\u00A4": "\u002E", "\u00A5": "\u002E", "\u00A6": "\u002E", "\u00A7": "\u002E", "\u045E": "\u002E", "\u045F": "\u002E", "\u20AC": "\u0045", + "\u00A8": "\u002E", "\u00A9": "\u002E", "\u00AA": "\u002E", "\u00AB": "\u002E", "\u00AC": "\u002E", "\u00AD": "\u002E", "\u00AE": "\u002E", "\u00AF": "\u002E", + "\u00B0": "\u002E", "\u00B1": "\u002E", "\u00B2": "\u002E", "\u00B3": "\u002E", "\u00B4": "\u002E", "\u00B5": "\u002E", "\u00B6": "\u002E", "\u00B7": "\u002E", + "\u00B8": "\u002E", "\u00B9": "\u002E", "\u00BA": "\u002E", "\u00BB": "\u002E", "\u00BC": "\u002E", "\u00BD": "\u002E", "\u00BE": "\u002E", "\u00BF": "\u003F", + "\u00C0": "\u0041", "\u00C1": "\u0041", "\u00C2": "\u0041", "\u00C3": "\u0041", "\u00C4": "\u0041", "\u00C5": "\u0041", "\u00C6": "\u0041", "\u00C7": "\u0043", + "\u00C8": "\u0045", "\u00C9": "\u0045", "\u00CA": "\u0045", "\u00CB": "\u0045", "\u00CC": "\u0049", "\u00CD": "\u0049", "\u00CE": "\u0049", "\u00CF": "\u0049", + "\u00D0": "\u002E", "\u00D1": "\u004E", "\u00D2": "\u004F", "\u00D3": "\u004F", "\u00D4": "\u004F", "\u00D5": "\u004F", "\u00D6": "\u004F", "\u00D7": "\u002E", + "\u00D8": "\u004F", "\u00D9": "\u0055", "\u00DA": "\u0055", "\u00DB": "\u0055", "\u00DC": "\u0055", "\u00DD": "\u0059", "\u00DE": "\u0054\u0048", "\u00DF": "\u0073", + "\u00E0": "\u0061", "\u00E1": "\u0061", "\u00E2": "\u0061", "\u00E3": "\u0061", "\u00E4": "\u0061", "\u00E5": "\u0061", "\u00E6": "\u0061", "\u00E7": "\u0063", + "\u00E8": "\u0065", "\u00E9": "\u0065", "\u00EA": "\u0065", "\u00EB": "\u0065", "\u00EC": "\u0069", "\u00ED": "\u0069", "\u00EE": "\u0069", "\u00EF": "\u0069", + "\u00F0": "\u002E", "\u00F1": "\u006E", "\u00F2": "\u006F", "\u00F3": "\u006F", "\u00F4": "\u006F", "\u00F5": "\u006F", "\u00F6": "\u006F", "\u00F7": "\u002E", + "\u00F8": "\u006F", "\u00F9": "\u0075", "\u00FA": "\u0075", "\u00FB": "\u0075", "\u00FC": "\u0075", "\u00FD": "\u0079", "\u00FE": "\u0074", "\u00FF": "\u0079", + "\u0100": "\u0041", "\u0101": "\u0061", "\u0102": "\u0041", "\u0103": "\u0061", "\u0104": "\u0041", "\u0105": "\u0061", "\u0106": "\u0043", "\u0107": "\u0063", + "\u0108": "\u0043", "\u0109": "\u0063", "\u010A": "\u0043", "\u010B": "\u0063", "\u010C": "\u0043", "\u010D": "\u0063", "\u010E": "\u0044", "\u010F": "\u0064", + "\u0110": "\u0044", "\u0111": "\u0064", "\u0112": "\u0045", "\u0113": "\u0065", "\u0114": "\u0045", "\u0115": "\u0065", "\u0116": "\u0045", "\u0117": "\u0065", + "\u0118": "\u0045", "\u0119": "\u0065", "\u011A": "\u0045", "\u011B": "\u0065", "\u011C": "\u0047", "\u011D": "\u0067", "\u011E": "\u0047", "\u011F": "\u0067", + "\u0120": "\u0047", "\u0121": "\u0067", "\u0122": "\u0047", "\u0123": "\u0067", "\u0124": "\u0048", "\u0125": "\u0069", "\u0126": "\u0048", "\u0127": "\u0069", + "\u0128": "\u0049", "\u0129": "\u0069", "\u012A": "\u0049", "\u012B": "\u0069", "\u012C": "\u0049", "\u012D": "\u0069", "\u012E": "\u0049", "\u012F": "\u0069", + "\u0130": "\u0049", "\u0131": "\u0069", "\u0132": "\u0049", "\u0133": "\u0069", "\u0134": "\u004A", "\u0135": "\u006A", "\u0136": "\u004B", "\u0137": "\u006B", + "\u0138": "\u002E", "\u0139": "\u004C", "\u013A": "\u006C", "\u013B": "\u004C", "\u013C": "\u006C", "\u013D": "\u004C", "\u013E": "\u006C", "\u013F": "\u004C", + "\u0140": "\u006C", "\u0141": "\u004C", "\u0142": "\u006C", "\u0143": "\u004E", "\u0144": "\u006E", "\u0145": "\u004E", "\u0146": "\u006E", "\u0147": "\u004E", + "\u0148": "\u006E", "\u0149": "\u002E", "\u014A": "\u002E", "\u014B": "\u002E", "\u014C": "\u002E", "\u014D": "\u002E", "\u014E": "\u002E", "\u014F": "\u002E", + "\u0150": "\u004F", "\u0151": "\u006F", "\u0152": "\u004F", "\u0153": "\u006F", "\u0154": "\u0052", "\u0155": "\u0072", "\u0156": "\u0052", "\u0157": "\u0072", + "\u0158": "\u0052", "\u0159": "\u0072", "\u015A": "\u0053", "\u015B": "\u0073", "\u015C": "\u0053", "\u015D": "\u0073", "\u015E": "\u0053", "\u015F": "\u0073", + "\u0160": "\u0053", "\u0161": "\u0073", "\u0162": "\u0054", "\u0163": "\u0074", "\u0164": "\u0054", "\u0165": "\u0074", "\u0166": "\u0054", "\u0167": "\u0074", + "\u0168": "\u0055", "\u0169": "\u0075", "\u016A": "\u0055", "\u016B": "\u0075", "\u016C": "\u0055", "\u016D": "\u0075", "\u016E": "\u0055", "\u016F": "\u0075", + "\u0170": "\u0055", "\u0171": "\u0075", "\u0172": "\u0055", "\u0173": "\u0075", "\u0174": "\u0057", "\u0175": "\u0077", "\u0176": "\u0059", "\u0177": "\u0079", + "\u0178": "\u0059", "\u0179": "\u005A", "\u017A": "\u007A", "\u017B": "\u005A", "\u017C": "\u007A", "\u017D": "\u005A", "\u017E": "\u007A", "\u017F": "\u002E", + "\u0180": "\u002E", "\u0181": "\u002E", "\u0182": "\u002E", "\u0183": "\u002E", "\u0184": "\u002E", "\u0185": "\u002E", "\u0186": "\u002E", "\u0187": "\u002E", + "\u0188": "\u002E", "\u0189": "\u002E", "\u018A": "\u002E", "\u018B": "\u002E", "\u018C": "\u002E", "\u018D": "\u002E", "\u018E": "\u002E", "\u018F": "\u002E", + "\u0190": "\u002E", "\u0191": "\u002E", "\u0192": "\u002E", "\u0193": "\u002E", "\u0194": "\u002E", "\u0195": "\u002E", "\u0196": "\u002E", "\u0197": "\u002E", + "\u0198": "\u002E", "\u0199": "\u002E", "\u019A": "\u002E", "\u019B": "\u002E", "\u019C": "\u002E", "\u019D": "\u002E", "\u019E": "\u002E", "\u019F": "\u002E", + "\u01A0": "\u002E", "\u01A1": "\u002E", "\u01A2": "\u002E", "\u01A3": "\u002E", "\u01A4": "\u002E", "\u01A5": "\u002E", "\u01A6": "\u002E", "\u01A7": "\u002E", + "\u01A8": "\u002E", "\u01A9": "\u002E", "\u01AA": "\u002E", "\u01AB": "\u002E", "\u01AC": "\u002E", "\u01AD": "\u002E", "\u01AE": "\u002E", "\u01AF": "\u002E", + "\u01B0": "\u002E", "\u01B1": "\u002E", "\u01B2": "\u002E", "\u01B3": "\u002E", "\u01B4": "\u002E", "\u01B5": "\u002E", "\u01B6": "\u002E", "\u01B7": "\u002E", + "\u01B8": "\u002E", "\u01B9": "\u002E", "\u01BA": "\u002E", "\u01BB": "\u002E", "\u01BC": "\u002E", "\u01BD": "\u002E", "\u01BE": "\u002E", "\u01BF": "\u002E", + "\u01C0": "\u002E", "\u01C1": "\u002E", "\u01C2": "\u002E", "\u01C3": "\u002E", "\u01C4": "\u002E", "\u01C5": "\u002E", "\u01C6": "\u002E", "\u01C7": "\u002E", + "\u01C8": "\u002E", "\u01C9": "\u002E", "\u01CA": "\u002E", "\u01CB": "\u002E", "\u01CC": "\u002E", "\u01CD": "\u002E", "\u01CE": "\u002E", "\u01CF": "\u002E", + "\u01D0": "\u002E", "\u01D1": "\u002E", "\u01D2": "\u002E", "\u01D3": "\u002E", "\u01D4": "\u002E", "\u01D5": "\u002E", "\u01D6": "\u002E", "\u01D7": "\u002E", + "\u01D8": "\u002E", "\u01D9": "\u002E", "\u01DA": "\u002E", "\u01DB": "\u002E", "\u01DC": "\u002E", "\u01DD": "\u002E", "\u01DE": "\u002E", "\u01DF": "\u002E", + "\u01E0": "\u002E", "\u01E1": "\u002E", "\u01E2": "\u002E", "\u01E3": "\u002E", "\u01E4": "\u002E", "\u01E5": "\u002E", "\u01E6": "\u002E", "\u01E7": "\u002E", + "\u01E8": "\u002E", "\u01E9": "\u002E", "\u01EA": "\u002E", "\u01EB": "\u002E", "\u01EC": "\u002E", "\u01ED": "\u002E", "\u01EE": "\u002E", "\u01EF": "\u002E", + "\u01F0": "\u002E", "\u01F1": "\u002E", "\u01F2": "\u002E", "\u01F3": "\u002E", "\u01F4": "\u002E", "\u01F5": "\u002E", "\u01F6": "\u002E", "\u01F7": "\u002E", + "\u01F8": "\u002E", "\u01F9": "\u002E", "\u01FA": "\u002E", "\u01FB": "\u002E", "\u01FC": "\u002E", "\u01FD": "\u002E", "\u01FE": "\u002E", "\u01FF": "\u002E", + "\u0200": "\u002E", "\u0201": "\u002E", "\u0202": "\u002E", "\u0203": "\u002E", "\u0204": "\u002E", "\u0205": "\u002E", "\u0206": "\u002E", "\u0207": "\u002E", + "\u0208": "\u002E", "\u0209": "\u002E", "\u020A": "\u002E", "\u020B": "\u002E", "\u020C": "\u002E", "\u020D": "\u002E", "\u020E": "\u002E", "\u020F": "\u002E", + "\u0210": "\u002E", "\u0211": "\u002E", "\u0212": "\u002E", "\u0213": "\u002E", "\u0214": "\u002E", "\u0215": "\u002E", "\u0216": "\u002E", "\u0217": "\u002E", + "\u0218": "\u0053", "\u0219": "\u0073", "\u021A": "\u0054", "\u021B": "\u0074", "\u021C": "\u002E", "\u021D": "\u002E", "\u021E": "\u002E", "\u021F": "\u002E", + "\u0220": "\u002E", "\u0221": "\u002E", "\u0222": "\u002E", "\u0223": "\u002E", "\u0224": "\u002E", "\u0225": "\u002E", "\u0226": "\u002E", "\u0227": "\u002E", + "\u0228": "\u002E", "\u0229": "\u002E", "\u022A": "\u002E", "\u022B": "\u002E", "\u022C": "\u002E", "\u022D": "\u002E", "\u022E": "\u002E", "\u022F": "\u002E", + "\u0230": "\u002E", "\u0231": "\u002E", "\u0232": "\u002E", "\u0233": "\u002E", "\u0234": "\u002E", "\u0235": "\u002E", "\u0236": "\u002E", "\u0237": "\u002E", + "\u0238": "\u002E", "\u0239": "\u002E", "\u023A": "\u002E", "\u023B": "\u002E", "\u023C": "\u002E", "\u023D": "\u002E", "\u023E": "\u002E", "\u023F": "\u002E", + "\u0240": "\u002E", "\u0241": "\u002E", "\u0242": "\u002E", "\u0243": "\u002E", "\u0244": "\u002E", "\u0245": "\u002E", "\u0246": "\u002E", "\u0247": "\u002E", + "\u0248": "\u002E", "\u0249": "\u002E", "\u024A": "\u002E", "\u024B": "\u002E", "\u024C": "\u002E", "\u024D": "\u002E", "\u024E": "\u002E", "\u024F": "\u002E", + "\u0250": "\u002E", "\u0251": "\u002E", "\u0252": "\u002E", "\u0253": "\u002E", "\u0254": "\u002E", "\u0255": "\u002E", "\u0256": "\u002E", "\u0257": "\u002E", + "\u0258": "\u002E", "\u0259": "\u002E", "\u025A": "\u002E", "\u025B": "\u002E", "\u025C": "\u002E", "\u025D": "\u002E", "\u025E": "\u002E", "\u025F": "\u002E", + "\u0260": "\u002E", "\u0261": "\u002E", "\u0262": "\u002E", "\u0263": "\u002E", "\u0264": "\u002E", "\u0265": "\u002E", "\u0266": "\u002E", "\u0267": "\u002E", + "\u0268": "\u002E", "\u0269": "\u002E", "\u026A": "\u002E", "\u026B": "\u002E", "\u026C": "\u002E", "\u026D": "\u002E", "\u026E": "\u002E", "\u026F": "\u002E", + "\u0270": "\u002E", "\u0271": "\u002E", "\u0272": "\u002E", "\u0273": "\u002E", "\u0274": "\u002E", "\u0275": "\u002E", "\u0276": "\u002E", "\u0277": "\u002E", + "\u0278": "\u002E", "\u0279": "\u002E", "\u027A": "\u002E", "\u027B": "\u002E", "\u027C": "\u002E", "\u027D": "\u002E", "\u027E": "\u002E", "\u027F": "\u002E", + "\u0280": "\u002E", "\u0281": "\u002E", "\u0282": "\u002E", "\u0283": "\u002E", "\u0284": "\u002E", "\u0285": "\u002E", "\u0286": "\u002E", "\u0287": "\u002E", + "\u0288": "\u002E", "\u0289": "\u002E", "\u028A": "\u002E", "\u028B": "\u002E", "\u028C": "\u002E", "\u028D": "\u002E", "\u028E": "\u002E", "\u028F": "\u002E", + "\u0290": "\u002E", "\u0291": "\u002E", "\u0292": "\u002E", "\u0293": "\u002E", "\u0294": "\u002E", "\u0295": "\u002E", "\u0296": "\u002E", "\u0297": "\u002E", + "\u0298": "\u002E", "\u0299": "\u002E", "\u029A": "\u002E", "\u029B": "\u002E", "\u029C": "\u002E", "\u029D": "\u002E", "\u029E": "\u002E", "\u029F": "\u002E", + "\u02A0": "\u002E", "\u02A1": "\u002E", "\u02A2": "\u002E", "\u02A3": "\u002E", "\u02A4": "\u002E", "\u02A5": "\u002E", "\u02A6": "\u002E", "\u02A7": "\u002E", + "\u02A8": "\u002E", "\u02A9": "\u002E", "\u02AA": "\u002E", "\u02AB": "\u002E", "\u02AC": "\u002E", "\u02AD": "\u002E", "\u02AE": "\u002E", "\u02AF": "\u002E", + "\u02B0": "\u002E", "\u02B1": "\u002E", "\u02B2": "\u002E", "\u02B3": "\u002E", "\u02B4": "\u002E", "\u02B5": "\u002E", "\u02B6": "\u002E", "\u02B7": "\u002E", + "\u02B8": "\u002E", "\u02B9": "\u002E", "\u02BA": "\u002E", "\u02BB": "\u002E", "\u02BC": "\u002E", "\u02BD": "\u002E", "\u02BE": "\u002E", "\u02BF": "\u002E", + "\u02C0": "\u002E", "\u02C1": "\u002E", "\u02C2": "\u002E", "\u02C3": "\u002E", "\u02C4": "\u002E", "\u02C5": "\u002E", "\u02C6": "\u002E", "\u02C7": "\u002E", + "\u02C8": "\u002E", "\u02C9": "\u002E", "\u02CA": "\u002E", "\u02CB": "\u002E", "\u02CC": "\u002E", "\u02CD": "\u002E", "\u02CE": "\u002E", "\u02CF": "\u002E", + "\u02D0": "\u002E", "\u02D1": "\u002E", "\u02D2": "\u002E", "\u02D3": "\u002E", "\u02D4": "\u002E", "\u02D5": "\u002E", "\u02D6": "\u002E", "\u02D7": "\u002E", + "\u02D8": "\u002E", "\u02D9": "\u002E", "\u02DA": "\u002E", "\u02DB": "\u002E", "\u02DC": "\u002E", "\u02DD": "\u002E", "\u02DE": "\u002E", "\u02DF": "\u002E", + "\u02E0": "\u002E", "\u02E1": "\u002E", "\u02E2": "\u002E", "\u02E3": "\u002E", "\u02E4": "\u002E", "\u02E5": "\u002E", "\u02E6": "\u002E", "\u02E7": "\u002E", + "\u02E8": "\u002E", "\u02E9": "\u002E", "\u02EA": "\u002E", "\u02EB": "\u002E", "\u02EC": "\u002E", "\u02ED": "\u002E", "\u02EE": "\u002E", "\u02EF": "\u002E", + "\u02F0": "\u002E", "\u02F1": "\u002E", "\u02F2": "\u002E", "\u02F3": "\u002E", "\u02F4": "\u002E", "\u02F5": "\u002E", "\u02F6": "\u002E", "\u02F7": "\u002E", + "\u02F8": "\u002E", "\u02F9": "\u002E", "\u02FA": "\u002E", "\u02FB": "\u002E", "\u02FC": "\u002E", "\u02FD": "\u002E", "\u02FE": "\u002E", "\u02FF": "\u002E", + "\u0300": "\u002E", "\u0301": "\u002E", "\u0302": "\u002E", "\u0303": "\u002E", "\u0304": "\u002E", "\u0305": "\u002E", "\u0306": "\u002E", "\u0307": "\u002E", + "\u0308": "\u002E", "\u0309": "\u002E", "\u030A": "\u002E", "\u030B": "\u002E", "\u030C": "\u002E", "\u030D": "\u002E", "\u030E": "\u002E", "\u030F": "\u002E", + "\u0310": "\u002E", "\u0311": "\u002E", "\u0312": "\u002E", "\u0313": "\u002E", "\u0314": "\u002E", "\u0315": "\u002E", "\u0316": "\u002E", "\u0317": "\u002E", + "\u0318": "\u002E", "\u0319": "\u002E", "\u031A": "\u002E", "\u031B": "\u002E", "\u031C": "\u002E", "\u031D": "\u002E", "\u031E": "\u002E", "\u031F": "\u002E", + "\u0320": "\u002E", "\u0321": "\u002E", "\u0322": "\u002E", "\u0323": "\u002E", "\u0324": "\u002E", "\u0325": "\u002E", "\u0326": "\u002E", "\u0327": "\u002E", + "\u0328": "\u002E", "\u0329": "\u002E", "\u032A": "\u002E", "\u032B": "\u002E", "\u032C": "\u002E", "\u032D": "\u002E", "\u032E": "\u002E", "\u032F": "\u002E", + "\u0330": "\u002E", "\u0331": "\u002E", "\u0332": "\u002E", "\u0333": "\u002E", "\u0334": "\u002E", "\u0335": "\u002E", "\u0336": "\u002E", "\u0337": "\u002E", + "\u0338": "\u002E", "\u0339": "\u002E", "\u033A": "\u002E", "\u033B": "\u002E", "\u033C": "\u002E", "\u033D": "\u002E", "\u033E": "\u002E", "\u033F": "\u002E", + "\u0340": "\u002E", "\u0341": "\u002E", "\u0342": "\u002E", "\u0343": "\u002E", "\u0344": "\u002E", "\u0345": "\u002E", "\u0346": "\u002E", "\u0347": "\u002E", + "\u0348": "\u002E", "\u0349": "\u002E", "\u034A": "\u002E", "\u034B": "\u002E", "\u034C": "\u002E", "\u034D": "\u002E", "\u034E": "\u002E", "\u034F": "\u002E", + "\u0350": "\u002E", "\u0351": "\u002E", "\u0352": "\u002E", "\u0353": "\u002E", "\u0354": "\u002E", "\u0355": "\u002E", "\u0356": "\u002E", "\u0357": "\u002E", + "\u0358": "\u002E", "\u0359": "\u002E", "\u035A": "\u002E", "\u035B": "\u002E", "\u035C": "\u002E", "\u035D": "\u002E", "\u035E": "\u002E", "\u035F": "\u002E", + "\u0360": "\u002E", "\u0361": "\u002E", "\u0362": "\u002E", "\u0363": "\u002E", "\u0364": "\u002E", "\u0365": "\u002E", "\u0366": "\u002E", "\u0367": "\u002E", + "\u0368": "\u002E", "\u0369": "\u002E", "\u036A": "\u002E", "\u036B": "\u002E", "\u036C": "\u002E", "\u036D": "\u002E", "\u036E": "\u002E", "\u036F": "\u002E", + "\u0370": "\u002E", "\u0371": "\u002E", "\u0372": "\u002E", "\u0373": "\u002E", "\u0374": "\u002E", "\u0375": "\u002E", "\u0376": "\u002E", "\u0377": "\u002E", + "\u0378": "\u002E", "\u0379": "\u002E", "\u037A": "\u002E", "\u037B": "\u002E", "\u037C": "\u002E", "\u037D": "\u002E", "\u037E": "\u002E", "\u037F": "\u002E", + "\u0380": "\u002E", "\u0381": "\u002E", "\u0382": "\u002E", "\u0383": "\u002E", "\u0384": "\u002E", "\u0385": "\u002E", "\u0386": "\u0041", "\u0387": "\u002E", + "\u0388": "\u0045", "\u0389": "\u0049", "\u038A": "\u0049", "\u038B": "\u002E", "\u038C": "\u004F", "\u038D": "\u002E", "\u038E": "\u0059", "\u038F": "\u004F", + "\u0390": "\u0069", "\u0391": "\u0041", "\u0392": "\u0056", "\u0393": "\u0047", "\u0394": "\u0044", "\u0395": "\u0045", "\u0396": "\u005A", "\u0397": "\u0049", + "\u0398": "\u0054\u0048", "\u0399": "\u0049", "\u039A": "\u004B", "\u039B": "\u004C", "\u039C": "\u004D", "\u039D": "\u004E", "\u039E": "\u0058", "\u039F": "\u004F", + "\u03A0": "\u0050", "\u03A1": "\u0052", "\u03A2": "\u002E", "\u03A3": "\u0053", "\u03A4": "\u0054", "\u03A5": "\u0059", "\u03A6": "\u0046", "\u03A7": "\u0043\u0048", + "\u03A8": "\u0050\u0053", "\u03A9": "\u004F", "\u03AA": "\u0049", "\u03AB": "\u0059", "\u03AC": "\u0061", "\u03AD": "\u0065", "\u03AE": "\u0069", "\u03AF": "\u0069", + "\u03B0": "\u0079", "\u03B1": "\u0061", "\u03B2": "\u0076", "\u03B3": "\u0067", "\u03B4": "\u0064", "\u03B5": "\u0065", "\u03B6": "\u007A", "\u03B7": "\u0069", + "\u03B8": "\u0074\u0068", "\u03B9": "\u0069", "\u03BA": "\u006B", "\u03BB": "\u006C", "\u03BC": "\u006D", "\u03BD": "\u006E", "\u03BE": "\u0078", "\u03BF": "\u006F", + "\u03C0": "\u0070", "\u03C1": "\u0072", "\u03C2": "\u0073", "\u03C3": "\u0073", "\u03C4": "\u0074", "\u03C5": "\u0079", "\u03C6": "\u0066", "\u03C7": "\u0063\u0068", + "\u03C8": "\u0070\u0073", "\u03C9": "\u006F", "\u03CA": "\u0069", "\u03CB": "\u0079", "\u03CC": "\u006F", "\u03CD": "\u0079", "\u03CE": "\u006F", "\u03CF": "\u002E", + "\u03D0": "\u002E", "\u03D1": "\u002E", "\u03D2": "\u002E", "\u03D3": "\u002E", "\u03D4": "\u002E", "\u03D5": "\u002E", "\u03D6": "\u002E", "\u03D7": "\u002E", + "\u03D8": "\u002E", "\u03D9": "\u002E", "\u03DA": "\u002E", "\u03DB": "\u002E", "\u03DC": "\u002E", "\u03DD": "\u002E", "\u03DE": "\u002E", "\u03DF": "\u002E", + "\u03E0": "\u002E", "\u03E1": "\u002E", "\u03E2": "\u002E", "\u03E3": "\u002E", "\u03E4": "\u002E", "\u03E5": "\u002E", "\u03E6": "\u002E", "\u03E7": "\u002E", + "\u03E8": "\u002E", "\u03E9": "\u002E", "\u03EA": "\u002E", "\u03EB": "\u002E", "\u03EC": "\u002E", "\u03ED": "\u002E", "\u03EE": "\u002E", "\u03EF": "\u002E", + "\u03F0": "\u002E", "\u03F1": "\u002E", "\u03F2": "\u002E", "\u03F3": "\u002E", "\u03F4": "\u002E", "\u03F5": "\u002E", "\u03F6": "\u002E", "\u03F7": "\u002E", + "\u03F8": "\u002E", "\u03F9": "\u002E", "\u03FA": "\u002E", "\u03FB": "\u002E", "\u03FC": "\u002E", "\u03FD": "\u002E", "\u03FE": "\u002E", "\u03FF": "\u002E", + "\u0400": "\u002E", "\u0401": "\u002E", "\u0402": "\u002E", "\u0403": "\u002E", "\u0404": "\u002E", "\u0405": "\u002E", "\u0406": "\u002E", "\u0407": "\u002E", + "\u0408": "\u002E", "\u0409": "\u002E", "\u040A": "\u002E", "\u040B": "\u002E", "\u040C": "\u002E", "\u040D": "\u002E", "\u040E": "\u002E", "\u040F": "\u002E", + "\u0410": "\u0041", "\u0411": "\u0042", "\u0412": "\u0056", "\u0413": "\u0047", "\u0414": "\u0044", "\u0415": "\u0045", "\u0416": "\u005A\u0048", "\u0417": "\u005A", + "\u0418": "\u0049", "\u0419": "\u0059", "\u041A": "\u004B", "\u041B": "\u004C", "\u041C": "\u004D", "\u041D": "\u004E", "\u041E": "\u004F", "\u041F": "\u0050", + "\u0420": "\u0052", "\u0421": "\u0053", "\u0422": "\u0054", "\u0423": "\u0055", "\u0424": "\u0046", "\u0425": "\u0048", "\u0426": "\u0054\u0053", + "\u0427": "\u0043\u0048", "\u0428": "\u0053\u0048", "\u0429": "\u0053\u0048\u0054", "\u042A": "\u0041", "\u042B": "\u002E", "\u042C": "\u0059", "\u042D": "\u002E", + "\u042E": "\u0059\u0055", "\u042F": "\u0059\u0041", "\u0430": "\u0061", "\u0431": "\u0062", "\u0432": "\u0076", "\u0433": "\u0067", "\u0434": "\u0064", + "\u0435": "\u0065", "\u0436": "\u007A\u0068", "\u0437": "\u007A", "\u0438": "\u0069", "\u0439": "\u0079", "\u043A": "\u006B", "\u043B": "\u006C", "\u043C": "\u006D", + "\u043D": "\u006E", "\u043E": "\u006F", "\u043F": "\u0070", "\u0440": "\u0072", "\u0441": "\u0073", "\u0442": "\u0074", "\u0443": "\u0075", "\u0444": "\u0066", + "\u0445": "\u0068", "\u0446": "\u0074\u0073", "\u0447": "\u0063\u0068", "\u0448": "\u0073\u0068", "\u0449": "\u0073\u0068\u0074", "\u044A": "\u0061", + "\u044B": "\u002E", "\u044C": "\u0079", "\u044D": "\u002E", "\u044E": "\u0079\u0075", "\u044F": "\u0079\u0061", "\u0450": "\u002E", "\u0451": "\u002E", + "\u0452": "\u002E", "\u0453": "\u002E", "\u0454": "\u002E", "\u0455": "\u002E", "\u0456": "\u002E", "\u0457": "\u002E", "\u0458": "\u002E", "\u0459": "\u002E", + "\u045A": "\u002E", "\u045B": "\u002E", "\u045C": "\u002E", "\u045D": "\u002E", +} diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/__init__.py b/dev_odex30_accounting/odex30_account_batch_payment/report/__init__.py new file mode 100644 index 0000000..9ac3f1a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/report/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import account_batch_payment_report diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..76984a8 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/account_batch_payment_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/account_batch_payment_report.cpython-311.pyc new file mode 100644 index 0000000..5c9d28d Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/report/__pycache__/account_batch_payment_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report.py b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report.py new file mode 100644 index 0000000..5a7f866 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models + + +class PrintBatchPayment(models.AbstractModel): + _name = 'report.account_batch_payment.print_batch_payment' + _template = 'odex30_account_batch_payment.print_batch_payment' + _description = 'Batch Deposit Report' + + @api.model + def _get_report_values(self, docids, data=None): + report_name = 'odex30_account_batch_payment.print_batch_payment' + report = self.env['ir.actions.report']._get_report_from_name(report_name) + return { + 'doc_ids': docids, + 'doc_model': report.model, + 'docs': self.env[report.model].browse(docids), + } diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report_templates.xml b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report_templates.xml new file mode 100644 index 0000000..48bbccb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_report_templates.xml @@ -0,0 +1,56 @@ + + + + + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_reports.xml b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_reports.xml new file mode 100644 index 0000000..204b849 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/report/account_batch_payment_reports.xml @@ -0,0 +1,13 @@ + + + + Print Batch Payment + account.batch.payment + qweb-pdf + odex30_account_batch_payment.print_batch_payment + odex30_account_batch_payment.print_batch_payment + + + report + + \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_batch_payment/security/account_batch_payment_security.xml b/dev_odex30_accounting/odex30_account_batch_payment/security/account_batch_payment_security.xml new file mode 100644 index 0000000..2d4e997 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/security/account_batch_payment_security.xml @@ -0,0 +1,10 @@ + + + + + Account batch payment company rule + + ['|',('journal_id.company_id','=',False),('journal_id.company_id','parent_of', company_ids)] + + + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_batch_payment/security/ir.model.access.csv new file mode 100644 index 0000000..ce0d688 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_batch_payment_readonly,account.batch.payment,model_account_batch_payment,account.group_account_readonly,1,0,0,0 +access_account_batch_payment,account.batch.payment,model_account_batch_payment,account.group_account_invoice,1,1,1,1 +access_account_batch_error_wizard,access.account.batch.error.wizard,model_account_batch_error_wizard,account.group_account_invoice,1,1,1,0 +access_account_batch_error_wizard_line,access.account.batch.error.wizard.line,model_account_batch_error_wizard_line,account.group_account_invoice,1,1,1,0 diff --git a/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.png b/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.png new file mode 100644 index 0000000..2ee6057 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.png differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.svg b/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.svg new file mode 100644 index 0000000..2ea414e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/static/src/scss/report_batch_payment.scss b/dev_odex30_accounting/odex30_account_batch_payment/static/src/scss/report_batch_payment.scss new file mode 100644 index 0000000..ba8f328 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/static/src/scss/report_batch_payment.scss @@ -0,0 +1,7 @@ +.page_batch_payment { + page-break-after: always; + .batch_details { + margin: 0.2in 0; + font-size: 1.5em; + } +} diff --git a/dev_odex30_accounting/odex30_account_batch_payment/tests/__init__.py b/dev_odex30_accounting/odex30_account_batch_payment/tests/__init__.py new file mode 100644 index 0000000..a0cf368 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import test_account_batch_payment +from . import test_sepa_mapping diff --git a/dev_odex30_accounting/odex30_account_batch_payment/tests/test_account_batch_payment.py b/dev_odex30_accounting/odex30_account_batch_payment/tests/test_account_batch_payment.py new file mode 100644 index 0000000..e534525 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/tests/test_account_batch_payment.py @@ -0,0 +1,464 @@ +# -*- coding: utf-8 -*- +from odoo import Command +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from odoo.exceptions import RedirectWarning, ValidationError + +@tagged('post_install', '-at_install') +class TestAccountBatchPayment(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id |= cls.env.ref('account.group_validate_bank_account') + cls.other_currency = cls.setup_other_currency('EUR') + cls.other_currency_2 = cls.setup_other_currency('CHF') + + cls.payment_debit_account_id = cls.copy_account(cls.inbound_payment_method_line.payment_account_id) + cls.payment_credit_account_id = cls.copy_account(cls.outbound_payment_method_line.payment_account_id) + + cls.partner_bank_account = cls.env['res.partner.bank'].create({ + 'acc_number': 'BE32707171912447', + 'partner_id': cls.partner_a.id, + 'allow_out_payment': True, + 'acc_type': 'bank', + }) + + def _create_multi_company_payments_and_context(self, companies_dict, add_company_context=None): + payments = self.env['account.payment'] + companies_context = self.env['res.company'] + field_record = self.env['ir.model.fields']._get('res.partner', 'property_account_receivable_id') + property_account_receivable = self.env['ir.default'].search( + [('field_id', '=', field_record.id), ('company_id', '=', self.company_data['company'].id)], limit=1 + ) + + for company, create_property_account_receivable in companies_dict.items(): + if create_property_account_receivable: + # needed for computation of payment.destination_account_id + property_account_receivable.copy({'company_id': company.id}) + payment = self.env['account.payment'].with_company(company).create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': self.partner_a.id, + }) + payment.action_post() + payments += payment + companies_context += company + + if add_company_context: + companies_context += add_company_context + + context = { + **self.env.context, + 'allowed_company_ids': companies_context.ids, + 'active_ids': payments.ids, + 'active_model': 'account.payment', + } + + return payments, context + + def test_create_batch_payment_from_payment(self): + payments = self.env['account.payment'] + for dummy in range(2): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.other_currency.id, + 'partner_bank_id': self.partner_bank_account.id, + }) + + payments.action_post() + batch_payment_action = payments.create_batch_payment() + batch_payment_id = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + self.assertEqual(len(batch_payment_id.payment_ids), 2) + + def test_change_payment_state(self): + """ + Check if the amount is well computed when we change a payment state + """ + payments = self.env['account.payment'] + for _ in range(2): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'partner_bank_id': self.partner_bank_account.id, + }) + payments.action_post() + + batch_payment = self.env['account.batch.payment'].create( + { + 'journal_id': payments.journal_id.id, + 'payment_method_id': payments.payment_method_id.id, + 'payment_ids': [ + (6, 0, payments.ids) + ], + } + ) + + self.assertRecordValues(batch_payment, [{ + 'amount': 200.0, + 'amount_residual': 200.0, + 'amount_residual_currency': 200.0, + }]) + + payments[0].move_id.button_draft() + + # Check that we still keep it + self.assertRecordValues(batch_payment, [{ + 'amount': 200.0, + 'amount_residual': 200.0, + 'amount_residual_currency': 200.0, + }]) + + def test_change_payment_state_valid(self): + """ + Check if the amount is well computed when we change a payment state into a non valid payment status + """ + if self.env['account.move']._get_invoice_in_payment_state() != 'in_payment': + self.skipTest('Accounting not installed') + payments = self.env['account.payment'] + bank_journal_2 = self.company_data['default_journal_bank'].copy() + for _ in range(2): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'journal_id': bank_journal_2.id, + }) + payments.action_post() + + batch_payment = self.env['account.batch.payment'].create( + { + 'journal_id': payments.journal_id.id, + 'payment_method_id': payments.payment_method_id.id, + 'payment_ids': [ + (6, 0, payments.ids) + ], + } + ) + + self.assertRecordValues(batch_payment, [{ + 'amount': 200.0, + 'amount_residual': 200.0, + 'amount_residual_currency': 200.0, + }]) + + # Change move state to 'paid', if accounting is installed it won't be a valid payment state for batches + payments[0].action_validate() + + self.assertRecordValues(batch_payment, [{ + 'amount': 200.0, + 'amount_residual': 100.0, + 'amount_residual_currency': 100.0, + }]) + + def test_validate_batch(self): + """ + Check that we can only validate a batch if all the payments are in progress + """ + payments = self.env['account.payment'] + for _ in range(2): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'partner_bank_id': self.partner_bank_account.id, + }) + payments.action_post() + + batch_payment = self.env['account.batch.payment'].create( + { + 'journal_id': payments.journal_id.id, + 'payment_method_id': payments.payment_method_id.id, + 'payment_ids': [Command.set(payments.ids)] + } + ) + payments[0].action_validate() + + # In accounting, we can only validate a batch if all the payments are in process + # While in enterprise invoicing, we can validate a batch even if some payments are paid + is_accounting_installed = self.env['account.move']._get_invoice_in_payment_state() == 'in_payment' + if is_accounting_installed: + with self.assertRaisesRegex(RedirectWarning, "To validate the batch, payments must be in process"): + batch_payment.validate_batch() + # Set payment to in process state + payments[0].action_draft() + payments[0].action_post() + action = batch_payment.validate_batch() + self.assertFalse(action) + + def test_batch_payment_sub_company(self): + """Test the creation of a batch payment from a sub company""" + self.company_data['company'].write({'child_ids': [Command.create({'name': 'Good Company'})]}) + child_comp = self.company_data['company'].child_ids[0] + + payment, context = self._create_multi_company_payments_and_context({child_comp: True}) + + batch = self.env['account.batch.payment'].with_context(context).create({ + 'journal_id': payment.journal_id.id, + }) + self.assertTrue(batch) + + def test_batch_payment_branches(self): + """ + Test the creation of a batch payment with branches. When all payments are branches of + a common head office, a batch payment should be allowed to be created. + """ + main_company = self.company_data['company'] + main_company.vat = '123' + branch_1 = self.env['res.company'].create({'name': "Branch 1", 'parent_id': main_company.id}) + branch_2 = self.env['res.company'].create({'name': "Branch 2", 'parent_id': main_company.id, 'vat': '456'}) + branch_2_1 = self.env['res.company'].create({'name': "Branch 2 sub-branch 1", 'parent_id': branch_2.id}) + + payments, context = self._create_multi_company_payments_and_context({branch_1: True, branch_2: True, branch_2_1: True}) + + batch = self.env['account.batch.payment'].with_context(context).create({ + 'journal_id': payments[0].journal_id.id, + 'payment_ids': payments.ids, + }) + self.assertTrue(batch) + + def test_batch_payment_different_companies(self): + """ Payments from different companies not belonging to the same head company should raise an error. """ + main_company = self.company_data['company'] + company_b = self.setup_other_company()['company'] + + payments, context = self._create_multi_company_payments_and_context({main_company: False, company_b: False}, main_company) + + with self.assertRaisesRegex(ValidationError, "The journal of the batch payment and of the payments it contains must be the same."): + self.env['account.batch.payment'].with_context(context).create({ + 'journal_id': payments[0].journal_id.id, + 'payment_ids': payments.ids, + }) + + def test_batch_payment_foreign_currency(self): + """ + Make sure that payments in foreign currency are converted for the total amount to be displayed + currency rate = 1$:10€ + amount_company_currency = 100$ + amount_foreign_currency = 100€ -> 10$ + => batch.amount = 110$ + """ + payments = self.env['account.payment'] + company_currency = self.env.company.currency_id + foreign_currency = self.other_currency + + self.env['res.currency.rate'].create({ + 'name': '2024-05-14', + 'rate': 10, + 'currency_id': foreign_currency.id, + 'company_id': self.env.company.id, + }) + + for currency in (company_currency, foreign_currency): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'currency_id': currency.id, + 'date': '2024-05-14', + }) + + payments.action_post() + batch_payment_action = payments.create_batch_payment() + batch_payment = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + self.assertRecordValues(batch_payment, [{ + 'amount': 110.0, + 'amount_residual': 110.0, + 'amount_residual_currency': 110.0, + }]) + + def test_batch_payment_move_different_currencies(self): + """ + Make sure that payments linked to a move in foreign currency 1 are converted correctly when + the batch is in foreign currency 2 + """ + payments = self.env['account.payment'] + bank_journal_2 = self.company_data['default_journal_bank'].copy({'currency_id': self.other_currency_2.id}) + + outstanding_payment_B = self.inbound_payment_method_line.payment_account_id.copy() + bank_journal_2.inbound_payment_method_line_ids.payment_account_id = outstanding_payment_B + + for currency, rate in [(self.other_currency, 10), (self.other_currency_2, 20)]: + self.env['res.currency.rate'].create({ + 'name': '2024-05-14', + 'rate': rate, + 'currency_id': currency.id, + 'company_id': self.env.company.id, + }) + + for amount in (100.0, 15.0): + payments += self.env['account.payment'].create({ + 'amount': amount, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'currency_id': self.other_currency.id, + 'journal_id': bank_journal_2.id, + 'date': '2024-05-14', + }) + + payments.action_post() + batch_payment_action = payments.create_batch_payment() + batch_payment = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + self.assertRecordValues(batch_payment, [{ + 'amount': 230.0, + 'amount_residual': 11.5, + 'amount_residual_currency': 230.0, + }]) + + def test_foreign_currency_batch_payment(self): + """ + Make sure that payments in company_currency are converted when the batch is in + foreign currency + """ + payments = self.env['account.payment'] + foreign_currency = self.other_currency + + bank_journal_2 = self.company_data['default_journal_bank'].copy() + + self.env['res.currency.rate'].create({ + 'name': '2024-05-14', + 'rate': 10, + 'currency_id': foreign_currency.id, + 'company_id': self.env.company.id, + }) + + for amount in (100, 15): + payments += self.env['account.payment'].create({ + 'amount': amount, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'currency_id': self.other_currency.id, + 'date': '2024-05-14', + 'journal_id': bank_journal_2.id, + }) + + payments.action_post() + batch_payment_action = payments.create_batch_payment() + batch_payment = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + self.assertRecordValues(batch_payment, [{ + 'amount': 11.5, + 'amount_residual': 11.5, + 'amount_residual_currency': 11.5, + }]) + + def test_batch_payment_journal_foreign_currency(self): + """ + Test that, if a bank journal is set in a foreign currency, the batch payment will be correctly converted + currency rate = 1$:10€ + payment of 100€ -> 100☺ + payment of 100$ -> 1000☺ + Total -> 1100 + """ + payments = self.env['account.payment'] + company_currency = self.env.company.currency_id + foreign_currency = self.other_currency + + self.env['res.currency.rate'].create({ + 'name': '2024-05-14', + 'rate': 10, + 'currency_id': foreign_currency.id, + 'company_id': self.env.company.id, + }) + bank_journal_foreign = self.env['account.journal'].create({ + 'name': 'Bank2', + 'type': 'bank', + 'code': 'BNK2', + 'currency_id': foreign_currency.id, + }) + + for currency in (company_currency, foreign_currency): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'currency_id': currency.id, + 'date': '2024-05-14', + 'journal_id': bank_journal_foreign.id + }) + + payments.action_post() + batch_payment_action = payments.create_batch_payment() + batch_payment = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + self.assertRecordValues(batch_payment, [{ + 'amount': 1100.0, + 'amount_residual': 110.0, + 'amount_residual_currency': 1100.0, + }]) + + def test_create_batch_from_payment_already_in_batch(self): + payment = self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'outbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'currency_id': self.other_currency.id, + 'partner_bank_id': self.partner_bank_account.id, + }) + payment.action_post() + batch_payment_action = payment.create_batch_payment() + batch_payment_id = self.env['account.batch.payment'].browse(batch_payment_action.get('res_id')) + batch_payment_id.validate_batch() + with self.assertRaises(ValidationError): + payment.create_batch_payment() + + def test_amount_in_paid_state(self): + """ + Verify that the batch payment amount is correctly computed when the payment state is 'paid'. + """ + payments = self.env['account.payment'] + + # Create two inbound payments of 100€ each + for _ in range(2): + payments += self.env['account.payment'].create({ + 'amount': 100.0, + 'payment_type': 'inbound', + 'partner_type': 'supplier', + 'partner_id': self.partner_a.id, + 'destination_account_id': self.partner_a.property_account_payable_id.id, + 'partner_bank_id': self.partner_bank_account.id, + }) + # Post the payments to validate them + payments.action_post() + + # Update payment states to 'paid' + payments.write({'state': 'paid'}) + + # Ensure all payments are now in the 'paid' state + self.assertTrue(all(payment.state == 'paid' for payment in payments), "Payments should be in 'paid' state") + + # Create a batch payment including the two payments + batch_payment = self.env['account.batch.payment'].create( + { + 'journal_id': payments.journal_id.id, + 'payment_method_id': payments.payment_method_id.id, + 'payment_ids': [Command.set(payments.ids)], + } + ) + + # Ensure the amount remains correct after recomputation + # When accountant is not installed, payments with paid state + # should be counted when recomputing amount_residual + if self.env['ir.module.module']._get('accountant').state == 'installed': + self.assertEqual(batch_payment.amount_residual, 0) + self.assertEqual(batch_payment.amount_residual_currency, 0) + else: + self.assertEqual(batch_payment.amount_residual, 200) + self.assertEqual(batch_payment.amount_residual_currency, 200) + self.assertEqual(batch_payment.amount, 200) + diff --git a/dev_odex30_accounting/odex30_account_batch_payment/tests/test_sepa_mapping.py b/dev_odex30_accounting/odex30_account_batch_payment/tests/test_sepa_mapping.py new file mode 100644 index 0000000..0bf1d54 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/tests/test_sepa_mapping.py @@ -0,0 +1,15 @@ +from odoo.addons.odex30_account_batch_payment.models.sepa_mapping import sanitize_communication +from odoo.tests.common import TransactionCase + + +class TestSepaMapping(TransactionCase): + def test_sepa_mapping(self): + self.assertEqual(sanitize_communication("Hello/world", 5), "Hello") + self.assertEqual(sanitize_communication("Hello / World"), "Hello / World") + self.assertEqual(sanitize_communication("Hello // World"), "Hello / World") + self.assertEqual(sanitize_communication("Hello //// W//orld"), "Hello / W/orld") + self.assertEqual(sanitize_communication("/Hello / World/"), "Hello / World") + self.assertEqual(sanitize_communication("Hello / World /"), "Hello / World ") + self.assertEqual(sanitize_communication("\u1F9E/Hello"), "Hello") + self.assertEqual(sanitize_communication("Hello/\u1F9E"), "Hello") + self.assertEqual(sanitize_communication("Hello/\u1F9E/ World"), "Hello/ World") diff --git a/dev_odex30_accounting/odex30_account_batch_payment/views/account_batch_payment_views.xml b/dev_odex30_accounting/odex30_account_batch_payment/views/account_batch_payment_views.xml new file mode 100644 index 0000000..79021b1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/views/account_batch_payment_views.xml @@ -0,0 +1,180 @@ + + + + + Create batch payment + + + list + code + +if records: + action = records.create_batch_payment() + + + + + account.batch.payment.form + account.batch.payment + +
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + account.batch.payment.search + account.batch.payment + + + + + + + + + + + + + + + + + account.batch.payment.list + account.batch.payment + + + + + + + + + + + + + + + Customer Batch Payments + account.batch.payment + list,form,kanban,activity + + + [('batch_type', '=', 'inbound')] + {'search_default_open': 1, 'default_batch_type': 'inbound'} + +

    + Create a new customer batch payment +

    + Batch payments allow you grouping different payments to ease + reconciliation. They are also useful when depositing checks + to the bank. +

    +
    +
    + + + Vendor Batch Payments + account.batch.payment + list,form,kanban,activity + + + [('batch_type', '=', 'outbound')] + {'search_default_open': 1, 'default_batch_type': 'outbound'} + +

    + Create a new vendor batch payment +

    + Batch payments allow you grouping different payments to ease + reconciliation. They are also useful when depositing checks + to the bank. +

    +
    +
    + + + account.batch.payment.move.kanban + account.batch.payment + + + + + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + + + + +
    diff --git a/dev_odex30_accounting/odex30_account_batch_payment/views/account_journal_views.xml b/dev_odex30_accounting/odex30_account_batch_payment/views/account_journal_views.xml new file mode 100644 index 0000000..c8bc579 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/views/account_journal_views.xml @@ -0,0 +1,16 @@ + + + + + account.journal.dashboard.kanban.inherited + account.journal + + + + + + + + \ No newline at end of file diff --git a/dev_odex30_accounting/odex30_account_batch_payment/views/account_payment_views.xml b/dev_odex30_accounting/odex30_account_batch_payment/views/account_payment_views.xml new file mode 100644 index 0000000..0669d99 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/views/account_payment_views.xml @@ -0,0 +1,97 @@ + + + + + + + + account.payment.search.inherit.account_batch_payment + account.payment + + + + + + + + + + + + + + + + + + + + + + + + account.payment.list.inherit.account_batch_payment + account.payment + + +
    +
    + + + + + + + +
    + + + account.payment.form.inherit.account_batch_payment + account.payment + + + +
    + + + +
    + +
    +
    + + + account.payment.list.popup.inherit.account_batch_payment + account.payment + + primary + 10 + + + 1 + + + hide + + + hide + + + hide + + + + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_batch_payment/wizard/__init__.py b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__init__.py new file mode 100644 index 0000000..0a172cf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import batch_error diff --git a/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..96204e8 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/batch_error.cpython-311.pyc b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/batch_error.cpython-311.pyc new file mode 100644 index 0000000..d3a79d4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_batch_payment/wizard/__pycache__/batch_error.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error.py b/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error.py new file mode 100644 index 0000000..5791cf2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, fields, _ + + +class BatchErrorWizard(models.TransientModel): + _name = 'account.batch.error.wizard' + _description = "Batch payments error reporting wizard" + + batch_payment_id = fields.Many2one(comodel_name='account.batch.payment', required=True, help="The batch payment generating the errors and warnings displayed in this wizard.") + error_line_ids = fields.One2many(comodel_name='account.batch.error.wizard.line', inverse_name='error_wizard_id') + warning_line_ids = fields.One2many(comodel_name='account.batch.error.wizard.line', inverse_name='warning_wizard_id') + show_remove_options = fields.Boolean(required=True, help="True if and only if the options to remove the payments causing the errors or warnings from the batch should be shown") + + @api.model + def create_from_errors_list(self, batch, errors_list, warnings_list): + rslt = self.create({'batch_payment_id': batch.id, 'show_remove_options': batch.export_file}) + self._create_error_wizard_lines(rslt.id, 'error_wizard_id', errors_list) + self._create_error_wizard_lines(rslt.id, 'warning_wizard_id', warnings_list) + return rslt + + @api.model + def _create_error_wizard_lines(self, wizard_id, wizard_link_field, entries): + wizard_line = self.env['account.batch.error.wizard.line'] + for entry in entries: + wizard_line.create({ + wizard_link_field: wizard_id, + 'description': entry['title'], + 'help_message': entry.get('help', None), + 'payment_ids': [(6, False, entry['records'].ids)], + }) + + def proceed_with_validation(self): + self.ensure_one() + return self.batch_payment_id._send_after_validation() + +class BatchErrorWizardLine(models.TransientModel): + _name = 'account.batch.error.wizard.line' + _description = "Batch payments error reporting wizard line" + + description = fields.Char(string="Description", required=True) + help_message = fields.Char(string="Help") + payment_ids = fields.Many2many(string='Payments', comodel_name='account.payment', required=True) + error_wizard_id = fields.Many2one(comodel_name='account.batch.error.wizard') + warning_wizard_id = fields.Many2one(comodel_name='account.batch.error.wizard') + # Whether or not this line should display a button allowing to remove its related payments from the batch + show_remove_button = fields.Boolean(compute="_compute_show_remove_button") + + def _compute_show_remove_button(self): + for record in self: + record.show_remove_button = record.error_wizard_id.show_remove_options or record.warning_wizard_id.show_remove_options + + def open_payments(self): + return self.payment_ids._get_records_action(name=_('Payments in Error')) + + def remove_payments_from_batch(self): + for payment in self.payment_ids: + payment.batch_payment_id = None + + # We try revalidating the batch if we still have payments for it (we hence do nothing for empty batches) + batch = (self.error_wizard_id or self.warning_wizard_id).batch_payment_id + if batch.payment_ids: + return batch.validate_batch() diff --git a/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error_views.xml b/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error_views.xml new file mode 100644 index 0000000..71c88d1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_batch_payment/wizard/batch_error_views.xml @@ -0,0 +1,56 @@ + + + + + + account.batch.error.wizard.form + account.batch.error.wizard + +
    + +
    + + + The following errors occurred + + + + The following warnings were also raised; they do not impeach validation +
    + +
    + + + Please first consider the following warnings +
    + + +
    +
    +
    +
    +
    +
    + + + account.batch.error.wizard.line.list + account.batch.error.wizard.line + + + + + + +
    + +

    + Preferred address for follow-up reports. Selected by default when you send reminders about overdue invoices. +

    +
    + + + + + res.partner.property.form.followup + res.partner + + + + + +
    + +
    +
    +
    +
    +
    + + diff --git a/dev_odex30_accounting/odex30_account_followup/views/report_followup.xml b/dev_odex30_accounting/odex30_account_followup/views/report_followup.xml new file mode 100644 index 0000000..4e4db59 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/views/report_followup.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + Print Follow-up Letter + res.partner + qweb-pdf + odex30_account_followup.report_followup_print_all + odex30_account_followup.report_followup_print_all + 'Follow-up ' + object.display_name + + + + + diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/__init__.py b/dev_odex30_accounting/odex30_account_followup/wizard/__init__.py new file mode 100644 index 0000000..6e3f9fc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import followup_manual_reminder +from . import followup_missing_information diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..30384a4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_manual_reminder.cpython-311.pyc b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_manual_reminder.cpython-311.pyc new file mode 100644 index 0000000..e86201a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_manual_reminder.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_missing_information.cpython-311.pyc b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_missing_information.cpython-311.pyc new file mode 100644 index 0000000..51ff027 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_followup/wizard/__pycache__/followup_missing_information.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder.py b/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder.py new file mode 100644 index 0000000..c939ce6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, Command + + +class FollowupManualReminder(models.TransientModel): + _name = 'account_followup.manual_reminder' + _inherit = 'mail.composer.mixin' + _description = "Wizard for sending manual reminders to clients" + + def default_get(self, fields_list): + defaults = super().default_get(fields_list) + assert self.env.context['active_model'] == 'res.partner' + partner = self.env['res.partner'].browse(self.env.context['active_ids']) + partner.ensure_one() + followup_line = partner.followup_line_id + if followup_line: + defaults.update(self._get_defaults_from_followup_line(followup_line)) + defaults.update( + partner_id=partner.id, + attachment_ids=[Command.set(partner.unreconciled_aml_ids.move_id.message_main_attachment_id.ids)], + render_model='res.partner' + ) + return defaults + + partner_id = fields.Many2one(comodel_name='res.partner') + + # email fields + email = fields.Boolean() + email_recipient_ids = fields.Many2many(string="Extra Recipients", comodel_name='res.partner', + compute='_compute_email_recipient_ids', store=True, readonly=False, + relation='rel_followup_manual_reminder_res_partner') # override + + # sms fields + sms = fields.Boolean() + sms_body = fields.Char(compute='_compute_sms_body', readonly=False, store=True) + sms_template_id = fields.Many2one(comodel_name='sms.template', domain=[('model', '=', 'res.partner')]) + + # print fields + print = fields.Boolean(default=True) + join_invoices = fields.Boolean(string="Attach Invoices") + + # attachments fields + attachment_ids = fields.Many2many(comodel_name='ir.attachment') + + def _compute_render_model(self): + # OVERRIDES mail.renderer.mixin + self.render_model = 'res.partner' + + def _get_defaults_from_followup_line(self, followup_line): + return { + 'email': followup_line.send_email, + 'sms': followup_line.send_sms, + 'template_id': followup_line.mail_template_id.id, + 'sms_template_id': followup_line.sms_template_id.id, + 'join_invoices': followup_line.join_invoices, + } + + @api.depends('template_id') + def _compute_subject(self): + for wizard in self: + options = { + 'partner_id': wizard.partner_id.id, + 'mail_template': wizard.template_id, + } + wizard.subject = self.env['account.followup.report']._get_email_subject(options) + + @api.depends('template_id') + def _compute_body(self): + # OVERRIDES mail.composer.mixin + for wizard in self: + options = { + 'partner_id': wizard.partner_id.id, + 'mail_template': wizard.template_id, + } + wizard.body = self.env['account.followup.report']._get_main_body(options) + + @api.depends('template_id') + def _compute_email_recipient_ids(self): + for wizard in self: + partner = wizard.partner_id + template = wizard.template_id + wizard.email_recipient_ids = partner._get_all_followup_contacts() or partner + if template: + rendered_values = template._generate_template_recipients( + [partner.id], + {'partner_to', 'email_cc', 'email_to'}, + True + )[partner.id] + if rendered_values.get('partner_ids'): + wizard.email_recipient_ids = [Command.link(partner_id) for partner_id in rendered_values['partner_ids']] + + @api.depends('sms_template_id') + def _compute_sms_body(self): + for wizard in self: + options = { + 'partner_id': wizard.partner_id.id, + 'sms_template': wizard.sms_template_id, + } + wizard.sms_body = self.env['account.followup.report']._get_sms_body(options) + + def _get_wizard_options(self): + """ Returns a dictionary of options, containing values from this wizard that are needed to process the followup + """ + return { + 'partner_id': self.partner_id, + 'email': self.email, + 'email_from': self.template_id.email_from, + 'email_subject': self.subject, + 'email_recipient_ids': self.email_recipient_ids, + 'body': self.body, + 'attachment_ids': self.attachment_ids.ids, + 'sms': self.sms, + 'sms_body': self.sms_body, + 'print': self.print, + 'join_invoices': self.join_invoices, + 'manual_followup': True, + 'template_id': self.template_id, + } + + def process_followup(self): + """ Method run by pressing the 'Send and Print' button in the wizard. + It will process the followup for the active partner, taking into account the fields from the wizard. + Send email/sms and print the followup letter (pdf) depending on which is activated. + Once the followup has been processed, we simply close the wizard. + """ + options = self._get_wizard_options() + options['author_id'] = self.env.user.partner_id.id + action = self.partner_id.execute_followup(options) + return action or { + 'type': 'ir.actions.act_window_close', + } diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder_views.xml b/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder_views.xml new file mode 100644 index 0000000..ad4d673 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/wizard/followup_manual_reminder_views.xml @@ -0,0 +1,98 @@ + + + + account.followup.manual_reminder.view.form + account_followup.manual_reminder + +
    + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + + Send and Print + account_followup.manual_reminder + form + new + +
    diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.py b/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.py new file mode 100644 index 0000000..75c7d5f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.py @@ -0,0 +1,20 @@ +from odoo import models, _ + + +class FollowupMissingInformation(models.TransientModel): + _name = "account_followup.missing.information.wizard" + _description = "Followup missing information wizard" + + def view_partners_action(self): + """ Returns a list view containing all the partners with missing information with the option to edit in place. + """ + view_id = self.env.ref('odex30_account_followup.missing_information_view_tree').id + + return { + 'name': _('Missing information'), + 'res_model': 'res.partner', + 'view_mode': 'list', + 'views': [(view_id, 'list')], + 'domain': [('id', 'in', self.env.context.get('default_partner_ids', []))], + 'type': 'ir.actions.act_window', + } diff --git a/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.xml b/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.xml new file mode 100644 index 0000000..1ef9dc3 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_followup/wizard/followup_missing_information.xml @@ -0,0 +1,33 @@ + + + + missing.information.view.form + account_followup.missing.information.wizard + +
    +

    We were not able to process some of the automated follow-up actions due to missing information on the partners.

    +
    +
    +
    +
    +
    + + + missing.information.view.list + res.partner + + + + + + + + + + + + + +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/__init__.py new file mode 100644 index 0000000..bbc5580 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models +from . import wizard diff --git a/dev_odex30_accounting/odex30_account_online_sync/__manifest__.py b/dev_odex30_accounting/odex30_account_online_sync/__manifest__.py new file mode 100644 index 0000000..5946b4a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/__manifest__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Online Bank Statement Synchronization", + 'summary': """ + This module is used for Online bank synchronization.""", + + 'description': """ + This module is used for Online bank synchronization. It provides basic methods to synchronize bank statement. + """, + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'version': '2.0', + 'depends': ['odex30_account_accountant'], + + 'data': [ + 'data/config_parameter.xml', + 'data/ir_cron.xml', + 'data/mail_activity_type_data.xml', + 'data/sync_reminder_email_template.xml', + + 'security/ir.model.access.csv', + 'security/account_online_sync_security.xml', + + 'views/account_online_sync_views.xml', + 'views/account_bank_statement_view.xml', + 'views/account_journal_view.xml', + 'views/account_online_sync_portal_templates.xml', + 'views/account_journal_dashboard_view.xml', + + 'wizard/account_bank_selection_wizard.xml', + 'wizard/account_journal_missing_transactions.xml', + 'wizard/account_journal_duplicate_transactions.xml', + 'wizard/account_bank_statement_line.xml', + ], + 'license': 'OEEL-1', + 'auto_install': True, + 'assets': { + 'web.assets_backend': [ + 'odex30_account_online_sync/static/src/components/**/*', + 'odex30_account_online_sync/static/src/js/odoo_fin_connector.js', + ], + 'web.assets_frontend': [ + 'odex30_account_online_sync/static/src/js/online_sync_portal.js', + ], + 'web.qunit_suite_tests': [ + 'odex30_account_online_sync/static/tests/helpers/*.js', + 'odex30_account_online_sync/static/tests/*.js', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_online_sync/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e37619d Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/controllers/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/controllers/__init__.py new file mode 100644 index 0000000..8c3feb6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6439cb3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/portal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/portal.cpython-311.pyc new file mode 100644 index 0000000..28f9ba1 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/controllers/__pycache__/portal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/controllers/portal.py b/dev_odex30_accounting/odex30_account_online_sync/controllers/portal.py new file mode 100644 index 0000000..9556ea4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/controllers/portal.py @@ -0,0 +1,58 @@ +import json + +from odoo import http +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.tools import format_amount, format_date +from odoo.exceptions import AccessError, MissingError, UserError + + +class OnlineSynchronizationPortal(CustomerPortal): + + @http.route(['/renew_consent/'], type='http', auth="public", website=True, sitemap=False) + def portal_online_sync_renew_consent(self, journal_id, access_token=None, **kw): + # Display a page to the user allowing to renew the consent for his bank sync. + # Requires the same rights as the button in odoo. + try: + journal_sudo = self._document_check_access('account.journal', journal_id, access_token) + except (AccessError, MissingError): + return request.redirect('/my') + values = self._prepare_portal_layout_values() + # Ignore the route if the journal isn't one using bank sync. + if not journal_sudo.account_online_account_id: + raise request.not_found() + + balance = journal_sudo.account_online_account_id.balance + if journal_sudo.account_online_account_id.currency_id: + formatted_balance = format_amount(request.env, balance, journal_sudo.account_online_account_id.currency_id) + else: + formatted_balance = format_amount(request.env, balance, journal_sudo.currency_id or journal_sudo.company_id.currency_id) + + values.update({ + 'bank': journal_sudo.bank_account_id.bank_name or journal_sudo.account_online_account_id.name, + 'bank_account': journal_sudo.bank_account_id.acc_number, + 'journal': journal_sudo.name, + 'latest_balance_formatted': formatted_balance, + 'latest_balance': balance, + 'latest_sync': format_date(request.env, journal_sudo.account_online_account_id.last_sync, date_format="MMM dd, YYYY"), + 'iframe_params': json.dumps(journal_sudo.action_extend_consent()), + }) + return request.render("account_online_synchronization.portal_renew_consent", values) + + + @http.route(['/renew_consent//complete'], type='http', auth="public", methods=['POST'], website=True) + def portal_online_sync_action_complete(self, journal_id, access_token=None, **kw): + # Complete the consent renewal process + try: + journal_sudo = self._document_check_access('account.journal', journal_id, access_token) + except (AccessError, MissingError): + return request.redirect('/my') + # Ignore the route if the journal isn't one using bank sync. + if not journal_sudo.account_online_link_id: + raise request.not_found() + try: + journal_sudo.account_online_link_id._update_connection_status() + journal_sudo.manual_sync() + except UserError: + pass + return request.make_response(json.dumps({'status': 'done'})) diff --git a/dev_odex30_accounting/odex30_account_online_sync/data/config_parameter.xml b/dev_odex30_accounting/odex30_account_online_sync/data/config_parameter.xml new file mode 100644 index 0000000..5a8a8fa --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/data/config_parameter.xml @@ -0,0 +1,13 @@ + + + + + odex30_account_online_sync.proxy_mode + production + + + odex30_account_online_sync.request_timeout + 60 + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/data/ir_cron.xml b/dev_odex30_accounting/odex30_account_online_sync/data/ir_cron.xml new file mode 100644 index 0000000..5ed932b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/data/ir_cron.xml @@ -0,0 +1,48 @@ + + + + + + Account: Journal online sync + + code + model._cron_fetch_online_transactions() + + + 12 + hours + + + Account: Journal online Waiting Synchronization + + code + model._cron_fetch_waiting_online_transactions() + + + 5 + minutes + + + + Account: Journal online sync reminder + + code + model._cron_send_reminder_email() + + + 1 + days + + + + Account: Journal online sync cleanup unused connections + + code + model._cron_delete_unused_connection() + + + 1 + days + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/data/mail_activity_type_data.xml b/dev_odex30_accounting/odex30_account_online_sync/data/mail_activity_type_data.xml new file mode 100644 index 0000000..0a96bcc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/data/mail_activity_type_data.xml @@ -0,0 +1,21 @@ + + + + + Bank Synchronization: Update consent + fa-university + warning + account.journal + 0 + + + + Consent Renewal + + + account.journal + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/data/neutralize.sql b/dev_odex30_accounting/odex30_account_online_sync/data/neutralize.sql new file mode 100644 index 0000000..465aaa7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/data/neutralize.sql @@ -0,0 +1,4 @@ +-- disable bank synchronisation links +UPDATE account_online_link + SET provider_data = '', + client_id = 'duplicate'; diff --git a/dev_odex30_accounting/odex30_account_online_sync/data/sync_reminder_email_template.xml b/dev_odex30_accounting/odex30_account_online_sync/data/sync_reminder_email_template.xml new file mode 100644 index 0000000..abf2b28 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/data/sync_reminder_email_template.xml @@ -0,0 +1,71 @@ + + + + + Bank connection expiration reminder + Your bank connection is expiring soon + {{ object.company_id.email_formatted or user.email_formatted }} + {{ object.renewal_contact_email }} + + + + + + + + + + + +
    + + + + + + + +
    + + + + + + + +
    +
    + Hello,

    + The connection between https://yourcompany.odoo.com and Belfius expired.expires in 10 days.
    + + Security Tip: Check that the domain name you are redirected to is: https://yourcompany.odoo.com +
    +
    +
    +
    +
    +
    + + + + +
    + Powered by Odoo +
    + + + + +
    + PS: This is an automated email sent by Odoo Accounting to remind you before a bank sync consent expiration. +
    +
    +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/i18n/account_online_synchronization.pot b/dev_odex30_accounting/odex30_account_online_sync/i18n/account_online_synchronization.pot new file mode 100644 index 0000000..b437820 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/i18n/account_online_synchronization.pot @@ -0,0 +1,1322 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_online_sync +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-07-11 18:50+0000\n" +"PO-Revision-Date: 2025-07-11 18:50+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: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"\n" +"\n" +"If you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_duplicate_transactions.py:0 +msgid "%s duplicate transactions" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +").\n" +" This might cause duplicate entries." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "0 transaction fetched" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_duplicate_transaction_wizard_view_form +msgid " Delete Selected" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_journal_form +msgid " Send Now" +msgstr "" + +#. module: odex30_account_online_sync +#: model:mail.template,body_html:account_online_synchronization.email_template_sync_reminder +msgid "" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +"
    \n" +" Hello,

    \n" +" The connection between https://yourcompany.odoo.com and Belfius expired.expires in 10 days.
    \n" +" \n" +" Security Tip: Check that the domain name you are redirected to is: https://yourcompany.odoo.com\n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" Powered by Odoo\n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" PS: This is an automated email sent by Odoo Accounting to remind you before a bank sync consent expiration.\n" +"
    \n" +"
    \n" +" " +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__access_token +msgid "Access Token" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__account_data +msgid "Account Data" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__name +msgid "Account Name" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__name +msgid "Account Name as provided by third party provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__account_number +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__account_number +msgid "Account Number" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__account_online_account_ids +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__online_account_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__account_online_account_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__account_online_account_ids +msgid "Account Online Account" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__account_online_link_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line__online_link_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__account_online_link_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__account_online_link_id +msgid "Account Online Link" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:account_online_synchronization.online_sync_cron_waiting_synchronization_ir_actions_server +msgid "Account: Journal online Waiting Synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:account_online_synchronization.online_sync_cron_ir_actions_server +msgid "Account: Journal online sync" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:account_online_synchronization.online_sync_unused_connection_cron_ir_actions_server +msgid "Account: Journal online sync cleanup unused connections" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:account_online_synchronization.online_sync_mail_cron_ir_actions_server +msgid "Account: Journal online sync reminder" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_needaction +msgid "Action Needed" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_ids +msgid "Activities" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_state +msgid "Activity State" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_mail_activity_type +msgid "Activity Type" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_type_icon +msgid "Activity Type Icon" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__amount +msgid "Amount" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__amount_currency +msgid "Amount in Currency" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_attachment_count +msgid "Attachment Count" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__auto_sync +msgid "Automatic synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__balance +msgid "Balance" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__balance +msgid "Balance of the account sent by the third party provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Bank" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_online_link +msgid "Bank Connection" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "" + +#. module: odex30_account_online_sync +#: model:mail.activity.type,name:account_online_synchronization.bank_sync_activity_update_consent +msgid "Bank Synchronization: Update consent" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Bank Synchronization: Update your consent" +msgstr "" + +#. module: odex30_account_online_sync +#: model:mail.template,name:account_online_synchronization.email_template_sync_reminder +msgid "Bank connection expiration reminder" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_bank_rec_widget +msgid "Bank reconciliation widget for a single statement line" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_missing_transaction_wizard_view_form +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.view_account_bank_selection_form_wizard +msgid "Cancel" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Check the documentation" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_duplicate_transaction_wizard_view_form +msgid "" +"Choose a date and a journal from which you want to check the transactions." +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_missing_transaction_wizard_view_form +msgid "Choose a date and a journal from which you want to fetch transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__client_id +msgid "Client" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Client id" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_journal__renewal_contact_email +msgid "" +"Comma separated list of email addresses to send consent renewal " +"notifications 15, 3 and 1 days before expiry" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_res_company +msgid "Companies" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__company_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__company_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__company_id +msgid "Company" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Connect" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.view_account_bank_selection_form_wizard +msgid "Connect Bank" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_journal_dashboard_inherit_online_sync +msgid "Connect bank" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Connect my Bank" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Connect your bank account to Odoo" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_link__state__connected +msgid "Connected" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +msgid "Connected until" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__renewal_contact_email +msgid "Connection Requests" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__connection_state_details +msgid "Connection State Details" +msgstr "" + +#. module: odex30_account_online_sync +#: model:mail.message.subtype,name:account_online_synchronization.bank_sync_consent_renewal +msgid "Consent Renewal" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_res_partner +msgid "Contact" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__create_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__create_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__create_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__create_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__create_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__create_uid +msgid "Created by" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__create_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__create_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__create_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__create_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__create_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__create_date +msgid "Created on" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__currency_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__currency_id +msgid "Currency" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__date +msgid "Date" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_journal__expiring_synchronization_date +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__expiring_synchronization_date +msgid "Date when the consent for this connection expires" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__display_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__display_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__display_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__display_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__display_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__display_name +msgid "Display Name" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_account__fetching_status__done +msgid "Done" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0 +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_link__state__error +msgid "Error" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__expiring_synchronization_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__expiring_synchronization_date +msgid "Expiring Synchronization Date" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__expiring_synchronization_due_day +msgid "Expiring Synchronization Due Day" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +msgid "Extend Connection" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__account_data +msgid "Extra information needed by third party provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_missing_transaction_wizard_view_form +msgid "Fetch" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_duplicate_transaction_wizard_view_form +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_missing_transaction_wizard_view_form +msgid "Fetch Missing Bank Statements Wizard" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Fetch Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Fetched Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__online_sync_fetching_status +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__fetching_status +msgid "Fetching Status" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "Fetching..." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml:0 +msgid "Find Duplicate Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml:0 +msgid "Find Missing Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__first_ids_in_group +msgid "First Ids In Group" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_follower_ids +msgid "Followers" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_partner_ids +msgid "Followers (Partners)" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__foreign_currency_id +msgid "Foreign Currency" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__has_message +msgid "Has Message" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__has_unlinked_accounts +msgid "Has Unlinked Accounts" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Here" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__id +msgid "ID" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_exception_icon +msgid "Icon" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__online_identifier +msgid "Id used to identify account by third party provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__message_needaction +msgid "If checked, new messages require your attention." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__message_has_error +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__inverse_balance_sign +msgid "If checked, the balance sign will be inverted" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__inverse_transaction_sign +msgid "If checked, the transaction sign will be inverted" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__auto_sync +msgid "" +"If possible, we will try to automatically fetch new transactions for this record\n" +" \n" +"If the automatic sync is disabled. that will be due to security policy on the bank's end. So, they have to launch the sync manually" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "Import Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__provider_data +msgid "Information needed to interact with third party provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_bank_selection__institution_name +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__name +msgid "Institution Name" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Internal Error" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Invalid URL" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Invalid value for proxy_mode config parameter." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__inverse_balance_sign +msgid "Inverse Balance Sign" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__inverse_transaction_sign +msgid "Inverse Transaction Sign" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_is_follower +msgid "Is Follower" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_journal +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__journal_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__journal_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__journal_id +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__journal_ids +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__journal_ids +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Journal" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"Journal %(journal_name)s has been set up with a different currency and " +"already has existing entries. You can't link selected bank account in " +"%(currency_name)s to it" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__last_refresh +msgid "Last Refresh" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__write_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__write_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__write_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__write_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__write_uid +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__write_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__write_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__write_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__write_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__write_date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__write_date +msgid "Last Updated on" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Last refresh" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__last_sync +msgid "Last synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Latest Balance" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_bank_selection +msgid "Link a bank account to the selected journal" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Manual Bank Statement Lines" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Message" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_has_error +msgid "Message Delivery error" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_ids +msgid "Messages" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Missing and Pending Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__institution_name +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__name +msgid "Name" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_summary +msgid "Next Activity Summary" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_type_id +msgid "Next Activity Type" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__next_refresh +msgid "Next synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_link__state__disconnected +msgid "Not Connected" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_needaction_counter +msgid "Number of Actions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_has_error_counter +msgid "Number of errors" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Odoo" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line__online_account_id +msgid "Online Account" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Online Accounts" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_account__online_identifier +msgid "Online Identifier" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__next_link_synchronization +msgid "Online Link Next synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line__online_partner_information +#: model:ir.model.fields,field_description:account_online_synchronization.field_res_partner__online_partner_information +#: model:ir.model.fields,field_description:account_online_synchronization.field_res_users__online_partner_information +msgid "Online Partner Information" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: model:ir.actions.act_window,name:account_online_synchronization.action_account_online_link_form +#: model:ir.ui.menu,name:account_online_synchronization.menu_action_online_link_account +#: model_terms:ir.actions.act_window,help:account_online_synchronization.action_account_online_link_form +msgid "Online Synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line__online_transaction_identifier +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__online_transaction_identifier +msgid "Online Transaction Identifier" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0 +msgid "Opening statement: first synchronization" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__partner_name +msgid "Partner Name" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__payment_ref +msgid "Payment Ref" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_bank_statement_line_transient__state__pending +msgid "Pending" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_account__fetching_status__planned +msgid "Planned" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Please enter a valid Starting Date to continue." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Please reconnect your online account." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0 +msgid "Please select first the transactions you wish to import." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_bank_statement_line_transient__state__posted +msgid "Posted" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.missing_bank_statement_line_search +msgid "Posted Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_account__fetching_status__processing +msgid "Processing" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__provider_data +msgid "Provider Data" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__provider_type +msgid "Provider Type" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__rating_ids +msgid "Ratings" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Reconnect" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_journal_dashboard_inherit_online_sync +msgid "Reconnect Bank" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Redirect" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "Refresh" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__refresh_token +msgid "Refresh Token" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +msgid "Report Issue" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Report issue" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__client_id +msgid "Represent a link for a given user towards a banking institution" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Reset" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__activity_user_id +msgid "Responsible User" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__message_has_sms_error +msgid "SMS Delivery error" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "Search over" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "" +"Security Tip: always check the domain name of this page, before clicking on " +"the button." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "See error" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.view_account_bank_selection_form_wizard +msgid "Select a Bank Account" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.view_account_bank_selection_form_wizard +msgid "Select the" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_selection__selected_account +msgid "Selected Account" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__sequence +msgid "Sequence" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_account__account_number +msgid "Set if third party provider has the full account number" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "Setup Bank" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Setup Bank Account" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__show_sync_actions +msgid "Show Sync Actions" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0 +msgid "Some transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__date +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_missing_transaction_wizard__date +msgid "Starting Date" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__state +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_journal__account_online_link_state +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__state +msgid "State" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "Thank You!" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "The consent for the selected account has expired." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"The online synchronization service is not available at the moment. Please " +"try again later." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__provider_type +msgid "Third Party Provider" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_duplicate_transaction_wizard_view_form +msgid "" +"This action will delete all selected transactions. Are you sure you want to " +"proceed?" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "This button will reset the fetching status" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"This version of Odoo appears to be outdated and does not support the '%s' " +"sync mode. Installing the latest update might solve this." +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.actions.act_window,help:account_online_synchronization.action_account_online_link_form +msgid "" +"To create a synchronization with your banking institution,
    \n" +" please click on Add a Bank Account." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__access_token +msgid "Token used to access API." +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__refresh_token +msgid "Token used to sign API request, Never disclose it" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_duplicate_transaction_wizard__transaction_ids +msgid "Transaction" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_bank_statement_line_transient__transaction_details +msgid "Transaction Details" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_duplicate_transaction_wizard_view_form +msgid "Transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_bank_statement_line_transient +msgid "Transient model for bank statement line" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__has_unlinked_accounts +msgid "" +"True if that connection still has accounts that are not linked to an Odoo " +"journal" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.account_online_link_view_form +msgid "Update Credentials" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:account_online_synchronization.selection__account_online_account__fetching_status__waiting +msgid "Waiting" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:account_online_synchronization.field_account_online_link__website_message_ids +msgid "Website Messages" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:account_online_synchronization.field_account_online_link__website_message_ids +msgid "Website communication history" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_duplicate_transaction_wizard +msgid "Wizard for duplicate transactions" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_missing_transaction_wizard +msgid "Wizard for missing transactions" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +"You are importing transactions before the creation of your online synchronization\n" +" (" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "You can contact Odoo support" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +msgid "You can only execute this action for bank-synchronized journals." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "" +"You can't find missing transactions for a journal that isn't connected." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "You cannot have two journals associated with the same Online Account." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0 +msgid "You cannot import pending transactions." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "You have" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "You have to select one journal to continue." +msgstr "" + +#. module: odex30_account_online_sync +#: model:mail.template,subject:account_online_synchronization.email_template_sync_reminder +msgid "Your bank connection is expiring soon" +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.view_account_bank_selection_form_wizard +msgid "account to connect:" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "banks" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "entries" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "loading..." +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0 +msgid "may be duplicates." +msgstr "" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:account_online_synchronization.portal_renew_consent +msgid "on" +msgstr "" + +#. module: odex30_account_online_sync +#: model:ir.model,name:account_online_synchronization.model_account_online_account +msgid "representation of an online bank account" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "transactions fetched" +msgstr "" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +"within this period that were not created using the online synchronization. " +"This might cause duplicate entries." +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_online_sync/i18n/ar.po b/dev_odex30_accounting/odex30_account_online_sync/i18n/ar.po new file mode 100644 index 0000000..61d8a10 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/i18n/ar.po @@ -0,0 +1,1418 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_online_sync +# +# Translators: +# Mustafa J. Kadhem , 2024 +# Malaz Abuidris , 2025 +# Wil Odoo, 2025 +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-07-11 18:50+0000\n" +"PO-Revision-Date: 2024-09-25 09:44+0000\n" +"Last-Translator: Wil Odoo, 2025\n" +"Language-Team: Arabic (https://app.transifex.com/odoo/teams/41243/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"\n" +"\n" +"If you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly." +msgstr "" +"\n" +"\n" +"إذا قمت بفتح تذكرة دعم لهذه المشكلة بالفعل، لا تقم بالإبلاغ عنها مجدداً: سيتواصل معك أحد موظفي الدعم عما قريب. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_duplicate_transactions.py:0 +msgid "%s duplicate transactions" +msgstr "%s معاملات مكررة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +").\n" +" This might cause duplicate entries." +msgstr "" +").\n" +" قد يتسبب هذا في تكرار القيود. " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "0 transaction fetched" +msgstr "تم جلب 0 معاملات " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form +msgid " Delete Selected" +msgstr " حذف العناصر المحددة " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_journal_form +msgid " Send Now" +msgstr " إرسال الآن " + +#. module: odex30_account_online_sync +#: model:mail.template,body_html:odex30_account_online_sync.email_template_sync_reminder +msgid "" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +"
    \n" +" Hello,

    \n" +" The connection between https://yourcompany.odoo.com and Belfius expired.expires in 10 days.
    \n" +" \n" +" Security Tip: Check that the domain name you are redirected to is: https://yourcompany.odoo.com\n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" Powered by Odoo\n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" PS: This is an automated email sent by Odoo Accounting to remind you before a bank sync consent expiration.\n" +"
    \n" +"
    \n" +" " +msgstr "" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +"
    \n" +" مرحباً،

    \n" +" الاتصال بين https://yourcompany.odoo.com و Belfius قد انتهت صلاحيته.تنتهي صلاحيته في 10 أيام.
    \n" +" \n" +" نصيحة للأمان: تحقق من أن اسم النطاق الذي تمت إعادة توجيهك إليه هو: https://yourcompany.odoo.com\n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" مشغل بواسطة أودو\n" +"
    \n" +" \n" +" \n" +" \n" +" \n" +"
    \n" +" ملاحظة: هذا البريد الإلكتروني التلقائي مرسل بواسطة محاسبة أودو لتذكيرك قبل انتهاء صلاحية إذن مزامنة البنك.\n" +"
    \n" +"
    \n" +" " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__access_token +msgid "Access Token" +msgstr "رمز الوصول " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_data +msgid "Account Data" +msgstr "بيانات الحساب " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__name +msgid "Account Name" +msgstr "اسم الحساب" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__name +msgid "Account Name as provided by third party provider" +msgstr "اسم الحساب حسب مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__account_number +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_number +msgid "Account Number" +msgstr "رقم الحساب" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__account_online_account_ids +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__online_account_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_account_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__account_online_account_ids +msgid "Account Online Account" +msgstr "حساب عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__account_online_link_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_link_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_link_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__account_online_link_id +msgid "Account Online Link" +msgstr "ربط الحساب عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_cron_waiting_synchronization_ir_actions_server +msgid "Account: Journal online Waiting Synchronization" +msgstr "الحساب: دفتر اليومية أونلاين بانتظار المزامنة " + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_cron_ir_actions_server +msgid "Account: Journal online sync" +msgstr "الحساب: مزامنة دفتر اليومية عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_unused_connection_cron_ir_actions_server +msgid "Account: Journal online sync cleanup unused connections" +msgstr "الحساب: مسح الاتصالات غير المستخدمة عند مزامنة دفتر اليومية أونلاين " + +#. module: odex30_account_online_sync +#: model:ir.actions.server,name:odex30_account_online_sync.online_sync_mail_cron_ir_actions_server +msgid "Account: Journal online sync reminder" +msgstr "الحساب: تذكير مزامنة دفتر اليومية أونلاين " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_needaction +msgid "Action Needed" +msgstr "إجراء مطلوب" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_ids +msgid "Activities" +msgstr "الأنشطة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_exception_decoration +msgid "Activity Exception Decoration" +msgstr "زخرفة استثناء النشاط" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_state +msgid "Activity State" +msgstr "حالة النشاط" + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_mail_activity_type +msgid "Activity Type" +msgstr "نوع النشاط" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_type_icon +msgid "Activity Type Icon" +msgstr "أيقونة نوع النشاط" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__amount +msgid "Amount" +msgstr "مبلغ" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__amount_currency +msgid "Amount in Currency" +msgstr "المبلغ بالعملة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_attachment_count +msgid "Attachment Count" +msgstr "عدد المرفقات" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__auto_sync +msgid "Automatic synchronization" +msgstr "المزامنة الآلية " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__balance +msgid "Balance" +msgstr "الرصيد" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__balance +msgid "Balance of the account sent by the third party provider" +msgstr "رصيد الحساب الذي أرسله مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Bank" +msgstr "البنك" + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_online_link +msgid "Bank Connection" +msgstr "اتصال البنك" + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_bank_statement_line +msgid "Bank Statement Line" +msgstr "بند كشف الحساب البنكي" + +#. module: odex30_account_online_sync +#: model:mail.activity.type,name:odex30_account_online_sync.bank_sync_activity_update_consent +msgid "Bank Synchronization: Update consent" +msgstr "مزامنة البنك: تحديث الإذن " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Bank Synchronization: Update your consent" +msgstr "مزامنة البنك: قم بتحديث الإذن " + +#. module: odex30_account_online_sync +#: model:mail.template,name:odex30_account_online_sync.email_template_sync_reminder +msgid "Bank connection expiration reminder" +msgstr "تذكير انتهاء صلاحية اتصال البنك " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_bank_rec_widget +msgid "Bank reconciliation widget for a single statement line" +msgstr "أداة التسوية البنكية لبند كشف حساب واحد " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard +msgid "Cancel" +msgstr "إلغاء" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Check the documentation" +msgstr "ألقِ نظرة على الوثائق " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form +msgid "" +"Choose a date and a journal from which you want to check the transactions." +msgstr "اختر التاريخ ودفتر اليومية الذي ترغب في التحقق من المعاملات فيه " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form +msgid "Choose a date and a journal from which you want to fetch transactions" +msgstr "اختر التاريخ ودفتر اليومية الذي ترغب في جلب المعاملات منه " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__client_id +msgid "Client" +msgstr "العميل" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Client id" +msgstr "معرف العميل " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_journal__renewal_contact_email +msgid "" +"Comma separated list of email addresses to send consent renewal " +"notifications 15, 3 and 1 days before expiry" +msgstr "" +"قائمة مفصولة بفواصل بعناوين البريد الإلكترونية لإرسال إشعارات تجديد الإذن " +"قبل 15، 3، و1 يوم من تاريخ انتهاء الصلاحية " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_res_company +msgid "Companies" +msgstr "الشركات" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__company_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__company_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Connect" +msgstr "اتصل" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard +msgid "Connect Bank" +msgstr "توصيل البنك " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_journal_dashboard_inherit_online_sync +msgid "Connect bank" +msgstr "توصيل البنك " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Connect my Bank" +msgstr "توصيل بنكي " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Connect your bank account to Odoo" +msgstr "قم بتوصيل حسابك البنكي بأودو " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__connected +msgid "Connected" +msgstr "متصل " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +msgid "Connected until" +msgstr "متصل حتى " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__renewal_contact_email +msgid "Connection Requests" +msgstr "طلبات الاتصال " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__connection_state_details +msgid "Connection State Details" +msgstr "تفاصيل حالة الاتصال " + +#. module: odex30_account_online_sync +#: model:mail.message.subtype,name:odex30_account_online_sync.bank_sync_consent_renewal +msgid "Consent Renewal" +msgstr "تجديد الإذن " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_res_partner +msgid "Contact" +msgstr "جهة الاتصال" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__create_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__create_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__create_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__create_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__create_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__create_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__currency_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__currency_id +msgid "Currency" +msgstr "العملة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__date +msgid "Date" +msgstr "التاريخ" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_journal__expiring_synchronization_date +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__expiring_synchronization_date +msgid "Date when the consent for this connection expires" +msgstr "تاريخ انتهاء صلاحية إذن هذا الاتصال " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__display_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__display_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__display_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__done +msgid "Done" +msgstr "منتهي " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0 +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__error +msgid "Error" +msgstr "خطأ" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__expiring_synchronization_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__expiring_synchronization_date +msgid "Expiring Synchronization Date" +msgstr "تاريخ انتهاء صلاحية المزامنة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__expiring_synchronization_due_day +msgid "Expiring Synchronization Due Day" +msgstr "التاريخ الذي ستنتهي فيه صلاحية المزامنة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +msgid "Extend Connection" +msgstr "تمديد الإذن " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__account_data +msgid "Extra information needed by third party provider" +msgstr "معلومات إضافية مطلوبة من قِبَل مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form +msgid "Fetch" +msgstr "جلب " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_missing_transaction_wizard_view_form +msgid "Fetch Missing Bank Statements Wizard" +msgstr "معالج جلب كشوفات الحسابات المفقودة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Fetch Transactions" +msgstr "جلب المعاملات " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Fetched Transactions" +msgstr "المعاملات التي تم جلبها " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__online_sync_fetching_status +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__fetching_status +msgid "Fetching Status" +msgstr "حالة الجلب " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "Fetching..." +msgstr "جري جلب... " + +#. module: odex30_account_online_sync +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml:0 +msgid "Find Duplicate Transactions" +msgstr "العثور على المعاملات المكررة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml:0 +msgid "Find Missing Transactions" +msgstr "العثور على المعاملات المفقودة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__first_ids_in_group +msgid "First Ids In Group" +msgstr "المُعرّفات الأولى في المجموعة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_follower_ids +msgid "Followers" +msgstr "المتابعين" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_partner_ids +msgid "Followers (Partners)" +msgstr "المتابعين (الشركاء) " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_type_icon +msgid "Font awesome icon e.g. fa-tasks" +msgstr "أيقونة من Font awesome مثال: fa-tasks " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__foreign_currency_id +msgid "Foreign Currency" +msgstr "عملة أجنبية " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__has_message +msgid "Has Message" +msgstr "يحتوي على رسالة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__has_unlinked_accounts +msgid "Has Unlinked Accounts" +msgstr "يحتوي على حسابات غير مرتبطة " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Here" +msgstr "هنا " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_exception_icon +msgid "Icon" +msgstr "الأيقونة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_exception_icon +msgid "Icon to indicate an exception activity." +msgstr "الأيقونة للإشارة إلى النشاط المستثنى. " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__online_identifier +msgid "Id used to identify account by third party provider" +msgstr "المعرف المستخدَم لتعرف الحساب من قِبَل مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_needaction +msgid "If checked, new messages require your attention." +msgstr "إذا كان محددًا، فهناك رسائل جديدة عليك رؤيتها. " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_error +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_sms_error +msgid "If checked, some messages have a delivery error." +msgstr "إذا كان محددًا، فقد حدث خطأ في تسليم بعض الرسائل." + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__inverse_balance_sign +msgid "If checked, the balance sign will be inverted" +msgstr "إذا كان محدداً، سيتم عكس علامة الرصيد " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__inverse_transaction_sign +msgid "If checked, the transaction sign will be inverted" +msgstr "إذا كان محدداً، سيتم عكس علامة المعاملة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__auto_sync +msgid "" +"If possible, we will try to automatically fetch new transactions for this record\n" +" \n" +"If the automatic sync is disabled. that will be due to security policy on the bank's end. So, they have to launch the sync manually" +msgstr "" +"إذا أمكن، سنحاول جلب المعاملات الجديدة تلقائياً لهذا السجل\n" +" \n" +"إذا كانت خاصية المزامنة التلقائية معطلة. سيكون ذلك بسبب سياسة الأمن التي يفرضها البنك. لذلك، سيتوجب عليهم تشغيل عملية المزامنة يدوياً " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "Import Transactions" +msgstr "استيراد المعاملات " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__provider_data +msgid "Information needed to interact with third party provider" +msgstr "المعلومات المطلوبة للتفاعل مع مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_bank_selection__institution_name +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__name +msgid "Institution Name" +msgstr "اسم المنشأة " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Internal Error" +msgstr "خطأ داخلي " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Invalid URL" +msgstr "رابط URL غير صحيح " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Invalid value for proxy_mode config parameter." +msgstr "قيمة غير صالحة لمعيار تهيئة proxy_mode " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__inverse_balance_sign +msgid "Inverse Balance Sign" +msgstr "علامة عكس الرصيد " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__inverse_transaction_sign +msgid "Inverse Transaction Sign" +msgstr "عكس علامة المعاملة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_is_follower +msgid "Is Follower" +msgstr "متابع" + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_journal +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__journal_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__journal_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__journal_id +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__journal_ids +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__journal_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"Journal %(journal_name)s has been set up with a different currency and " +"already has existing entries. You can't link selected bank account in " +"%(currency_name)s to it" +msgstr "" +"تم إعداد دفتر اليومية %(journal_name)s بعملة مختلفة ويحتوي بالفعل على قيود " +"موجودة. لا يمكنك ربط الحساب البنكي المحدد %(currency_name)s به " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__last_refresh +msgid "Last Refresh" +msgstr "التحديث الأخير" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__write_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__write_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__write_uid +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__write_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__write_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__write_date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Last refresh" +msgstr "آخر تحديث " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__last_sync +msgid "Last synchronization" +msgstr "آخر مزامنة" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Latest Balance" +msgstr "آخر رصيد " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_bank_selection +msgid "Link a bank account to the selected journal" +msgstr " قم بربط حساب بنكي واحد بدفتر اليومية المحدد " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Manual Bank Statement Lines" +msgstr "بنود كشف الحساب البنكي اليدوية " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Message" +msgstr "الرسالة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_error +msgid "Message Delivery error" +msgstr "خطأ في تسليم الرسائل" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_ids +msgid "Messages" +msgstr "الرسائل" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Missing and Pending Transactions" +msgstr "المعاملات المفقودة وقيد الانتظار " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__my_activity_date_deadline +msgid "My Activity Deadline" +msgstr "الموعد النهائي لنشاطاتي " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__institution_name +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__name +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_calendar_event_id +msgid "Next Activity Calendar Event" +msgstr "الفعالية التالية في تقويم الأنشطة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_date_deadline +msgid "Next Activity Deadline" +msgstr "الموعد النهائي للنشاط التالي" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_summary +msgid "Next Activity Summary" +msgstr "ملخص النشاط التالي" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_type_id +msgid "Next Activity Type" +msgstr "نوع النشاط التالي" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__next_refresh +msgid "Next synchronization" +msgstr "المزامنة التالية" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_link__state__disconnected +msgid "Not Connected" +msgstr "غير متصل " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_needaction_counter +msgid "Number of Actions" +msgstr "عدد الإجراءات" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_error_counter +msgid "Number of errors" +msgstr "عدد الأخطاء " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_needaction_counter +msgid "Number of messages requiring action" +msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__message_has_error_counter +msgid "Number of messages with delivery error" +msgstr "عدد الرسائل الحادث بها خطأ في التسليم" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Odoo" +msgstr "أودو" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_account_id +msgid "Online Account" +msgstr "حساب عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Online Accounts" +msgstr "حسابات عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_account__online_identifier +msgid "Online Identifier" +msgstr "معرف عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__next_link_synchronization +msgid "Online Link Next synchronization" +msgstr "ربط المزامنة التالية عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_partner_information +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_res_partner__online_partner_information +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_res_users__online_partner_information +msgid "Online Partner Information" +msgstr "معلومات الشريك عبر الإنترنت " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: model:ir.actions.act_window,name:odex30_account_online_sync.action_account_online_link_form +#: model:ir.ui.menu,name:odex30_account_online_sync.menu_action_online_link_account +#: model_terms:ir.actions.act_window,help:odex30_account_online_sync.action_account_online_link_form +msgid "Online Synchronization" +msgstr "المزامنة عبر الإنترنت " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line__online_transaction_identifier +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__online_transaction_identifier +msgid "Online Transaction Identifier" +msgstr "معرف المعاملات عبر الإنترنت " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_bank_statement.py:0 +msgid "Opening statement: first synchronization" +msgstr "البيان الافتتاحي: المزامنة الأولى" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__partner_name +msgid "Partner Name" +msgstr "اسم الشريك" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__payment_ref +msgid "Payment Ref" +msgstr "مرجع الدفع " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_bank_statement_line_transient__state__pending +msgid "Pending" +msgstr "قيد الانتظار " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__planned +msgid "Planned" +msgstr "المخطط له " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "Please enter a valid Starting Date to continue." +msgstr "يرجى إدخال تاريخ بدء صالح للمتابعة. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Please reconnect your online account." +msgstr "الرجاء إعادة توصيل حسابك عبر الإنترنت. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0 +msgid "Please select first the transactions you wish to import." +msgstr "يرجى أولاً تحديد المعاملات التي ترغب في استيرادها. " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_bank_statement_line_transient__state__posted +msgid "Posted" +msgstr "مُرحّل " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.missing_bank_statement_line_search +msgid "Posted Transactions" +msgstr "المعاملات التي تم ترحيلها " + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__processing +msgid "Processing" +msgstr "معالجة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__provider_data +msgid "Provider Data" +msgstr "بيانات المزود " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__provider_type +msgid "Provider Type" +msgstr "نوع Plaid" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__rating_ids +msgid "Ratings" +msgstr "التقييمات" + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Reconnect" +msgstr "إعادة توصيل " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml:0 +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_journal_dashboard_inherit_online_sync +msgid "Reconnect Bank" +msgstr "إعادة توصيل البنك " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Redirect" +msgstr "إعادة توجيه" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "Refresh" +msgstr "تحديث " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__refresh_token +msgid "Refresh Token" +msgstr "تحديث الرمز" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +msgid "Report Issue" +msgstr "إبلاغ عن إساءة " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Report issue" +msgstr "إبلاغ عن مشكلة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__client_id +msgid "Represent a link for a given user towards a banking institution" +msgstr "تقديم رابط لمستخدم معين إلى منشأة بنكية " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Reset" +msgstr "إعادة الضبط " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__activity_user_id +msgid "Responsible User" +msgstr "المستخدم المسؤول" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__message_has_sms_error +msgid "SMS Delivery error" +msgstr "خطأ في تسليم الرسائل النصية القصيرة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "Search over" +msgstr "البحث " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "" +"Security Tip: always check the domain name of this page, before clicking on " +"the button." +msgstr "" +"نصيحة للأمان: تحقق دائماً من اسم النطاق لهذه الصفحة، عن طريق الضغط على الزر." +" " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "See error" +msgstr "انظر إلى الخطأ " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard +msgid "Select a Bank Account" +msgstr "تحديد حساب بنكي " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard +msgid "Select the" +msgstr "قم بتحديد " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_selection__selected_account +msgid "Selected Account" +msgstr "الحساب المحدد " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__sequence +msgid "Sequence" +msgstr "تسلسل " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_account__account_number +msgid "Set if third party provider has the full account number" +msgstr "التعيين إذا كان لدى مزود الطرف الثالث رقم الحساب الكامل " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "Setup Bank" +msgstr "إعداد البنك " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "Setup Bank Account" +msgstr "إعداد الحساب البنكي " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__show_sync_actions +msgid "Show Sync Actions" +msgstr "عرض إجراءات المزامنة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0 +msgid "Some transactions" +msgstr "بعض المعاملات " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__date +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_missing_transaction_wizard__date +msgid "Starting Date" +msgstr "تاريخ البدء" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__state +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_journal__account_online_link_state +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__state +msgid "State" +msgstr "الحالة " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_state +msgid "" +"Status based on activities\n" +"Overdue: Due date is already passed\n" +"Today: Activity date is today\n" +"Planned: Future activities." +msgstr "" +"الأنشطة المعتمدة على الحالة\n" +"المتأخرة: تاريخ الاستحقاق مر\n" +"اليوم: تاريخ النشاط هو اليوم\n" +"المخطط: الأنشطة المستقبلية." + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "Thank You!" +msgstr "شكرا لك! " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "The consent for the selected account has expired." +msgstr "انتهت صلاحية الإذن للحساب المحدد. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"The online synchronization service is not available at the moment. Please " +"try again later." +msgstr "" +"خدمة المزامنة عبر الإنترنت غير متوفرة في الوقت الحالي. الرجاء المحاولة " +"مجدداً لاحقاً " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__provider_type +msgid "Third Party Provider" +msgstr "مزود الطرف الثالث " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form +msgid "" +"This action will delete all selected transactions. Are you sure you want to " +"proceed?" +msgstr "" +"سيؤدي هذا الإجراء إلى حذف كافة المعاملات المحددة. هل أنت متأكد أنك ترغب في " +"المتابعة؟ " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "This button will reset the fetching status" +msgstr "سيؤدي هذا الزر إلى إعادة ضبط حالة عملية البحث " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "" +"This version of Odoo appears to be outdated and does not support the '%s' " +"sync mode. Installing the latest update might solve this." +msgstr "" +"يبدو أن هذه النسخة من أودو قديمة ولا تدعم وضع المزامنة '%s'. قد يحل تثبيت " +"آخر تحديث هذه المشكلة. " + +#. module: odex30_account_online_sync +#: model_terms:ir.actions.act_window,help:odex30_account_online_sync.action_account_online_link_form +msgid "" +"To create a synchronization with your banking institution,
    \n" +" please click on Add a Bank Account." +msgstr "" +"لإنشاء مزامنة مع المنشأة البنكية،
    \n" +" يرجى الضغط على إضافة حساب بنكي. " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__access_token +msgid "Token used to access API." +msgstr "الرمز المستخدَم للوصول إلى الواجهة البرمجية للتطبيق. " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__refresh_token +msgid "Token used to sign API request, Never disclose it" +msgstr "" +"الرمز المستخدَم للتوقيع على طلب الواجهة البرمجية للتطبيق، لا تقم بالإفصاح " +"عنه أبداً " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_duplicate_transaction_wizard__transaction_ids +msgid "Transaction" +msgstr "معاملة" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_bank_statement_line_transient__transaction_details +msgid "Transaction Details" +msgstr "تفاصيل المعاملة " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_duplicate_transaction_wizard_view_form +msgid "Transactions" +msgstr "المعاملات " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_bank_statement_line_transient +msgid "Transient model for bank statement line" +msgstr "Transient model for bank statement line" + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__has_unlinked_accounts +msgid "" +"True if that connection still has accounts that are not linked to an Odoo " +"journal" +msgstr "" +"تكون القيمة صحيحة إذا كان الاتصال لا يزال يحتوي على حسابات غير مرتبطة بدفتر " +"يومية أودو " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__activity_exception_decoration +msgid "Type of the exception activity on record." +msgstr "نوع النشاط المستثنى في السجل. " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.account_online_link_view_form +msgid "Update Credentials" +msgstr "تحديث بيانات الاعتماد" + +#. module: odex30_account_online_sync +#: model:ir.model.fields.selection,name:odex30_account_online_sync.selection__account_online_account__fetching_status__waiting +msgid "Waiting" +msgstr "قيد الانتظار " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,field_description:odex30_account_online_sync.field_account_online_link__website_message_ids +msgid "Website Messages" +msgstr "رسائل الموقع الإلكتروني " + +#. module: odex30_account_online_sync +#: model:ir.model.fields,help:odex30_account_online_sync.field_account_online_link__website_message_ids +msgid "Website communication history" +msgstr "سجل تواصل الموقع الإلكتروني " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_duplicate_transaction_wizard +msgid "Wizard for duplicate transactions" +msgstr "معالج للمعاملات المكررة " + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_missing_transaction_wizard +msgid "Wizard for missing transactions" +msgstr "معالج للمعاملات المفقودة " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +"You are importing transactions before the creation of your online synchronization\n" +" (" +msgstr "" +"أنت تقوم باستيراد المعاملات قبل إنشاء المزامنة عبر الإنترنت\n" +" (" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "You can contact Odoo support" +msgstr "يمكنك التواصل مع فريق الدعم لدى أودو " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +msgid "You can only execute this action for bank-synchronized journals." +msgstr "لا يمكنك تنفيذ هذا الإجراء إلا لدفاتر اليومية المتزامنة مع البنك. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "" +"You can't find missing transactions for a journal that isn't connected." +msgstr "لا يمكنك العثور على المعاملات المفقودة لدفتر يومية غير متصل. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/models/account_journal.py:0 +#: code:addons/odex30_account_online_sync/models/account_online.py:0 +msgid "You cannot have two journals associated with the same Online Account." +msgstr "لا يمكن أن يكون لديك حسابان مرتبطان بنفس الحساب عبر الإنترنت. " + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_bank_statement_line.py:0 +msgid "You cannot import pending transactions." +msgstr "لا يمكنك استيراد المعاملات المعلقة. " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "You have" +msgstr "لديك" + +#. module: odex30_account_online_sync +#. odoo-python +#: code:addons/odex30_account_online_sync/wizard/account_journal_missing_transactions.py:0 +msgid "You have to select one journal to continue." +msgstr "عليك تحديد دفتر يومية واحد للاستمرار. " + +#. module: odex30_account_online_sync +#: model:mail.template,subject:odex30_account_online_sync.email_template_sync_reminder +msgid "Your bank connection is expiring soon" +msgstr "سوف ينتهي اتصال البنك الخاص بك قريباً " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.view_account_bank_selection_form_wizard +msgid "account to connect:" +msgstr "الحساب لربطه: " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "banks" +msgstr "البنوك " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "entries" +msgstr "القيود" + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml:0 +msgid "loading..." +msgstr "جاري التحميل..." + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml:0 +msgid "may be duplicates." +msgstr "قد تكون عناك نُسخ مكررة " + +#. module: odex30_account_online_sync +#: model_terms:ir.ui.view,arch_db:odex30_account_online_sync.portal_renew_consent +msgid "on" +msgstr "في" + +#. module: odex30_account_online_sync +#: model:ir.model,name:odex30_account_online_sync.model_account_online_account +msgid "representation of an online bank account" +msgstr "تمثيل لحساب بنكي عبر الإنترنت " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml:0 +msgid "transactions fetched" +msgstr "تم جلب المعاملات " + +#. module: odex30_account_online_sync +#. odoo-javascript +#: code:addons/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml:0 +msgid "" +"within this period that were not created using the online synchronization. " +"This might cause duplicate entries." +msgstr "" +"خلال هذه الفترة والتي لم يتم إنشاؤها باستخدام المزامنة عبر الإنترنت. قد " +"يتسبب هذا في استنساخ القيود. " diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py new file mode 100644 index 0000000..fa59fc5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from . import account_bank_statement +from . import account_journal +from . import account_online +from . import company +from . import mail_activity_type +from . import partner +from . import bank_rec_widget diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..807c970 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc new file mode 100644 index 0000000..161301f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_bank_statement.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc new file mode 100644 index 0000000..65ca1e2 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_journal.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc new file mode 100644 index 0000000..eb8eff3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/account_online.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc new file mode 100644 index 0000000..23f8bc4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/bank_rec_widget.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc new file mode 100644 index 0000000..9dba055 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/company.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc new file mode 100644 index 0000000..2aabcb1 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/mail_activity_type.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc new file mode 100644 index 0000000..3a6a04c Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/odoofin_auth.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc new file mode 100644 index 0000000..a501c7f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_online_sync/models/__pycache__/partner.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py new file mode 100644 index 0000000..bade26e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_bank_statement.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +import threading +import time +import json + +from odoo import api, fields, models, SUPERUSER_ID, tools, _ +from odoo.tools import date_utils +from odoo.exceptions import UserError, ValidationError + +STATEMENT_LINE_CREATION_BATCH_SIZE = 500 # When importing transactions, batch the process to commit after importing batch_size + + +class AccountBankStatementLine(models.Model): + _inherit = 'account.bank.statement.line' + + online_transaction_identifier = fields.Char("Online Transaction Identifier", readonly=True) + online_partner_information = fields.Char(readonly=True) + online_account_id = fields.Many2one(comodel_name='account.online.account', readonly=True) + online_link_id = fields.Many2one( + comodel_name='account.online.link', + related='online_account_id.account_online_link_id', + store=True, + readonly=True, + ) + + @api.model_create_multi + def create(self, vals_list): + """ + Some transactions can be marked as "Zero Balancing", + which is a transaction used at the end of the day to summarize all the transactions + of the day. As we already manage the details of all the transactions, this one is not + useful and moreover create duplicates. To deal with that, we cancel the move and so + the bank statement line. + """ + # EXTEND account + bank_statement_lines = super().create(vals_list) + moves_to_cancel = self.env['account.move'] + for bank_statement_line in bank_statement_lines: + transaction_details = json.loads(bank_statement_line.transaction_details) if bank_statement_line.transaction_details else {} + if not transaction_details.get('is_zero_balancing'): + continue + moves_to_cancel |= bank_statement_line.move_id + moves_to_cancel.button_cancel() + + return bank_statement_lines + + @api.model + def _online_sync_bank_statement(self, transactions, online_account): + """ + build bank statement lines from a list of transaction and post messages is also post in the online_account of the journal. + :param transactions: A list of transactions that will be created. + The format is : [{ + 'id': online id, (unique ID for the transaction) + 'date': transaction date, (The date of the transaction) + 'name': transaction description, (The description) + 'amount': transaction amount, (The amount of the transaction. Negative for debit, positive for credit) + }, ...] + :param online_account: The online account for this statement + Return: The number of imported transaction for the journal + """ + start_time = time.time() + lines_to_reconcile = self.env['account.bank.statement.line'] + try: + for journal in online_account.journal_ids: + # Since the synchronization succeeded, set it as the bank_statements_source of the journal + journal.sudo().write({'bank_statements_source': 'online_sync'}) + if not transactions: + continue + + sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date']) + total = self.env.context.get('transactions_total') or sum([transaction['amount'] for transaction in transactions]) + + # For first synchronization, an opening line is created to fill the missing bank statement data + any_st_line = self.search_count([('journal_id', '=', journal.id)], limit=1) + journal_currency = journal.currency_id or journal.company_id.currency_id + # If there are neither statement and the ending balance != 0, we create an opening bank statement at the day of the oldest transaction. + # We set the sequence to >1 to ensure the computed internal_index will force its display before any other statement with the same date. + if not any_st_line and not journal_currency.is_zero(online_account.balance - total): + opening_st_line = self.with_context(skip_statement_line_cron_trigger=True).create({ + 'date': sorted_transactions[0]['date'], + 'journal_id': journal.id, + 'payment_ref': _("Opening statement: first synchronization"), + 'amount': online_account.balance - total, + 'sequence': 2, + }) + lines_to_reconcile += opening_st_line + + filtered_transactions = online_account._get_filtered_transactions(sorted_transactions) + + do_commit = not (hasattr(threading.current_thread(), 'testing') and threading.current_thread().testing) + if filtered_transactions: + # split transactions import in batch and commit after each batch except in testing mode + for index in range(0, len(filtered_transactions), STATEMENT_LINE_CREATION_BATCH_SIZE): + lines_to_reconcile += self.with_user(SUPERUSER_ID).with_company(journal.company_id).with_context(skip_statement_line_cron_trigger=True).create(filtered_transactions[index:index + STATEMENT_LINE_CREATION_BATCH_SIZE]) + if do_commit: + self.env.cr.commit() + # Set last sync date as the last transaction date + journal.account_online_account_id.sudo().write({'last_sync': filtered_transactions[-1]['date']}) + + if lines_to_reconcile: + # 'limit_time_real_cron' defaults to -1. + # Manual fallback applied for non-POSIX systems where this key is disabled (set to None). + cron_limit_time = tools.config['limit_time_real_cron'] or -1 + limit_time = (cron_limit_time if cron_limit_time > 0 else 180) - (time.time() - start_time) + if limit_time > 0: + lines_to_reconcile._cron_try_auto_reconcile_statement_lines(limit_time=limit_time) + # Catch any configuration error that would prevent creating the entries, reset fetching_status flag and re-raise the error + # Otherwise flag is never reset and user is under the impression that we are still fetching transactions + except (UserError, ValidationError) as e: + self.env.cr.rollback() + online_account.account_online_link_id._log_information('error', subject=_("Error"), message=str(e)) + self.env.cr.commit() + raise + return lines_to_reconcile diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py new file mode 100644 index 0000000..afb0777 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_journal.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- + +import logging +import requests +from dateutil.relativedelta import relativedelta +from requests.exceptions import RequestException, Timeout + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError, ValidationError, RedirectWarning +from odoo.tools import SQL + +_logger = logging.getLogger(__name__) + + +class AccountJournal(models.Model): + _inherit = "account.journal" + + def __get_bank_statements_available_sources(self): + rslt = super(AccountJournal, self).__get_bank_statements_available_sources() + rslt.append(("online_sync", _("Online Synchronization"))) + return rslt + + next_link_synchronization = fields.Datetime("Online Link Next synchronization", related='account_online_link_id.next_refresh') + expiring_synchronization_date = fields.Date(related='account_online_link_id.expiring_synchronization_date') + expiring_synchronization_due_day = fields.Integer(compute='_compute_expiring_synchronization_due_day') + account_online_account_id = fields.Many2one('account.online.account', copy=False, ondelete='set null') + account_online_link_id = fields.Many2one('account.online.link', related='account_online_account_id.account_online_link_id', readonly=True, store=True) + account_online_link_state = fields.Selection(related="account_online_link_id.state", readonly=True) + renewal_contact_email = fields.Char( + string='Connection Requests', + help='Comma separated list of email addresses to send consent renewal notifications 15, 3 and 1 days before expiry', + default=lambda self: self.env.user.email, + ) + online_sync_fetching_status = fields.Selection(related="account_online_account_id.fetching_status", readonly=True) + + def write(self, vals): + # When changing the bank_statement_source, unlink the connection if there is any + if 'bank_statements_source' in vals and vals.get('bank_statements_source') != 'online_sync': + for journal in self: + if journal.bank_statements_source == 'online_sync': + # unlink current connection + vals['account_online_account_id'] = False + journal.account_online_link_id.has_unlinked_accounts = True + return super().write(vals) + + @api.depends('expiring_synchronization_date') + def _compute_expiring_synchronization_due_day(self): + for record in self: + if record.expiring_synchronization_date: + due_day_delta = record.expiring_synchronization_date - fields.Date.context_today(record) + record.expiring_synchronization_due_day = due_day_delta.days + else: + record.expiring_synchronization_due_day = 0 + + def _fill_bank_cash_dashboard_data(self, dashboard_data): + super()._fill_bank_cash_dashboard_data(dashboard_data) + # Caching data to avoid one call per journal + self.browse(list(dashboard_data.keys())).fetch(['type', 'account_online_account_id']) + for journal_id, journal_data in dashboard_data.items(): + journal = self.browse(journal_id) + journal_data['display_connect_bank_in_dashboard'] = journal.type in ('bank', 'credit') \ + and not journal.account_online_account_id \ + and journal.company_id.id == self.env.company.id + + @api.constrains('account_online_account_id') + def _check_account_online_account_id(self): + for journal in self: + if len(journal.account_online_account_id.journal_ids) > 1: + raise ValidationError(_('You cannot have two journals associated with the same Online Account.')) + + def _fetch_online_transactions(self): + for journal in self: + try: + journal.account_online_link_id._pop_connection_state_details(journal=journal) + journal.manual_sync() + # for cron jobs it is usually recommended committing after each iteration, + # so that a later error or job timeout doesn't discard previous work + self.env.cr.commit() + except (UserError, RedirectWarning): + # We need to rollback here otherwise the next iteration will still have the error when trying to commit + self.env.cr.rollback() + + def fetch_online_sync_favorite_institutions(self): + self.ensure_one() + timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.request_timeout')) or 60 + endpoint_url = self.env['account.online.link']._get_odoofin_url('/proxy/v1/get_dashboard_institutions') + params = {'country': self.sudo().company_id.account_fiscal_country_id.code, 'limit': 28} + try: + resp = requests.post(endpoint_url, json=params, timeout=timeout) + resp_dict = resp.json()['result'] + for institution in resp_dict: + if institution['picture'].startswith('/'): + institution['picture'] = self.env['account.online.link']._get_odoofin_url(institution['picture']) + return resp_dict + except (Timeout, ConnectionError, RequestException, ValueError) as e: + _logger.warning(e) + return [] + + @api.model + def _cron_fetch_waiting_online_transactions(self): + """ This method is only called when the user fetch transactions asynchronously. + We only fetch transactions on synchronizations that are in "waiting" status. + Once the synchronization is done, the status is changed for "done". + We have to that to avoid having too much logic in the same cron function to do + 2 different things. This cron should only be used for asynchronous fetchs. + """ + + # 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120. + # Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None). + limit_time = tools.config['limit_time_real_cron'] or -1 + if limit_time <= 0: + limit_time = tools.config['limit_time_real'] or 120 + journals = self.search([ + '|', + ('online_sync_fetching_status', 'in', ('planned', 'waiting')), + '&', + ('online_sync_fetching_status', '=', 'processing'), + ('account_online_link_id.last_refresh', '<', fields.Datetime.now() - relativedelta(seconds=limit_time)), + ]) + journals.with_context(cron=True)._fetch_online_transactions() + + @api.model + def _cron_fetch_online_transactions(self): + """ This method is called by the cron (by default twice a day) to fetch (for all journals) + the new transactions. + """ + journals = self.search([('account_online_account_id', '!=', False)]) + journals.with_context(cron=True)._fetch_online_transactions() + + @api.model + def _cron_send_reminder_email(self): + for journal in self.search([('account_online_account_id', '!=', False)]): + if journal.expiring_synchronization_due_day in {1, 3, 15}: + journal.action_send_reminder() + + def manual_sync(self): + self.ensure_one() + if self.account_online_link_id: + account = self.account_online_account_id + return self.account_online_link_id._fetch_transactions(accounts=account) + + def unlink(self): + """ + Override of the unlink method. + That's useful to unlink account.online.account too. + """ + if self.account_online_account_id: + self.account_online_account_id.unlink() + return super(AccountJournal, self).unlink() + + def action_configure_bank_journal(self): + """ + Override the "action_configure_bank_journal" and change the flow for the + "Configure" button in dashboard. + """ + self.ensure_one() + return self.env['account.online.link'].action_new_synchronization() + + def action_open_account_online_link(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': self.account_online_link_id.name, + 'res_model': 'account.online.link', + 'target': 'main', + 'view_mode': 'form', + 'views': [[False, 'form']], + 'res_id': self.account_online_link_id.id, + } + + def action_extend_consent(self): + """ + Extend the consent of the user by redirecting him to update his credentials + """ + self.ensure_one() + return self.account_online_link_id._open_iframe( + mode='updateCredentials', + include_param={ + 'account_online_identifier': self.account_online_account_id.online_identifier, + }, + ) + + def action_reconnect_online_account(self): + self.ensure_one() + return self.account_online_link_id.action_reconnect_account() + + def action_send_reminder(self): + self.ensure_one() + self._portal_ensure_token() + template = self.env.ref('odex30_account_online_sync.email_template_sync_reminder') + subtype = self.env.ref('odex30_account_online_sync.bank_sync_consent_renewal') + self.message_post_with_source(source_ref=template, subtype_id=subtype.id) + + def action_open_missing_transaction_wizard(self): + """ This method allows to open the wizard to fetch the missing + transactions and the pending ones. + Depending on where the function is called, we'll receive + one journal or none of them. + If we receive more or less than one journal, we do not set + it on the wizard, the user should select it by himself. + + :return: An action opening the wizard. + """ + journal_id = None + if len(self) == 1: + if not self.account_online_account_id or self.account_online_link_state != 'connected': + raise UserError(_("You can't find missing transactions for a journal that isn't connected.")) + + journal_id = self.id + + wizard = self.env['account.missing.transaction.wizard'].create({'journal_id': journal_id}) + return { + 'name': _("Find Missing Transactions"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.missing.transaction.wizard', + 'res_id': wizard.id, + 'views': [(False, 'form')], + 'target': 'new', + } + + def action_open_duplicate_transaction_wizard(self, from_date=None): + """ This method allows to open the wizard to find duplicate transactions. + :param from_date: date from with we must check for duplicates. + + :return: An action opening the wizard. + """ + wizard = self.env['account.duplicate.transaction.wizard'].create({ + 'journal_id': self.id if len(self) == 1 else None, + **({'date': from_date} if from_date else {}), + }) + return wizard._get_records_action(name=_("Find Duplicate Transactions")) + + def _has_duplicate_transactions(self, date_from): + """ Has any transaction with + - same amount & + - same date & + - same account number + We do not check on online_transaction_identifier because this is called after the fetch + where transitions would already have been filtered on existing online_transaction_identifier. + + :param from_date: date from with we must check for duplicates. + """ + self.env.cr.execute(SQL.join(SQL(''), [ + self._get_duplicate_amount_date_account_transactions_query(date_from), + SQL('LIMIT 1'), + ])) + return bool(self.env.cr.rowcount) + + def _get_duplicate_transactions(self, date_from): + """Find all transaction with + - same amount & + - same date & + - same account number + or + - same transaction id + + :param from_date: date from with we must check for duplicates. + """ + query = SQL.join(SQL(''), [ + self._get_duplicate_amount_date_account_transactions_query(date_from), + SQL('UNION'), + self._get_duplicate_online_transaction_identifier_transactions_query(date_from), + SQL('ORDER BY ids'), + ]) + return [res[0] for res in self.env.execute_query(query)] + + def _get_duplicate_amount_date_account_transactions_query(self, date_from): + self.ensure_one() + return SQL(''' + SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids + FROM account_bank_statement_line st_line + JOIN account_move move ON move.id = st_line.move_id + WHERE st_line.journal_id = %(journal_id)s AND move.date >= %(date_from)s + GROUP BY st_line.currency_id, st_line.amount, st_line.account_number, move.date + HAVING count(st_line.id) > 1 + ''', + journal_id=self.id, + date_from=date_from, + ) + + def _get_duplicate_online_transaction_identifier_transactions_query(self, date_from): + return SQL(''' + SELECT ARRAY_AGG(st_line.id ORDER BY st_line.id) AS ids + FROM account_bank_statement_line st_line + JOIN account_move move ON move.id = st_line.move_id + WHERE st_line.journal_id = %(journal_id)s AND + move.date >= %(prior_date)s AND + st_line.online_transaction_identifier IS NOT NULL + GROUP BY st_line.online_transaction_identifier + HAVING count(st_line.id) > 1 AND BOOL_OR(move.date >= %(date_from)s) -- at least one date is > date_from + ''', + journal_id=self.id, + date_from=date_from, + prior_date=date_from - relativedelta(months=3), # allow 1 of duplicate statements to be older than "from" date + ) + + def action_open_dashboard_asynchronous_action(self): + """ This method allows to open action asynchronously + during the fetching process. + When a user clicks on the Fetch Transactions button in + the dashboard, we fetch the transactions asynchronously + and save connection state details on the synchronization. + This action allows the user to open the action saved in + the connection state details. + """ + self.ensure_one() + + if not self.account_online_account_id: + raise UserError(_("You can only execute this action for bank-synchronized journals.")) + + connection_state_details = self.account_online_link_id._pop_connection_state_details(journal=self) + if connection_state_details and connection_state_details.get('action'): + if connection_state_details.get('error_type') == 'redirect_warning': + self.env.cr.commit() + raise RedirectWarning(connection_state_details['error_message'], connection_state_details['action'], _('Report Issue')) + else: + return connection_state_details['action'] + + return {'type': 'ir.actions.client', 'tag': 'soft_reload'} + + def _get_journal_dashboard_data_batched(self): + dashboard_data = super()._get_journal_dashboard_data_batched() + for journal in self.filtered(lambda j: j.type in ('bank', 'credit')): + if journal.account_online_account_id: + if journal.company_id.id not in self.env.companies.ids: + continue + connection_state_details = journal.account_online_link_id._get_connection_state_details(journal=journal) + if not connection_state_details and journal.account_online_account_id.fetching_status in ('waiting', 'processing'): + connection_state_details = {'status': 'fetching'} + dashboard_data[journal.id]['connection_state_details'] = connection_state_details + dashboard_data[journal.id]['show_sync_actions'] = journal.account_online_link_id.show_sync_actions + return dashboard_data + + def get_related_connection_state_details(self): + """ This method allows JS widget to get the last connection state details + It's useful if the user wasn't on the dashboard when we send the message + by websocket that the asynchronous flow is finished. + In case we don't have a connection state details and if the fetching + status is set on "waiting" or "processing". We're returning that the sync + is currently fetching. + """ + self.ensure_one() + connection_state_details = self.account_online_link_id._get_connection_state_details(journal=self) + if not connection_state_details and self.account_online_account_id.fetching_status in ('waiting', 'processing'): + connection_state_details = {'status': 'fetching'} + return connection_state_details + + def _consume_connection_state_details(self): + self.ensure_one() + if self.account_online_link_id and self.env.user.has_group('account.group_account_manager'): + # In case we have a bank synchronization connected to the journal + # we want to remove the last connection state because it means that we + # have "mark as read" this state, and we don't want to display it again to + # the user. + self.account_online_link_id._pop_connection_state_details(journal=self) + + def open_action(self): + # Extends 'account_accountant' + if not self._context.get('action_name') and self.type == 'bank' and self.bank_statements_source == 'online_sync': + self._consume_connection_state_details() + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={'search_default_journal_id': self.id}, + ) + return super().open_action() + + def action_open_reconcile(self): + # Extends 'account_accountant' + self._consume_connection_state_details() + return super().action_open_reconcile() + + def action_open_bank_transactions(self): + # Extends 'account_accountant' + self._consume_connection_state_details() + return super().action_open_bank_transactions() + + @api.model + def _toggle_asynchronous_fetching_cron(self): + cron = self.env.ref('odex30_account_online_sync.online_sync_cron_waiting_synchronization', raise_if_not_found=False) + if cron: + cron.sudo().toggle(model=self._name, domain=[('account_online_account_id', '!=', False)]) diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py b/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py new file mode 100644 index 0000000..63554d4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/account_online.py @@ -0,0 +1,1170 @@ +# -*- coding: utf-8 -*- + +import base64 +import datetime +import requests +import logging +import re +import uuid +import urllib.parse +import odoo +import odoo.release +from dateutil.relativedelta import relativedelta +from markupsafe import Markup + +from requests.exceptions import RequestException, Timeout, ConnectionError +from odoo import api, fields, models, modules, tools +from odoo.exceptions import UserError, CacheMiss, MissingError, ValidationError, RedirectWarning +from odoo.http import request +from odoo.addons.odex30_account_online_sync.models.odoofin_auth import OdooFinAuth +from odoo.tools.misc import format_amount, format_date, get_lang +from odoo.tools import _, LazyTranslate + +_lt = LazyTranslate(__name__) +_logger = logging.getLogger(__name__) +pattern = re.compile("^[a-z0-9-_]+$") +runbot_pattern = re.compile(r"^https:\/\/[a-z0-9-_]+\.[a-z0-9-_]+\.odoo\.com$") + +class OdooFinRedirectException(UserError): + """ When we need to open the iframe in a given mode. """ + + def __init__(self, message=_lt('Redirect'), mode='link'): + self.mode = mode + super().__init__(message) + +class AccountOnlineAccount(models.Model): + _name = 'account.online.account' + _description = 'representation of an online bank account' + + name = fields.Char(string="Account Name", help="Account Name as provided by third party provider") + online_identifier = fields.Char(help='Id used to identify account by third party provider', readonly=True) + balance = fields.Float(readonly=True, help='Balance of the account sent by the third party provider') + account_number = fields.Char(help='Set if third party provider has the full account number') + account_data = fields.Char(help='Extra information needed by third party provider', readonly=True) + + account_online_link_id = fields.Many2one('account.online.link', readonly=True, ondelete='cascade') + journal_ids = fields.One2many('account.journal', 'account_online_account_id', string='Journal', domain="[('type', 'in', ('bank', 'credit')), ('company_id', '=', company_id)]") + last_sync = fields.Date("Last synchronization") + company_id = fields.Many2one('res.company', related='account_online_link_id.company_id') + currency_id = fields.Many2one('res.currency') + fetching_status = fields.Selection( + selection=[ + ('planned', 'Planned'), # When all the transactions couldn't be imported in one go and is waiting for next batch + ('waiting', 'Waiting'), # When waiting for the provider to fetch the transactions + ('processing', 'Processing'), # When currently importing in odoo + ('done', 'Done'), # When every transaction have been imported in odoo + ] + ) + + inverse_balance_sign = fields.Boolean( + string="Inverse Balance Sign", + help="If checked, the balance sign will be inverted", + ) + inverse_transaction_sign = fields.Boolean( + string="Inverse Transaction Sign", + help="If checked, the transaction sign will be inverted", + ) + + @api.constrains('journal_ids') + def _check_journal_ids(self): + for online_account in self: + if len(online_account.journal_ids) > 1: + raise ValidationError(_('You cannot have two journals associated with the same Online Account.')) + + @api.model_create_multi + def create(self, vals): + result = super().create(vals) + if any(data.get('fetching_status') in {'waiting', 'processing', 'planned'} for data in vals): + self.env['account.journal']._toggle_asynchronous_fetching_cron() + return result + + def write(self, vals): + result = super().write(vals) + if vals.get('fetching_status') in {'waiting', 'processing', 'planned'}: + self.env['account.journal']._toggle_asynchronous_fetching_cron() + return result + + def unlink(self): + result = super().unlink() + self.env['account.journal']._toggle_asynchronous_fetching_cron() + return result + + def _assign_journal(self, swift_code=False): + """ + This method allows to link an online account to a journal with the following heuristics + Also, Create and assign bank & swift/bic code if odoofin returns one + If a journal is present in the context (active_model = account.journal and active_id), we assume that + We started the journey from a journal and we assign the online_account to that particular journal. + Otherwise we will create a new journal on the fly and assign the online_account to it. + If an online_account was previously set on the journal, it will be removed and deleted. + This will also set the 'online_sync' source on the journal and create an activity for the consent renewal + The date to fetch transaction will also be set and have the following value: + date of the latest statement line on the journal + or date of the fiscalyear lock date + or False (we fetch transactions as far as possible) + """ + currency_id = self.currency_id.id if not self.currency_id.is_current_company_currency else False + existing_journal = self.env['account.journal'].search([ + ('bank_acc_number', '=', self.account_number), + ('currency_id', '=', currency_id), + ('type', '=', 'bank'), + ('account_online_account_id', '=', False), + ], limit=1) + + self.ensure_one() + if (active_id := self.env.context.get('active_id')) and self.env.context.get('active_model') == 'account.journal': + journal = self.env['account.journal'].browse(active_id) + # If we already have a linked account on that journal, it means we are in the process of relinking + # it is due to an error that occured which require to redo the connection (can't fix it). + # Hence we delete the previously linked account.online.link to prevent showing multiple + # duplicate existing connections when opening the iframe + if journal.account_online_link_id: + journal.account_online_link_id.unlink() + + # Ensure the journal's currency matches the bank account's currency. + if self.currency_id.id != journal.currency_id.id: + # If the journal already has entries in a different currency, raise an error. + statement_lines_in_other_currency = self.env['account.bank.statement.line'].search_count([ + ('journal_id', '=', journal.id), + ('currency_id', 'not in', (False, self.currency_id.id)), + ], limit=1) + if statement_lines_in_other_currency: + raise UserError(_("Journal %(journal_name)s has been set up with a different currency and already has existing entries. " + "You can't link selected bank account in %(currency_name)s to it", + journal_name=journal.name, currency_name=self.currency_id.name)) + else: + # If the journal's default bank account has entries in a differente currency, silently do nothing to avoid an error. + move_lines_in_other_currency = self.env['account.move.line'].search_count([ + ('account_id', '=', journal.default_account_id.id), + ('currency_id', '!=', self.currency_id.id), + ], limit=1) + if not move_lines_in_other_currency: + # If not set yet and there are no conflicting entries, set it. + journal.sudo().currency_id = self.currency_id.id + elif existing_journal: + journal = existing_journal + else: + journal = self.env['account.journal'].create({ + 'name': self.account_number or self.display_name, + 'code': self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company), + 'type': 'bank', + 'company_id': self.env.company.id, + 'currency_id': currency_id, + }) + + self.sudo().journal_ids = journal + + journal_vals = { + 'bank_statements_source': 'online_sync', + } + if self.account_number and not self.journal_ids.bank_acc_number: + journal_vals['bank_acc_number'] = self.account_number + self.journal_ids.sudo().write(journal_vals) + # Update connection status and get consent expiration date and create an activity on related journal + self.account_online_link_id._update_connection_status() + + # Set last_sync date (date of latest statement or one day after accounting lock date or False) + lock_date = self.env.company._get_user_fiscal_lock_date(journal) + last_sync = lock_date + relativedelta(days=1) if lock_date and lock_date > datetime.date.min else None + bnk_stmt_line = self.env['account.bank.statement.line'].search([('journal_id', 'in', self.journal_ids.ids)], order="date desc", limit=1) + if bnk_stmt_line: + last_sync = bnk_stmt_line.date + self.last_sync = last_sync + + if swift_code: + if self.journal_ids.bank_account_id.bank_id: + if not self.journal_ids.bank_account_id.bank_id.bic: + self.journal_ids.bank_account_id.bank_id.bic = swift_code + else: + bank_rec = self.env['res.bank'].search([('bic', '=', swift_code)], limit=1) + if not bank_rec: + bank_rec = self.env['res.bank'].create({'name': self.account_online_link_id.display_name, 'bic': swift_code}) + self.journal_ids.bank_account_id.bank_id = bank_rec.id + + def _refresh(self): + """ + This method is called on an online_account in order to check the current refresh status of the + account. If we are in manual mode and if the provider allows it, this will also trigger a + manual refresh on the provider side. Call to /proxy/v1/refresh will return a boolean + telling us if the refresh was successful or not. When not successful, we should avoid + trying to fetch transactions. Cases where we can receive an unsuccessful response are as follow + (non exhaustive list) + - Another refresh was made too early and provider/bank limit the number of refresh allowed + - Provider is in the process of importing the transactions so we should wait until he has + finished before fetching them in Odoo + :return: True if provider has refreshed the account and we can start fetching transactions + """ + data = {'account_id': self.online_identifier} + while True: + # While this is kind of a bad practice to do, it can happen that provider_data/account_data change between + # 2 calls, the reason is that those field contains the encrypted information needed to access the provider + # and first call can result in an error due to the encrypted token inside provider_data being expired for example. + # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data + # which result in the information having changed, henceforth why those fields are passed at every loop. + data.update({ + 'provider_data': self.account_online_link_id.provider_data, + 'account_data': self.account_data, + 'fetching_status': self.fetching_status, + }) + resp_json = self.account_online_link_id._fetch_odoo_fin('/proxy/v1/refresh', data=data) + if resp_json.get('account_data'): + self.account_data = resp_json['account_data'] + currently_fetching = resp_json.get('currently_fetching') + success = resp_json.get('success', True) + if currently_fetching: + # Provider has not finished fetching transactions, set status to waiting + self.fetching_status = 'waiting' + if not resp_json.get('next_data'): + break + data['next_data'] = resp_json.get('next_data') or {} + return {'success': not currently_fetching and success, 'data': resp_json.get('data', {})} + + def _retrieve_transactions(self, date=None, include_pendings=False): + last_stmt_line = self.env['account.bank.statement.line'].search([ + ('date', '<=', self.last_sync or fields.Date().today()), + ('online_transaction_identifier', '!=', False), + ('journal_id', 'in', self.journal_ids.ids), + ('online_account_id', '=', self.id) + ], order="date desc", limit=1) + transactions = [] + + start_date = date or last_stmt_line.date or self.last_sync + data = { + # If we are in a new sync, we do not give a start date; We will fetch as much as possible. Otherwise, the last sync is the start date. + 'start_date': start_date and format_date(self.env, start_date, date_format='yyyy-MM-dd'), + 'account_id': self.online_identifier, + 'last_transaction_identifier': last_stmt_line.online_transaction_identifier if not include_pendings else None, + 'currency_code': self.currency_id.name or self.journal_ids[0].currency_id.name or self.company_id.currency_id.name, + 'include_pendings': include_pendings, + 'include_foreign_currency': True, + } + pendings = [] + while True: + # While this is kind of a bad practice to do, it can happen that provider_data/account_data change between + # 2 calls, the reason is that those field contains the encrypted information needed to access the provider + # and first call can result in an error due to the encrypted token inside provider_data being expired for example. + # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data + # which result in the information having changed, henceforth why those fields are passed at every loop. + data.update({ + 'provider_data': self.account_online_link_id.provider_data, + 'account_data': self.account_data, + }) + resp_json = self.account_online_link_id._fetch_odoo_fin('/proxy/v1/transactions', data=data) + if resp_json.get('balance'): + sign = -1 if self.inverse_balance_sign else 1 + self.balance = sign * resp_json['balance'] + if resp_json.get('account_data'): + self.account_data = resp_json['account_data'] + transactions += resp_json.get('transactions', []) + pendings += resp_json.get('pendings', []) + if not resp_json.get('next_data'): + break + data['next_data'] = resp_json.get('next_data') or {} + + return { + 'transactions': self._format_transactions(transactions), + 'pendings': self._format_transactions(pendings), + } + + def get_formatted_balances(self): + balances = {} + for account in self: + if account.currency_id: + formatted_balance = format_amount(self.env, account.balance, account.currency_id) + else: + formatted_balance = '%.2f' % account.balance + balances[account.id] = [formatted_balance, account.balance] + return balances + + ########### + # HELPERS # + ########### + + def _get_filtered_transactions(self, new_transactions): + """ This function will filter transaction to avoid duplicate transactions. + To do that, we're comparing the received online_transaction_identifier with + those in the database. If there is a match, the new transaction is ignored. + """ + self.ensure_one() + + journal_id = self.journal_ids[0] + existing_bank_statement_lines = self.env['account.bank.statement.line'].search_fetch( + [ + ('journal_id', '=', journal_id.id), + ('online_transaction_identifier', 'in', [ + transaction['online_transaction_identifier'] + for transaction in new_transactions + if transaction.get('online_transaction_identifier') + ]), + ], + ['online_transaction_identifier'] + ) + existing_online_transaction_identifier = set(existing_bank_statement_lines.mapped('online_transaction_identifier')) + + filtered_transactions = [] + # Remove transactions already imported in Odoo + for transaction in new_transactions: + if transaction_identifier := transaction['online_transaction_identifier']: + if transaction_identifier in existing_online_transaction_identifier: + continue + existing_online_transaction_identifier.add(transaction_identifier) + + filtered_transactions.append(transaction) + return filtered_transactions + + def _format_transactions(self, new_transactions): + """ This function format transactions: + It will: + - Replace the foreign currency code with the corresponding currency id and activating the currencies that are not active + - Change inverse the transaction sign if the setting is activated + - Parsing the date + - Setting the account online account and the account journal + """ + self.ensure_one() + transaction_sign = -1 if self.inverse_transaction_sign else 1 + currencies = self.env['res.currency'].with_context(active_test=False).search([]) + currency_code_mapping = {currency.name: currency for currency in currencies} + + formatted_transactions = [] + for transaction in new_transactions: + if transaction.get('foreign_currency_code'): + currency = currency_code_mapping.get(transaction.pop('foreign_currency_code')) + if currency: + transaction.update({'foreign_currency_id': currency.id}) + if not currency.active: + currency.active = True + + formatted_transactions.append({ + **transaction, + 'amount': transaction['amount'] * transaction_sign, + 'date': fields.Date.from_string(transaction['date']), + 'online_account_id': self.id, + 'journal_id': self.journal_ids[0].id, + 'company_id': self.company_id.id, + }) + return formatted_transactions + + def action_reset_fetching_status(self): + """ + This action will reset the fetching status to avoid the problem when there is an error during the + synchronisation that would block the customer with his connection since we block the fetch due that value. + With this he has a button that can reset the fetching status. + """ + self.fetching_status = None + + +class AccountOnlineLink(models.Model): + _name = 'account.online.link' + _description = 'Bank Connection' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + def _compute_next_synchronization(self): + for rec in self: + rec.next_refresh = self.env['ir.cron'].sudo().search([('id', '=', self.env.ref('odex30_account_online_sync.online_sync_cron').id)], limit=1).nextcall + + account_online_account_ids = fields.One2many('account.online.account', 'account_online_link_id') + last_refresh = fields.Datetime(readonly=True, default=fields.Datetime.now) + next_refresh = fields.Datetime("Next synchronization", compute='_compute_next_synchronization') + state = fields.Selection([('connected', 'Connected'), ('error', 'Error'), ('disconnected', 'Not Connected')], + default='disconnected', tracking=True, required=True, readonly=True) + connection_state_details = fields.Json() + auto_sync = fields.Boolean( + default=True, + string="Automatic synchronization", + help="""If possible, we will try to automatically fetch new transactions for this record + \nIf the automatic sync is disabled. that will be due to security policy on the bank's end. So, they have to launch the sync manually""", + ) + company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company) + has_unlinked_accounts = fields.Boolean(default=True, help="True if that connection still has accounts that are not linked to an Odoo journal") + show_sync_actions = fields.Boolean(compute='_compute_show_sync_actions') + + # Information received from OdooFin, should not be tampered with + name = fields.Char(help="Institution Name", readonly=True) + client_id = fields.Char(help="Represent a link for a given user towards a banking institution", readonly=True) + refresh_token = fields.Char(help="Token used to sign API request, Never disclose it", + readonly=True, groups="base.group_system") + access_token = fields.Char(help="Token used to access API.", readonly=True, groups="account.group_account_basic") + provider_data = fields.Char(help="Information needed to interact with third party provider", readonly=True) + expiring_synchronization_date = fields.Date(help="Date when the consent for this connection expires", + readonly=True) + journal_ids = fields.One2many('account.journal', compute='_compute_journal_ids') + provider_type = fields.Char(help="Third Party Provider", readonly=True) + + ################### + # Compute methods # + ################### + + @api.depends('account_online_account_ids') + def _compute_journal_ids(self): + for online_link in self: + online_link.journal_ids = online_link.account_online_account_ids.journal_ids + + @api.depends('company_id') + @api.depends_context('allowed_company_ids') + def _compute_show_sync_actions(self): + for online_link in self: + online_link.show_sync_actions = online_link.company_id in self.env.companies + + ########################## + # Wizard opening actions # + ########################## + def create_new_bank_account_action(self, data=None): + self.ensure_one() + # We do return the bank account setup wizard if we don't have minimum info + if not data or not data.get('account_number'): + ctx = self.env.context + # if this was called from kanban box, active_model is in context + if ctx.get('active_model') == 'account.journal': + ctx = {**ctx, 'default_linked_journal_id': ctx.get('active_id', False), 'dialog_size': 'medium'} + return { + 'type': 'ir.actions.act_window', + 'name': _('Setup Bank Account'), + 'res_model': 'account.setup.bank.manual.config', + 'target': 'new', + 'view_mode': 'form', + 'context': ctx, + 'views': [(False, 'form')], + } + + bank = self.env['res.bank'] + if data.get('name'): + bank = self.env['res.bank'].sudo().create({ + 'name': data['name'], + 'bic': data.get('swift_code'), + }) + + bank_account = self.env['res.partner.bank'].sudo().create({ + 'acc_number': data.get('account_number'), + 'bank_id': bank.id, + 'partner_id': self.company_id.partner_id.id, + }) + + self.env['account.journal'].sudo().create({ + 'name': data.get('account_number'), + 'type': data.get('journal_type') or 'bank', + 'bank_account_id': bank_account.id, + }) + + return {'type': 'ir.actions.client', 'tag': 'soft_reload'} + + def _link_accounts_to_journals_action(self, swift_code): + """ + This method opens a wizard allowing the user to link + his bank accounts with new or existing journal. + :return: An action openning a wizard to link bank accounts with account journal. + """ + self.ensure_one() + account_bank_selection_wizard = self.env['account.bank.selection'].create({ + 'account_online_link_id': self.id, + }) + + return { + "name": _("Select a Bank Account"), + "type": "ir.actions.act_window", + "res_model": "account.bank.selection", + "views": [[False, "form"]], + "target": "new", + "res_id": account_bank_selection_wizard.id, + 'context': dict(self.env.context, swift_code=swift_code), + } + + @api.model + def _show_fetched_transactions_action(self, stmt_line_ids, duplicates_from_date): + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=[('id', 'in', stmt_line_ids.ids)], + name=_('Fetched Transactions'), + **({'default_context': {'duplicates_from_date': duplicates_from_date}} if duplicates_from_date else {}), + ) + + def _get_connection_state_details(self, journal): + self.ensure_one() + if self.connection_state_details and self.connection_state_details.get(str(journal.id)): + # We have to check that we have a key and a right value for this journal + # Because if we have an empty dict, the JS part will handle it as a Proxy object. + # To avoid that, we checked if we have a key in the dict and if the value is truthy. + return self.connection_state_details[str(journal.id)] + return None + + def _pop_connection_state_details(self, journal): + self.ensure_one() + if journal_connection_state_details := self._get_connection_state_details(journal): + self._set_connection_state_details(journal, {}) + return journal_connection_state_details + return None + + def _set_connection_state_details(self, journal, connection_state_details): + self.ensure_one() + existing_connection_state_details = self.connection_state_details or {} + self.connection_state_details = { + **existing_connection_state_details, + str(journal.id): connection_state_details, + } + + def _notify_connection_update(self, journal, connection_state_details): + """ The aim of this function is saving the last connection state details + (like if the status is success or in error) on the account.online.link + object. At the same moment, we're sending a websocket message to + accounting dashboard where we return the status of the connection. + To make sure that we don't return sensitive information, we filtered + the connection state details to only send by websocket information + like the connection status, how many transactions we fetched, and + the error type. In case of an error, the function is calling rollback + on the cursor and is committing the save on the account online link. + It's also usefull to commit in case of error to send the websocket message. + The commit is only called if we aren't in test mode and if the connection is + in error. + + :param journal: The journal for which we want to save the connection state details. + :param connection_state_details: The information about the status of the connection (like how many transactions fetched, ...) + """ + self.ensure_one() + + connection_state_details_status = connection_state_details['status'] # We're always waiting for a status in the dict. + if connection_state_details_status == 'error': + # In case the connection status is in error, we roll back everything before saving the status. + self.env.cr.rollback() + if not (connection_state_details_status == 'success' and connection_state_details.get('nb_fetched_transactions', 0) == 0): + self._set_connection_state_details( + journal=journal, + connection_state_details=connection_state_details, + ) + self.env.ref('account.group_account_user').users._bus_send( + 'online_sync', + { + 'id': journal.id, + 'connection_state_details': { + key: value + for key, value in connection_state_details.items() + if key in ('status', 'error_type', 'nb_fetched_transactions') + }, + }, + ) + if connection_state_details_status == 'error' and not tools.config['test_enable'] and not modules.module.current_test: + # In case the status is in error, and we aren't in test mode, we commit to save the last connection state and to send the websocket message + self.env.cr.commit() + + def _handle_odoofin_redirect_exception(self, mode='link'): + if mode == 'link': + return self.with_context({'redirect_reconnection': True}).action_new_synchronization() + return self.with_context({'redirect_reconnection': True})._open_iframe(mode=mode) + + ####################################################### + # Generic methods to contact server and handle errors # + ####################################################### + + @api.model + def _get_odoofin_url(self, url): + proxy_mode = self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.proxy_mode') or 'production' + if not pattern.match(proxy_mode) and not runbot_pattern.match(proxy_mode): + raise UserError(_('Invalid value for proxy_mode config parameter.')) + endpoint_url = 'https://%s.odoofin.com%s' % (proxy_mode, url) + if runbot_pattern.match(proxy_mode): + endpoint_url = '%s%s' % (proxy_mode, url) + return endpoint_url + + def _fetch_odoo_fin(self, url, data=None, ignore_status=False): + """ + Method used to fetch data from the Odoo Fin proxy. + :param url: Proxy's URL end point. + :param data: HTTP data request. + :return: A dict containing all data. + """ + if not data: + data = {} + if self.state == 'disconnected' and not ignore_status: + raise UserError(_('Please reconnect your online account.')) + if not url.startswith('/'): + raise UserError(_('Invalid URL')) + + timeout = int(self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.request_timeout')) or 60 + endpoint_url = self._get_odoofin_url(url) + cron = self.env.context.get('cron', False) + data['utils'] = { + 'request_timeout': timeout, + 'lang': get_lang(self.env).code, + 'server_version': odoo.release.serie, + 'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'), + 'cron': cron, + } + if request: + # many banking institutions require the end-user IP/user_agent for traceability + # of client-initiated actions. It won't be stored on odoofin side. + data['utils']['psu_info'] = { + 'ip': request.httprequest.remote_addr, + 'user_agent': request.httprequest.user_agent.string, + } + + try: + # We have to use sudo to pass record as some fields are protected from read for common users. + resp = requests.post(url=endpoint_url, json=data, timeout=timeout, auth=OdooFinAuth(record=self.sudo())) + resp_json = resp.json() + return self._handle_response(resp_json, url, data, ignore_status) + except (Timeout, ConnectionError, RequestException, ValueError): + _logger.warning('synchronization error') + raise UserError( + _("The online synchronization service is not available at the moment. " + "Please try again later.")) + + def _handle_response(self, resp_json, url, data, ignore_status=False): + # Response is a json-rpc response, therefore data is encapsulated inside error in case of error + # and inside result in case of success. + if not resp_json.get('error'): + result = resp_json.get('result') + state = result.get('odoofin_state') or False + message = result.get('display_message') or False + subject = message and _('Message') or False + self._log_information(state=state, message=message, subject=subject) + if result.get('provider_data'): + # Provider_data is extremely important and must be saved as soon as we received it + # as it contains encrypted credentials from external provider and if we loose them we + # loose access to the bank account, As it is possible that provider_data + # are received during a transaction containing multiple calls to the proxy, we ensure + # that provider_data is committed in database as soon as we received it. + self.provider_data = result.get('provider_data') + self.env.cr.commit() + return result + else: + error = resp_json.get('error') + # Not considered as error + if error.get('code') == 101: # access token expired, not an error + self._get_access_token() + return self._fetch_odoo_fin(url, data, ignore_status) + elif error.get('code') == 102: # refresh token expired, not an error + self._get_refresh_token() + self._get_access_token() + # We need to commit here because if we got a new refresh token, and a new access token + # It means that the token is active on the proxy and any further call resulting in an + # error would lose the new refresh_token hence blocking the account ad vitam eternam + self.env.cr.commit() + if self.journal_ids: # We can't do it unless we already have a journal + self._update_connection_status() + return self._fetch_odoo_fin(url, data, ignore_status) + elif error.get('code') == 300: # redirect, not an error + raise OdooFinRedirectException(mode=error.get('data', {}).get('mode', 'link')) + # If we are in the process of deleting the record ignore code 100 (invalid signature), 104 (account deleted) + # 106 (provider_data corrupted) and allow user to delete his record from this side. + elif error.get('code') in (100, 104, 106) and self.env.context.get('delete_sync'): + return {'delete': True} + # Log and raise error + error_details = error.get('data') + subject = error.get('message') + message = error_details.get('message') + state = error_details.get('odoofin_state') or 'error' + ctx = self.env.context.copy() + ctx['error_reference'] = error_details.get('error_reference') + ctx['provider_type'] = error_details.get('provider_type') + ctx['redirect_warning_url'] = error_details.get('redirect_warning_url') + + self.with_context(ctx)._log_information(state=state, subject=subject, message=message, reset_tx=True) + + def _log_information(self, state, subject=None, message=None, reset_tx=False): + # If the reset_tx flag is passed, it means that we have an error, and we want to log it on the record + # and then raise the error to the end user. To do that we first roll back the current transaction, + # then we write the error on the record, we commit those changes, and finally we raise the error. + if reset_tx: + self.env.cr.rollback() + try: + # if state is disconnected, and new state is error: ignore it + if state == 'error' and self.state == 'disconnected': + state = 'disconnected' + if state and self.state != state: + self.write({'state': state}) + if state in ('error', 'disconnected'): + self.account_online_account_ids.fetching_status = 'done' + if reset_tx: + context = self.env.context + button_label = url = None + if subject and message: + message_post = message + error_reference = context.get('error_reference') + provider = context.get('provider_type') + odoo_help_description = f'''ClientID: {self.client_id}\nInstitution: {self.name}\nError Reference: {error_reference}\nError Message: {message_post}\n''' + odoo_help_summary = f'Bank sync error ref: {error_reference} - Provider: {provider} - Client ID: {self.client_id}' + if context.get('redirect_warning_url'): + if context['redirect_warning_url'] == 'odoo_support': + url_params = urllib.parse.urlencode({'stage': 'bank_sync', 'summary': odoo_help_summary, 'description': odoo_help_description[:1500]}) + url = f'https://www.odoo.com/help?{url_params}' + message += _("\n\nIf you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.") + message_post = Markup('%s
    %s %s') % (message, _("You can contact Odoo support"), url, _("Here")) + button_label = _('Report issue') + else: + url = "https://www.odoo.com/documentation/18.0/applications/finance/accounting/bank/bank_synchronization.html#faq" + message_post = Markup('%s
    %s %s') % (message_post, _("Check the documentation"), url, _("Here")) + button_label = _('Check the documentation') + self.message_post(body=message_post, subject=subject) + # In case of reset_tx, we commit the changes in order to have the message post saved + self.env.cr.commit() + # and then raise either a redirectWarning error so that customer can easily open an issue with Odoo, + # or eventually bring the user to the documentation if there's no need to contact the support. + if url: + action_id = { + "type": "ir.actions.act_url", + "url": url, + } + raise RedirectWarning(message, action_id, button_label) #pylint: disable=E0601 + # either a userError if there's no need to bother the support, or link to the doc. + raise UserError(message) + except (CacheMiss, MissingError): + # This exception can happen if record was created and rollbacked due to error in same transaction + # Therefore it is not possible to log information on it, in this case we just ignore it. + pass + + ############### + # API methods # + ############### + + def _get_access_token(self): + for link in self: + resp_json = link._fetch_odoo_fin('/proxy/v1/get_access_token', ignore_status=True) + link.access_token = resp_json.get('access_token', False) + + def _get_refresh_token(self): + # Use sudo as refresh_token field is not accessible to most user + for link in self.sudo(): + resp_json = link._fetch_odoo_fin('/proxy/v1/renew_token', ignore_status=True) + link.refresh_token = resp_json.get('refresh_token', False) + + def unlink(self): + to_unlink = self.env['account.online.link'] + for link in self: + try: + resp_json = link.with_context(delete_sync=True)._fetch_odoo_fin('/proxy/v1/delete_user', data={'provider_data': link.provider_data}, ignore_status=True) # delete proxy user + if resp_json.get('delete', True) is True: + to_unlink += link + except OdooFinRedirectException: + # Can happen that this call returns a redirect in mode link, in which case we delete the record + to_unlink += link + continue + except (UserError, RedirectWarning): + to_unlink += link + continue + result = super(AccountOnlineLink, to_unlink).unlink() + self.env['account.journal']._toggle_asynchronous_fetching_cron() + return result + + def _fetch_accounts(self, online_identifier=False): + self.ensure_one() + if online_identifier: + matching_account = self.account_online_account_ids.filtered(lambda l: l.online_identifier == online_identifier) + # Ignore account that is already there and linked to a journal as there is no need to fetch information for that one + if matching_account and matching_account.journal_ids: + return matching_account + # If we have the account locally but didn't link it to a journal yet, delete it first. + # This way, we'll get the information back from the proxy with updated balances. Avoiding potential issues. + elif matching_account and not matching_account.journal_ids: + matching_account.unlink() + accounts = {} + data = { + 'currency_code': self.company_id.currency_id.name, + } + swift_code = False + while True: + # While this is kind of a bad practice to do, it can happen that provider_data changes between + # 2 calls, the reason is that that field contains the encrypted information needed to access the provider + # and first call can result in an error due to the encrypted token inside provider_data being expired for example. + # In such a case, we renew the token with the provider and send back the newly encrypted token inside provider_data + # which result in the information having changed, henceforth why that field is passed at every loop. + data['provider_data'] = self.provider_data + # Retrieve information about a specific account + if online_identifier: + data['online_identifier'] = online_identifier + + resp_json = self._fetch_odoo_fin('/proxy/v1/accounts', data) + for acc in resp_json.get('accounts', []): + acc['account_online_link_id'] = self.id + currency_id = self.env['res.currency'].with_context(active_test=False).search([('name', '=', acc.pop('currency_code', ''))], limit=1) + if currency_id: + if not currency_id.active: + currency_id.sudo().active = True + acc['currency_id'] = currency_id.id + accounts[str(acc.get('online_identifier'))] = acc + swift_code = resp_json.get('swift_code') + if not resp_json.get('next_data'): + break + data['next_data'] = resp_json.get('next_data') + + if accounts: + self.has_unlinked_accounts = True + return self.env['account.online.account'].create(accounts.values()), swift_code + return False, False + + def _pre_check_fetch_transactions(self): + self.ensure_one() + # 'limit_time_real_cron' and 'limit_time_real' default respectively to -1 and 120. + # Manual fallbacks applied for non-POSIX systems where this key is disabled (set to None). + limit_time = tools.config['limit_time_real_cron'] or -1 + if limit_time <= 0: + limit_time = tools.config['limit_time_real'] or 120 + limit_time += 20 # Add 20 seconds to be sure that the process will have been killed + # if any account is actually creating entries and last_refresh was made less than cron_limit_time ago, skip fetching + if (self.account_online_account_ids.filtered(lambda account: account.fetching_status == 'processing') and + self.last_refresh + relativedelta(seconds=limit_time) > fields.Datetime.now()): + return False + # If not in the process of importing and auto_sync is not set, skip fetching + if (self.env.context.get('cron') and + not self.auto_sync and + not self.account_online_account_ids.filtered(lambda acc: acc.fetching_status in ('planned', 'waiting', 'processing'))): + return False + return True + + def _fetch_transactions(self, refresh=True, accounts=False, check_duplicates=False): + self.ensure_one() + # return early if condition to fetch transactions are not met + if not self._pre_check_fetch_transactions(): + return + + is_cron_running = self.env.context.get('cron') + acc = (accounts or self.account_online_account_ids).filtered('journal_ids') + self.last_refresh = fields.Datetime.now() + try: + # When manually fetching, refresh must still be done in case a redirect occurs + # however since transactions are always fetched inside a cron, in case we are manually + # fetching, trigger the cron and redirect customer to accounting dashboard + accounts_to_synchronize = acc + if not is_cron_running: + accounts_not_to_synchronize = self.env['account.online.account'] + account_to_reauth = False + for online_account in acc: + # Only get transactions on account linked to a journal + if refresh and online_account.fetching_status not in ('planned', 'processing'): + refresh_res = online_account._refresh() + if not refresh_res['success']: + if refresh_res['data'].get('mode') == 'updateCredentials': + account_to_reauth = online_account + accounts_not_to_synchronize += online_account + continue + online_account.fetching_status = 'waiting' + if account_to_reauth: + return self._open_iframe( + mode='updateCredentials', + include_param={ + 'account_online_identifier': account_to_reauth.online_identifier, + }, + ) + accounts_to_synchronize = acc - accounts_not_to_synchronize + if not accounts_to_synchronize: + return + + def get_duplicates_from_date(statement_lines, journal): + if check_duplicates and statement_lines: + from_date = fields.Date.to_string(statement_lines.sorted('date')[0].date) + if journal._has_duplicate_transactions(from_date): + return from_date + + for online_account in accounts_to_synchronize: + journal = online_account.journal_ids[0] + online_account.fetching_status = 'processing' + # Committing here so that multiple thread calling this method won't execute in parallel and import duplicates transaction + self.env.cr.commit() + try: + transactions = online_account._retrieve_transactions().get('transactions', []) + except RedirectWarning as redirect_warning: + self._notify_connection_update( + journal=journal, + connection_state_details={ + 'status': 'error', + 'error_type': 'redirect_warning', + 'error_message': redirect_warning.args[0], + 'action': redirect_warning.args[1], + }, + ) + raise + except OdooFinRedirectException as redirect_exception: + self._notify_connection_update( + journal=journal, + connection_state_details={ + 'status': 'error', + 'error_type': 'odoofin_redirect', + 'action': self._handle_odoofin_redirect_exception(mode=redirect_exception.mode), + }, + ) + raise + + sorted_transactions = sorted(transactions, key=lambda transaction: transaction['date']) + if not is_cron_running: + # we want to import the first 100 transaction, show them to the user + # and import the rest asynchronously with the 'online_sync_cron_waiting_synchronization' cron + total = sum([transaction['amount'] for transaction in transactions]) + statement_lines = self.env['account.bank.statement.line'].with_context(transactions_total=total)._online_sync_bank_statement(sorted_transactions[:100], online_account) + online_account.fetching_status = 'planned' if len(transactions) > 100 else 'done' + domain = None + if statement_lines: + domain = [('id', 'in', statement_lines.ids)] + + duplicates_from_date = get_duplicates_from_date(statement_lines, journal) + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + extra_domain=domain, + name=_('Fetched Transactions'), + default_context={**self.env.context, 'default_journal_id': journal.id, 'duplicates_from_date': duplicates_from_date}, + ) + else: + statement_lines = self.env['account.bank.statement.line']._online_sync_bank_statement(sorted_transactions, online_account) + online_account.fetching_status = 'done' + duplicates_from_date = get_duplicates_from_date(statement_lines, journal) + self._notify_connection_update( + journal=journal, + connection_state_details={ + 'status': 'success', + 'nb_fetched_transactions': len(statement_lines), + 'action': self._show_fetched_transactions_action(statement_lines, duplicates_from_date), + }, + ) + return + except OdooFinRedirectException as e: + return self._handle_odoofin_redirect_exception(mode=e.mode) + + def _get_consent_expiring_date(self, data=None): + self.ensure_one() + if not data: # Small hack to avoid breaking the stable policy + data = self._fetch_odoo_fin('/proxy/v1/consent_expiring_date', ignore_status=True) + + if data.get('consent_expiring_date'): + expiring_synchronization_date = fields.Date.to_date(data['consent_expiring_date']) + if expiring_synchronization_date != self.expiring_synchronization_date: + # TDE TODO: master: use generic activity mixin methods instead + bank_sync_activity_type_id = self.env.ref('odex30_account_online_sync.bank_sync_activity_update_consent') + account_journal_model_id = self.env['ir.model']._get_id('account.journal') + + # Remove old activities + self.env['mail.activity'].search([ + ('res_id', 'in', self.journal_ids.ids), + ('res_model_id', '=', account_journal_model_id), + ('activity_type_id', '=', bank_sync_activity_type_id.id), + ('date_deadline', '<=', self.expiring_synchronization_date), + ('user_id', '=', self.env.user.id), + ]).unlink() + + # Create a new activity for each journals for this synch + self.expiring_synchronization_date = expiring_synchronization_date + new_activity_vals = [] + for journal in self.journal_ids: + new_activity_vals.append({ + 'res_id': journal.id, + 'res_model_id': account_journal_model_id, + 'date_deadline': self.expiring_synchronization_date, + 'summary': _("Bank Synchronization: Update your consent"), + 'note': data.get('activity_message') or '', + 'activity_type_id': bank_sync_activity_type_id.id, + }) + self.env['mail.activity'].create(new_activity_vals) + elif self.expiring_synchronization_date and self.expiring_synchronization_date < fields.Date.context_today(self): + # Avoid an infinite "expired synchro" if the provider + # doesn't send us a new consent expiring date + self.expiring_synchronization_date = None + + def _update_connection_status(self): + self.ensure_one() + resp_json = self._fetch_odoo_fin('/proxy/v2/connection_status', ignore_status=True) + + self._get_consent_expiring_date(resp_json) + + # Returning what we receive from Odoo Fin to allow function extension + return resp_json + + def _authorize_access(self, data_access_token): + """ + This method is used to allow an existing connection to give temporary access + to a new connection in order to see the list of available unlinked accounts. + We pass as parameter the list of already linked account, so that if there are + no more accounts to link, we will receive a response telling us so and we won't + call authorize for that connection later on. + """ + self.ensure_one() + data = { + 'linked_accounts': self.account_online_account_ids.filtered('journal_ids').mapped('online_identifier'), + 'record_access_token': data_access_token, + } + try: + resp_json = self._fetch_odoo_fin('/proxy/v1/authorize_access', data) + self.has_unlinked_accounts = resp_json.get('has_unlinked_accounts') + except UserError: + # We don't want to throw an error to the customer so ignore error + pass + + @api.model + def _cron_delete_unused_connection(self): + account_online_links = self.search([ + ('write_date', '<=', fields.Datetime.now() - relativedelta(months=1)), + ]) + for link in account_online_links: + if not link.account_online_account_ids.filtered('journal_ids'): + link.unlink() + + @api.returns('mail.message', lambda value: value.id) + def message_post(self, **kwargs): + """Override to log all message to the linked journal as well.""" + for journal in self.journal_ids: + journal.message_post(**kwargs) + return super(AccountOnlineLink, self).message_post(**kwargs) + + ################################ + # Callback methods from iframe # + ################################ + + def success(self, mode, data): + if data: + self.write(data) + # Provider_data is extremely important and must be saved as soon as we received it + # as it contains encrypted credentials from external provider and if we loose them we + # loose access to the bank account, As it is possible that provider_data + # are received during a transaction containing multiple calls to the proxy, we ensure + # that provider_data is committed in database as soon as we received it. + if data.get('provider_data'): + self.env.cr.commit() + + self._update_connection_status() + # if for some reason we just have to update the record without doing anything else, the mode will be set to 'none' + if mode == 'none': + return {'type': 'ir.actions.client', 'tag': 'reload'} + try: + method_name = '_success_%s' % mode + method = getattr(self, method_name) + except AttributeError: + message = _("This version of Odoo appears to be outdated and does not support the '%s' sync mode. " + "Installing the latest update might solve this.", mode) + _logger.info('Online sync: %s' % (message,)) + self.env.cr.rollback() + self._log_information(state='error', subject=_('Internal Error'), message=message, reset_tx=True) + raise UserError(message) + action = method() + return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban') + + @api.model + def connect_existing_account(self, data): + # extract client_id and online_identifier from data and retrieve the account detail from the connection. + # If we have a journal in context, assign to journal, otherwise create new journal then fetch transaction + client_id = data.get('client_id') + online_identifier = data.get('online_identifier') + if client_id and online_identifier: + online_link = self.search([('client_id', '=', client_id)], limit=1) + if not online_link: + return {'type': 'ir.actions.client', 'tag': 'reload'} + new_account, swift_code = online_link._fetch_accounts(online_identifier=online_identifier) + if new_account: + new_account._assign_journal(swift_code) + action = online_link._fetch_transactions(accounts=new_account, check_duplicates=True) + return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban') + raise UserError(_("The consent for the selected account has expired.")) + return {'type': 'ir.actions.client', 'tag': 'reload'} + + def exchange_token(self, exchange_token): + self.ensure_one() + # Exchange token to retrieve client_id and refresh_token from proxy account + data = { + 'exchange_token': exchange_token, + 'company_id': self.env.company.id, + 'user_id': self.env.user.id + } + resp_json = self._fetch_odoo_fin('/proxy/v1/exchange_token', data=data, ignore_status=True) + # Write in sudo mode as those fields are protected from users + self.sudo().write({ + 'client_id': resp_json.get('client_id'), + 'refresh_token': resp_json.get('refresh_token'), + 'access_token': resp_json.get('access_token') + }) + return True + + def _success_link(self): + self.ensure_one() + self._log_information(state='connected') + account_online_accounts, swift_code = self._fetch_accounts() + if account_online_accounts and len(account_online_accounts) == 1: + account_online_accounts._assign_journal(swift_code) + return self._fetch_transactions(accounts=account_online_accounts, check_duplicates=True) + return self._link_accounts_to_journals_action(swift_code) + + def _success_updateCredentials(self): + self.ensure_one() + return self._fetch_transactions(refresh=False) + + def _success_refreshAccounts(self): + self.ensure_one() + return self._fetch_transactions(refresh=False) + + def _success_reconnect(self): + self.ensure_one() + self._log_information(state='connected') + return self._fetch_transactions(check_duplicates=True) + + ################## + # action buttons # + ################## + + def action_new_synchronization(self, preferred_inst=None, journal_id=False): + # Search for an existing link that was not fully connected + online_link = self + if not online_link or online_link.provider_data: + online_link = self.search([('account_online_account_ids', '=', False)], limit=1) + # If not found, create a new one + if not online_link or online_link.provider_data: + online_link = self.create({}) + return online_link._open_iframe('link', preferred_institution=preferred_inst, journal_id=journal_id) + + def action_update_credentials(self): + return self._open_iframe('updateCredentials') + + def action_fetch_transactions(self): + self.account_online_account_ids.fetching_status = None + action = self._fetch_transactions() + return action or self.env['ir.actions.act_window']._for_xml_id('account.open_account_journal_dashboard_kanban') + + def action_reconnect_account(self): + return self._open_iframe('reconnect') + + def _open_iframe(self, mode='link', include_param=None, preferred_institution=False, journal_id=False): + self.ensure_one() + if self.client_id and self.sudo().refresh_token: + try: + self._get_access_token() + except OdooFinRedirectException: + # Delete record and open iframe in a new one + self.unlink() + return self.create({})._open_iframe('link') + + proxy_mode = self.env['ir.config_parameter'].sudo().get_param('odex30_account_online_sync.proxy_mode') or 'production' + country = self.env['account.journal'].browse(journal_id).company_id.account_fiscal_country_id or self.env.company.country_id + action = { + 'type': 'ir.actions.client', + 'tag': 'odoo_fin_connector', + 'id': self.id, + 'params': { + 'proxyMode': proxy_mode, + 'clientId': self.client_id, + 'accessToken': self.access_token, + 'mode': mode, + 'includeParam': { + 'lang': get_lang(self.env).code, + 'countryCode': country.code, + 'countryName': country.display_name, + 'redirect_reconnection': self.env.context.get('redirect_reconnection'), + 'serverVersion': odoo.release.serie, + 'mfa_type': self.env.user._mfa_type(), + } + }, + 'context': { + 'dialog_size': 'medium', + }, + } + if self.provider_data: + action['params']['providerData'] = self.provider_data + if preferred_institution: + action['params']['includeParam']['clickedInstitution'] = preferred_institution + if journal_id: + action['context']['active_model'] = 'account.journal' + action['context']['active_id'] = journal_id + + if mode == 'link': + user_email = self.env.user.email or self.env.ref('base.user_admin').email or '' # Necessary for some providers onboarding + action['params']['includeParam']['dbUuid'] = self.env['ir.config_parameter'].sudo().get_param('database.uuid') + action['params']['includeParam']['userEmail'] = user_email + # Compute a hash of a random string for each connection in success + existing_link = self.search([('state', '!=', 'disconnected'), ('has_unlinked_accounts', '=', True)]) + if existing_link: + record_access_token = base64.b64encode(uuid.uuid4().bytes).decode('utf-8') + for link in existing_link: + link._authorize_access(record_access_token) + action['params']['includeParam']['recordAccessToken'] = record_access_token + + if include_param: + action['params']['includeParam'].update(include_param) + return action diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py b/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py new file mode 100644 index 0000000..273c43f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/bank_rec_widget.py @@ -0,0 +1,16 @@ +from odoo import models + + +class BankRecWidget(models.Model): + _inherit = 'bank.rec.widget' + + def _action_validate(self): + # EXTENDS account_accountant + super()._action_validate() + line = self.st_line_id + if line.partner_id and line.online_partner_information: + # write value for account and merchant on partner only if partner has no value, + # in case value are different write False + value_merchant = line.partner_id.online_partner_information or line.online_partner_information + value_merchant = value_merchant if value_merchant == line.online_partner_information else False + line.partner_id.online_partner_information = value_merchant diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/company.py b/dev_odex30_accounting/odex30_account_online_sync/models/company.py new file mode 100644 index 0000000..84feee2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/company.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + @api.model + def setting_init_bank_account_action(self): + """ + Override the "setting_init_bank_account_action" in accounting menu + and change the flow for the "Add a bank account" menu item in dashboard. + """ + return self.env['account.online.link'].action_new_synchronization() diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py b/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py new file mode 100644 index 0000000..93e61bf --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/mail_activity_type.py @@ -0,0 +1,14 @@ +from odoo import api, models + + +class MailActivityType(models.Model): + _inherit = "mail.activity.type" + + @api.model + def _get_model_info_by_xmlid(self): + info = super()._get_model_info_by_xmlid() + info['odex30_account_online_sync.bank_sync_activity_update_consent'] = { + 'res_model': 'account.journal', + 'unlink': False, + } + return info diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py b/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py new file mode 100644 index 0000000..19288f6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/odoofin_auth.py @@ -0,0 +1,46 @@ +import base64 +import hashlib +import hmac +import json +import requests +import time +import werkzeug.urls + + +class OdooFinAuth(requests.auth.AuthBase): + + def __init__(self, record=None): + self.access_token = record and record.access_token or False + self.refresh_token = record and record.refresh_token or False + self.client_id = record and record.client_id or False + + def __call__(self, request): + # We don't sign request that still don't have a client_id/refresh_token + if not self.client_id or not self.refresh_token: + return request + # craft the message (timestamp|url path|client_id|access_token|query params|body content) + msg_timestamp = int(time.time()) + parsed_url = werkzeug.urls.url_parse(request.path_url) + + body = request.body + if isinstance(body, bytes): + body = body.decode('utf-8') + body = json.loads(body) + + message = '%s|%s|%s|%s|%s|%s' % ( + msg_timestamp, # timestamp + parsed_url.path, # url path + self.client_id, + self.access_token, + json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key + json.dumps(body, sort_keys=True)) # http request body + + h = hmac.new(base64.b64decode(self.refresh_token), message.encode('utf-8'), digestmod=hashlib.sha256) + + request.headers.update({ + 'odoofin-client-id': self.client_id, + 'odoofin-access-token': self.access_token, + 'odoofin-signature': base64.b64encode(h.digest()), + 'odoofin-timestamp': msg_timestamp, + }) + return request diff --git a/dev_odex30_accounting/odex30_account_online_sync/models/partner.py b/dev_odex30_accounting/odex30_account_online_sync/models/partner.py new file mode 100644 index 0000000..7cb6bc9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/models/partner.py @@ -0,0 +1,7 @@ +from odoo import models, fields + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + online_partner_information = fields.Char(readonly=True) diff --git a/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml b/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml new file mode 100644 index 0000000..9a4c704 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/security/account_online_sync_security.xml @@ -0,0 +1,15 @@ + + + + Account online link company rule + + + [('company_id', 'parent_of', company_ids)] + + + Online account company rule + + + [('account_online_link_id.company_id','parent_of', company_ids)] + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv new file mode 100644 index 0000000..adca78d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_online_link_id,access_account_online_link_id,model_account_online_link,account.group_account_basic,1,1,1,0 +access_account_online_link_id_readonly,access_account_online_link_id_readonly,model_account_online_link,account.group_account_readonly,1,0,0,0 +access_account_online_link_id_manager,access_account_online_link_id manager,model_account_online_link,account.group_account_manager,1,1,1,1 +access_account_online_account_id,access_account_online_account_id,model_account_online_account,account.group_account_basic,1,1,1,0 +access_account_online_account_id_readonly,access_account_online_account_id_readonly,model_account_online_account,account.group_account_readonly,1,0,0,0 +access_account_online_account_id_manager,access_account_online_account_id manager,model_account_online_account,account.group_account_manager,1,1,1,1 +access_account_bank_selection_manager,access.account.bank.selection manager,model_account_bank_selection,account.group_account_manager,1,1,1,1 +access_account_bank_selection,access.account.bank.selection basic,model_account_bank_selection,account.group_account_basic,1,1,1,0 +access_account_bank_statement_line_transient,access_account_bank_statement_line_transient,model_account_bank_statement_line_transient,account.group_account_manager,1,1,1,1 +access_account_missing_transaction_wizard,access_account_missing_transaction_wizard,model_account_missing_transaction_wizard,account.group_account_manager,1,1,1,1 +access_account_duplicate_transaction_wizard,access_account_duplicate_transaction_wizard,model_account_duplicate_transaction_wizard,account.group_account_user,1,1,1,0 diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js new file mode 100644 index 0000000..403bbe4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_form.js @@ -0,0 +1,37 @@ +import { formView } from "@web/views/form/form_view"; +import { FormController } from "@web/views/form/form_controller"; +import { registry } from "@web/core/registry"; +import { useCheckDuplicateService } from "./account_duplicate_transaction_hook"; + +export class AccountDuplicateTransactionsFormController extends FormController { + setup() { + super.setup(); + this.duplicateCheckService = useCheckDuplicateService(); + } + + async beforeExecuteActionButton(clickParams) { + if (clickParams.name === "delete_selected_transactions") { + const selected = this.duplicateCheckService.selectedLines; + if (selected.size) { + await this.orm.call( + "account.bank.statement.line", + "unlink", + [Array.from(selected)], + ); + this.env.services.action.doAction({type: 'ir.actions.client', tag: 'reload'}); + } + return false; + } + return super.beforeExecuteActionButton(...arguments); + } + + get cogMenuProps() { + const props = super.cogMenuProps; + props.items.action = []; + return props; + } +} + +export const form = { ...formView, Controller: AccountDuplicateTransactionsFormController }; + +registry.category("views").add("account_duplicate_transactions_form", form); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js new file mode 100644 index 0000000..64b1604 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_hook.js @@ -0,0 +1,6 @@ +import { useService } from "@web/core/utils/hooks"; +import { useState } from "@odoo/owl"; + +export function useCheckDuplicateService() { + return useState(useService("odex30_account_online_sync.duplicate_check_service")); +} diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js new file mode 100644 index 0000000..b5571d2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transaction_service.js @@ -0,0 +1,21 @@ +import { registry } from "@web/core/registry"; + +class AccountDuplicateTransactionsServiceModel { + constructor() { + this.selectedLines = new Set(); + } + + updateLIne(selected, id) { + this.selectedLines[selected ? "add" : "delete"](id); + } +} + +const duplicateCheckService = { + start(env, services) { + return new AccountDuplicateTransactionsServiceModel(); + }, +}; + +registry + .category("services") + .add("odex30_account_online_sync.duplicate_check_service", duplicateCheckService); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js new file mode 100644 index 0000000..d1d2a8e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.js @@ -0,0 +1,50 @@ +import { onMounted } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { ListRenderer } from "@web/views/list/list_renderer"; +import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field"; +import { useCheckDuplicateService } from "./account_duplicate_transaction_hook"; + +export class AccountDuplicateTransactionsListRenderer extends ListRenderer { + static template = "odex30_account_online_sync.AccountDuplicateTransactionsListRenderer"; + static recordRowTemplate = "odex30_account_online_sync.AccountDuplicateTransactionsRecordRow"; + + setup() { + super.setup(); + this.duplicateCheckService = useCheckDuplicateService(); + + onMounted(() => { + this.deleteButton = document.querySelector('button[name="delete_selected_transactions"]'); + this.deleteButton.disabled = true; + }); + } + + toggleRecordSelection(selected, record) { + this.duplicateCheckService.updateLIne(selected, record.data.id); + this.deleteButton.disabled = this.duplicateCheckService.selectedLines.size === 0; + } + + get hasSelectors() { + return true; + } + + getRowClass(record) { + let classes = super.getRowClass(record); + const firstIdsInGroup = this.env.model.root.data.first_ids_in_group; + if (firstIdsInGroup instanceof Array && firstIdsInGroup.includes(record.data.id)) { + classes += " account_duplicate_transactions_lines_list_x2many_group_line"; + } + return classes; + } +} + +export class AccountDuplicateTransactionsLinesListX2ManyField extends X2ManyField { + static components = { + ...X2ManyField.components, + ListRenderer: AccountDuplicateTransactionsListRenderer, + }; +} + +registry.category("fields").add("account_duplicate_transactions_lines_list_x2many", { + ...x2ManyField, + component: AccountDuplicateTransactionsLinesListX2ManyField, +}); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss new file mode 100644 index 0000000..4b147a5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.scss @@ -0,0 +1,3 @@ +.account_duplicate_transactions_lines_list_x2many_group_line { + border-top-width: thick; +} diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml new file mode 100644 index 0000000..52f66ff --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/account_duplicate_transaction/account_duplicate_transactions_x2many.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.js new file mode 100644 index 0000000..0469a8d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.js @@ -0,0 +1,84 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { useService, useBus } from "@web/core/utils/hooks"; +import { SIZES } from "@web/core/ui/ui_service"; +import { Component, useState, useRef, onWillStart } from "@odoo/owl"; + +class BankConfigureWidget extends Component { + static template = "account.BankConfigureWidget"; + static props = { + ...standardWidgetProps, + } + setup() { + this.container = useRef("container"); + this.allInstitutions = []; + this.state = useState({ + isLoading: true, + institutions: [], + gridStyle: "grid-template-columns: repeat(5, minmax(90px, 1fr));" + }); + this.orm = useService("orm"); + this.action = useService("action"); + this.ui = useService("ui"); + onWillStart(this.fetchInstitutions); + useBus(this.ui.bus, "resize", this.computeGrid); + } + + computeGrid() { + if (this.allInstitutions.length > 4) { + let containerWidth = this.container.el ? this.container.el.offsetWidth - 32 : 0; + // when the container width can't be computed, use the screen size and number of journals. + if (!containerWidth) { + if (this.ui.size >= SIZES.XXL) { + containerWidth = window.innerWidth / (this.props.record.model.root.count < 6 ? 2 : 3); + } else { + containerWidth = Math.max(this.ui.size * 100, 400); + } + } + const canFit = Math.floor(containerWidth / 100); + const numberOfRows = (Math.floor((this.allInstitutions.length + 1) / 2) >= canFit) + 1; + this.state.gridStyle = `grid-template-columns: repeat(${canFit}, minmax(90px, 1fr)); + grid-template-rows: repeat(${numberOfRows}, 1fr); + grid-auto-rows: 0px; + `; + } + this.state.institutions = this.allInstitutions; + } + + async fetchInstitutions() { + this.orm.silent.call(this.props.record.resModel, "fetch_online_sync_favorite_institutions", [this.props.record.resId]) + .then((response) => { + this.allInstitutions = response; + }) + .finally(() => { + this.state.isLoading = false; + this.computeGrid(); + }); + } + + async connectBank(institutionId=null) { + const action = await this.orm.call("account.online.link", "action_new_synchronization", [[]], { + preferred_inst: institutionId, + journal_id: this.props.record.resId, + }) + this.action.doAction(action); + } + + async fallbackConnectBank() { + const action = await this.orm.call('account.online.link', 'create_new_bank_account_action', [], { + context: { + active_model: 'account.journal', + active_id: this.props.record.resId, + } + }); + this.action.doAction(action); + } +} + +export const bankConfigureWidget = { + component: BankConfigureWidget, +} + +registry.category("view_widgets").add("bank_configure", bankConfigureWidget); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.scss b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.scss new file mode 100644 index 0000000..3b96ed6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.scss @@ -0,0 +1,15 @@ +.bank_configure_container { + .d-grid { + overflow: hidden; + column-gap: 0.25rem; + } + .dashboard_bank { + aspect-ratio: 1 / 1; + .align-self-center { + background-color: $gray-100; + border: 1px solid $gray-100; + } + margin-bottom: 0.25rem; + overflow: hidden; + } +} diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml new file mode 100644 index 0000000..5068148 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_configure/bank_configure.xml @@ -0,0 +1,29 @@ + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.js new file mode 100644 index 0000000..6899842 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const cogMenuRegistry = registry.category("cogMenu"); + +/** + * 'Fetch Missing Transactions' menu + * + * This component is used to open a wizard allowing the user to fetch their missing/pending + * transaction since a specific date. + * It's only available in the bank reconciliation widget. + * By default, if there is only one selected journal, this journal is directly selected. + * In case there is no selected journal or more than one, we let the user choose which + * journal he/she wants. This part is handled by the server. + * @extends Component + */ +export class FetchMissingTransactions extends Component { + static template = "odex30_account_online_sync.FetchMissingTransactions"; + static components = { DropdownItem }; + static props = {}; + + setup() { + this.action = useService("action"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async openFetchMissingTransactionsWizard() { + const { context } = this.env.searchModel; + const activeModel = context.active_model; + let activeIds = []; + if (activeModel === "account.journal") { + activeIds = context.active_ids; + } else if (!!context.default_journal_id) { + activeIds = context.default_journal_id; + } + // We have to use this.env.services.orm.call instead of using useService + // for a specific reason. useService implies that function calls with + // are "protected", it means that if the component is closed the + // response will be pending and the code stop their execution. + // By passing directly from the env, this protection is not activated. + const action = await this.env.services.orm.call( + "account.journal", + "action_open_missing_transaction_wizard", + [activeIds] + ); + return this.action.doAction(action); + } +} + +export const fetchMissingTransactionItem = { + Component: FetchMissingTransactions, + groupNumber: 5, + isDisplayed: ({ config, isSmall }) => { + return !isSmall && + config.actionType === "ir.actions.act_window" && + ["kanban", "list"].includes(config.viewType) && + ["bank_rec_widget_kanban", "bank_rec_list"].includes(config.viewSubType); + }, +}; + +cogMenuRegistry.add("fetch-missing-transaction-menu", fetchMissingTransactionItem, { sequence: 1 }); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml new file mode 100644 index 0000000..07256ff --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/fetch_missing_transactions_cog_menu.xml @@ -0,0 +1,8 @@ + + + + + Find Missing Transactions + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.js new file mode 100644 index 0000000..dc2968e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.js @@ -0,0 +1,64 @@ +import { Component } from "@odoo/owl"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +/** + * 'Find Duplicate Transactions' menu + * + * This component is used to open a wizard allowing the user to find duplicate + * transactions since a specific date. + * It's only available in the bank reconciliation widget. + * By default, if there is only one selected journal, this journal is directly selected. + * In case there is no selected journal or more than one, we let the user choose. + * @extends Component + */ +export class FindDuplicateTransactions extends Component { + static template = "odex30_account_online_sync.FindDuplicateTransactions"; + static components = { DropdownItem }; + static props = {}; + + setup() { + this.action = useService("action"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async openFindDuplicateTransactionsWizard() { + const { context } = this.env.searchModel; + const activeModel = context.active_model; + let activeIds = []; + if (activeModel === "account.journal") { + activeIds = context.active_ids; + } else if (context.default_journal_id) { + activeIds = context.default_journal_id; + } + return this.action.doActionButton({ + type: "object", + resModel: "account.journal", + name:"action_open_duplicate_transaction_wizard", + resIds: activeIds, + }) + } +} + +export const findDuplicateTransactionItem = { + Component: FindDuplicateTransactions, + groupNumber: 5, // same group as fetch missing transactions + isDisplayed: ({ config, isSmall }) => { + return ( + !isSmall && + config.actionType === "ir.actions.act_window" && + ["kanban", "list"].includes(config.viewType) && + ["bank_rec_widget_kanban", "bank_rec_list"].includes(config.viewSubType) + ) + }, +}; + +registry.category("cogMenu").add( + "find-duplicate-transaction-menu", + findDuplicateTransactionItem, + { sequence: 3 }, // after fetch missing transactions +); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml new file mode 100644 index 0000000..1e75c99 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/bank_reconciliation/find_duplicate_transactions_cog_menu.xml @@ -0,0 +1,8 @@ + + + + + Find Duplicate Transactions + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.js new file mode 100644 index 0000000..4792386 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.js @@ -0,0 +1,61 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { useService } from "@web/core/utils/hooks"; +import { Component, useState } from "@odoo/owl"; + +class ConnectedUntil extends Component { + static template = "odex30_account_online_sync.ConnectedUntil"; + static props = { ...standardWidgetProps }; + + setup() { + this.state = useState({ + isHovered: false, + displayReconnectButton: false, + }); + + if (this.isConnectionExpiredIn(0)) { + this.state.displayReconnectButton = true; + } + + this.action = useService("action"); + this.orm = useService("orm"); + } + + get cssClasses() { + let cssClasses = "text-nowrap w-100"; + if (this.isConnectionExpiredIn(7)) { + cssClasses += this.isConnectionExpiredIn(3) ? " text-danger" : " text-warning"; + } + return cssClasses; + } + + onMouseEnter() { + this.state.isHovered = true; + } + + onMouseLeave() { + this.state.isHovered = false; + } + + isConnectionExpiredIn(nbDays) { + return this.props.record.data.expiring_synchronization_due_day <= nbDays; + } + + async extendConnection() { + const action = await this.orm.call( + "account.journal", + "action_extend_consent", + [this.props.record.resId], + {} + ); + this.action.doAction(action); + } +} + +export const connectedUntil = { + component: ConnectedUntil, +}; + +registry.category("view_widgets").add("connected_until_widget", connectedUntil); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml new file mode 100644 index 0000000..18714c9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/connected_until_widget/connected_until_widget.xml @@ -0,0 +1,23 @@ + + + +
    + + + + + + + Connected until + + + + Extend Connection + + + +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.js new file mode 100644 index 0000000..a0e1aa0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.js @@ -0,0 +1,41 @@ +/** @odoo-module **/ + +import { onMounted, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { RadioField, radioField } from "@web/views/fields/radio/radio_field"; +import { useService } from '@web/core/utils/hooks'; + + +class OnlineAccountRadio extends RadioField { + static template = "odex30_account_online_sync.OnlineAccountRadio"; + setup() { + super.setup(); + this.orm = useService("orm"); + this.state = useState({balances: {}}); + + onMounted(async () => { + this.state.balances = await this.loadData(); + // Make sure the first option is selected by default. + this.onChange(this.items[0]); + }); + } + + async loadData() { + const ids = this.items.map(i => i[0]); + return await this.orm.call("account.online.account", "get_formatted_balances", [ids]); + } + + getBalanceName(itemID) { + return this.state.balances?.[itemID]?.[0] ?? "Loading ..."; + } + + isNegativeAmount(itemID) { + // In case of the value is undefined, it will return false as intended. + return this.state.balances?.[itemID]?.[1] < 0; + } +} + +registry.category("fields").add("online_account_radio", { + ...radioField, + component: OnlineAccountRadio, +}); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.xml new file mode 100644 index 0000000..2407b88 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/online_account_radio/online_account_radio.xml @@ -0,0 +1,29 @@ + + + +
    + +
    + +
    +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js new file mode 100644 index 0000000..8944dfc --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.js @@ -0,0 +1,99 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { useService } from "@web/core/utils/hooks"; +import { Component, useState, onWillStart, markup } from "@odoo/owl"; + +class RefreshSpin extends Component { + static template = "odex30_account_online_sync.RefreshSpin"; + static props = { ...standardWidgetProps }; + + setup() { + this.state = useState({ + isHovered: false, + fetchingStatus: false, + connectionStateDetails: null, + }); + + this.actionService = useService("action"); + this.busService = this.env.services.bus_service; + this.orm = useService("orm"); + this.state.fetchingStatus = this.props.record.data.online_sync_fetching_status; + + this.busService.subscribe("online_sync", (notification) => { + if (notification?.id === this.recordId && notification?.connection_state_details) { + this.state.connectionStateDetails = notification.connection_state_details; + } + }); + + onWillStart(() => { + this._initConnectionStateDetails(); + }); + } + + refresh() { + this.actionService.restore(this.actionService.currentController.jsId); + } + + onMouseEnter() { + this.state.isHovered = true; + } + + onMouseLeave() { + this.state.isHovered = false; + } + + async openAction() { + /** + * This function is used to open the action that the asynchronous process saved + * on the databsase. It allows users to call the action when they want and not when + * the process is over. + */ + const action = await this.orm.call( + "account.journal", + "action_open_dashboard_asynchronous_action", + [this.recordId], + ); + this.actionService.doAction(action); + this.state.connectionStateDetails = null; + } + + async fetchTransactions() { + /** + * This function call the function to fetch transactions. + * In the main case, we don't do anything after calling the function. + * The idea is that websockets will update the status by themselves. + * In one specific case, we have to return an action to the user to open + * the Odoo Fin iframe to refresh the connection. + */ + this.state.connectionStateDetails = { status: "fetching" }; + const action = await this.orm.call("account.journal", "manual_sync", [this.recordId]); + if (action) { + action.help = markup(action.help); + this.actionService.doAction(action); + } + } + + _initConnectionStateDetails() { + /** + * This function is used to get the last state of the connection (if there is one) + */ + const kanbanDashboardData = JSON.parse(this.props.record.data.kanban_dashboard); + this.state.connectionStateDetails = kanbanDashboardData?.connection_state_details; + } + + get recordId() { + return this.props.record.data.id; + } + + get connectionStatus() { + return this.state.connectionStateDetails?.status; + } +} + +export const refreshSpin = { + component: RefreshSpin, +}; + +registry.category("view_widgets").add("refresh_spin_widget", refreshSpin); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml new file mode 100644 index 0000000..869292b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/refresh_spin_journal_widget/refresh_spin_journal_widget.xml @@ -0,0 +1,42 @@ + + + + +
    + + + transactions fetched + + + + 0 transaction fetched + +
    +
    + +
    + + See error + + +
    +
    + +
    + + Refresh + + + Fetching... + +
    +
    + + + Fetch Transactions + + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js new file mode 100644 index 0000000..6b9993e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { ListRenderer } from "@web/views/list/list_renderer"; +import { ListController } from "@web/views/list/list_controller"; +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { useService } from "@web/core/utils/hooks"; + +export class TransientBankStatementLineListController extends ListController { + + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + } + + async onClickImportTransactions() { + const resIds = await this.getSelectedResIds(); + const resultAction = await this.orm.call("account.bank.statement.line.transient", "action_import_transactions", [resIds]); + this.action.doAction(resultAction); + } +} + +export class TransientBankStatementLineListRenderer extends ListRenderer { + + static template = "odex30_account_online_sync.TransientBankStatementLineRenderer"; + + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + } + + async openManualEntries() { + if (this.env.searchModel.context.active_model === "account.missing.transaction.wizard" && this.env.searchModel.context.active_ids) { + const activeIds = this.env.searchModel.context.active_ids; + const action = await this.orm.call("account.missing.transaction.wizard", "action_open_manual_bank_statement_lines", activeIds); + this.action.doAction(action); + } + } + +} + +export const TransientBankStatementLineListView = { + ...listView, + Renderer: TransientBankStatementLineListRenderer, + Controller: TransientBankStatementLineListController, + buttonTemplate: "TransientBankStatementLineButtonTemplate", +} + +registry.category("views").add("transient_bank_statement_line_list_view", TransientBankStatementLineListView); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml new file mode 100644 index 0000000..1e9c17c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/transient_bank_statement_line_list_view/transient_bank_statement_line_list_view.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban.js new file mode 100644 index 0000000..bd42d29 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban.js @@ -0,0 +1,19 @@ +import { patch } from "@web/core/utils/patch"; +import { BankRecKanbanController } from "@odex30_account_accountant/components/bank_reconciliation/kanban"; + +patch(BankRecKanbanController.prototype, { + setup() { + super.setup(); + this.displayDuplicateWarning = !!this.props.context.duplicates_from_date; + }, + async onWarningClick () { + const { context } = this.env.searchModel; + return this.action.doActionButton({ + type: "object", + resModel: "account.journal", + name:"action_open_duplicate_transaction_wizard", + resId: this.state.journalId, + args: JSON.stringify([context.duplicates_from_date]), + }) + }, +}) diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml new file mode 100644 index 0000000..6cdfe99 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/components/views/account_online_authorization_kanban_controller.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js new file mode 100644 index 0000000..b8d18ca --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/odoo_fin_connector.js @@ -0,0 +1,86 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { loadJS } from "@web/core/assets"; +import { cookie } from "@web/core/browser/cookie"; +import { markup } from "@odoo/owl"; +const actionRegistry = registry.category('actions'); +/* global OdooFin */ + +function OdooFinConnector(parent, action) { + const orm = parent.services.orm; + const actionService = parent.services.action; + const notificationService = parent.services.notification; + const debugMode = parent.debug; + + const id = action.id; + action.params.colorScheme = cookie.get("color_scheme"); + let mode = action.params.mode || 'link'; + // Ensure that the proxyMode is valid + const modeRegexp = /^[a-z0-9-_]+$/; + const runbotRegexp = /^https:\/\/[a-z0-9-_]+\.[a-z0-9-_]+\.odoo\.com$/; + if (!modeRegexp.test(action.params.proxyMode) && !runbotRegexp.test(action.params.proxyMode)) { + return; + } + let url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link'; + if (runbotRegexp.test(action.params.proxyMode)) { + url = action.params.proxyMode + '/proxy/v1/odoofin_link'; + } + let actionResult = false; + + loadJS(url) + .then(function () { + // Create and open the iframe + const params = { + data: action.params, + proxyMode: action.params.proxyMode, + onEvent: async function (event, data) { + switch (event) { + case 'close': + return; + case 'reload': + return actionService.doAction({type: 'ir.actions.client', tag: 'reload'}); + case 'notification': + notificationService.add(data.message, data); + break; + case 'exchange_token': + await orm.call('account.online.link', 'exchange_token', + [[id], data], {context: action.context}); + break; + case 'success': + mode = data.mode || mode; + actionResult = await orm.call('account.online.link', 'success', [[id], mode, data], {context: action.context}); + actionResult.help = markup(actionResult.help) + return actionService.doAction(actionResult); + case 'connect_existing_account': + actionResult = await orm.call('account.online.link', 'connect_existing_account', [data], {context: action.context}); + actionResult.help = markup(actionResult.help) + return actionService.doAction(actionResult); + default: + return; + } + }, + onAddBank: async function (data) { + // If the user doesn't find his bank + actionResult = await orm.call( + "account.online.link", + "create_new_bank_account_action", + [[id], data], + { context: action.context } + ); + return actionService.doAction(actionResult); + } + }; + // propagate parent debug mode to iframe + if (typeof debugMode !== "undefined" && debugMode) { + params.data["debug"] = debugMode; + } + OdooFin.create(params); + OdooFin.open(); + }); + return; +} + +actionRegistry.add('odoo_fin_connector', OdooFinConnector); + +export default OdooFinConnector; diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js new file mode 100644 index 0000000..8dde787 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/src/js/online_sync_portal.js @@ -0,0 +1,57 @@ +/** @odoo-module **/ + + import publicWidget from "@web/legacy/js/public/public_widget"; + import { loadJS } from "@web/core/assets"; + /* global OdooFin */ + + publicWidget.registry.OnlineSyncPortal = publicWidget.Widget.extend({ + selector: '.oe_online_sync', + events: Object.assign({}, { + 'click #renew_consent_button': '_onRenewConsent', + }), + + OdooFinConnector: function (parent, action) { + // Ensure that the proxyMode is valid + const modeRegexp = /^[a-z0-9-_]+$/i; + if (!modeRegexp.test(action.params.proxyMode)) { + return; + } + const url = 'https://' + action.params.proxyMode + '.odoofin.com/proxy/v1/odoofin_link'; + + loadJS(url) + .then(() => { + // Create and open the iframe + const params = { + data: action.params, + proxyMode: action.params.proxyMode, + onEvent: function (event, data) { + switch (event) { + case 'success': + const processUrl = window.location.pathname + '/complete' + window.location.search; + $('.js_reconnect').toggleClass('d-none'); + $.post(processUrl, {csrf_token: odoo.csrf_token}); + default: + return; + } + }, + }; + OdooFin.create(params); + OdooFin.open(); + }); + return; + }, + + /** + * @private + * @param {Event} ev + */ + _onRenewConsent: async function (ev) { + ev.preventDefault(); + const action = JSON.parse($(ev.currentTarget).attr('iframe-params')); + return this.OdooFinConnector(this, action); + }, + }); + + export default { + OnlineSyncPortal: publicWidget.registry.OnlineSyncPortal, + }; diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js b/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js new file mode 100644 index 0000000..6434c51 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/tests/helpers/model_definitions_setup.js @@ -0,0 +1,5 @@ +/** @odoo-module **/ + +import { addModelNamesToFetch } from '@bus/../tests/helpers/model_definitions_helpers'; + +addModelNamesToFetch(["account.online.link", "account.online.account", "account.bank.selection"]); diff --git a/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js b/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js new file mode 100644 index 0000000..3be8ad5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/static/tests/online_account_radio_test.js @@ -0,0 +1,83 @@ +/* @odoo-module */ + +import { startServer } from "@bus/../tests/helpers/mock_python_environment"; + +import { openFormView, start } from "@mail/../tests/helpers/test_utils"; + +import { click, contains } from "@web/../tests/utils"; + +QUnit.module("Views", {}, function () { + QUnit.module("AccountOnlineSynchronizationAccountRadio"); + + QUnit.test("can be rendered", async () => { + const pyEnv = await startServer(); + const onlineLink = pyEnv["account.online.link"].create([ + { + state: "connected", + name: "Fake Bank", + }, + ]); + pyEnv["account.online.account"].create([ + { + name: "account_1", + online_identifier: "abcd", + balance: 10.0, + account_number: "account_number_1", + account_online_link_id: onlineLink, + }, + { + name: "account_2", + online_identifier: "efgh", + balance: 20.0, + account_number: "account_number_2", + account_online_link_id: onlineLink, + }, + ]); + const bankSelection = pyEnv["account.bank.selection"].create([ + { + account_online_link_id: onlineLink, + }, + ]); + + const views = { + "account.bank.selection,false,form": `
    +
    + + +
    +
    `, + }; + await start({ + serverData: { views }, + mockRPC: function (route, args) { + if ( + route === "/web/dataset/call_kw/account.online.account/get_formatted_balances" + ) { + return { + 1: ["$ 10.0", 10.0], + 2: ["$ 20.0", 20.0], + }; + } + }, + }); + await openFormView("account.bank.selection", bankSelection); + await contains(".o_radio_item", { count: 2 }); + await contains(":nth-child(1 of .o_radio_item)", { + contains: [ + ["p", { text: "$ 10.0" }], + ["label", { text: "account_1" }], + [".o_radio_input:checked"], + ], + }); + await contains(":nth-child(2 of .o_radio_item)", { + contains: [ + ["p", { text: "$ 20.0" }], + ["label", { text: "account_2" }], + [".o_radio_input:not(:checked)"], + ], + }); + await click(":nth-child(2 of .o_radio_item) .o_radio_input"); + await contains(":nth-child(1 of .o_radio_item) .o_radio_input:not(:checked)"); + await contains(":nth-child(2 of .o_radio_item) .o_radio_input:checked"); + }); +}); diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py b/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py new file mode 100644 index 0000000..eacefb9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- encoding: utf-8 -*- + +from . import common +from . import test_account_online_account +from . import test_online_sync_creation_statement +from . import test_account_missing_transactions_wizard +from . import test_online_sync_branch_companies diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/common.py b/dev_odex30_accounting/odex30_account_online_sync/tests/common.py new file mode 100644 index 0000000..ffcbc53 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/common.py @@ -0,0 +1,110 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import Command, fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged +from unittest.mock import MagicMock + + +@tagged('post_install', '-at_install') +class AccountOnlineSynchronizationCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.other_currency = cls.setup_other_currency('EUR') + + cls.euro_bank_journal = cls.env['account.journal'].create({ + 'name': 'Euro Bank Journal', + 'type': 'bank', + 'code': 'EURB', + 'currency_id': cls.other_currency.id, + }) + cls.account_online_link = cls.env['account.online.link'].create({ + 'name': 'Test Bank', + 'client_id': 'client_id_1', + 'refresh_token': 'refresh_token', + 'access_token': 'access_token', + }) + cls.account_online_account = cls.env['account.online.account'].create({ + 'name': 'MyBankAccount', + 'account_online_link_id': cls.account_online_link.id, + 'journal_ids': [Command.set(cls.euro_bank_journal.id)] + }) + cls.BankStatementLine = cls.env['account.bank.statement.line'] + + def setUp(self): + super().setUp() + self.transaction_id = 1 + self.account_online_account.balance = 0.0 + + def _create_one_online_transaction(self, transaction_identifier=None, date=None, payment_ref=None, amount=10.0, partner_name=None, foreign_currency_code=None, amount_currency=8.0): + """ This method allows to create an online transaction granularly + + :param transaction_identifier: Online identifier of the transaction, by default transaction_id from the + setUp. If used, transaction_id is not incremented. + :param date: Date of the transaction, by default the date of today + :param payment_ref: Label of the transaction + :param amount: Amount of the transaction, by default equals 10.0 + :param foreign_currency_code: Code of transaction's foreign currency + :param amount_currency: Amount of transaction in foreign currency, update transaction only if foreign_currency_code is given, by default equals 8.0 + :return: A dictionnary representing an online transaction (not formatted) + """ + transaction_identifier = transaction_identifier if transaction_identifier is not None else self.transaction_id + if date: + date = date if isinstance(date, str) else fields.Date.to_string(date) + else: + date = fields.Date.to_string(fields.Date.today()) + + payment_ref = payment_ref or f'transaction_{transaction_identifier}' + transaction = { + 'online_transaction_identifier': transaction_identifier, + 'date': date, + 'payment_ref': payment_ref, + 'amount': amount, + 'partner_name': partner_name, + } + if foreign_currency_code: + transaction.update({ + 'foreign_currency_code': foreign_currency_code, + 'amount_currency': amount_currency + }) + return transaction + + def _create_online_transactions(self, dates): + """ This method returns a list of transactions with the + given dates. + All amounts equals 10.0 + + :param dates: A list of dates, one transaction is created for each given date. + :return: A formatted list of transactions + """ + transactions = [] + for date in dates: + transactions.append(self._create_one_online_transaction(date=date)) + self.transaction_id += 1 + return self.account_online_account._format_transactions(transactions) + + def _mock_odoofin_response(self, data=None): + if not data: + data = {} + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'result': data, + } + return mock_response + + def _mock_odoofin_error_response(self, code=200, message='Default', data=None): + if not data: + data = {} + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'error': { + 'code': code, + 'message': message, + 'data': data, + }, + } + return mock_response diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py new file mode 100644 index 0000000..1d9989f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_missing_transactions_wizard.py @@ -0,0 +1,45 @@ +from odoo import fields +from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon +from odoo.tests import tagged +from unittest.mock import patch + + +@tagged('post_install', '-at_install') +class TestAccountMissingTransactionsWizard(AccountOnlineSynchronizationCommon): + """ Tests the account journal missing transactions wizard. """ + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin') + def test_fetch_missing_transaction(self, patched_fetch_odoofin): + self.account_online_link.state = 'connected' + patched_fetch_odoofin.side_effect = [{ + 'transactions': [ + self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06', foreign_currency_code='EGP', amount_currency=8.0), + ], + 'pendings': [ + self._create_one_online_transaction(transaction_identifier='ABCD02_pending', date='2023-07-25', foreign_currency_code='GBP', amount_currency=8.0), + ] + }] + start_date = fields.Date.from_string('2023-07-01') + wizard = self.env['account.missing.transaction.wizard'].new({ + 'date': start_date, + 'journal_id': self.euro_bank_journal.id, + }) + + action = wizard.action_fetch_missing_transaction() + transient_transactions = self.env['account.bank.statement.line.transient'].search(domain=action['domain']) + egp_currency = self.env['res.currency'].search([('name', '=', 'EGP')]) + gbp_currency = self.env['res.currency'].search([('name', '=', 'GBP')]) + + self.assertEqual(2, len(transient_transactions)) + # Posted Transaction + self.assertEqual(transient_transactions[0]['online_transaction_identifier'], 'ABCD01') + self.assertEqual(transient_transactions[0]['date'], fields.Date.from_string('2023-07-06')) + self.assertEqual(transient_transactions[0]['state'], 'posted') + self.assertEqual(transient_transactions[0]['foreign_currency_id'], egp_currency) + self.assertEqual(transient_transactions[0]['amount_currency'], 8.0) + # Pending Transaction + self.assertEqual(transient_transactions[1]['online_transaction_identifier'], 'ABCD02_pending') + self.assertEqual(transient_transactions[1]['date'], fields.Date.from_string('2023-07-25')) + self.assertEqual(transient_transactions[1]['state'], 'pending') + self.assertEqual(transient_transactions[1]['foreign_currency_id'], gbp_currency) + self.assertEqual(transient_transactions[1]['amount_currency'], 8.0) diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py new file mode 100644 index 0000000..35c5bcd --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_account_online_account.py @@ -0,0 +1,491 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from datetime import datetime, timedelta +from freezegun import freeze_time +from unittest.mock import patch + +from odoo import Command, fields, tools +from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon +from odoo.tests import tagged + +_logger = logging.getLogger(__name__) + +@tagged('post_install', '-at_install') +class TestAccountOnlineAccount(AccountOnlineSynchronizationCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.bank_account_id = cls.env['account.account'].create({ + 'name': 'Bank Account', + 'account_type': 'asset_cash', + 'code': cls.env['account.account']._search_new_account_code('BNK100'), + }) + cls.bank_journal = cls.env['account.journal'].create({ + 'name': 'A bank journal', + 'default_account_id': cls.bank_account_id.id, + 'type': 'bank', + 'code': cls.env['account.journal'].get_next_bank_cash_default_code('bank', cls.company_data['company']), + }) + + @freeze_time('2023-08-01') + def test_get_filtered_transactions(self): + """ This test verifies that duplicate transactions are filtered """ + self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({ + 'date': '2023-08-01', + 'journal_id': self.euro_bank_journal.id, + 'online_transaction_identifier': 'ABCD01', + 'payment_ref': 'transaction_ABCD01', + 'amount': 10.0, + }) + + transactions_to_filtered = [ + self._create_one_online_transaction(transaction_identifier='ABCD01'), + self._create_one_online_transaction(transaction_identifier='ABCD02'), + ] + + filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered) + + self.assertEqual( + filtered_transactions, + [ + { + 'payment_ref': 'transaction_ABCD02', + 'date': '2023-08-01', + 'online_transaction_identifier': 'ABCD02', + 'amount': 10.0, + 'partner_name': None, + } + ] + ) + + @freeze_time('2023-08-01') + def test_get_filtered_transactions_with_empty_transaction_identifier(self): + """ This test verifies that transactions without a transaction identifier + are not filtered due to their empty transaction identifier. + """ + self.BankStatementLine.with_context(skip_statement_line_cron_trigger=True).create({ + 'date': '2023-08-01', + 'journal_id': self.euro_bank_journal.id, + 'online_transaction_identifier': '', + 'payment_ref': 'transaction_ABCD01', + 'amount': 10.0, + }) + + transactions_to_filtered = [ + self._create_one_online_transaction(transaction_identifier=''), + self._create_one_online_transaction(transaction_identifier=''), + ] + + filtered_transactions = self.account_online_account._get_filtered_transactions(transactions_to_filtered) + + self.assertEqual( + filtered_transactions, + [ + { + 'payment_ref': 'transaction_', + 'date': '2023-08-01', + 'online_transaction_identifier': '', + 'amount': 10.0, + 'partner_name': None, + }, + { + 'payment_ref': 'transaction_', + 'date': '2023-08-01', + 'online_transaction_identifier': '', + 'amount': 10.0, + 'partner_name': None, + }, + ] + ) + + @freeze_time('2023-08-01') + def test_format_transactions(self): + transactions_to_format = [ + self._create_one_online_transaction(transaction_identifier='ABCD01'), + self._create_one_online_transaction(transaction_identifier='ABCD02'), + ] + formatted_transactions = self.account_online_account._format_transactions(transactions_to_format) + self.assertEqual( + formatted_transactions, + [ + { + 'payment_ref': 'transaction_ABCD01', + 'date': fields.Date.from_string('2023-08-01'), + 'online_transaction_identifier': 'ABCD01', + 'amount': 10.0, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + 'partner_name': None, + }, + { + 'payment_ref': 'transaction_ABCD02', + 'date': fields.Date.from_string('2023-08-01'), + 'online_transaction_identifier': 'ABCD02', + 'amount': 10.0, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + 'partner_name': None, + }, + ] + ) + + @freeze_time('2023-08-01') + def test_format_transactions_invert_sign(self): + transactions_to_format = [ + self._create_one_online_transaction(transaction_identifier='ABCD01', amount=25.0), + ] + self.account_online_account.inverse_transaction_sign = True + formatted_transactions = self.account_online_account._format_transactions(transactions_to_format) + self.assertEqual( + formatted_transactions, + [ + { + 'payment_ref': 'transaction_ABCD01', + 'date': fields.Date.from_string('2023-08-01'), + 'online_transaction_identifier': 'ABCD01', + 'amount': -25.0, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + 'partner_name': None, + }, + ] + ) + + @freeze_time('2023-08-01') + def test_format_transactions_foreign_currency_code_to_id_with_activation(self): + """ This test ensures conversion of foreign currency code to foreign currency id and activates foreign currency if not already activate """ + gbp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'GBP')]) + egp_currency = self.env['res.currency'].with_context(active_test=False).search([('name', '=', 'EGP')]) + + transactions_to_format = [ + self._create_one_online_transaction(transaction_identifier='ABCD01', foreign_currency_code='GBP'), + self._create_one_online_transaction(transaction_identifier='ABCD02', foreign_currency_code='EGP', amount_currency=500.0), + ] + formatted_transactions = self.account_online_account._format_transactions(transactions_to_format) + + self.assertTrue(gbp_currency.active) + self.assertTrue(egp_currency.active) + + self.assertEqual( + formatted_transactions, + [ + { + 'payment_ref': 'transaction_ABCD01', + 'date': fields.Date.from_string('2023-08-01'), + 'online_transaction_identifier': 'ABCD01', + 'amount': 10.0, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + 'partner_name': None, + 'foreign_currency_id': gbp_currency.id, + 'amount_currency': 8.0, + }, + { + 'payment_ref': 'transaction_ABCD02', + 'date': fields.Date.from_string('2023-08-01'), + 'online_transaction_identifier': 'ABCD02', + 'amount': 10.0, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + 'partner_name': None, + 'foreign_currency_id': egp_currency.id, + 'amount_currency': 500.0, + }, + ] + ) + + @freeze_time('2023-07-25') + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin') + def test_retrieve_pending_transactions(self, patched_fetch_odoofin): + self.account_online_link.state = 'connected' + patched_fetch_odoofin.side_effect = [{ + 'transactions': [ + self._create_one_online_transaction(transaction_identifier='ABCD01', date='2023-07-06'), + self._create_one_online_transaction(transaction_identifier='ABCD02', date='2023-07-22'), + ], + 'pendings': [ + self._create_one_online_transaction(transaction_identifier='ABCD03_pending', date='2023-07-25'), + self._create_one_online_transaction(transaction_identifier='ABCD04_pending', date='2023-07-25'), + ] + }] + + start_date = fields.Date.from_string('2023-07-01') + result = self.account_online_account._retrieve_transactions(date=start_date, include_pendings=True) + self.assertEqual( + result, + { + 'transactions': [ + { + 'payment_ref': 'transaction_ABCD01', + 'date': fields.Date.from_string('2023-07-06'), + 'online_transaction_identifier': 'ABCD01', + 'amount': 10.0, + 'partner_name': None, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + }, + { + 'payment_ref': 'transaction_ABCD02', + 'date': fields.Date.from_string('2023-07-22'), + 'online_transaction_identifier': 'ABCD02', + 'amount': 10.0, + 'partner_name': None, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + } + ], + 'pendings': [ + { + 'payment_ref': 'transaction_ABCD03_pending', + 'date': fields.Date.from_string('2023-07-25'), + 'online_transaction_identifier': 'ABCD03_pending', + 'amount': 10.0, + 'partner_name': None, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + }, + { + 'payment_ref': 'transaction_ABCD04_pending', + 'date': fields.Date.from_string('2023-07-25'), + 'online_transaction_identifier': 'ABCD04_pending', + 'amount': 10.0, + 'partner_name': None, + 'online_account_id': self.account_online_account.id, + 'journal_id': self.euro_bank_journal.id, + 'company_id': self.euro_bank_journal.company_id.id, + } + ] + } + ) + + @freeze_time('2023-01-01 01:10:15') + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={}) + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}}) + def test_basic_flow_manual_fetching_transactions(self, patched_refresh, patched_transactions): + self.addCleanup(self.env.registry.leave_test_mode) + # flush and clear everything for the new "transaction" + self.env.invalidate_all() + + self.env.registry.enter_test_mode(self.cr) + with self.env.registry.cursor() as test_cr: + test_env = self.env(cr=test_cr) + test_link_account = self.account_online_link.with_env(test_env) + test_link_account.state = 'connected' + # Call fetch_transaction in manual mode and check that a call was made to refresh and to transaction + test_link_account._fetch_transactions() + patched_refresh.assert_called_once() + patched_transactions.assert_called_once() + self.assertEqual(test_link_account.account_online_account_ids[0].fetching_status, 'done') + + @freeze_time('2023-01-01 01:10:15') + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={}) + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin') + def test_refresh_incomplete_fetching_transactions(self, patched_refresh, patched_transactions): + patched_refresh.return_value = {'success': False} + # Call fetch_transaction and if call result is false, don't call transaction + self.account_online_link._fetch_transactions() + patched_transactions.assert_not_called() + + patched_refresh.return_value = {'success': False, 'currently_fetching': True} + # Call fetch_transaction and if call result is false but in the process of fetching, don't call transaction + # and wait for the async cron to try again + self.account_online_link._fetch_transactions() + patched_transactions.assert_not_called() + self.assertEqual(self.account_online_account.fetching_status, 'waiting') + + @freeze_time('2023-01-01 01:10:15') + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._retrieve_transactions', return_value={}) + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineAccount._refresh', return_value={'success': True, 'data': {}}) + def test_currently_processing_fetching_transactions(self, patched_refresh, patched_transactions): + self.account_online_account.fetching_status = 'processing' # simulate the fact that we are currently creating entries in odoo + limit_time = tools.config['limit_time_real_cron'] if tools.config['limit_time_real_cron'] > 0 else tools.config['limit_time_real'] + self.account_online_link.last_refresh = datetime.now() + with freeze_time(datetime.now() + timedelta(seconds=(limit_time - 10))): + # Call to fetch_transaction should be skipped, and the cron should not try to fetch either + self.account_online_link._fetch_transactions() + self.euro_bank_journal._cron_fetch_waiting_online_transactions() + patched_refresh.assert_not_called() + patched_transactions.assert_not_called() + + self.addCleanup(self.env.registry.leave_test_mode) + # flush and clear everything for the new "transaction" + self.env.invalidate_all() + + self.env.registry.enter_test_mode(self.cr) + with self.env.registry.cursor() as test_cr: + test_env = self.env(cr=test_cr) + with freeze_time(datetime.now() + timedelta(seconds=(limit_time + 100))): + # Call to fetch_transaction should be started by the cron when the time limit is exceeded and still in processing + self.euro_bank_journal.with_env(test_env)._cron_fetch_waiting_online_transactions() + patched_refresh.assert_not_called() + patched_transactions.assert_called_once() + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests') + def test_delete_with_redirect_error(self, patched_request): + # Use case being tested: call delete on a record, first call returns token expired exception + # Which trigger a call to get a new token, which result in a 104 user_deleted_error, since version 17, + # such error are returned as a OdooFinRedirectException with mode link to reopen the iframe and link with a new + # bank. In our case we don't want that and want to be able to delete the record instead. + # Such use case happen when db_uuid has changed as the check for db_uuid is done after the check for token_validity + account_online_link = self.env['account.online.link'].create({ + 'name': 'Test Delete', + 'client_id': 'client_id_test', + 'refresh_token': 'refresh_token', + 'access_token': 'access_token', + }) + first_call = self._mock_odoofin_error_response(code=102) + second_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'}) + patched_request.post.side_effect = [first_call, second_call] + nb_connections = len(self.env['account.online.link'].search([])) + # Try to delete record + account_online_link.unlink() + # Record should be deleted + self.assertEqual(len(self.env['account.online.link'].search([])), nb_connections - 1) + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests') + def test_redirect_mode_link(self, patched_request): + # Use case being tested: Call to open the iframe which result in a OdoofinRedirectException in link mode + # This should not trigger a traceback but delete the current online.link and reopen the iframe + account_online_link = self.env['account.online.link'].create({ + 'name': 'Test Delete', + 'client_id': 'client_id_test', + 'refresh_token': 'refresh_token', + 'access_token': 'access_token', + }) + link_id = account_online_link.id + first_call = self._mock_odoofin_error_response(code=300, data={'mode': 'link'}) + second_call = self._mock_odoofin_response(data={'delete': True}) + patched_request.post.side_effect = [first_call, second_call] + # Try to open iframe with broken connection + action = account_online_link.action_new_synchronization() + # Iframe should open in mode link and with a different record (old one should have been deleted) + self.assertEqual(action['params']['mode'], 'link') + self.assertNotEqual(action['id'], link_id) + self.assertEqual(len(self.env['account.online.link'].search([('id', '=', link_id)])), 0) + + @patch("odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status", return_value={}) + def test_assign_journal_with_currency_on_account_online_account(self, patched_update_connection_status): + self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': fields.Date.from_string('2025-06-25'), + 'journal_id': self.bank_journal.id, + 'invoice_line_ids': [ + Command.create({ + 'name': 'a line', + 'account_id': self.bank_account_id.id, + 'debit': 100, + 'currency_id': self.company_data['currency'].id, + }), + Command.create({ + 'name': 'another line', + 'account_id': self.company_data['default_account_expense'].id, + 'credit': 100, + 'currency_id': self.company_data['currency'].id, + }), + ], + }, + { + 'move_type': 'entry', + 'date': fields.Date.from_string('2025-06-26'), + 'journal_id': self.bank_journal.id, + 'invoice_line_ids': [ + Command.create({ + 'name': 'a line', + 'account_id': self.bank_account_id.id, + 'debit': 220, + 'currency_id': self.company_data['currency'].id, + }), + Command.create({ + 'name': 'another line', + 'account_id': self.company_data['default_account_expense'].id, + 'credit': 220, + 'currency_id': self.company_data['currency'].id, + }), + ], + }, + ]) + + self.account_online_account.currency_id = self.company_data['currency'].id + self.account_online_account.with_context(active_id=self.bank_journal.id, active_model='account.journal')._assign_journal() + self.assertEqual( + self.bank_journal.currency_id.id, + self.company_data['currency'].id, + ) + self.assertEqual( + self.bank_journal.default_account_id.currency_id.id, + self.company_data['currency'].id, + ) + + @patch("odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status", return_value={}) + def test_set_currency_on_journal_when_existing_currencies_on_move_lines(self, patched_update_connection_status): + bank_account_id = self.env['account.account'].create({ + 'name': 'Bank Account', + 'account_type': 'asset_cash', + 'code': self.env['account.account']._search_new_account_code('BNK100'), + }) + bank_journal = self.env['account.journal'].create({ + 'name': 'A bank journal', + 'default_account_id': bank_account_id.id, + 'type': 'bank', + 'code': self.env['account.journal'].get_next_bank_cash_default_code('bank', self.company_data['company']), + }) + + self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': fields.Date.from_string('2025-06-25'), + 'journal_id': bank_journal.id, + 'invoice_line_ids': [ + Command.create({ + 'name': 'a line', + 'account_id': bank_account_id.id, + 'debit': 100, + 'currency_id': self.other_currency.id, + }), + Command.create({ + 'name': 'another line', + 'account_id': self.company_data['default_account_expense'].id, + 'credit': 100, + 'currency_id': self.other_currency.id, + }), + ], + }, + { + 'move_type': 'entry', + 'date': fields.Date.from_string('2025-06-26'), + 'journal_id': bank_journal.id, + 'invoice_line_ids': [ + Command.create({ + 'name': 'a line', + 'account_id': bank_account_id.id, + 'debit': 220, + 'currency_id': self.company_data['currency'].id, + }), + Command.create({ + 'name': 'another line', + 'account_id': self.company_data['default_account_expense'].id, + 'credit': 220, + 'currency_id': self.company_data['currency'].id, + }), + ], + }, + ]) + + self.account_online_account.currency_id = self.company_data['currency'].id + self.account_online_account.with_context(active_id=bank_journal.id, active_model='account.journal')._assign_journal() + + # Silently ignore the error and don't set currency on the journal and on the account + self.assertEqual(bank_journal.currency_id.id, False) + self.assertEqual(bank_journal.default_account_id.currency_id.id, False) diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py new file mode 100644 index 0000000..16cb0ef --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_branch_companies.py @@ -0,0 +1,86 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestSynchInBranches(AccountOnlineSynchronizationCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.mother_company = cls.env['res.company'].create({'name': 'Mother company 2000'}) + cls.branch_company = cls.env['res.company'].create({'name': 'Branch company', 'parent_id': cls.mother_company.id}) + + cls.mother_bank_journal = cls.env['account.journal'].create({ + 'name': 'Mother Bank Journal', + 'type': 'bank', + 'code': 'MBJ', + 'company_id': cls.mother_company.id, + }) + cls.mother_account_online_link = cls.env['account.online.link'].create({ + 'name': 'Test Bank', + 'client_id': 'client_id_1', + 'refresh_token': 'refresh_token', + 'access_token': 'access_token', + 'company_id': cls.mother_company.id, + }) + + def test_show_sync_actions(self): + """We test if the sync actions are correctly displayed based on the selected and enabled companies. + + Let's have company A with an online link, and a branch of that company: company B. + + - If we only have company A enabled and selected, the sync actions should be shown. + - If company A and B are enabled, no matter which company is selected, the sync actions should be shown. + - If we only have company B enabled and selected, the sync actions should be hidden. + """ + self.assertTrue( + self.mother_account_online_link + .with_context(allowed_company_ids=(self.mother_company)._ids) + .with_company(self.mother_company) + .show_sync_actions + ) + + self.assertTrue( + self.mother_account_online_link + .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids) + .with_company(self.mother_company) + .show_sync_actions + ) + + self.assertTrue( + self.mother_account_online_link + .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids) + .with_company(self.branch_company) + .show_sync_actions + ) + + self.assertFalse( + self.mother_account_online_link + .with_context(allowed_company_ids=(self.branch_company)._ids) + .with_company(self.branch_company) + .show_sync_actions + ) + + def test_show_bank_connect(self): + """We test if the 'connect' bank button appears on the journal on the dashboard given the selected company. + + Let's have company A with an bank journal, and a branch of that company: company B. + + - On the dashboard of company A, the connect bank button should appear on the journal. + - On the dashboard of company B, the connect bank button should not appear on the journal, even with company A enabled. + """ + dashboard_data = self.mother_bank_journal\ + .with_context(allowed_company_ids=(self.mother_company)._ids)\ + .with_company(self.mother_company)\ + ._get_journal_dashboard_data_batched() + self.assertTrue(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard')) + + dashboard_data = self.mother_bank_journal\ + .with_context(allowed_company_ids=(self.branch_company + self.mother_company)._ids)\ + .with_company(self.branch_company)\ + ._get_journal_dashboard_data_batched() + self.assertFalse(dashboard_data[self.mother_bank_journal.id].get('display_connect_bank_in_dashboard')) diff --git a/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py new file mode 100644 index 0000000..a68e1da --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/tests/test_online_sync_creation_statement.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from unittest.mock import MagicMock, patch + +from odoo.addons.base.models.res_bank import sanitize_account_number +from odoo.addons.odex30_account_online_sync.tests.common import AccountOnlineSynchronizationCommon +from odoo.exceptions import RedirectWarning +from odoo.tests import tagged +from odoo import fields, Command + + +@tagged('post_install', '-at_install') +class TestSynchStatementCreation(AccountOnlineSynchronizationCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.account = cls.env['account.account'].create({ + 'name': 'Fixed Asset Account', + 'code': 'AA', + 'account_type': 'asset_fixed', + }) + + def reconcile_st_lines(self, st_lines): + for line in st_lines: + wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=line.id).new({}) + line = wizard.line_ids.filtered(lambda x: x.flag == 'auto_balance') + wizard._js_action_mount_line_in_edit(line.index) + line.name = "toto" + wizard._line_value_changed_name(line) + line.account_id = self.account + wizard._line_value_changed_account_id(line) + wizard._action_validate() + + # Tests + def test_creation_initial_sync_statement(self): + transactions = self._create_online_transactions(['2016-01-01', '2016-01-03']) + self.account_online_account.balance = 1000 + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement + # it should create a statement before this one with the initial statement line + created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc') + self.assertEqual(len(created_st_lines), 3, 'Should have created an initial bank statement line and two for the synchronization') + transactions = self._create_online_transactions(['2016-01-05']) + self.account_online_account.balance = 2000 + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc') + self.assertRecordValues( + created_st_lines, + [ + {'date': fields.Date.from_string('2016-01-01'), 'amount': 980.0}, + {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0}, + {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0}, + {'date': fields.Date.from_string('2016-01-05'), 'amount': 10.0}, + ] + ) + + def test_creation_initial_sync_statement_bis(self): + transactions = self._create_online_transactions(['2016-01-01', '2016-01-03']) + self.account_online_account.balance = 20 + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + # Since ending balance is 20$ and we only have 20$ of transactions and that it is the first statement + # it should NOT create a initial statement before this one + created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc') + self.assertRecordValues( + created_st_lines, + [ + {'date': fields.Date.from_string('2016-01-01'), 'amount': 10.0}, + {'date': fields.Date.from_string('2016-01-03'), 'amount': 10.0}, + ] + ) + + def test_creation_initial_sync_statement_invert_sign(self): + self.account_online_account.balance = -20 + self.account_online_account.inverse_transaction_sign = True + self.account_online_account.inverse_balance_sign = True + transactions = self._create_online_transactions(['2016-01-01', '2016-01-03']) + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + # Since ending balance is 1000$ and we only have 20$ of transactions and that it is the first statement + # it should create a statement before this one with the initial statement line + created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc') + self.assertEqual(len(created_st_lines), 2, 'Should have created two bank statement lines for the synchronization') + transactions = self._create_online_transactions(['2016-01-05']) + self.account_online_account.balance = -30 + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + created_st_lines = self.BankStatementLine.search([('journal_id', '=', self.euro_bank_journal.id)], order='internal_index asc') + self.assertRecordValues( + created_st_lines, + [ + {'date': fields.Date.from_string('2016-01-01'), 'amount': -10.0}, + {'date': fields.Date.from_string('2016-01-03'), 'amount': -10.0}, + {'date': fields.Date.from_string('2016-01-05'), 'amount': -10.0}, + ] + ) + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_transactions') + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._update_connection_status') + def test_automatic_journal_assignment(self, patched_update_connection_status, patched_fetch_transactions): + def create_online_account(name, link_id, iban, currency_id): + return self.env['account.online.account'].create({ + 'name': name, + 'account_online_link_id': link_id, + 'account_number': iban, + 'currency_id' : currency_id, + }) + + def create_bank_account(account_number, partner_id): + return self.env['res.partner.bank'].create({ + 'acc_number': account_number, + 'partner_id': partner_id, + }) + + def create_journal(name, journal_type, code, currency_id=False, bank_account_id=False): + return self.env['account.journal'].create({ + 'name': name, + 'type': journal_type, + 'code': code, + 'currency_id': currency_id, + 'bank_account_id': bank_account_id, + }) + + bank_account_1 = create_bank_account('BE48485444456727', self.company_data['company'].partner_id.id) + bank_account_2 = create_bank_account('BE23798242487491', self.company_data['company'].partner_id.id) + + bank_journal_with_account_gol = create_journal('Bank with account', 'bank', 'BJWA1', self.other_currency.id) + bank_journal_with_account_usd = create_journal('Bank with account USD', 'bank', 'BJWA3', self.env.ref('base.USD').id, bank_account_2.id) + + online_account_1 = create_online_account('OnlineAccount1', self.account_online_link.id, 'BE48485444456727', self.other_currency.id) + online_account_2 = create_online_account('OnlineAccount2', self.account_online_link.id, 'BE61954856342317', self.other_currency.id) + online_account_3 = create_online_account('OnlineAccount3', self.account_online_link.id, 'BE23798242487495', self.other_currency.id) + + patched_fetch_transactions.return_value = True + patched_update_connection_status.return_value = { + 'consent_expiring_date': None, + 'is_payment_enabled': False, + 'is_payment_activated': False, + } + + account_link_journal_wizard = self.env['account.bank.selection'].create({'account_online_link_id': self.account_online_link.id}) + account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_gol.id).sync_now() + self.assertEqual( + online_account_1.id, bank_journal_with_account_gol.account_online_account_id.id, + "The wizard should have linked the online account to the journal with the same account." + ) + self.assertEqual(bank_journal_with_account_gol.bank_account_id, bank_account_1, "Account should be set on the journal") + + # Test with no context present, should create a new journal + previous_number = self.env['account.journal'].search_count([]) + account_link_journal_wizard.selected_account = online_account_2 + account_link_journal_wizard.sync_now() + actual_number = self.env['account.journal'].search_count([]) + self.assertEqual(actual_number, previous_number+1, "should have created a new journal") + self.assertEqual(online_account_2.journal_ids.currency_id, self.other_currency) + self.assertEqual(online_account_2.journal_ids.bank_account_id.sanitized_acc_number, sanitize_account_number('BE61954856342317')) + + # Test assigning to a journal in another currency + account_link_journal_wizard.selected_account = online_account_3 + account_link_journal_wizard.with_context(active_model='account.journal', active_id=bank_journal_with_account_usd.id).sync_now() + self.assertEqual(online_account_3.id, bank_journal_with_account_usd.account_online_account_id.id) + self.assertEqual(bank_journal_with_account_usd.bank_account_id, bank_account_2, "Bank Account should not have changed") + self.assertEqual(bank_journal_with_account_usd.currency_id, self.other_currency, "Currency should have changed") + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin') + def test_fetch_transaction_date_start(self, patched_fetch): + """ This test verifies that the start_date params used when fetching transaction is correct """ + patched_fetch.return_value = {'transactions': []} + # Since no transactions exists in db, we should fetch transactions without a starting_date + self.account_online_account._retrieve_transactions() + data = { + 'start_date': False, + 'account_id': False, + 'last_transaction_identifier': False, + 'currency_code': 'EUR', + 'provider_data': False, + 'account_data': False, + 'include_pendings': False, + 'include_foreign_currency': True, + } + patched_fetch.assert_called_with('/proxy/v1/transactions', data=data) + + # No transaction exists in db but we have a value for last_sync on the online_account, we should use that date + self.account_online_account.last_sync = '2020-03-04' + data['start_date'] = '2020-03-04' + self.account_online_account._retrieve_transactions() + patched_fetch.assert_called_with('/proxy/v1/transactions', data=data) + + # We have transactions, we should use the date of the latest one instead of the last_sync date + transactions = self._create_online_transactions(['2016-01-01', '2016-01-03']) + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + self.account_online_account.last_sync = '2020-03-04' + data['start_date'] = '2016-01-03' + data['last_transaction_identifier'] = '2' + self.account_online_account._retrieve_transactions() + patched_fetch.assert_called_with('/proxy/v1/transactions', data=data) + + def test_multiple_transaction_identifier_fetched(self): + # Ensure that if we receive twice the same transaction within the same call, it won't be created twice + transactions = self._create_online_transactions(['2016-01-01', '2016-01-03']) + # Add first transactions to the list again + transactions.append(transactions[0]) + self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + bnk_stmt_lines = self.BankStatementLine.search([('online_transaction_identifier', '!=', False), ('journal_id', '=', self.euro_bank_journal.id)]) + self.assertEqual(len(bnk_stmt_lines), 2, 'Should only have created two lines') + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.AccountOnlineLink._fetch_odoo_fin') + def test_fetch_transactions_reauth(self, patched_refresh): + patched_refresh.side_effect = [ + { + 'success': False, + 'code': 300, + 'data': {'mode': 'updateCredentials'}, + }, + { + 'access_token': 'open_sesame', + }, + ] + self.account_online_account.account_online_link_id.state = 'connected' + res = self.account_online_account.account_online_link_id._fetch_transactions() + self.assertTrue('account_online_identifier' in res.get('params', {}).get('includeParam', {})) + + def test_duplicate_transaction_date_amount_account(self): + """ This test verifies that the duplicate transaction wizard is detects transactions with + same date, amount, account_number and currency + """ + # Create 2 groups of respectively 2 and 3 duplicate transactions. We create one transaction the day before so the opening statement does not interfere with the test. + transactions = self._create_online_transactions([ + '2024-01-01', + '2024-01-02', '2024-01-02', + '2024-01-03', '2024-01-03', '2024-01-03', + ]) + bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db + duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions( + fields.Date.to_date('2000-01-01') + ) + group_1 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-02')).ids + group_2 = bsls.filtered(lambda bsl: bsl.date == fields.Date.from_string('2024-01-03')).ids + + self.assertEqual(duplicate_transactions, [group_1, group_2]) + + # check has_duplicate_transactions + has_duplicate_transactions = self.euro_bank_journal._has_duplicate_transactions( + fields.Date.to_date('2000-01-01') + ) + self.assertTrue(has_duplicate_transactions is True) # explicit check on bool type + + def test_duplicate_transaction_online_transaction_identifier(self): + """ This test verifies that the duplicate transaction wizard is detects transactions with + same online_transaction_identifier + """ + # Create transactions + transactions = self._create_online_transactions([ + '2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05' + ]) + bsls = self.BankStatementLine._online_sync_bank_statement(transactions, self.account_online_account) + + group_1, group_2 = [], [] + for bsl in bsls: + # have to update the online_transaction_identifier after to force duplicates + if bsl.payment_ref in ('transaction_1', 'transaction_2'): + group_1.append(bsl.id) + bsl.online_transaction_identifier = 'same_oti_1' + if bsl.payment_ref in ('transaction_3, transaction_4, transaction_5'): + group_2.append(bsl.id) + bsl.online_transaction_identifier = 'same_oti_2' + + self.env.flush_all() # _get_duplicate_transactions make sql request, must write to db + duplicate_transactions = self.euro_bank_journal._get_duplicate_transactions( + fields.Date.to_date('2000-01-01') + ) + self.assertEqual(duplicate_transactions, [group_1, group_2]) + + @patch('odoo.addons.odex30_account_online_sync.models.account_online.requests') + def test_fetch_receive_error_message(self, patched_request): + # We want to test that when we receive an error, a redirectWarning with the correct parameter is thrown + # However the method _log_information that we need to test for that is performing a rollback as it needs + # to save the message error on the record as well (so it rollback, save message, commit, raise error). + # So in order to test the method, we need to use a "test cursor". + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'error': { + 'code': 400, + 'message': 'Shit Happened', + 'data': { + 'exception_type': 'random', + 'message': 'This kind of things can happen.', + 'error_reference': 'abc123', + 'provider_type': 'theonlyone', + 'redirect_warning_url': 'odoo_support', + }, + }, + } + patched_request.post.return_value = mock_response + + generated_url = 'https://www.odoo.com/help?stage=bank_sync&summary=Bank+sync+error+ref%3A+abc123+-+Provider%3A+theonlyone+-+Client+ID%3A+client_id_1&description=ClientID%3A+client_id_1%0AInstitution%3A+Test+Bank%0AError+Reference%3A+abc123%0AError+Message%3A+This+kind+of+things+can+happen.%0A' + return_act_url = { + 'type': 'ir.actions.act_url', + 'url': generated_url + } + body_generated_url = generated_url.replace('&', '&') #in post_message, & has been escaped to & + message_body = f"""

    This kind of things can happen. + +If you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.
    You can contact Odoo support Here

    """ + + # flush and clear everything for the new "transaction" + self.env.invalidate_all() + try: + self.env.registry.enter_test_mode(self.cr) + with self.env.registry.cursor() as test_cr: + test_env = self.env(cr=test_cr) + test_link_account = self.account_online_link.with_env(test_env) + test_link_account.state = 'connected' + + # this hand-written self.assertRaises() does not roll back self.cr, + # which is necessary below to inspect the message being posted + try: + test_link_account._fetch_odoo_fin('/testthisurl') + except RedirectWarning as exception: + self.assertEqual(exception.args[0], "This kind of things can happen.\n\nIf you've already opened a ticket for this issue, don't report it again: a support agent will contact you shortly.") + self.assertEqual(exception.args[1], return_act_url) + self.assertEqual(exception.args[2], 'Report issue') + else: + self.fail("Expected RedirectWarning not raised") + self.assertEqual(test_link_account.message_ids[0].body, message_body) + finally: + self.env.registry.leave_test_mode() + + def test_account_online_link_having_journal_ids(self): + """ This test verifies that the account online link object + has all the journal in the field journal_ids. + It's important to handle these journals because we need + them to add the consent expiring date. + """ + # Create a bank sync connection having 2 online accounts (with one journal connected for each account) + online_link = self.env['account.online.link'].create({ + 'name': 'My New Bank connection', + }) + online_accounts = self.env['account.online.account'].create([ + { + 'name': 'Account 1', + 'account_online_link_id': online_link.id, + 'journal_ids': [Command.create({ + 'name': 'Account 1', + 'code': 'BK1', + 'type': 'bank', + })], + }, + { + 'name': 'Account 2', + 'account_online_link_id': online_link.id, + 'journal_ids': [Command.create({ + 'name': 'Account 2', + 'code': 'BK2', + 'type': 'bank', + })], + }, + ]) + self.assertEqual(online_link.account_online_account_ids, online_accounts) + self.assertEqual(len(online_link.journal_ids), 2) # Our online link connections should have 2 journals. + + def test_transaction_details_json_compatibility_from_html(self): + """ This test checks that, after being imported from the transient model + the records of account.bank.statement.line will have the + 'transaction_details' field able to be decoded to a JSON, + i.e. it is not encapsulated in

    tags. + """ + transaction = self._create_one_online_transaction() + transaction['transaction_details'] = '{\n "account_id": "1",\n "status": "posted"\n}' + transient_transaction = self.env['account.bank.statement.line.transient'].create(transaction) + transaction_details = transient_transaction.read(fields=['transaction_details'], load=None)[0]['transaction_details'] + self.assertFalse(transaction_details.startswith('

    '), 'Transient transaction details should not start with

    when read.') + self.assertFalse(transaction_details.endswith('

    '), 'Transient transaction details should not end with

    when read.') diff --git a/dev_odex30_accounting/odex30_account_online_sync/views/account_bank_statement_view.xml b/dev_odex30_accounting/odex30_account_online_sync/views/account_bank_statement_view.xml new file mode 100644 index 0000000..678dc78 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/views/account_bank_statement_view.xml @@ -0,0 +1,27 @@ + + + + + bank.statement.line.list.inherit + account.bank.statement.line + + + + + + + + + + + + account.bank.statement.line.form.bank_rec_widget.inherit + account.bank.statement.line + primary + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_dashboard_view.xml b/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_dashboard_view.xml new file mode 100644 index 0000000..ebe51e5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_dashboard_view.xml @@ -0,0 +1,86 @@ + + + + + account.journal.dashboard.inherit.online.sync + account.journal + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
    +
    + + + + + + + account.group_account_manager + + dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6' + + + + account.group_account_manager + + dashboard.display_connect_bank_in_dashboard ? 'col-4' : 'col-6' + + + + + + + + + + + + + + + + + +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_view.xml b/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_view.xml new file mode 100644 index 0000000..4e356f0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/views/account_journal_view.xml @@ -0,0 +1,22 @@ + + + + + account.journal.form.online.sync + account.journal + + + + + + +
    + + + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_portal_templates.xml b/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_portal_templates.xml new file mode 100644 index 0000000..c0cd687 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_portal_templates.xml @@ -0,0 +1,59 @@ + + + + diff --git a/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_views.xml b/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_views.xml new file mode 100644 index 0000000..e43ecef --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/views/account_online_sync_views.xml @@ -0,0 +1,126 @@ + + + + + account.online.link.form + account.online.link + +
    +
    +
    + +
    +

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.py b/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.py new file mode 100644 index 0000000..f6e0c2c --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.py @@ -0,0 +1,78 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import format_date + + +class AccountMissingTransaction(models.TransientModel): + _name = 'account.missing.transaction.wizard' + _description = 'Wizard for missing transactions' + + date = fields.Date( + string="Starting Date", + default=lambda self: fields.Date.today() - relativedelta(months=1), + ) + journal_id = fields.Many2one( + comodel_name='account.journal', + domain="[('type', '=', 'bank'), ('account_online_account_id', '!=', 'False'), ('account_online_link_state', '=', 'connected')]" + ) + + def _get_manual_bank_statement_lines(self): + return self.env['account.bank.statement.line'].search( + domain=[ + ('date', '>=', self.date), + ('journal_id', '=', self.journal_id.id), + ('online_transaction_identifier', '=', False), + ], + ) + + def action_fetch_missing_transaction(self): + self.ensure_one() + + if not self.journal_id: + raise UserError(_("You have to select one journal to continue.")) + + if not self.date: + raise UserError(_("Please enter a valid Starting Date to continue.")) + + if self.journal_id.account_online_link_state != 'connected': + raise UserError(_("You can't find missing transactions for a journal that isn't connected.")) + + fetched_transactions = self.journal_id.account_online_account_id._retrieve_transactions(date=self.date, include_pendings=True) + transactions = fetched_transactions.get('transactions') or [] + pendings = fetched_transactions.get('pendings') or [] + + pendings = [{**pending, 'state': 'pending'} for pending in pendings] + filtered_transactions = self.journal_id.account_online_account_id._get_filtered_transactions(transactions + pendings) + + transient_transactions_ids = self.env['account.bank.statement.line.transient'].create(filtered_transactions) + + return { + 'name': _("Missing and Pending Transactions"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement.line.transient', + 'view_mode': 'list', + 'views': [(False, 'list')], + 'domain': [('id', 'in', transient_transactions_ids.ids)], + 'context': { + 'has_manual_entries': bool(self._get_manual_bank_statement_lines()), + 'is_fetch_before_creation': self.date < self.journal_id.account_online_link_id.create_date.date(), + 'account_online_link_create_date': format_date(self.env, self.journal_id.account_online_link_id.create_date), + 'search_default_filter_posted': bool([transaction for transaction in filtered_transactions if transaction.get('state') != 'pending']), # Activate this default filter only if we have posted transactions + }, + } + + def action_open_manual_bank_statement_lines(self): + self.ensure_one() + bank_statement_lines = self._get_manual_bank_statement_lines() + + return { + 'name': _("Manual Bank Statement Lines"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement.line', + 'views': [(False, 'list'), (False, 'form')], + 'domain': [('id', 'in', bank_statement_lines.ids)], + } diff --git a/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.xml b/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.xml new file mode 100644 index 0000000..5d85de2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_online_sync/wizard/account_journal_missing_transactions.xml @@ -0,0 +1,22 @@ + + + + account.missing.transaction.wizard.form + account.missing.transaction.wizard + +
    +

    + Choose a date and a journal from which you want to fetch transactions +

    + + + + +
    +
    +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_reports/__init__.py b/dev_odex30_accounting/odex30_account_reports/__init__.py new file mode 100644 index 0000000..f0d2677 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers +from . import wizard + + +def set_periodicity_journal_on_companies(env): + for company in env['res.company'].search([]): + company.account_tax_periodicity_journal_id = company._get_default_misc_journal() + company.account_tax_periodicity_journal_id.show_on_dashboard = True + company._initiate_account_onboardings() diff --git a/dev_odex30_accounting/odex30_account_reports/__manifest__.py b/dev_odex30_accounting/odex30_account_reports/__manifest__.py new file mode 100644 index 0000000..881f1d9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/__manifest__.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name' : 'Accounting Reports', + 'summary': 'View and create reports', + 'category': 'Odex30-Accounting/Odex30-Accounting', + 'author': "Expert Co. Ltd.", + 'website': "http://www.exp-sa.com", + 'description': """ +Accounting Reports +================== + """, + 'depends': ['account'], + 'data': [ + 'security/ir.model.access.csv', + 'data/pdf_export_templates.xml', + 'data/customer_reports_pdf_export_templates.xml', + 'data/balance_sheet.xml', + 'data/cash_flow_report.xml', + 'data/executive_summary.xml', + 'data/profit_and_loss.xml', + 'data/bank_reconciliation_report.xml', + 'data/aged_partner_balance.xml', + 'data/general_ledger.xml', + 'data/trial_balance.xml', + 'data/sales_report.xml', + 'data/partner_ledger.xml', + 'data/customer_statement.xml', + 'data/followup_report.xml', + 'data/multicurrency_revaluation_report.xml', + 'data/deferred_reports.xml', + 'data/journal_report.xml', + 'data/generic_tax_report.xml', + 'views/account_report_view.xml', + 'data/account_report_actions.xml', + 'data/report_send_cron.xml', + 'data/menuitems.xml', + 'data/mail_activity_type_data.xml', + 'data/mail_templates.xml', + 'views/account_move_views.xml', + 'views/res_company_views.xml', + 'views/account_journal_dashboard_view.xml', + 'views/mail_activity_views.xml', + 'views/res_config_settings_views.xml', + 'views/res_partner_views.xml', + 'views/report_template.xml', + 'wizard/account_report_send.xml', + 'wizard/multicurrency_revaluation.xml', + 'wizard/report_export_wizard.xml', + 'wizard/account_report_file_download_error_wizard.xml', + 'wizard/fiscal_year.xml', + 'wizard/mail_activity_schedule_views.xml', + 'views/account_activity.xml', + 'views/account_account_views.xml', + 'views/account_tax_views.xml', + ], + 'auto_install': True, + 'installable': True, + 'post_init_hook': 'set_periodicity_journal_on_companies', + 'assets': { + 'odex30_account_reports.assets_pdf_export': [ + ('include', 'web._assets_helpers'), + 'web/static/src/scss/pre_variables.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + 'web/static/lib/bootstrap/scss/_variables-dark.scss', + 'web/static/lib/bootstrap/scss/_maps.scss', + ('include', 'web._assets_bootstrap_backend'), + 'web/static/fonts/fonts.scss', + 'odex30_account_reports/static/src/scss/**/*', + ], + 'web.report_assets_common': [ + 'odex30_account_reports/static/src/scss/account_pdf_export_template.scss', + ], + + 'web.assets_backend': [ + 'odex30_account_reports/static/src/components/account_report/account_report.xml', # أضف هذا أولاً + 'odex30_account_reports/static/src/components/account_report/account_report.js', + 'odex30_account_reports/static/src/components/**/*.xml', # أضف هذا + 'odex30_account_reports/static/src/components/**/*.js', + 'odex30_account_reports/static/src/js/**/*', + 'odex30_account_reports/static/src/widgets/**/*', + ], + + 'web.assets_web_dark': [ + 'odex30_account_reports/static/src/scss/*.dark.scss', + ], + 'web.qunit_suite_tests': [ + 'odex30_account_reports/static/tests/legacy/*.js', + ], + 'web.assets_unit_tests': [ + 'odex30_account_reports/static/tests/*.js', + 'odex30_account_reports/static/tests/account_report/**/*.js', + ], + 'web.assets_tests': [ + 'odex30_account_reports/static/tests/tours/**/*', + ], + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..97bbe6b Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/controllers/__init__.py b/dev_odex30_accounting/odex30_account_reports/controllers/__init__.py new file mode 100644 index 0000000..5d4b25d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7c52b63 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/main.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..d373e6a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/controllers/__pycache__/main.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/controllers/main.py b/dev_odex30_accounting/odex30_account_reports/controllers/main.py new file mode 100644 index 0000000..e02c708 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/controllers/main.py @@ -0,0 +1,85 @@ +import werkzeug +from werkzeug.exceptions import InternalServerError + +from odoo.addons.odex30_account_reports.models.account_report import AccountReportFileDownloadException +from odoo.addons.account.controllers.download_docs import _get_headers +from odoo import http +from odoo.http import content_disposition, request +from odoo.tools.misc import html_escape + +import json + + +class AccountReportController(http.Controller): + + @http.route('/account_reports', type='http', auth='user', methods=['POST'], csrf=False) + def get_report(self, options, file_generator, **kwargs): + uid = request.uid + options = json.loads(options) + + allowed_company_ids = request.env['account.report'].get_report_company_ids(options) + if not allowed_company_ids: + company_str = request.cookies.get('cids', str(request.env.user.company_id.id)) + allowed_company_ids = [int(str_id) for str_id in company_str.split('-')] + + report = request.env['account.report'].with_user(uid).with_context(allowed_company_ids=allowed_company_ids).browse(options['report_id']) + + try: + generated_file_data = report.dispatch_report_action(options, file_generator) + file_content = generated_file_data['file_content'] + file_type = generated_file_data['file_type'] + response_headers = self._get_response_headers(file_type, generated_file_data['file_name'], file_content) + + if file_type == 'xlsx': + response = request.make_response(None, headers=response_headers) + response.stream.write(file_content) + else: + response = request.make_response(file_content, headers=response_headers) + + if file_type in ('zip', 'xaf'): + + response.direct_passthrough = True + + return response + except AccountReportFileDownloadException as e: + if e.content: + e.content['file_content'] = e.content['file_content'].decode() + data = { + 'name': type(e).__name__, + 'arguments': [e.errors, e.content], + } + raise InternalServerError(response=self._generate_response(data)) from e + except Exception as e: # noqa: BLE001 + data = http.serialize_exception(e) + raise InternalServerError(response=self._generate_response(data)) from e + + def _generate_response(self, data): + error = { + 'code': 200, + 'message': 'Odoo Server Error', + 'data': data, + } + return request.make_response(html_escape(json.dumps(error))) + + def _get_response_headers(self, file_type, file_name, file_content): + headers = [ + ('Content-Type', request.env['account.report'].get_export_mime_type(file_type)), + ('Content-Disposition', content_disposition(file_name)), + ] + + if file_type in ('xml', 'txt', 'csv', 'kvr', 'csv'): + headers.append(('Content-Length', len(file_content))) + + return headers + + @http.route('/account_reports/download_attachments/', type='http', auth='user') + def download_report_attachments(self, attachments): + attachments.check_access('read') + assert all(attachment.res_id and attachment.res_model == 'res.partner' for attachment in attachments) + if len(attachments) == 1: + headers = _get_headers(attachments.name, attachments.mimetype, attachments.raw) + return request.make_response(attachments.raw, headers) + else: + content = attachments._build_zip_from_attachments() + headers = _get_headers('attachments.zip', 'zip', content) + return request.make_response(content, headers) diff --git a/dev_odex30_accounting/odex30_account_reports/data/account_report_actions.xml b/dev_odex30_accounting/odex30_account_reports/data/account_report_actions.xml new file mode 100644 index 0000000..e6de725 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/account_report_actions.xml @@ -0,0 +1,165 @@ + + + + + + Cash Flow Statement + account_report + + + + + Balance Sheet + account_report + balance-sheet + + + + + Executive Summary + account_report + executive-summary + + + + + Profit and Loss + account_report + profit-and-loss + + + + + Tax Return + account_report + tax-report + + + + + Journal Audit + account_report + journal-report + + + + + General Ledger + account_report + general-ledger + + + + + Unrealized Currency Gains/Losses + account_report + + + + + Aged Receivable + account_report + aged-receivable + + + + + Aged Payable + account_report + aged-payable + + + + + Trial Balance + account_report + trial-balance + + + + + Partner Ledger + account_report + partner-ledger + + + + + Customer Statement + account_report + + + + + EC Sales List + account_report + + + + + Deferred Expense + account_report + deferred-expense + + + + Deferred Revenue + account_report + deferred-revenue + + + + + + + + + + + + + + + + + Create Menu Item + + + code + form + +if records: + action = records._create_menu_item_for_report() + + + + + Accounting Reports + account.report + list,form + + + + + + Horizontal Groups + account.report.horizontal.group + list,form + + + + + Bank Reconciliation + account_report + + + + + Financial Budgets + account.report.budget + list,form + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/aged_partner_balance.xml b/dev_odex30_accounting/odex30_account_reports/data/aged_partner_balance.xml new file mode 100644 index 0000000..9989e42 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/aged_partner_balance.xml @@ -0,0 +1,326 @@ + + + + Aged Receivable + + + + + receivable + never + + selector + today + + + + Invoice Date + invoice_date + date + + + + Amount Currency + amount_currency + + + Currency + currency + string + + + Account + account_name + string + + + At Date + period0 + + + + Period 1 + period1 + + + + Period 2 + period2 + + + + Period 3 + period3 + + + + Period 4 + period4 + + + + Older + period5 + + + + Total + total + + + + + + Aged Receivable + partner_id, id + + + invoice_date + custom + _report_custom_engine_aged_receivable + invoice_date + + + + amount_currency + custom + _report_custom_engine_aged_receivable + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_aged_receivable + currency_id + + + currency + custom + _report_custom_engine_aged_receivable + currency + + + + account_name + custom + _report_custom_engine_aged_receivable + account_name + + + + period0 + custom + _report_custom_engine_aged_receivable + period0 + + + + period1 + custom + _report_custom_engine_aged_receivable + period1 + + + + period2 + custom + _report_custom_engine_aged_receivable + period2 + + + + period3 + custom + _report_custom_engine_aged_receivable + period3 + + + + period4 + custom + _report_custom_engine_aged_receivable + period4 + + + + period5 + custom + _report_custom_engine_aged_receivable + period5 + + + + total + custom + _report_custom_engine_aged_receivable + total + + + + + + + + + Aged Payable + + + + + payable + never + + selector + today + + + + Invoice Date + invoice_date + date + + + + Amount Currency + amount_currency + + + Currency + currency + string + + + Account + account_name + string + + + At Date + period0 + + + + Period 1 + period1 + + + + Period 2 + period2 + + + + Period 3 + period3 + + + + Period 4 + period4 + + + + Older + period5 + + + + Total + total + + + + + + Aged Payable + partner_id, id + + + invoice_date + custom + _report_custom_engine_aged_payable + invoice_date + + + + amount_currency + custom + _report_custom_engine_aged_payable + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_aged_payable + currency_id + + + currency + custom + _report_custom_engine_aged_payable + currency + + + + account_name + custom + _report_custom_engine_aged_payable + account_name + + + + period0 + custom + _report_custom_engine_aged_payable + period0 + + + + period1 + custom + _report_custom_engine_aged_payable + period1 + + + + period2 + custom + _report_custom_engine_aged_payable + period2 + + + + period3 + custom + _report_custom_engine_aged_payable + period3 + + + + period4 + custom + _report_custom_engine_aged_payable + period4 + + + + period5 + custom + _report_custom_engine_aged_payable + period5 + + + + total + custom + _report_custom_engine_aged_payable + total + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/balance_sheet.xml b/dev_odex30_accounting/odex30_account_reports/data/balance_sheet.xml new file mode 100644 index 0000000..fb5d771 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/balance_sheet.xml @@ -0,0 +1,285 @@ + + + + Balance Sheet + + + + + selector + today + + + + Balance + balance + + + + + ASSETS + 0 + TA + left + CA.balance + FA.balance + PNCA.balance + + + Current Assets + CA + BA.balance + REC.balance + CAS.balance + PRE.balance + + + Bank and Cash Accounts + BA + account_id + + sum([('account_id.account_type', '=', 'asset_cash')]) + + + Receivables + REC + account_id + + sum([('account_id.account_type', '=', 'asset_receivable'), ('account_id.non_trade', '=', False)]) + + + Current Assets + CAS + account_id + + sum(['|', ('account_id.account_type', '=', 'asset_current'), '&', ('account_id.account_type', '=', 'asset_receivable'), ('account_id.non_trade', '=', True)]) + + + Prepayments + PRE + account_id + + sum([('account_id.account_type', '=', 'asset_prepayments')]) + + + + + Plus Fixed Assets + FA + account_id + + sum([('account_id.account_type', '=', 'asset_fixed')]) + + + Plus Non-current Assets + PNCA + account_id + + sum([('account_id.account_type', '=', 'asset_non_current')]) + + + + + LIABILITIES + 0 + L + right + + + balance + aggregation + CL.balance + NL.balance + + + + + + Current Liabilities + CL + + + balance + aggregation + CL1.balance + CL2.balance + + + + + + Current Liabilities + CL1 + account_id + + + + balance + domain + + -sum + + + + + + Payables + CL2 + account_id + + + + balance + domain + + -sum + + + + + + + + Plus Non-current Liabilities + NL + account_id + + + + balance + domain + + -sum + + + + + + + + EQUITY + 0 + EQ + right + UNAFFECTED_EARNINGS.balance + RETAINED_EARNINGS.balance + + + Unallocated Earnings + UNAFFECTED_EARNINGS + CURR_YEAR_EARNINGS.balance + PREV_YEAR_EARNINGS.balance + + + Current Year Unallocated Earnings + CURR_YEAR_EARNINGS + + + + pnl + aggregation + NEP.balance + from_fiscalyear + cross_report + + + alloc + domain + + from_fiscalyear + -sum + + + balance + aggregation + CURR_YEAR_EARNINGS.pnl + CURR_YEAR_EARNINGS.alloc + + + + + Previous Years Unallocated Earnings + PREV_YEAR_EARNINGS + + + allocated_earnings + domain + + -sum + from_beginning + + + balance_domain + domain + + -sum + from_beginning + + + balance + aggregation + PREV_YEAR_EARNINGS.balance_domain + PREV_YEAR_EARNINGS.allocated_earnings - CURR_YEAR_EARNINGS.balance + + + + + + + Retained Earnings + RETAINED_EARNINGS + CURR_RETAINED_EARNINGS.balance + PREV_RETAINED_EARNINGS.balance + + + + + Current Year Retained Earnings + CURR_RETAINED_EARNINGS + account_id + + + + balance + domain + + -sum + from_fiscalyear + + + + + Previous Years Retained Earnings + PREV_RETAINED_EARNINGS + + + total + domain + + -sum + + + balance + aggregation + PREV_RETAINED_EARNINGS.total - CURR_RETAINED_EARNINGS.balance + + + + + + + + + LIABILITIES + EQUITY + 0 + LE + right + + + balance + aggregation + L.balance + EQ.balance + + + + + + OFF BALANCE SHEET ACCOUNTS + 0 + OS + account_id + + + -sum([('account_id.account_type', '=', 'off_balance')]) + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/bank_reconciliation_report.xml b/dev_odex30_accounting/odex30_account_reports/data/bank_reconciliation_report.xml new file mode 100644 index 0000000..bb25524 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/bank_reconciliation_report.xml @@ -0,0 +1,474 @@ + + + + Bank Reconciliation Report + + + + by_default + + today + + + + Date + date + date + + + Label + label + string + + + Amount Currency + amount_currency + monetary + + + Currency + currency + string + + + Amount + amount + monetary + + + + + Balance of Bank + balance_bank + 0 + + + amount + aggregation + last_statement_balance.amount + transaction_without_statement.amount + misc_operations.amount + + + + _currency_amount + custom + _report_custom_engine_forced_currency_amount + amount_currency_id + + + + + Last statement balance + last_statement_balance + + + amount + custom + _report_custom_engine_last_statement_balance_amount + amount + + + + _currency_amount + custom + _report_custom_engine_last_statement_balance_amount + amount_currency_id + + + + + Including Unreconciled Receipts + last_statement_receipts + id + + + + date + custom + _report_custom_engine_unreconciled_last_statement_receipts + date + + + + label + custom + _report_custom_engine_unreconciled_last_statement_receipts + label + + + + amount_currency + custom + _report_custom_engine_unreconciled_last_statement_receipts + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_unreconciled_last_statement_receipts + amount_currency_currency_id + + + currency + custom + _report_custom_engine_unreconciled_last_statement_receipts + currency + + + + amount + custom + _report_custom_engine_unreconciled_last_statement_receipts + amount + + + + _currency_amount + custom + _report_custom_engine_unreconciled_last_statement_receipts + amount_currency_id + + + + + Including Unreconciled Payments + last_statement_payments + id + + + + date + custom + _report_custom_engine_unreconciled_last_statement_payments + date + + + + label + custom + _report_custom_engine_unreconciled_last_statement_payments + label + + + + amount_currency + custom + _report_custom_engine_unreconciled_last_statement_payments + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_unreconciled_last_statement_payments + amount_currency_currency_id + + + currency + custom + _report_custom_engine_unreconciled_last_statement_payments + currency + + + + amount + custom + _report_custom_engine_unreconciled_last_statement_payments + amount + + + + _currency_amount + custom + _report_custom_engine_unreconciled_last_statement_payments + amount_currency_id + + + + + + + Transactions without statement + transaction_without_statement + + + amount + custom + _report_custom_engine_transaction_without_statement_amount + amount + + + + _currency_amount + custom + _report_custom_engine_transaction_without_statement_amount + amount_currency_id + + + + + Including Unreconciled Receipts + unreconciled_receipt + id + + + + date + custom + _report_custom_engine_unreconciled_receipts + date + + + + label + custom + _report_custom_engine_unreconciled_receipts + label + + + + amount_currency + custom + _report_custom_engine_unreconciled_receipts + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_unreconciled_receipts + amount_currency_currency_id + + + currency + custom + _report_custom_engine_unreconciled_receipts + currency + + + + amount + custom + _report_custom_engine_unreconciled_receipts + amount + + + + _currency_amount + custom + _report_custom_engine_unreconciled_receipts + amount_currency_id + + + + + Including Unreconciled Payments + unreconciled_payments + id + + + + date + custom + _report_custom_engine_unreconciled_payments + date + + + + label + custom + _report_custom_engine_unreconciled_payments + label + + + + amount_currency + custom + _report_custom_engine_unreconciled_payments + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_unreconciled_payments + amount_currency_currency_id + + + currency + custom + _report_custom_engine_unreconciled_payments + currency + + + + amount + custom + _report_custom_engine_unreconciled_payments + amount + + + + _currency_amount + custom + _report_custom_engine_unreconciled_payments + amount_currency_id + + + + + + + Misc. operations + misc_operations + + + amount + custom + _report_custom_engine_misc_operations + amount + + + + _currency_amount + custom + _report_custom_engine_misc_operations + amount_currency_id + + + + + + + Outstanding Receipts/Payments + 0 + + + amount + aggregation + outstanding_receipts.amount + outstanding_payments.amount + + + + _currency_amount + custom + _report_custom_engine_forced_currency_amount + amount_currency_id + + + + + (+) Outstanding Receipts + outstanding_receipts + id + + + + date + custom + _report_custom_engine_outstanding_receipts + date + + + + label + custom + _report_custom_engine_outstanding_receipts + label + + + + amount_currency + custom + _report_custom_engine_outstanding_receipts + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_outstanding_receipts + amount_currency_currency_id + + + currency + custom + _report_custom_engine_outstanding_receipts + currency + + + + amount + custom + _report_custom_engine_outstanding_receipts + amount + + + + _currency_amount + custom + _report_custom_engine_outstanding_receipts + amount_currency_id + + + + + (-) Outstanding Payments + outstanding_payments + id + + + + date + custom + _report_custom_engine_outstanding_payments + date + + + + label + custom + _report_custom_engine_outstanding_payments + label + + + + amount_currency + custom + _report_custom_engine_outstanding_payments + amount_currency + + + + _currency_amount_currency + custom + _report_custom_engine_outstanding_payments + amount_currency_currency_id + + + currency + custom + _report_custom_engine_outstanding_payments + currency + + + + amount + custom + _report_custom_engine_outstanding_payments + amount + + + + _currency_amount + custom + _report_custom_engine_outstanding_payments + amount_currency_id + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/cash_flow_report.xml b/dev_odex30_accounting/odex30_account_reports/data/cash_flow_report.xml new file mode 100644 index 0000000..6179612 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/cash_flow_report.xml @@ -0,0 +1,19 @@ + + + + Cash Flow Statement + + + + + selector + current + + + + Balance + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/customer_reports_pdf_export_templates.xml b/dev_odex30_accounting/odex30_account_reports/data/customer_reports_pdf_export_templates.xml new file mode 100644 index 0000000..31c2f27 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/customer_reports_pdf_export_templates.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/customer_statement.xml b/dev_odex30_accounting/odex30_account_reports/data/customer_statement.xml new file mode 100644 index 0000000..2ef481a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/customer_statement.xml @@ -0,0 +1,32 @@ + + + Customer Statement + + + + + + Invoice Date + invoice_date + date + + + Due Date + date_maturity + date + + + Amount + amount + + + Amount Currency + amount_currency + + + Balance + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/deferred_reports.xml b/dev_odex30_accounting/odex30_account_reports/data/deferred_reports.xml new file mode 100644 index 0000000..437c872 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/deferred_reports.xml @@ -0,0 +1,43 @@ + + + + + Deferred Expense Report + + + + + selector + + by_default + previous_month + + + + + Current + current + + + + + + Deferred Revenue Report + + + + + selector + + by_default + previous_month + + + + + Current + current + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/executive_summary.xml b/dev_odex30_accounting/odex30_account_reports/data/executive_summary.xml new file mode 100644 index 0000000..a771e25 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/executive_summary.xml @@ -0,0 +1,395 @@ + + + + Executive Summary + selector + this_year + + + Balance + balance + + + + + Cash + 0 + + + Cash received + CR + + + balance + domain + + sum + + + + + + Cash spent + CS + + + balance + domain + + sum + + + + + + + Cash surplus + + + balance + aggregation + CR.balance + CS.balance + + + + + + Closing bank balance + + + balance + domain + + from_beginning + sum + + + + + + + + Profitability + 0 + + + Revenue + + + balance + aggregation + REV.balance + strict_range + cross_report + + + + + + Cost of Revenue + EXEC_COS + + + balance + aggregation + COS.balance + strict_range + cross_report + + + + + + + Gross profit + + + balance + aggregation + GRP.balance + strict_range + cross_report + + + + + + Expenses + + + balance + aggregation + EXP.balance + strict_range + cross_report + + + + + + + Net Profit + EXEC_NEP + + + balance + aggregation + NEP.balance + strict_range + cross_report + + + + + + + + Balance Sheet + 0 + + + Receivables + DEB + + + balance + domain + + from_beginning + sum + + + + + + Payables + CRE + + + balance + domain + + from_beginning + sum + + + + + + + Net assets + EXEC_SUMMARY_NA + + + balance + aggregation + TA.balance - L.balance + from_beginning + cross_report + + + + + + + + Performance + 0 + + + Gross profit margin (gross profit / operating income) + GPMARGIN0 + + + balance + aggregation + GPMARGIN0.grp / GPMARGIN0.opinc * 100 + ignore_zero_division + percentage + + + + grp + aggregation + GRP.balance + strict_range + cross_report + + + opinc + aggregation + REV.balance + strict_range + cross_report + + + + + Net profit margin (net profit / revenue) + NPMARGIN0 + + + balance + aggregation + NPMARGIN0.nep / NPMARGIN0.rev * 100 + ignore_zero_division + percentage + + + + nep + aggregation + NEP.balance + strict_range + cross_report + + + rev + aggregation + REV.balance + strict_range + cross_report + + + + + Return on investments (net profit / assets) + ROI + + + balance + aggregation + ROI.nep / ROI.ta * 100 + ignore_zero_division + percentage + + + + nep + aggregation + NEP.balance + strict_range + cross_report + + + ta + aggregation + TA.balance + from_beginning + cross_report + + + + + + + Position + 0 + + + Average debtors days + AVG_DEBT_DAYS + + + balance + aggregation + DEB.balance / AVG_DEBT_DAYS.opinc * AVG_DEBT_DAYS.NDays + ignore_zero_division + + float + + + + opinc + aggregation + REV.balance + strict_range + cross_report + + + NDays + custom + _report_custom_engine_executive_summary_ndays + + + + + + Average creditors days + AVG_CRED_DAYS + + + balance + aggregation + -CRE.balance / (AVG_CRED_DAYS.cos + AVG_CRED_DAYS.exp) * AVG_CRED_DAYS.NDays + ignore_zero_division + + float + + + + cos + aggregation + COS.balance + strict_range + cross_report + + + exp + aggregation + EXP.balance + strict_range + cross_report + + + NDays + custom + _report_custom_engine_executive_summary_ndays + + + + + + Short term cash forecast + + + balance + aggregation + DEB.balance + CRE.balance + + + + + + Current assets to liabilities + CATL + + + balance + aggregation + CATL.ca / CATL.cl + ignore_zero_division + float + + + + ca + aggregation + CA.balance + from_beginning + cross_report + + + cl + aggregation + CL.balance + from_beginning + cross_report + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/followup_report.xml b/dev_odex30_accounting/odex30_account_reports/data/followup_report.xml new file mode 100644 index 0000000..928be76 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/followup_report.xml @@ -0,0 +1,37 @@ + + + + Follow-Up Report + + today + + + + + receivable + + + Invoice Date + invoice_date + date + + + Due Date + date_maturity + date + + + Amount + amount + + + Amount Currency + amount_currency + + + Balance + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/general_ledger.xml b/dev_odex30_accounting/odex30_account_reports/data/general_ledger.xml new file mode 100644 index 0000000..d48f428 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/general_ledger.xml @@ -0,0 +1,49 @@ + + + + General Ledger + + + + selector + + never + this_month + + + + + + Date + date + date + + + Communication + communication + string + + + Partner + partner_name + string + + + Currency + amount_currency + + + Debit + debit + + + Credit + credit + + + Balance + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/generic_tax_report.xml b/dev_odex30_accounting/odex30_account_reports/data/generic_tax_report.xml new file mode 100644 index 0000000..3da471f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/generic_tax_report.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/journal_report.xml b/dev_odex30_accounting/odex30_account_reports/data/journal_report.xml new file mode 100644 index 0000000..09da773 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/journal_report.xml @@ -0,0 +1,239 @@ + + + + + Journal Report + + + + never + + + never + this_year + + + + Code + code + string + + + Debit + debit + + + Credit + credit + + + Balance + balance + + + + + Name + journal_id, account_id + 0 + + + code + custom + _report_custom_engine_journal_report + code + + + debit + custom + _report_custom_engine_journal_report + debit + + + credit + custom + _report_custom_engine_journal_report + credit + + + balance + custom + _report_custom_engine_journal_report + balance + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml b/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml new file mode 100644 index 0000000..4638127 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/mail_activity_type_data.xml @@ -0,0 +1,42 @@ + + + + + Tax Report + Tax Report + tax_report + account.journal + suggest + + + + Pay Tax + Tax is ready to be paid + tax_report + 0 + days + previous_activity + account.move + suggest + + + + Tax Report Ready + Tax report is ready to be sent to the administration + tax_report + 0 + days + current_date + account.move + suggest + + + + Tax Report - Error + Error sending Tax Report + tax_report + account.move + warning + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml b/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml new file mode 100644 index 0000000..b4af9e0 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/mail_templates.xml @@ -0,0 +1,26 @@ + + + Customer Statement + + {{ object._get_followup_responsible().email_formatted }} + {{ (object.company_id or object._get_followup_responsible().company_id).name }} Statement - {{ object.commercial_company_name }} + +
    +

    + Dear (), + Dear , +
    + Please find enclosed the statement of your account. +
    + Do not hesitate to contact us if you have any questions. +
    + Sincerely, +
    + +

    +
    +
    + {{ object.lang }} + +
    +
    diff --git a/dev_odex30_accounting/odex30_account_reports/data/menuitems.xml b/dev_odex30_accounting/odex30_account_reports/data/menuitems.xml new file mode 100644 index 0000000..fadfd37 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/menuitems.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/multicurrency_revaluation_report.xml b/dev_odex30_accounting/odex30_account_reports/data/multicurrency_revaluation_report.xml new file mode 100644 index 0000000..c18e4d6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/multicurrency_revaluation_report.xml @@ -0,0 +1,107 @@ + + + + Unrealized Currency Gains/Losses + + + previous_month + + + + Balance in Foreign Currency + balance_currency + + + Balance at Operation Rate + balance_operation + + + Balance at Current Rate + balance_current + + + Adjustment + adjustment + + + + + Accounts To Adjust + multicurrency_included + currency_id, account_id, id + + + balance_currency + custom + _report_custom_engine_multi_currency_revaluation_to_adjust + balance_currency + + + _currency_balance_currency + custom + _report_custom_engine_multi_currency_revaluation_to_adjust + currency_id + + + balance_operation + custom + _report_custom_engine_multi_currency_revaluation_to_adjust + balance_operation + + + + balance_current + custom + _report_custom_engine_multi_currency_revaluation_to_adjust + balance_current + + + + adjustment + custom + _report_custom_engine_multi_currency_revaluation_to_adjust + adjustment + + + + + + + Excluded Accounts + currency_id, account_id, id + + + balance_currency + custom + _report_custom_engine_multi_currency_revaluation_excluded + balance_currency + + + _currency_balance_currency + custom + _report_custom_engine_multi_currency_revaluation_excluded + currency_id + + + balance_operation + custom + _report_custom_engine_multi_currency_revaluation_excluded + balance_operation + + + balance_current + custom + _report_custom_engine_multi_currency_revaluation_excluded + balance_current + + + adjustment + custom + _report_custom_engine_multi_currency_revaluation_excluded + adjustment + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/partner_ledger.xml b/dev_odex30_accounting/odex30_account_reports/data/partner_ledger.xml new file mode 100644 index 0000000..cd4ff52 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/partner_ledger.xml @@ -0,0 +1,61 @@ + + + + Partner Ledger + + both + + + + + selector + never + this_year + + + + + + Journal + journal_code + string + + + Account + account_code + string + + + Invoice Date + invoice_date + date + + + Due Date + date_maturity + date + + + Matching + matching_number + string + + + Debit + debit + + + Credit + credit + + + Amount Currency + amount_currency + + + Balance + balance + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/pdf_export_templates.xml b/dev_odex30_accounting/odex30_account_reports/data/pdf_export_templates.xml new file mode 100644 index 0000000..7adaefe --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/pdf_export_templates.xml @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/profit_and_loss.xml b/dev_odex30_accounting/odex30_account_reports/data/profit_and_loss.xml new file mode 100644 index 0000000..cc40bf8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/profit_and_loss.xml @@ -0,0 +1,139 @@ + + + + Profit and Loss + + + + selector + + this_year + + + Balance + balance + + + + + Revenue + REV + 1 + account_id + + + + balance + domain + + -sum + + + + + Less Costs of Revenue + COS + + 1 + account_id + + + + balance + domain + + sum + + + + + + Gross Profit + GRP + + 0 + + + balance + aggregation + REV.balance - COS.balance + + + + + Less Operating Expenses + EXP + + 1 + account_id + + + + balance + domain + + sum + + + + + + Operating Income (or Loss) + 0 + INC + + + balance + aggregation + REV.balance - COS.balance - EXP.balance + + + + + Plus Other Income + OIN + + 1 + account_id + + + + balance + domain + + -sum + + + + + Less Other Expenses + OEXP + + 1 + account_id + + + + balance + domain + + sum + + + + + + Net Profit + 0 + NEP + + + balance + aggregation + REV.balance + OIN.balance - COS.balance - EXP.balance - OEXP.balance + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/report_send_cron.xml b/dev_odex30_accounting/odex30_account_reports/data/report_send_cron.xml new file mode 100644 index 0000000..3582846 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/report_send_cron.xml @@ -0,0 +1,11 @@ + + + Send account reports automatically + + code + model._cron_account_report_send(job_count=20) + + 1 + days + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/sales_report.xml b/dev_odex30_accounting/odex30_account_reports/data/sales_report.xml new file mode 100644 index 0000000..60e4aab --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/sales_report.xml @@ -0,0 +1,36 @@ + + + + Generic EC Sales List + + + + + + + selector + previous_month + + + + + + Country Code + country_code + string + + + + VAT Number + vat_number + string + + + + Amount + balance + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/data/trial_balance.xml b/dev_odex30_accounting/odex30_account_reports/data/trial_balance.xml new file mode 100644 index 0000000..da0c5f2 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/data/trial_balance.xml @@ -0,0 +1,26 @@ + + + + Trial Balance + + + + selector + + by_default + never + this_month + + + + + Debit + debit + + + Credit + credit + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/i18n/account_reports.pot b/dev_odex30_accounting/odex30_account_reports/i18n/account_reports.pot new file mode 100644 index 0000000..437d20a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/i18n/account_reports.pot @@ -0,0 +1,4482 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_reports +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-24 18:48+0000\n" +"PO-Revision-Date: 2025-10-24 18:48+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: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "\" account balance is affected by" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "%(journal)s - %(account)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%(names)s and %(remaining)s others" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%(names)s and one other" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "%(report_label)s: %(period)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/budget.py:0 +msgid "%s (copy)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%s is not a numeric value" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "%s selected" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"'Open General Ledger' caret option is only available form report lines " +"targetting accounts." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"'View Bank Statement' caret option is only available for report lines " +"targeting bank statements." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "'external' engine does not support groupby, limit nor offset." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(%s lines)" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.outstanding_receipts +msgid "(+) Outstanding Receipts" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.outstanding_payments +msgid "(-) Outstanding Payments" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(1 line)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "(No %s)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(No Group)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid ", leading to an unexplained difference of" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_move_form_vat_return +msgid "-> Refresh" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.template,body_html:account_reports.email_template_customer_statement +msgid "" +"
    \n" +"

    \n" +" Dear (),\n" +" Dear ,\n" +"
    \n" +" Please find enclosed the statement of your account.\n" +"
    \n" +" Do not hesitate to contact us if you have any questions.\n" +"
    \n" +" Sincerely,\n" +"
    \n" +"\t \n" +"

    \n" +"
    \n" +" " +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "" +"" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_file_download_error_wizard_form +msgid "" +"Errors marked with are critical and prevent " +"the file generation." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Reconciliation" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_file_download_error_wizard_form +msgid "One or more error(s) occurred during file generation:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.constraint,message:account_reports.constraint_account_report_horizontal_group_name_uniq +msgid "A horizontal group with the same name already exists." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0 +msgid "A line with a 'Group By' value cannot have children." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"A tax unit can only be created between companies sharing the same main " +"currency." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"A tax unit must contain a minimum of two companies. You might want to delete" +" the unit." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_total_assets0 +msgid "ASSETS" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:account_reports.aged_payable_report_account_name +#: model:account.report.column,name:account_reports.aged_receivable_report_account_name +#: model:account.report.column,name:account_reports.partner_ledger_report_account_code +#: model:ir.model,name:account_reports.model_account_account +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__account_id +msgid "Account" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_chart_template +msgid "Account Chart Template" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Account Code" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Account Code / Tag" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_display_representative_field +msgid "Account Display Representative Field" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Account Label" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Account Name" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_annotation +msgid "Account Report Annotation" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_custom_handler +msgid "Account Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_tax_report_handler +msgid "Account Report Handler for Tax Reports" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_send +msgid "Account Report Send" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_config_settings__account_reports_show_per_company_setting +msgid "Account Reports Show Per Company Setting" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_partner__account_represented_company_ids +#: model:ir.model.fields,field_description:account_reports.field_res_users__account_represented_company_ids +msgid "Account Represented Company" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_revaluation_journal_id +msgid "Account Revaluation Journal" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml:0 +msgid "Account:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_representative_id +msgid "Accounting Firm" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report +msgid "Accounting Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_budget +msgid "Accounting Report Budget" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_budget_item +msgid "Accounting Report Budget Item" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_expression +msgid "Accounting Report Expression" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_external_value +msgid "Accounting Report External Value" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_line +msgid "Accounting Report Line" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:account_reports.action_account_report_tree +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_tree +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_search +msgid "Accounting Reports" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Accounts" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Accounts Coverage Report" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.multicurrency_revaluation_to_adjust +msgid "Accounts To Adjust" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Accounts coverage" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_mail_activity_type__category +msgid "Action" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__actionable_errors +msgid "Actionable Errors" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_mail_activity_type__category +msgid "" +"Actions may trigger specific behavior like opening calendar view or " +"automatically mark as done when a document is uploaded" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_mail_activity +msgid "Activity" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_mail_activity_type +msgid "Activity Type" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "Add a line" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "Add contacts to notify..." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__totals_below_sections +#: model:ir.model.fields,field_description:account_reports.field_res_config_settings__totals_below_sections +msgid "Add totals below sections" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.multicurrency_revaluation_report_adjustment +msgid "Adjustment" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "Adjustment Entry" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Advance Payments received from customers" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Advance payments made to suppliers" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Advanced" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_aged_partner_balance_report_handler +msgid "Aged Partner Balance Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.aged_payable_report +#: model:account.report.line,name:account_reports.aged_payable_line +#: model:ir.actions.client,name:account_reports.action_account_report_ap +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_aged_payable +msgid "Aged Payable" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_aged_payable_report_handler +msgid "Aged Payable Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Aged Payables" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.aged_receivable_report +#: model:account.report.line,name:account_reports.aged_receivable_line +#: model:ir.actions.client,name:account_reports.action_account_report_ar +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_aged_receivable +msgid "Aged Receivable" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_aged_receivable_report_handler +msgid "Aged Receivable Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Aged Receivables" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0 +msgid "All" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "All Journals" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "All Payable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "All Receivable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "All Report Variants" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "" +"All selected companies or branches do not share the same Tax ID. Please " +"check the Tax ID of the selected companies." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.account_financial_report_ec_sales_amount +#: model:account.report.column,name:account_reports.bank_reconciliation_report_amount +#: model:account.report.column,name:account_reports.customer_statement_amount +#: model:account.report.column,name:account_reports.followup_report_amount +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__amount +msgid "Amount" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model:account.report.column,name:account_reports.aged_payable_report_amount_currency +#: model:account.report.column,name:account_reports.aged_receivable_report_amount_currency +#: model:account.report.column,name:account_reports.bank_reconciliation_report_amount_currency +#: model:account.report.column,name:account_reports.customer_statement_report_amount_currency +#: model:account.report.column,name:account_reports.followup_report_report_amount_currency +#: model:account.report.column,name:account_reports.partner_ledger_report_amount_currency +msgid "Amount Currency" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Amount in currency: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Lakhs" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Millions" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Thousands" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Analytic" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__filter_analytic_groupby +msgid "Analytic Group By" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Analytic Simulations" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/line_name.xml:0 +msgid "Annotate" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +msgid "Annotation" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__annotations_ids +msgid "Annotations" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "As of %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Ascending" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period0 +#: model:account.report.column,name:account_reports.aged_receivable_report_period0 +msgid "At Date" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "Attach a file" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line_name.xml:0 +msgid "Audit" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.ui.menu,name:account_reports.account_reports_audit_reports_menu +msgid "Audit Reports" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_avgcre0 +msgid "Average creditors days" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_avdebt0 +msgid "Average debtors days" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "B: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model:account.report.column,name:account_reports.balance_sheet_balance +#: model:account.report.column,name:account_reports.cash_flow_report_balance +#: model:account.report.column,name:account_reports.customer_statement_report_balance +#: model:account.report.column,name:account_reports.executive_summary_column +#: model:account.report.column,name:account_reports.followup_report_report_balance +#: model:account.report.column,name:account_reports.general_ledger_report_balance +#: model:account.report.column,name:account_reports.journal_report_balance +#: model:account.report.column,name:account_reports.partner_ledger_report_balance +#: model:account.report.column,name:account_reports.profit_and_loss_column +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Balance" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.balance_sheet +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_balancesheet0 +#: model:ir.actions.client,name:account_reports.action_account_report_bs +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_balance_sheet +msgid "Balance Sheet" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_balance_sheet_report_handler +msgid "Balance Sheet Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.multicurrency_revaluation_report_balance_current +msgid "Balance at Current Rate" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.multicurrency_revaluation_report_balance_operation +msgid "Balance at Operation Rate" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.multicurrency_revaluation_report_balance_currency +msgid "Balance in Foreign Currency" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "Balance of '%s'" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.balance_bank +msgid "Balance of Bank" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax advance payment account" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax current account (payable)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax current account (receivable)" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_bank_reconciliation +msgid "Bank Reconciliation" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.bank_reconciliation_report +msgid "Bank Reconciliation Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_bank_reconciliation_report_handler +msgid "Bank Reconciliation Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_bank_view0 +msgid "Bank and Cash Accounts" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.bank_information_customer_report +msgid "Bank:" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Base Amount" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +msgid "Based on" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Before" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__budget_id +msgid "Budget" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_account__budget_item_ids +msgid "Budget Item" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_budget_form +msgid "Budget Items" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_budget_form +msgid "Budget Name" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Budget items can only be edited from account lines." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/redirectAction/redirectAction.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_multicurrency_revaluation_wizard +#: model_terms:ir.ui.view,arch_db:account_reports.view_report_export_wizard +msgid "Cancel" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Cannot audit tax from another model than account.tax." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Cannot generate carryover values for all fiscal positions at once!" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "Carryover" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover adjustment for tax unit" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover can only be generated for a single column group." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover from %(date_from)s to %(date_to)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover lines for: %s" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_cash0 +msgid "Cash" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_cash_flow_report_handler +msgid "Cash Flow Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.cash_flow_report +#: model:ir.actions.client,name:account_reports.action_account_report_cs +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_cash_flow +msgid "Cash Flow Statement" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash and cash equivalents, beginning of period" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash and cash equivalents, closing balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from financing activities" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from investing & extraordinary activities" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from operating activities" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from unclassified activities" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash in" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash out" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash paid for operating activities" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_cash_received0 +msgid "Cash received" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash received from operating activities" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_cash_spent0 +msgid "Cash spent" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_cash_surplus0 +msgid "Cash surplus" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_change_lock_date +msgid "Change Lock Date" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Check Partner(s) Email(s)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "Check them" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_file_download_error_wizard_form +msgid "Close" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Closing Entry" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_closing_bank_balance0 +msgid "Closing bank balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:account.report.column,name:account_reports.journal_report_code +msgid "Code" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filter_code.xml:0 +msgid "Codes:" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Columns" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.general_ledger_report_communication +msgid "Communication" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model,name:account_reports.model_res_company +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__company_ids +msgid "Companies" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__company_id +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__company_id +msgid "Company" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"Company %(company)s already belongs to a tax unit in %(country)s. A company " +"can at most be part of one tax unit per country." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Company Currency" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Company Only" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Company Settings" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Comparison" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Configure start dates" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Configure your TAX accounts - %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_config_settings.py:0 +msgid "Configure your start dates" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Configure your tax accounts" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_res_partner +msgid "Contact" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.duplicated_vat_partner_tree_view +msgid "Contacts" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_body +msgid "Contents" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_direct_costs0 +msgid "Cost of Revenue" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Could not expand term %(term)s while evaluating formula " +"%(unexpanded_formula)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Could not parse account_code formula from token '%s'" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__country_id +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_search +msgid "Country" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.account_financial_report_ec_sales_country +msgid "Country Code" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +msgid "Create" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.server,name:account_reports.action_create_composite_report_list +msgid "Create Composite Report" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_multicurrency_revaluation_wizard +msgid "Create Entry" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.server,name:account_reports.action_create_report_menu +msgid "Create Menu Item" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__create_uid +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__create_uid +msgid "Created by" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__create_date +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__create_date +msgid "Created on" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:account_reports.general_ledger_report_credit +#: model:account.report.column,name:account_reports.journal_report_credit +#: model:account.report.column,name:account_reports.partner_ledger_report_credit +#: model:account.report.column,name:account_reports.trial_balance_report_credit +msgid "Credit" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_currency +#: model:account.report.column,name:account_reports.aged_receivable_report_currency +#: model:account.report.column,name:account_reports.bank_reconciliation_report_currency +#: model:account.report.column,name:account_reports.general_ledger_report_amount_currency +msgid "Currency" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Currency Code" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "Currency Rates (%s)" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Currency:" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.deferred_expense_current +#: model:account.report.column,name:account_reports.deferred_revenue_current +msgid "Current" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_current_assets0 +#: model:account.report.line,name:account_reports.account_financial_report_current_assets_view0 +msgid "Current Assets" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_current_liabilities0 +#: model:account.report.line,name:account_reports.account_financial_report_current_liabilities1 +msgid "Current Liabilities" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_retained_earnings_line_1 +msgid "Current Year Retained Earnings" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_current_year_earnings0 +msgid "Current Year Unallocated Earnings" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_ca_to_l0 +msgid "Current assets to liabilities" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Custom Dates" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__custom_handler_model_id +msgid "Custom Handler Model" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__custom_handler_model_name +msgid "Custom Handler Model Name" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "" +"Custom engine _report_custom_engine_last_statement_balance_amount does not " +"support groupby" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.customer_statement_report +#: model:ir.actions.client,name:account_reports.action_account_report_customer_statement +#: model:mail.template,name:account_reports.email_template_customer_statement +msgid "Customer Statement" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_customer_statement_report_handler +msgid "Customer Statement Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +#: model:account.report.column,name:account_reports.bank_reconciliation_report_date +#: model:account.report.column,name:account_reports.general_ledger_report_date +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__date +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__date +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__date +msgid "Date" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Date cannot be empty" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_report_annotation__date +msgid "Date considered as annotated by the annotation." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +msgid "Days" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:account_reports.general_ledger_report_debit +#: model:account.report.column,name:account_reports.journal_report_debit +#: model:account.report.column,name:account_reports.partner_ledger_report_debit +#: model:account.report.column,name:account_reports.trial_balance_report_debit +msgid "Debit" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Deductible" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Deferrals have not yet been completely generated for this period." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Deferrals have not yet been generated for this period." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Deferred Entries" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_deferred_expense +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_deferred_expense +msgid "Deferred Expense" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_deferred_expense_report_handler +msgid "Deferred Expense Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.deferred_expense_report +msgid "Deferred Expense Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_deferred_report_handler +msgid "Deferred Expense Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_deferred_revenue +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_deferred_revenue +msgid "Deferred Revenue" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_deferred_revenue_report_handler +msgid "Deferred Revenue Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.deferred_revenue_report +msgid "Deferred Revenue Report" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_expression_form +msgid "Definition" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_tax_periodicity +msgid "Delay units" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Depending moves" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Descending" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Difference from rounding taxes" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_line__display_custom_groupby_warning +msgid "Display Custom Groupby Warning" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__display_mail_composer +msgid "Display Mail Composer" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__display_name +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__display_name +msgid "Display Name" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Document" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__doc_name +msgid "Documents Name" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__domain +msgid "Domain" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Domestic" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__checkbox_download +msgid "Download" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_file_download_error_wizard_form +msgid "Download Anyway" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Download the Data Inalterability Check Report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Draft Entries" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Draft Entry" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_followup_report.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Due" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +#: model:account.report.column,name:account_reports.customer_statement_report_date_maturity +#: model:account.report.column,name:account_reports.followup_report_date_maturity +#: model:account.report.column,name:account_reports.partner_ledger_report_date_maturity +msgid "Due Date" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_sales +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_sales +msgid "EC Sales List" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_ec_sales_report_handler +msgid "EC Sales Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "EC tax on non EC countries" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "EC tax on same country" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_equity0 +msgid "EQUITY" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Editing a manual report line is not allowed in multivat setup when " +"displaying data from all fiscal positions." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Editing a manual report line is not allowed when multiple companies are " +"selected." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__checkbox_send_mail +msgid "Email" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_template_id +msgid "Email template" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__enable_download +msgid "Enable Download" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Enable Sections" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__enable_send_mail +msgid "Enable Send Mail" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0 +msgid "Enable more ..." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +msgid "End Balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Month" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Quarter" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Year" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Engine" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Entries with partners with no VAT" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Error message" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:account_reports.mail_activity_type_tax_report_error +msgid "Error sending Tax Report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filter_exchange_rate.xml:0 +msgid "Exchange Rates" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_move_line__exclude_bank_lines +msgid "Exclude Bank Lines" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_journal_report_audit_move_line_search +msgid "Exclude Bank lines" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_account__exclude_provision_currency_ids +msgid "Exclude Provision Currency" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.multicurrency_revaluation_excluded +msgid "Excluded Accounts" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.executive_summary +#: model:ir.actions.client,name:account_reports.action_account_report_exec_summary +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_exec_summary +msgid "Executive Summary" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__expense_provision_account_id +msgid "Expense Account" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_revaluation_expense_provision_account_id +msgid "Expense Provision Account" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Expense Provision for %s" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_expenses0 +msgid "Expenses" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_report_export_wizard +msgid "Export" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_reports_export_wizard_format +msgid "Export format for accounting's reports" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__export_format_ids +msgid "Export to" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_reports_export_wizard +msgid "Export wizard for accounting's reports" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_expression_form +msgid "Expression" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Expression labelled '%(label)s' of line '%(line)s' is being overwritten when" +" computing the current report. Make sure the cross-report aggregations of " +"this report only reference terms belonging to other reports." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__field_name +msgid "Field" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field %s does not exist on account.move.line, and is not supported by this " +"report's custom handler." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Field %s does not exist on account.move.line." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Field %s of account.move.line cannot be used in a groupby expression." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field %s of account.move.line is not searchable and can therefore not be " +"used in a groupby expression." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field 'Custom Handler Model' can only reference records inheriting from " +"[%s]." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__file_content +msgid "File Content" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_file_download_error_wizard_form +msgid "File Download Errors" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__file_name +msgid "File Name" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Filters" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Filters:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:account_reports.action_account_report_budget_tree +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_budget_tree +msgid "Financial Budgets" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_fiscal_position +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__fiscal_position_id +msgid "Fiscal Position" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +msgid "Fiscal Position:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__fpos_synced +msgid "Fiscal Positions Synchronised" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_tax_unit_form +msgid "" +"Fiscal Positions should apply to all companies of the tax unit. You may want" +" to" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.followup_report +msgid "Follow-Up Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_followup_report_handler +msgid "Follow-Up Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Foreign currencies adjustment entry as of %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Formula" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"From %(date_from)s\n" +"to %(date_to)s" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__fun_param +msgid "Function Parameter" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__fun_to_call +msgid "Function to Call" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0 +#: model:account.report,name:account_reports.general_ledger_report +#: model:ir.actions.client,name:account_reports.action_account_report_general_ledger +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_general_ledger +msgid "General Ledger" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_general_ledger_report_handler +msgid "General Ledger Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Generate entry" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0 +msgid "Generated Documents" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.generic_ec_sales_report +msgid "Generic EC Sales List" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_generic_tax_report_handler +msgid "Generic Tax Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_generic_tax_report_handler_account_tax +msgid "Generic Tax Report Custom Handler (Account -> Tax)" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_generic_tax_report_handler_tax_account +msgid "Generic Tax Report Custom Handler (Tax -> Account)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model_terms:ir.ui.view,arch_db:account_reports.journal_report_pdf_export_main +msgid "Global Tax Summary" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Goods" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Grid" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_gross_profit0 +msgid "Gross Profit" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_gross_profit0 +msgid "Gross profit" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_gpmargin0 +msgid "Gross profit margin (gross profit / operating income)" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_line_form +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_search +msgid "Group By" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_horizontal_group_form +msgid "Group Name" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Group by" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Grouped Deferral Entry of %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0 +msgid "Hide Account" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0 +msgid "Hide Debit/Credit" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Hide lines at 0" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Hierarchy and Subtotals" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__horizontal_group_id +msgid "Horizontal Group" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0 +msgid "Horizontal Group:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:account_reports.action_account_report_horizontal_groups +#: model:ir.model.fields,field_description:account_reports.field_account_report__horizontal_group_ids +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_horizontal_groups +msgid "Horizontal Groups" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_horizontal_group +msgid "Horizontal group for reports" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_horizontal_group_rule +msgid "Horizontal group rule for reports" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Horizontal:" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "How often tax returns have to be made" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__id +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__id +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__id +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__id +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__id +msgid "ID" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Impact On Grid" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Impacted Tax Grids" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "In %s" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_search +msgid "Inactive" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/journal_report/filter_extra_options.xml:0 +msgid "Include Payments" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filter_extra_options_template +msgid "Including Analytic Simulations" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.no_statement_unreconciled_payments +#: model:account.report.line,name:account_reports.unreconciled_last_statement_payments +msgid "Including Unreconciled Payments" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.no_statement_unreconciled_receipt +#: model:account.report.line,name:account_reports.unreconciled_last_statement_receipts +msgid "Including Unreconciled Receipts" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__income_provision_account_id +msgid "Income Account" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_revaluation_income_provision_account_id +msgid "Income Provision Account" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Income Provision for %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "Inconsistent Statements" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Inconsistent data: more than one external value at the same date for a " +"'most_recent' external line." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Inconsistent report_id in options dictionary. Options says " +"%(options_report)s; report is %(report)s." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +msgid "Initial Balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Integer Rounding" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0 +msgid "Intervals cannot be smaller than 1" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "Intra-community taxes are applied on" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Invalid domain formula in expression \"%(expression)s\" of line " +"\"%(line)s\": %(formula)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Invalid method “%s”" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Invalid subformula in expression \"%(expression)s\" of line \"%(line)s\": " +"%(subformula)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Invalid token '%(token)s' in account_codes formula '%(formula)s'" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +#: model:account.report.column,name:account_reports.aged_payable_report_invoice_date +#: model:account.report.column,name:account_reports.aged_receivable_report_invoice_date +#: model:account.report.column,name:account_reports.customer_statement_report_invoicing_date +#: model:account.report.column,name:account_reports.followup_report_invoicing_date +#: model:account.report.column,name:account_reports.partner_ledger_report_invoicing_date +msgid "Invoice Date" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_journal_report_audit_move_line_search +msgid "Invoice lines" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__is_account_coverage_report_available +msgid "Is Account Coverage Report Available" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "It seems there is some depending closing move to be posted" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "" +"It's not possible to select a budget with the horizontal group feature." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "" +"It's not possible to select a horizontal group with the budget feature." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__item_ids +msgid "Items" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.partner_ledger_report_journal_code +#: model:ir.model,name:account_reports.model_account_journal +#: model:ir.model.fields,field_description:account_reports.field_account_financial_year_op__account_tax_periodicity_journal_id +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__journal_id +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_tax_periodicity_journal_id +#: model:ir.model.fields,field_description:account_reports.field_res_config_settings__account_tax_periodicity_journal_id +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:account_reports.setup_financial_year_opening_form +msgid "Journal" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_ja +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_ja +msgid "Journal Audit" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_move_line +msgid "Journal Item" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/general_ledger/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Journal Items" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Journal Items for Tax Audit" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.journal_report +msgid "Journal Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_journal_report_handler +msgid "Journal Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Journal items with archived tax tags" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Journals" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Journals:" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_liabilities_view0 +msgid "LIABILITIES" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_liabilities_and_equity_view0 +msgid "LIABILITIES + EQUITY" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:account_reports.bank_reconciliation_report_label +msgid "Label" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_lang +msgid "Lang" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Last Statement balance + Transactions since statement" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__write_uid +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget_item__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_file_download_error_wizard__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__write_date +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__write_date +msgid "Last Updated on" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.last_statement_balance +msgid "Last statement balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Later" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_cost_sales0 +msgid "Less Costs of Revenue" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_expense0 +msgid "Less Operating Expenses" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_depreciation0 +msgid "Less Other Expenses" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__line_id +msgid "Line" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Line '%(child)s' is configured to appear before its parent '%(parent)s'. " +"This is not allowed." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Lines" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Load more..." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_attachments_widget +msgid "Mail Attachments Widget" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__main_company_id +msgid "Main Company" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_tax_unit__main_company_id +msgid "" +"Main company of this unit; the one actually reporting and paying the taxes." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_multicurrency_revaluation_wizard +msgid "Make Adjustment Entry" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_report_file_download_error_wizard +msgid "Manage the file generation errors from report exports." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Manual value" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Manual values" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.partner_ledger_report_matching_number +msgid "Matching" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_tax_unit__company_ids +msgid "Members of this unit" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Method '%(method_name)s' must start with the '%(prefix)s' prefix." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.misc_operations +msgid "Misc. operations" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mode +msgid "Mode" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group_rule__res_model_name +msgid "Model" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Month" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_budget_form +msgid "Months" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Multi-Ledger:" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Multi-ledger" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_multicurrency_revaluation_report_handler +msgid "Multicurrency Revaluation Report Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_multicurrency_revaluation_wizard +msgid "Multicurrency Revaluation Wizard" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__account_report_send__mode__multi +msgid "Multiple Recipients" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "" +"Multiple draft tax closing entries exist for fiscal position %(position)s after %(period_start)s. There should be at most one. \n" +" %(closing_entries)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "" +"Multiple draft tax closing entries exist for your domestic region after %(period_start)s. There should be at most one. \n" +" %(closing_entries)s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model:account.report.line,name:account_reports.journal_report_line +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__name +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__name +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__name +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__name +#: model_terms:ir.ui.view,arch_db:account_reports.duplicated_vat_partner_tree_view +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +#: model_terms:ir.ui.view,arch_db:account_reports.view_tax_unit_form +msgid "Name" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_reports_export_wizard__doc_name +msgid "Name to give to the generated documents." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_profit0 +#: model:account.report.line,name:account_reports.account_financial_report_net_profit0 +msgid "Net Profit" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_net_assets0 +msgid "Net assets" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Net increase in cash and cash equivalents" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_npmargin0 +msgid "Net profit margin (net profit / revenue)" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Never miss a tax deadline." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "No" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "No Comparison" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "No Journal" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "No VAT number associated with your company. Please define one." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "No adjustment needed" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0 +msgid "No data to display !" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/chart_template.py:0 +msgid "No default miscellaneous journal could be found for the active company" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "No entry to generate." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "No provision needed was found." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Non Trade Partners" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Non Trade Payable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Non Trade Receivable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Non-Deductible" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "None" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Not Started" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Number of periods cannot be smaller than 1" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_off_sheet +msgid "OFF BALANCE SHEET ACCOUNTS" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0 +msgid "Odoo Warning" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period5 +#: model:account.report.column,name:account_reports.aged_receivable_report_period5 +msgid "Older" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0 +msgid "One of the formats chosen can not be exported in the DMS" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Only Billing Administrators are allowed to change lock dates!" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.server,name:account_reports.action_account_reports_customer_statements +msgid "Open Customer Statements" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_financial_year_op +msgid "Opening Balance of Financial Year" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_operating_income0 +msgid "Operating Income (or Loss)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_expression_form +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Options" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filter_extra_options_template +msgid "Options:" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.outstanding +msgid "Outstanding Receipts/Payments" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_followup_report.py:0 +msgid "Overdue" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "PDF" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard__report_id +msgid "Parent Report Id" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_reports_export_wizard_format__export_wizard_id +msgid "Parent Wizard" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +#: model:account.report.column,name:account_reports.general_ledger_report_partner_name +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__partner_ids +msgid "Partner" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Partner Categories" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.partner_ledger_report +#: model:ir.actions.client,name:account_reports.action_account_report_partner_ledger +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_partner_ledger +msgid "Partner Ledger" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_partner_ledger_report_handler +msgid "Partner Ledger Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Partner is bad" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Partner is good" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Partner(s) should have an email address." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0 +msgid "Partners" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Partners Categories:" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Partners with duplicated VAT numbers" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filters +msgid "Partners:" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:account_reports.mail_activity_type_tax_report_to_pay +msgid "Pay Tax" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Pay tax: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Payable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Payable tax amount" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_current_liabilities_payable +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_creditors0 +msgid "Payables" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_performance0 +msgid "Performance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Period" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period1 +#: model:account.report.column,name:account_reports.aged_receivable_report_period1 +msgid "Period 1" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period2 +#: model:account.report.column,name:account_reports.aged_receivable_report_period2 +msgid "Period 2" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period3 +#: model:account.report.column,name:account_reports.aged_receivable_report_period3 +msgid "Period 3" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.aged_payable_report_period4 +#: model:account.report.column,name:account_reports.aged_receivable_report_period4 +msgid "Period 4" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Period order" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_config_settings__account_tax_periodicity +#: model:ir.model.fields,help:account_reports.field_account_financial_year_op__account_tax_periodicity +#: model:ir.model.fields,help:account_reports.field_res_company__account_tax_periodicity +#: model:ir.model.fields,help:account_reports.field_res_config_settings__account_tax_periodicity +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Periodicity" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_financial_year_op__account_tax_periodicity +msgid "Periodicity in month" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Periods" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Plans" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/budget.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Please enter a valid budget name." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filters.js:0 +msgid "Please enter a valid number." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Please select a mail template to send multiple statements." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Please select the main company and its branches in the company selector to " +"proceed." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Please set the deferred accounts in the accounting settings." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Please set the deferred journal in the accounting settings." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Please specify the accounts necessary for the Tax Closing Entry." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_fixed_assets_view0 +msgid "Plus Fixed Assets" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_non_current_assets_view0 +msgid "Plus Non-current Assets" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_non_current_liabilities0 +msgid "Plus Non-current Liabilities" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_other_income0 +msgid "Plus Other Income" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_position0 +msgid "Position" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "Post" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Posted Entries" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_prepayements0 +msgid "Prepayments" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__preview_data +msgid "Preview Data" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Period" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Periods" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Year" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Years" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_retained_earnings_line_2 +msgid "Previous Years Retained Earnings" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_previous_year_earnings0 +msgid "Previous Years Unallocated Earnings" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "Print & Send" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Proceed" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_multicurrency_revaluation_wizard +msgid "" +"Proceed with caution as there might be an existing adjustment for this " +"period (" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Product" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Product Category" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.profit_and_loss +#: model:ir.actions.client,name:account_reports.action_account_report_pl +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_profit_and_loss +msgid "Profit and Loss" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_profitability0 +msgid "Profitability" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_move_form_vat_return +msgid "Proposition of tax closing journal entry." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Provision for %(for_cur)s (1 %(comp_cur)s = %(rate)s %(for_cur)s)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Quarter" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0 +msgid "Rates" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Receivable" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Receivable tax amount" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_debtors0 +#: model:account.report.line,name:account_reports.account_financial_report_receivable0 +msgid "Receivables" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_partner_ids +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "Recipients" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_reports_journal_dashboard_kanban_view +msgid "Reconciliation Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_financial_year_op__account_tax_periodicity_reminder_day +#: model:ir.model.fields,field_description:account_reports.field_res_config_settings__account_tax_periodicity_reminder_day +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:account_reports.setup_financial_year_opening_form +msgid "Reminder" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__report_id +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__account_report_id +msgid "Report" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_line_form +msgid "Report Line" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Report Name" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__report_options +msgid "Report Options" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Report lines mentioning the account code" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0 +msgid "Report:" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Reporting" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__report_ids +msgid "Reports" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_line_form +msgid "Reset to Standard" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_retained_earnings0 +msgid "Retained Earnings" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_return_investment0 +msgid "Return on investments (net profit / assets)" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_income0 +#: model:account.report.line,name:account_reports.account_financial_report_revenue0 +msgid "Revenue" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__reversal_date +msgid "Reversal Date" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Reversal of Grouped Deferral Entry of %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Reversal of: %s" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_account_report_search +msgid "Root Report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_horizontal_group__rule_ids +msgid "Rules" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Same Period Last Year" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/search_bar/search_bar.xml:0 +msgid "Search..." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "Sections" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_customer_statement.py:0 +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send %s Statement" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__send_and_print_values +msgid "Send And Print Values" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__send_mail_readonly +msgid "Send Mail Readonly" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send Partner Ledgers" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.server,name:account_reports.ir_cron_account_report_send_ir_actions_server +msgid "Send account reports automatically" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Send tax report: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Sending statements" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_budget__sequence +msgid "Sequence" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Services" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_journal_report_audit_move_line_tree +msgid "Set as Checked" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.duplicated_vat_partner_tree_view +msgid "Set as main" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_report_executivesummary_st_cash_forecast0 +msgid "Short term cash forecast" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0 +msgid "Show Account" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +msgid "Show All Accounts" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0 +msgid "Show Currency" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_multicurrency_revaluation_wizard__show_warning_move_id +msgid "Show Warning Move" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Show already generated deferrals." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__account_report_send__mode__single +msgid "Single Recipient" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "Some" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "Some journal items appear to point to obsolete report lines." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "" +"Some lines in the report involve partners that share the same VAT number.\n" +"\n" +" Please review the" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Specific Date" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_res_company__account_representative_id +msgid "" +"Specify an Accounting Firm that will act as a representative when exporting " +"reports." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Split Horizontally" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report__tax_closing_start_date +msgid "Start Date" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_tax_periodicity_reminder_day +msgid "Start from" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Starting Balance" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +msgid "Statement" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Statements are being sent in the background." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Subformula" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__mail_subject +msgid "Subject" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_send_form +msgid "Subject..." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "T: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0 +msgid "Tags" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Tax Amount" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_journal_report_taxes_summary +msgid "Tax Applied" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_bank_statement_line__tax_closing_alert +#: model:ir.model.fields,field_description:account_reports.field_account_move__tax_closing_alert +msgid "Tax Closing Alert" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_bank_statement_line__tax_closing_report_id +#: model:ir.model.fields,field_description:account_reports.field_account_move__tax_closing_report_id +msgid "Tax Closing Report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Tax Declaration" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Tax Grids" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_tax_unit__vat +msgid "Tax ID" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.company_information +#: model_terms:ir.ui.view,arch_db:account_reports.tax_information_customer_report +msgid "Tax ID:" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Tax Paid Adjustment" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/filters/filter_date.xml:0 +msgid "Tax Period" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Tax Received Adjustment" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:account_reports.tax_closing_activity_type +#: model:mail.activity.type,summary:account_reports.tax_closing_activity_type +#: model_terms:ir.ui.view,arch_db:account_reports.view_move_form_vat_return +msgid "Tax Report" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:account_reports.mail_activity_type_tax_report_error +msgid "Tax Report - Error" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:account_reports.mail_activity_type_tax_report_to_be_sent +msgid "Tax Report Ready" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:account_reports.action_account_report_gt +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_gt +msgid "Tax Return" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "Tax Return Periodicity" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_tax_unit +#: model_terms:ir.ui.view,arch_db:account_reports.view_tax_unit_form +msgid "Tax Unit" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0 +msgid "Tax Unit:" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:account_reports.action_view_tax_units +#: model:ir.model.fields,field_description:account_reports.field_res_company__account_tax_unit_ids +#: model:ir.ui.menu,name:account_reports.menu_view_tax_units +msgid "Tax Units" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_mail_activity__account_tax_closing_params +msgid "Tax closing additional params" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:account_reports.mail_activity_type_tax_report_to_pay +msgid "Tax is ready to be paid" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__mail_activity_type__category__tax_report +msgid "Tax report" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:account_reports.mail_activity_type_tax_report_to_be_sent +msgid "Tax report is ready to be sent to the administration" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "Tax return" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Taxes" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +msgid "Taxes Applied" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_tax_unit__fpos_synced +msgid "" +"Technical field indicating whether Fiscal Positions exist for all companies " +"in the unit" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_ir_actions_account_report_download +msgid "Technical model for accounting report downloads" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js:0 +msgid "Text copied" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "The Accounts Coverage Report is not available for this report." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover_line/annotation_popover_line.js:0 +msgid "The annotation shouldn't have an empty value." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_annotation__text +msgid "The annotation's content." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"The attachments of the tax report can be found on the %(link_start)sclosing " +"entry%(link_end)s of the representative company." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "The column '%s' is not available for this report." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"The country detected for this VAT number does not match the one set on this " +"Tax Unit." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_tax_unit__country_id +msgid "" +"The country in which this tax unit is used to group your companies' tax " +"reports declaration." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "The currency rate cannot be equal to zero" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "The current balance in the" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "" +"The currently selected dates don't match a tax period. The closing entry " +"will be created for the closest-matching period according to your " +"periodicity setup." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_report_annotation__fiscal_position_id +msgid "The fiscal position used while annotating." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_report_annotation__line_id +msgid "The id of the annotated line." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_report_annotation__report_id +msgid "The id of the annotated report." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_tax_unit__vat +msgid "The identifier to be used when submitting a report for this unit." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "The main company of a tax unit has to be part of it." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_res_company__account_tax_unit_ids +msgid "The tax units this company belongs to." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "The used operator is not supported for this expression." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "There are" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "" +"There are currently reports waiting to be sent, please try again later." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0 +msgid "There is no data to display for the given filters." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This account exists in the Chart of Accounts but is not mentioned in any " +"line of the report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This account is reported in a line of the report but does not exist in the " +"Chart of Accounts" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This account is reported in multiple lines of the report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This account is reported multiple times on the same line of the report" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "" +"This allows you to choose the position of totals in your financial reports." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "" +"This company is part of a tax unit. You're currently not viewing the whole " +"unit." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0 +msgid "" +"This line and all its children will be deleted. Are you sure you want to " +"proceed?" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "This line is out of sequence." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "" +"This line is placed before its parent, which is not allowed. You can fix it " +"by dragging it to the proper position." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_line_form +msgid "This line uses a custom user-defined 'Group By' value." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "This option hides lines with a value of 0" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This report already has a menuitem." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "" +"This report contains inconsistencies. The affected lines are marked with a " +"warning." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "This report only displays the data of the active company." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.account_report_form +msgid "" +"This report uses report-specific code.\n" +" You can customize it manually, but any change in the parameters used for its computation could lead to errors." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "" +"This report uses the CTA conversion method to consolidate multiple companies using different currencies,\n" +" which can lead the report to be unbalanced." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This subformula references an unknown expression: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This tag is reported in a line of the report but is not linked to any " +"account of the Chart of Accounts" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_move_form_vat_return +msgid "" +"This tax closing entry is posted, but the tax lock date is earlier than the " +"covered period's last day. You might need to reset it to draft and refresh " +"its content, in case other entries using taxes have been posted in the " +"meantime." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Today" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +#: model:account.report.column,name:account_reports.aged_payable_report_total +#: model:account.report.column,name:account_reports.aged_receivable_report_total +msgid "Total" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Total %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Trade Partners" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.transaction_without_statement +msgid "Transactions without statement" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.trial_balance_report +#: model:ir.actions.client,name:account_reports.action_account_report_coa +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_coa +msgid "Trial Balance" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model,name:account_reports.model_account_trial_balance_report_handler +msgid "Trial Balance Custom Handler" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Triangular" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Trying to dispatch an action on a report not compatible with the provided " +"options." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Trying to expand a group for a line which was not generated by a report " +"line: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Trying to expand a line without an expansion function." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Trying to expand groupby results on lines without a groupby value." +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.line,name:account_reports.account_financial_unaffected_earnings0 +msgid "Unallocated Earnings" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Undefined" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Unfold All" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Unknown Partner" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown bound criterium: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown date scope: %s" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report,name:account_reports.multicurrency_revaluation_report +#: model:ir.actions.client,name:account_reports.action_account_report_multicurrency_revaluation +#: model:ir.ui.menu,name:account_reports.menu_action_account_report_multicurrency_revaluation +msgid "Unrealized Currency Gains/Losses" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filter_extra_options_template +msgid "Unreconciled Entries" +msgstr "" + +#. module: odex30_account_reports +#: model:account.report.column,name:account_reports.account_financial_report_ec_sales_vat +msgid "VAT Number" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.setup_financial_year_opening_form +msgid "VAT Periodicity" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Value" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Vat closing from %(date_from)s to %(date_to)s" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_journal_report_audit_move_line_tree +msgid "View" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Bank Statement" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "View Carryover Lines" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Journal Entry" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +#: model_terms:ir.ui.view,arch_db:account_reports.view_journal_report_audit_move_line_tree +msgid "View Partner" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "View Partner(s)" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Payment" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:account_reports.field_account_report_send__warnings +msgid "Warnings" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +msgid "" +"When ticked, totals and subtotals appear below the sections of the report" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_res_company__totals_below_sections +#: model:ir.model.fields,help:account_reports.field_res_config_settings__totals_below_sections +msgid "" +"When ticked, totals and subtotals appear below the sections of the report." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:account_reports.field_account_account__exclude_provision_currency_ids +msgid "" +"Whether or not we have to make provisions for the selected foreign " +"currencies." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_filter_extra_options_template +msgid "With Draft Entries" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Wrong ID for general ledger line to expand: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Wrong ID for partner ledger line to expand: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Wrong format for if_other_expr_above/if_other_expr_below formula: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "XLSX" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Year" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "Yes" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "You are using custom exchange rates." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "You can't open a tax report from a move without a VAT closing date." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move_line.py:0 +msgid "You cannot add taxes on a tax closing move line." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "" +"You cannot generate entries for a period that does not end at the end of the" +" month." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "You cannot generate entries for a period that is locked." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"You cannot reset this closing entry to draft, as another closing entry has " +"been posted at a later date." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"You cannot reset this closing entry to draft, as it would delete carryover " +"values impacting the tax report of a locked period. Please change the " +"following lock dates to proceed: %(lock_date_info)s." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "You cannot update this value as it's locked by: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "You need to activate more than one currency to access this report." +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "" +"You're about the generate the closing entries of multiple companies at once." +" Each of them will be created in accordance with its company tax " +"periodicity." +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.journal_report_pdf_export_main +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_main +#: model_terms:ir.ui.view,arch_db:account_reports.pdf_export_main_customer_report +msgid "[Draft]" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "addressed to" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "affected partners" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "and correct their tax tags if necessary." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__year +msgid "annually" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:account_reports.setup_financial_year_opening_form +msgid "days after period" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "doesn't match the balance of your" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__2_months +msgid "every 2 months" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__4_months +msgid "every 4 months" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "have a starting balance different from the previous ending balance." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "in the next period." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "invoices" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "journal items" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "last bank statement" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__monthly +msgid "monthly" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "n/a" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "partners" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "prior or included in this period." +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__trimester +msgid "quarterly" +msgstr "" + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:account_reports.selection__res_company__account_tax_periodicity__semester +msgid "semi-annually" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "statements" +msgstr "" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:account_reports.view_tax_unit_form +msgid "synchronize fiscal positions" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "tax unit [%s]" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "that are not established abroad." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "to" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "to resolve the duplication." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "unposted Journal Entries" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "were carried over to this line from previous period." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "which don't originate from a bank statement nor payment." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "who are not established in any of the EC countries." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "will be carried over to" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "will be carried over to this line in the next period." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "without a valid intra-community VAT number." +msgstr "" + +#. module: odex30_account_reports +#: model:mail.template,subject:account_reports.email_template_customer_statement +msgid "" +"{{ (object.company_id or object._get_followup_responsible().company_id).name" +" }} Statement - {{ object.commercial_company_name }}" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "⇒ Reset to Odoo’s Rate" +msgstr "" diff --git a/dev_odex30_accounting/odex30_account_reports/i18n/ar_001.po b/dev_odex30_accounting/odex30_account_reports/i18n/ar_001.po new file mode 100644 index 0000000..2cddacb --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/i18n/ar_001.po @@ -0,0 +1,4608 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * odex30_account_reports +# +# Translators: +# Martin Trigaux, 2024 +# Wil Odoo, 2025 +# Malaz Abuidris , 2025 +# Weblate , 2025. +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-24 18:48+0000\n" +"PO-Revision-Date: 2025-11-12 14:22+0000\n" +"Last-Translator: Weblate \n" +"Language-Team: Arabic \n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" +"X-Generator: Weblate 5.12.2\n" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "\" account balance is affected by" +msgstr "\" يتأثر رصيد الحساب بـ" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "%(journal)s - %(account)s" +msgstr "%(journal)s - %(account)s" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%(names)s and %(remaining)s others" +msgstr "%(names)s و%(remaining)s آخرين " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%(names)s and one other" +msgstr "%(names)s وشخص آخر " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "%(report_label)s: %(period)s" +msgstr "%(report_label)s: %(period)s" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/budget.py:0 +msgid "%s (copy)" +msgstr "%s (نسخة)" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "%s is not a numeric value" +msgstr "%s ليست قيمة عددية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "%s selected" +msgstr "تم تحديد %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"'Open General Ledger' caret option is only available form report lines " +"targetting accounts." +msgstr "" +"خيار علامة إقحام 'فتح دفتر الأستاذ العام' متاح فقط من بنود التقرير التي " +"تستهدف الحسابات. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"'View Bank Statement' caret option is only available for report lines " +"targeting bank statements." +msgstr "" +"خيار علامة إقحام 'عرض كشف الحساب البنكي' متاح فقط من بنود التقرير التي " +"تستهدف كشوف الحسابت البنكية. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "'external' engine does not support groupby, limit nor offset." +msgstr "لا يدعم المحرك 'الخارجي' عمليات التجميع حسب، أو التقييد، أو الإزاحة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(%s lines)" +msgstr "(%s بنود) " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.outstanding_receipts +msgid "(+) Outstanding Receipts" +msgstr "(+) الإيصالات المستحقة " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.outstanding_payments +msgid "(-) Outstanding Payments" +msgstr "(-) المدفوعات المستحقة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(1 line)" +msgstr "(بند 1) " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "(No %s)" +msgstr "(لا %s) " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "(No Group)" +msgstr "(بلا تجميع)" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid ", leading to an unexplained difference of" +msgstr "، والذي يؤدي إلى فريق لا يمكن تفسيره في " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return +msgid "-> Refresh" +msgstr "-> تحديث" + +#. module: odex30_account_reports +#: model:mail.template,body_html:odex30_account_reports.email_template_customer_statement +msgid "" +"
    \n" +"

    \n" +" Dear (),\n" +" Dear ,\n" +"
    \n" +" Please find enclosed the statement of your account.\n" +"
    \n" +" Do not hesitate to contact us if you have any " +"questions.\n" +"
    \n" +" Sincerely,\n" +"
    \n" +"\t \n" +"

    \n" +"
    \n" +" " +msgstr "" +"
    \n" +"

    \n" +" عزيزنا " +"(\n" +" عزيزنا ،\n" +"
    \n" +" يُرجى الاطلاع على كشف حسابك في المرفقات.\n" +"
    \n" +" لا تتردد في التواصل معنا إذا كانت لديك أي أسئلة أو " +"استفسارات.\n" +"
    \n" +" مع وافر الشكر والتقدير،\n" +"
    \n" +"\t \n" +"

    \n" +"
    \n" +" " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "" +"" +msgstr "" +"" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form +msgid "" +"Errors marked with are critical and prevent " +"the file generation." +msgstr "" +"تعتبر الأخطاء التي تم وضع علامة عليها خطيرة " +"وتمنع إنشاء الملف. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Reconciliation" +msgstr "التسوية" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form +msgid "One or more error(s) occurred during file generation:" +msgstr "وقع خطأ واحد أو أكثر أثناء عملية إنشاء الملف: " + +#. module: odex30_account_reports +#: model:ir.model.constraint,message:odex30_account_reports.constraint_account_report_horizontal_group_name_uniq +msgid "A horizontal group with the same name already exists." +msgstr "توجد مجموعة أفقية بنفس الاسم بالفعل. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0 +msgid "A line with a 'Group By' value cannot have children." +msgstr "البند الذي به قيمة 'التجميع حسب' لا يمكن أن يكون له توابع. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"A tax unit can only be created between companies sharing the same main " +"currency." +msgstr "يمكن إنشاء الوحدة الضريبية فقط بين الشركات التي تتشارك نفس العملة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"A tax unit must contain a minimum of two companies. You might want to delete " +"the unit." +msgstr "يجب أن تحتوي وحدة الضريبة على شركتين كحد أدنى. قد ترغب في حذف الوحدة. " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_total_assets0 +msgid "ASSETS" +msgstr "الأصول" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_account_name +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_account_name +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_account_code +#: model:ir.model,name:odex30_account_reports.model_account_account +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__account_id +msgid "Account" +msgstr "الحساب " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_chart_template +msgid "Account Chart Template" +msgstr "نموذج مخطط الحساب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Account Code" +msgstr "كود الحساب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Account Code / Tag" +msgstr "علامة تصنيف / كود الحساب " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_display_representative_field +msgid "Account Display Representative Field" +msgstr "حقل ممثل لعرض الحساب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Account Label" +msgstr "عنوان الحساب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Account Name" +msgstr "اسم الحساب" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_annotation +msgid "Account Report Annotation" +msgstr "شرح تقرير الحساب " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_custom_handler +msgid "Account Report Custom Handler" +msgstr "المعالج المخصص لتقارير الحساب " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_tax_report_handler +msgid "Account Report Handler for Tax Reports" +msgstr "معالج تقرير الحساب للتقارير الضريبية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_send +msgid "Account Report Send" +msgstr "إرسال التقرير المحاسبي " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_reports_show_per_company_setting +msgid "Account Reports Show Per Company Setting" +msgstr "عرض تقارير الحساب حسب إعدادات الشركة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_partner__account_represented_company_ids +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_users__account_represented_company_ids +msgid "Account Represented Company" +msgstr "شركة ممثَّلة في الحساب " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_journal_id +msgid "Account Revaluation Journal" +msgstr "يومية إعادة تقييم الحساب " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml:0 +msgid "Account:" +msgstr "الحساب: " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_representative_id +msgid "Accounting Firm" +msgstr "مؤسسة محاسبية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report +msgid "Accounting Report" +msgstr "تقرير المحاسبة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_budget +msgid "Accounting Report Budget" +msgstr "ميزانية التقرير المحاسبي " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_budget_item +msgid "Accounting Report Budget Item" +msgstr "عنصر ميزانية التقرير المحاسبي " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_expression +msgid "Accounting Report Expression" +msgstr "تعبير التقرير المحاسبي " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_external_value +msgid "Accounting Report External Value" +msgstr "القيمة الخارجية للتقرير المحاسبي" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_line +msgid "Accounting Report Line" +msgstr "بند التقرير المحاسبي " + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_tree +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_tree +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search +msgid "Accounting Reports" +msgstr "التقارير المحاسبية" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Accounts" +msgstr "الحسابات" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Accounts Coverage Report" +msgstr "تقرير تغطية الحسابات " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.multicurrency_revaluation_to_adjust +msgid "Accounts To Adjust" +msgstr "الحسابات بانتظار التعديل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Accounts coverage" +msgstr "تغطية الحسابات " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_mail_activity_type__category +msgid "Action" +msgstr "إجراء" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__actionable_errors +msgid "Actionable Errors" +msgstr "أخطاء قابلة للتنفيذ " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_mail_activity_type__category +msgid "" +"Actions may trigger specific behavior like opening calendar view or " +"automatically mark as done when a document is uploaded" +msgstr "" +"قد تؤدي الإجراءات إلى سلوك معين مثل فتح طريقة عرض التقويم أو وضع علامة " +"\"تم\" تلقائياً عند تحميل مستند " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_mail_activity +msgid "Activity" +msgstr "النشاط" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_mail_activity_type +msgid "Activity Type" +msgstr "نوع النشاط" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "Add a line" +msgstr "إضافة بند" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "Add contacts to notify..." +msgstr "إضافة جهات اتصال لإشعارهم..." + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__totals_below_sections +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__totals_below_sections +msgid "Add totals below sections" +msgstr "إضافة إجمالي تحت الأقسام" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_adjustment +msgid "Adjustment" +msgstr "التعديلات" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "Adjustment Entry" +msgstr "تعديل القيد" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Advance Payments received from customers" +msgstr "المدفوعات المدفوعة مقدماً من قِبَل العملاء " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Advance payments made to suppliers" +msgstr "المدفوعات المدفوعة مقدمًا للموردين" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Advanced" +msgstr "متقدم" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_aged_partner_balance_report_handler +msgid "Aged Partner Balance Custom Handler" +msgstr "المعالج المخصص لأعمار ديون الشريك " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.aged_payable_report +#: model:account.report.line,name:odex30_account_reports.aged_payable_line +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ap +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_aged_payable +msgid "Aged Payable" +msgstr "حسابات دائنة مستحقة متأخرة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_aged_payable_report_handler +msgid "Aged Payable Custom Handler" +msgstr "المعالج المخصص للحسابات الدائنة المستحقة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Aged Payables" +msgstr "حساب دائن مستحق متأخر " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.aged_receivable_report +#: model:account.report.line,name:odex30_account_reports.aged_receivable_line +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ar +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_aged_receivable +msgid "Aged Receivable" +msgstr "المتأخر المدين" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_aged_receivable_report_handler +msgid "Aged Receivable Custom Handler" +msgstr "المعالج المخصص للحسابات المدينة المستحقة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Aged Receivables" +msgstr "المتأخرات المدينة" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0 +msgid "All" +msgstr "الكل" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "All Journals" +msgstr "كافة اليوميات " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "All Payable" +msgstr "جميع الذمم مستحقة الدفع " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "All Receivable" +msgstr "جميع الذمم المدينة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "All Report Variants" +msgstr "كافة متغيرات التقرير " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "" +"All selected companies or branches do not share the same Tax ID. Please " +"check the Tax ID of the selected companies." +msgstr "" +"لا تتشارك كل الشركات أو الفروع المحددة في نفس المُعرِّف الضريبي. يُرجى التحقق من " +"المُعرِّف الضريبي للشركات المحددة. " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_amount +#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_amount +#: model:account.report.column,name:odex30_account_reports.customer_statement_amount +#: model:account.report.column,name:odex30_account_reports.followup_report_amount +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__amount +msgid "Amount" +msgstr "مبلغ" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_amount_currency +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_amount_currency +#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_amount_currency +#: model:account.report.column,name:odex30_account_reports.customer_statement_report_amount_currency +#: model:account.report.column,name:odex30_account_reports.followup_report_report_amount_currency +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_amount_currency +msgid "Amount Currency" +msgstr "عملة المبلغ" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Amount in currency: %s" +msgstr "المبلغ بالعملة: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Lakhs" +msgstr "المبلغ باللاكس " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Millions" +msgstr "المبالغ بالملايين " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Amounts in Thousands" +msgstr "المبالغ بالآلاف " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Analytic" +msgstr "تحليلي " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__filter_analytic_groupby +msgid "Analytic Group By" +msgstr "التجميع التحليلي حسب " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Analytic Simulations" +msgstr "عمليات المحاكاة التحليلية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/line_name.xml:0 +msgid "Annotate" +msgstr "تعليق" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +msgid "Annotation" +msgstr "الشرح " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__annotations_ids +msgid "Annotations" +msgstr "الشرح " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "As of %s" +msgstr "اعتبارًا من %s" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Ascending" +msgstr "تصاعدي " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period0 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period0 +msgid "At Date" +msgstr "بتاريخ" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "Attach a file" +msgstr "إرفاق ملف" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line_name.xml:0 +msgid "Audit" +msgstr "تدقيق" + +#. module: odex30_account_reports +#: model:ir.ui.menu,name:odex30_account_reports.account_reports_audit_reports_menu +msgid "Audit Reports" +msgstr "تقارير التدقيق" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_avgcre0 +msgid "Average creditors days" +msgstr "متوسط أيام الدائنين" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_avdebt0 +msgid "Average debtors days" +msgstr "متوسط أيام المدينين" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "B: %s" +msgstr "B: %s" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model:account.report.column,name:odex30_account_reports.balance_sheet_balance +#: model:account.report.column,name:odex30_account_reports.cash_flow_report_balance +#: model:account.report.column,name:odex30_account_reports.customer_statement_report_balance +#: model:account.report.column,name:odex30_account_reports.executive_summary_column +#: model:account.report.column,name:odex30_account_reports.followup_report_report_balance +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_balance +#: model:account.report.column,name:odex30_account_reports.journal_report_balance +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_balance +#: model:account.report.column,name:odex30_account_reports.profit_and_loss_column +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Balance" +msgstr "الرصيد" + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.balance_sheet +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_balancesheet0 +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_bs +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_balance_sheet +msgid "Balance Sheet" +msgstr "الميزانية العمومية" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_balance_sheet_report_handler +msgid "Balance Sheet Custom Handler" +msgstr "المعالج المخصص للميزانية العمومية " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_current +msgid "Balance at Current Rate" +msgstr "الرصيد بسعر الصرف الحالي " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_operation +msgid "Balance at Operation Rate" +msgstr "الرصيد بسعر العملية " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.multicurrency_revaluation_report_balance_currency +msgid "Balance in Foreign Currency" +msgstr "الرصيد بالعملة الأجنبية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "Balance of '%s'" +msgstr "رصيد '%s' " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.balance_bank +msgid "Balance of Bank" +msgstr "رصيد البنك " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax advance payment account" +msgstr "حساب رصيد الضريبة مسبقة الدفع " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax current account (payable)" +msgstr "حساب رصيد الضريبة مسبقة الدفع (الدائن)" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Balance tax current account (receivable)" +msgstr "حساب رصيد الضريبة مسبقة الدفع (المدين)" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_bank_reconciliation +msgid "Bank Reconciliation" +msgstr "التسوية البنكية" + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.bank_reconciliation_report +msgid "Bank Reconciliation Report" +msgstr "تقرير التسوية البنكية" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_bank_reconciliation_report_handler +msgid "Bank Reconciliation Report Custom Handler" +msgstr "المعالج المخصص لتقارير تسوية البنك " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_bank_view0 +msgid "Bank and Cash Accounts" +msgstr "الحسابات البنكية والنقدية " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.bank_information_customer_report +msgid "Bank:" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Base Amount" +msgstr "المبلغ الأساسي" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +msgid "Based on" +msgstr "بناءً على" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Before" +msgstr "قبل" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__budget_id +msgid "Budget" +msgstr "الميزانية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_account__budget_item_ids +msgid "Budget Item" +msgstr "عنصر الميزانية " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form +msgid "Budget Items" +msgstr "عناصر الميزانية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form +msgid "Budget Name" +msgstr "اسم الميزانية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Budget items can only be edited from account lines." +msgstr "يمكن تحرير عناصر الميزانية فقط من بنود الحساب. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/redirectAction/redirectAction.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_report_export_wizard +msgid "Cancel" +msgstr "إلغاء" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Cannot audit tax from another model than account.tax." +msgstr "لا يمكن تدقيق الضريبة من نموذج آخر غير account.tax. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Cannot generate carryover values for all fiscal positions at once!" +msgstr "لا يمكن إنشاء قيم الترحيل لكافة الأوضاع المالية في آن واحد! " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "Carryover" +msgstr "الترحيل" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover adjustment for tax unit" +msgstr "تعديل الترحيل لوحدة الضريبة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover can only be generated for a single column group." +msgstr "يمكن إنشاء الترحيل فقط لمجموعة عمود واحدة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover from %(date_from)s to %(date_to)s" +msgstr "ترحيل من %(date_from)s إلى %(date_to)s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Carryover lines for: %s" +msgstr "ترحيل القيود لـ: %s " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash0 +msgid "Cash" +msgstr "نقدي" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_cash_flow_report_handler +msgid "Cash Flow Report Custom Handler" +msgstr "المعالج المخصص لتقارير التدفق النقدي " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.cash_flow_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_cs +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_cash_flow +msgid "Cash Flow Statement" +msgstr "كشف التدفقات النقدية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash and cash equivalents, beginning of period" +msgstr "النقد ومعادِلات النقد، بداية الفترة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash and cash equivalents, closing balance" +msgstr "النقد وومعادِلات النقد، رصيد الإقفال " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from financing activities" +msgstr "التدفقات النقدية من الأنشطة المالية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from investing & extraordinary activities" +msgstr "التدفقات النقدية من الأنشطة الاستثمارية وغير العادية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from operating activities" +msgstr "التدفقات النقدية من الأنشطة التشغيلية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash flows from unclassified activities" +msgstr "التدفقات النقدية من الأنشطة غير المصنفة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash in" +msgstr "إيرادات" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash out" +msgstr "نفقات " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash paid for operating activities" +msgstr "نفقات الأنشطة التشغيلية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_received0 +msgid "Cash received" +msgstr "الأرباح النقدية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Cash received from operating activities" +msgstr "أرباح الأنشطة التشغيلية" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_spent0 +msgid "Cash spent" +msgstr "المبلغ المُنفَق " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_cash_surplus0 +msgid "Cash surplus" +msgstr "الفائض النقدي" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_change_lock_date +msgid "Change Lock Date" +msgstr "تغيير تاريخ الإقفال" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Check Partner(s) Email(s)" +msgstr "تحقق من عناوين البريد الإلكتروني للشركاء " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "Check them" +msgstr "تفقدهم " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form +msgid "Close" +msgstr "إغلاق" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Closing Entry" +msgstr "قيد الإقفال " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_closing_bank_balance0 +msgid "Closing bank balance" +msgstr "رصيد الإقفال البنكي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:account.report.column,name:odex30_account_reports.journal_report_code +msgid "Code" +msgstr "رمز " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filter_code.xml:0 +msgid "Codes:" +msgstr "الأكواد: " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Columns" +msgstr "الأعمدة" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_communication +msgid "Communication" +msgstr "التواصل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model,name:odex30_account_reports.model_res_company +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__company_ids +msgid "Companies" +msgstr "الشركات" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__company_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__company_id +msgid "Company" +msgstr "الشركة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"Company %(company)s already belongs to a tax unit in %(country)s. A company " +"can at most be part of one tax unit per country." +msgstr "" +"الشركة %(company)s تنتمي بالفعل إلى وحدة ضريبية في %(country)s. يمكن أن تكون " +"الشركة الواحدة جزءاً من وحدة ضريبية واحدة كحد أقصى لكل دولة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Company Currency" +msgstr "عملة الشركة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Company Only" +msgstr "الشركة فقط " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Company Settings" +msgstr "إعدادات الشركة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Comparison" +msgstr "مقارنة" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_res_config_settings +msgid "Config Settings" +msgstr "تهيئة الإعدادات " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Configure start dates" +msgstr "تهيئة تواريخ البدء " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Configure your TAX accounts - %s" +msgstr "قم بتهيئة حساباتك الضريبية - %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_config_settings.py:0 +msgid "Configure your start dates" +msgstr "قم بتهيئة تواريخ البدء " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Configure your tax accounts" +msgstr "قم بتهيئة حساباتك الضريبية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_res_partner +msgid "Contact" +msgstr "جهة الاتصال" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view +msgid "Contacts" +msgstr "جهات الاتصال" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_body +msgid "Contents" +msgstr "المحتويات" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_direct_costs0 +msgid "Cost of Revenue" +msgstr "تكاليف الإيرادات " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Could not expand term %(term)s while evaluating formula %" +"(unexpanded_formula)s" +msgstr "hello " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Could not parse account_code formula from token '%s'" +msgstr "تعذر تحليل صيغة account_code من الرمز '%s' " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__country_id +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search +msgid "Country" +msgstr "الدولة" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_country +msgid "Country Code" +msgstr "رمز الدولة" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +msgid "Create" +msgstr "إنشاء " + +#. module: odex30_account_reports +#: model:ir.actions.server,name:odex30_account_reports.action_create_composite_report_list +msgid "Create Composite Report" +msgstr "إنشاء تقرير مُركّب " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard +msgid "Create Entry" +msgstr "إنشاء قيد " + +#. module: odex30_account_reports +#: model:ir.actions.server,name:odex30_account_reports.action_create_report_menu +msgid "Create Menu Item" +msgstr "إنشاء عنصر قائمة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__create_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__create_uid +msgid "Created by" +msgstr "أنشئ بواسطة" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__create_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__create_date +msgid "Created on" +msgstr "أنشئ في" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_credit +#: model:account.report.column,name:odex30_account_reports.journal_report_credit +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_credit +#: model:account.report.column,name:odex30_account_reports.trial_balance_report_credit +msgid "Credit" +msgstr "الدائن" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_currency +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_currency +#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_currency +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_amount_currency +msgid "Currency" +msgstr "العملة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Currency Code" +msgstr "كود العملة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "Currency Rates (%s)" +msgstr "أسعار صرف العملة (%s)" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Currency:" +msgstr "العملة: " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.deferred_expense_current +#: model:account.report.column,name:odex30_account_reports.deferred_revenue_current +msgid "Current" +msgstr "الحالي " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_assets0 +#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_assets_view0 +msgid "Current Assets" +msgstr "الأصول المتداولة" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities0 +#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities1 +msgid "Current Liabilities" +msgstr "الالتزامات الجارية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings_line_1 +msgid "Current Year Retained Earnings" +msgstr "الأرباح المحتجزة للسنة الجارية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_current_year_earnings0 +msgid "Current Year Unallocated Earnings" +msgstr "الأرباح غير المخصصة للسنة الجارية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_ca_to_l0 +msgid "Current assets to liabilities" +msgstr "نسبة الأصول المتداولة إلى الالتزامات" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Custom Dates" +msgstr "التواريخ المخصصة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__custom_handler_model_id +msgid "Custom Handler Model" +msgstr "نموذج للمعالج المخصص " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__custom_handler_model_name +msgid "Custom Handler Model Name" +msgstr "اسم نموذج المعالج المخصص " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "" +"Custom engine _report_custom_engine_last_statement_balance_amount does not " +"support groupby" +msgstr "" +"المحرك المخصص _report_custom_engine_last_statement_balance_amount لا يدعم " +"خاصية groupby " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.customer_statement_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_customer_statement +#: model:mail.template,name:odex30_account_reports.email_template_customer_statement +msgid "Customer Statement" +msgstr "كشف حساب العميل " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_customer_statement_report_handler +msgid "Customer Statement Custom Handler" +msgstr "المعالج المخصص لكشف حساب العميل " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover/annotations_popover.xml:0 +#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_date +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__date +msgid "Date" +msgstr "التاريخ" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Date cannot be empty" +msgstr "لا يمكن ترك خانة التاريخ فارغة" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__date +msgid "Date considered as annotated by the annotation." +msgstr "يُعتَبَر التاريخ مشروحاً بواسطة الشرح. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +msgid "Days" +msgstr "أيام " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_debit +#: model:account.report.column,name:odex30_account_reports.journal_report_debit +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_debit +#: model:account.report.column,name:odex30_account_reports.trial_balance_report_debit +msgid "Debit" +msgstr "المدين" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Deductible" +msgstr "قابل للاستقطاع " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Deferrals have not yet been completely generated for this period." +msgstr "لم يتم بعد إنشاء التأجيلات بالكامل لهذه الفترة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Deferrals have not yet been generated for this period." +msgstr "لم يتم بعد إنشاء التأجيلات لهذه الفترة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Deferred Entries" +msgstr "القيم المؤجلة " + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_deferred_expense +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_deferred_expense +msgid "Deferred Expense" +msgstr "النفقات المؤجلة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_deferred_expense_report_handler +msgid "Deferred Expense Custom Handler" +msgstr "أداة مخصصة لمعالجة النفقات المؤجلة " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.deferred_expense_report +msgid "Deferred Expense Report" +msgstr "تقرير النفقات المؤجلة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_deferred_report_handler +msgid "Deferred Expense Report Custom Handler" +msgstr "أداة مخصصة لمعالجة تقرير النفقات المؤجلة " + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_deferred_revenue +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_deferred_revenue +msgid "Deferred Revenue" +msgstr "الإيرادات المؤجلة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_deferred_revenue_report_handler +msgid "Deferred Revenue Custom Handler" +msgstr "أداة مخصصة لمعالجة الإيرادات المؤجلة " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.deferred_revenue_report +msgid "Deferred Revenue Report" +msgstr "تقرير الإيرادات المؤجلة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form +msgid "Definition" +msgstr "تعريف" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity +msgid "Delay units" +msgstr "وحدات التأخير" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Depending moves" +msgstr "الحركات المعتمدة على غيرها " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Descending" +msgstr "تنازلي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Difference from rounding taxes" +msgstr "الفرق من تقريب الضرائب " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_line__display_custom_groupby_warning +msgid "Display Custom Groupby Warning" +msgstr "عرض تحذير \"التجميع حسب\" المخصص " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__display_mail_composer +msgid "Display Mail Composer" +msgstr "عرض أداة إنشاء البريد الإلكتروني " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__display_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__display_name +msgid "Display Name" +msgstr "اسم العرض " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Document" +msgstr "المستند " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__doc_name +msgid "Documents Name" +msgstr "اسم المستندات " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__domain +msgid "Domain" +msgstr "النطاق" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Domestic" +msgstr "محلي" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__checkbox_download +msgid "Download" +msgstr "تنزيل " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form +msgid "Download Anyway" +msgstr "التنزيل بأي حال " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Download the Data Inalterability Check Report" +msgstr "تحميل تقرير التحقق من بيانات عدم قابلية التغيير " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Draft Entries" +msgstr "القيود في حالة المسودة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Draft Entry" +msgstr "مسودة قيد " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_followup_report.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Due" +msgstr "مستحق" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +#: model:account.report.column,name:odex30_account_reports.customer_statement_report_date_maturity +#: model:account.report.column,name:odex30_account_reports.followup_report_date_maturity +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_date_maturity +msgid "Due Date" +msgstr "موعد إجراء المكالمة " + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_sales +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_sales +msgid "EC Sales List" +msgstr "قائمة مبيعات EC" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_ec_sales_report_handler +msgid "EC Sales Report Custom Handler" +msgstr "المعالج المخصص لتقارير مبيعات العمولة الأوروبية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "EC tax on non EC countries" +msgstr "ضريبة الاتحاد الأوروبي للدول خارج الاتحاد الأوروبي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "EC tax on same country" +msgstr "ضريبة الاتحاد الأوروبي في نفس الدولة " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_equity0 +msgid "EQUITY" +msgstr "رأس المال " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Editing a manual report line is not allowed in multivat setup when " +"displaying data from all fiscal positions." +msgstr "" +"لا يُسمح بتحرير تقرير يدوي في بيئة متعددة الضرائب عند عرض البيانات من كافة " +"الأوضاع المالية. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Editing a manual report line is not allowed when multiple companies are " +"selected." +msgstr "لا يُسمح بتحرير تقرير يدوي عندما يتم تحديد عدة شركات. " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__checkbox_send_mail +msgid "Email" +msgstr "البريد الإلكتروني" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_template_id +msgid "Email template" +msgstr "قالب البريد الإلكتروني " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__enable_download +msgid "Enable Download" +msgstr "تمكين التنزيل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Enable Sections" +msgstr "تمكين الأجزاء " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__enable_send_mail +msgid "Enable Send Mail" +msgstr "تمكين إرسال البريد الإلكتروني " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0 +msgid "Enable more ..." +msgstr "تمكين المزيد... " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +msgid "End Balance" +msgstr "الرصيد النهائي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Month" +msgstr "نهاية الشهر " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Quarter" +msgstr "نهاية ربع السنة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "End of Year" +msgstr "نهاية العام " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Engine" +msgstr "المحرك " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Entries with partners with no VAT" +msgstr "القيود التي بها شركاء بلا ضريبة القيمة المضافة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Error message" +msgstr "رسالة خطأ" + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_error +msgid "Error sending Tax Report" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filter_exchange_rate.xml:0 +msgid "Exchange Rates" +msgstr "أسعار الصرف " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move_line__exclude_bank_lines +msgid "Exclude Bank Lines" +msgstr "استثناء بنود البنك " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_search +msgid "Exclude Bank lines" +msgstr "استثناء بنود البنك " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_account__exclude_provision_currency_ids +msgid "Exclude Provision Currency" +msgstr "استثناء عملة الحكم " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.multicurrency_revaluation_excluded +msgid "Excluded Accounts" +msgstr "الحسابات المستثناة " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.executive_summary +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_exec_summary +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_exec_summary +msgid "Executive Summary" +msgstr "الملخص التنفيذي" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__expense_provision_account_id +msgid "Expense Account" +msgstr "حساب النفقات " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_expense_provision_account_id +msgid "Expense Provision Account" +msgstr "حساب أحكام النفقات " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Expense Provision for %s" +msgstr "حكم النفقات لـ %s" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_expenses0 +msgid "Expenses" +msgstr "النفقات " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_report_export_wizard +msgid "Export" +msgstr "تصدير" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_reports_export_wizard_format +msgid "Export format for accounting's reports" +msgstr "صيغة التصدير للتقارير المحاسبية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__export_format_ids +msgid "Export to" +msgstr "التصدير إلى " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_reports_export_wizard +msgid "Export wizard for accounting's reports" +msgstr "مُعالِج التصدير للتقارير المحاسبية " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form +msgid "Expression" +msgstr "تعبير " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Expression labelled '%(label)s' of line '%(line)s' is being overwritten when " +"computing the current report. Make sure the cross-report aggregations of " +"this report only reference terms belonging to other reports." +msgstr "" +"تتم الكتابة فوق التعبير الذي عنوانه \"%(label)s\" من البند \"%(line)s\" عند " +"احتساب التقرير الحالي. تأكد من أن مجموعات التقارير التبادلية لهذا التقرير " +"تشير فقط إلى الشروط التي تنتمي إلى تقارير أخرى. " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__field_name +msgid "Field" +msgstr "حقل" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field %s does not exist on account.move.line, and is not supported by this " +"report's custom handler." +msgstr "" +"الحقل %s غير موجود في account.move.line، وهو غير مدعوم في أداة معالجة " +"التقارير هذه. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Field %s does not exist on account.move.line." +msgstr "الحقل %s غير موجود في account.move.line. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Field %s of account.move.line cannot be used in a groupby expression." +msgstr "لا يمكن استخدام حقل%s account.move.line في تعبير groupby. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field %s of account.move.line is not searchable and can therefore not be " +"used in a groupby expression." +msgstr "" +"لا يمكن البحث في حقل %s لـ account.move.line، وبالتالي لا يمكن استخدامه في " +"تعبير groupby. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Field 'Custom Handler Model' can only reference records inheriting from [%s]." +msgstr "" +"الحقل 'نموذج المعالج المخصص' يمكنه فقط الإشارة إلى السجلات التي ترث من [%s]. " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__file_content +msgid "File Content" +msgstr "محتوى الملف " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_file_download_error_wizard_form +msgid "File Download Errors" +msgstr "أخطاء تنزيل الملف " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__file_name +msgid "File Name" +msgstr "اسم الملف" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Filters" +msgstr "عوامل التصفية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Filters:" +msgstr "عوامل التصفية: " + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_budget_tree +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_budget_tree +msgid "Financial Budgets" +msgstr "الميزانيات المالية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_fiscal_position +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__fiscal_position_id +msgid "Fiscal Position" +msgstr "الوضع المالي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_fiscal_position.xml:0 +msgid "Fiscal Position:" +msgstr "الوضع المالي: " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__fpos_synced +msgid "Fiscal Positions Synchronised" +msgstr "الأوضاع المالية المتزامنة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form +msgid "" +"Fiscal Positions should apply to all companies of the tax unit. You may want " +"to" +msgstr "" +"يجب أن تنطبق الأوضاع المالية على كافة الشركات لوحدة الضريبة. ربما عليك " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.followup_report +msgid "Follow-Up Report" +msgstr "تقرير المتابعة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_followup_report_handler +msgid "Follow-Up Report Custom Handler" +msgstr "المعالج المخصص لتقرير المتابعة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Foreign currencies adjustment entry as of %s" +msgstr "قيد تعديل العملات الأجنبية اعتباراً من %s" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Formula" +msgstr "الصيغة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"From %(date_from)s\n" +"to %(date_to)s" +msgstr "" +"من %(date_from)s\n" +"إلى %(date_to)s " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__fun_param +msgid "Function Parameter" +msgstr "معايير الوظيفة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__fun_to_call +msgid "Function to Call" +msgstr "الوظيفة لاستدعائها " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0 +#: model:account.report,name:odex30_account_reports.general_ledger_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_general_ledger +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_general_ledger +msgid "General Ledger" +msgstr "دفتر الأستاذ العام" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_general_ledger_report_handler +msgid "General Ledger Custom Handler" +msgstr "المعالج المخصص لدفتر الأستاذ العام " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Generate entry" +msgstr "إنشاء قيد " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0 +msgid "Generated Documents" +msgstr "إنشاء المستندات " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.generic_ec_sales_report +msgid "Generic EC Sales List" +msgstr "قائمة مبيعات EC عامة " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler +msgid "Generic Tax Report Custom Handler" +msgstr "معالج مخصص للتقرير الضريبي العام " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler_account_tax +msgid "Generic Tax Report Custom Handler (Account -> Tax)" +msgstr "معالج مخصص للتقرير الضريبي العام (الحساب -> الضريبة) " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_generic_tax_report_handler_tax_account +msgid "Generic Tax Report Custom Handler (Tax -> Account)" +msgstr "معالج مخصص للتقرير الضريبي العام (الضريبة -> الحساب) " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.journal_report_pdf_export_main +msgid "Global Tax Summary" +msgstr "الملخص الشامل للضريبة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Goods" +msgstr "البضائع " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Grid" +msgstr "الشبكة" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_gross_profit0 +msgid "Gross Profit" +msgstr "إجمالي الربح" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_gross_profit0 +msgid "Gross profit" +msgstr "إجمالي الربح" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_gpmargin0 +msgid "Gross profit margin (gross profit / operating income)" +msgstr "إجمالي هامش الربح (إجمالي الربح / الدخل التشغيلي)" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search +msgid "Group By" +msgstr "تجميع حسب" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_horizontal_group_form +msgid "Group Name" +msgstr "اسم المجموعة" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Group by" +msgstr "التجميع حسب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Grouped Deferral Entry of %s" +msgstr "القيد المؤجل المجمع لـ %s" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0 +msgid "Hide Account" +msgstr "إخفاء الحساب " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/filter_extra_options.xml:0 +msgid "Hide Debit/Credit" +msgstr "إخفاء الخصم / الائتمان " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Hide lines at 0" +msgstr "إخفاء البنود في 0 " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Hierarchy and Subtotals" +msgstr "التسلسل الهرمي والمجاميع الفرعية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__horizontal_group_id +msgid "Horizontal Group" +msgstr "المجموعة الأفقية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0 +msgid "Horizontal Group:" +msgstr "المجموعة الأفقية: " + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:odex30_account_reports.action_account_report_horizontal_groups +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__horizontal_group_ids +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_horizontal_groups +msgid "Horizontal Groups" +msgstr "المجموعات الأفقية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_horizontal_group +msgid "Horizontal group for reports" +msgstr "المجموعة الأفقية للتقارير " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_horizontal_group_rule +msgid "Horizontal group rule for reports" +msgstr "قاعدة المجموعة الأفقية للتقارير " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Horizontal:" +msgstr "أفقي: " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "How often tax returns have to be made" +msgstr "مدى تواتر الإقرارات الضريبية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__id +msgid "ID" +msgstr "المُعرف" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Impact On Grid" +msgstr "التأثير على الشبكة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Impacted Tax Grids" +msgstr "شبكات الضرائب المتأثرة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "In %s" +msgstr "في %s " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search +msgid "Inactive" +msgstr "غير نشط " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/journal_report/filter_extra_options.xml:0 +msgid "Include Payments" +msgstr "تضمين المدفوعات " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template +msgid "Including Analytic Simulations" +msgstr "شاملة عمليات المحاكات التحليلية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.no_statement_unreconciled_payments +#: model:account.report.line,name:odex30_account_reports.unreconciled_last_statement_payments +msgid "Including Unreconciled Payments" +msgstr "شاملة المدفوعات غير المسواة " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.no_statement_unreconciled_receipt +#: model:account.report.line,name:odex30_account_reports.unreconciled_last_statement_receipts +msgid "Including Unreconciled Receipts" +msgstr "شاملة الإيصالات غير المسواة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__income_provision_account_id +msgid "Income Account" +msgstr "حساب الدخل" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_revaluation_income_provision_account_id +msgid "Income Provision Account" +msgstr "حساب أحكام الدخل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Income Provision for %s" +msgstr "حكم الدخل لـ %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +msgid "Inconsistent Statements" +msgstr "كشوفات الحساب غير المتسقة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Inconsistent data: more than one external value at the same date for a " +"'most_recent' external line." +msgstr "" +"Inconsistent data: more than one external value at the same date for a " +"'most_recent' external line." + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Inconsistent report_id in options dictionary. Options says %" +"(options_report)s; report is %(report)s." +msgstr "" +"report_id غير متسق في دليل الخيارات. يقول الخيار %(options_report)s؛ التقرير " +"%(report)s. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +msgid "Initial Balance" +msgstr "الرصيد الافتتاحي" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Integer Rounding" +msgstr "تقريب العدد الصحيح " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0 +msgid "Intervals cannot be smaller than 1" +msgstr "لا يمكن أن تكون الفترات الزمنية أقل من 1 " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "Intra-community taxes are applied on" +msgstr "الضرائب بين المجتمعات مطبقة على " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Invalid domain formula in expression \"%(expression)s\" of line \"%" +"(line)s\": %(formula)s" +msgstr "" +"صيغة النطاق غير صالحة في التعبير \"%(expression)s\" في البند \"%(line)s\": %" +"(formula)s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Invalid method “%s”" +msgstr "الطريقة غير صحيحة \"%s\" " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Invalid subformula in expression \"%(expression)s\" of line \"%(line)s\": %" +"(subformula)s" +msgstr "" +"الصيغة الفرعية غير صالحة في التعبير \"%(expression)s\" في البند \"%" +"(line)s\": %(subformula)s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Invalid token '%(token)s' in account_codes formula '%(formula)s'" +msgstr "الرمز غير صالح '%(token)s' في صيغة account_codes '%(formula)s' " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_aging.xml:0 +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_invoice_date +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_invoice_date +#: model:account.report.column,name:odex30_account_reports.customer_statement_report_invoicing_date +#: model:account.report.column,name:odex30_account_reports.followup_report_invoicing_date +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_invoicing_date +msgid "Invoice Date" +msgstr "تاريخ الفاتورة" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_search +msgid "Invoice lines" +msgstr "بنود الفاتورة" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__is_account_coverage_report_available +msgid "Is Account Coverage Report Available" +msgstr "تقرير تغطية الحسابات متاح " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "It seems there is some depending closing move to be posted" +msgstr "يبدو أنه توجد حركة إغلاق معتمدة على غيرها يجب أن يتم ترحيلها. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "It's not possible to select a budget with the horizontal group feature." +msgstr "لا يمكن تحديد ميزانية باستخدام خاصية المجموعة الأفقية. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "It's not possible to select a horizontal group with the budget feature." +msgstr "لا يمكن تحديد مجموعة أفقية باستخدام خاصية الميزانية. " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__item_ids +msgid "Items" +msgstr "العناصر " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_journal_code +#: model:ir.model,name:odex30_account_reports.model_account_journal +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity_journal_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__journal_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity_journal_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity_journal_id +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form +msgid "Journal" +msgstr "دفتر اليومية" + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_ja +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_ja +msgid "Journal Audit" +msgstr "تدقيق دفتر اليومية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_move +msgid "Journal Entry" +msgstr "قيد اليومية" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_move_line +msgid "Journal Item" +msgstr "عنصر اليومية" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_trial_balance_report.py:0 +#: code:addons/odex30_account_reports/models/bank_reconciliation_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/general_ledger/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Journal Items" +msgstr "عناصر اليومية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Journal Items for Tax Audit" +msgstr "عناصر اليومية للتدقيق الضريبي" + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.journal_report +msgid "Journal Report" +msgstr "تقرير اليومية " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_journal_report_handler +msgid "Journal Report Custom Handler" +msgstr "المعالج المخصص لتقرير اليومية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Journal items with archived tax tags" +msgstr "عناصر دفتر اليومية مع علامات تصنيف مؤرشفة للضريبة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Journals" +msgstr "دفاتر اليومية" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Journals:" +msgstr "دفاتر اليومية:" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_liabilities_view0 +msgid "LIABILITIES" +msgstr "التزامات" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_liabilities_and_equity_view0 +msgid "LIABILITIES + EQUITY" +msgstr "الالتزامات + رأس المال " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +#: model:account.report.column,name:odex30_account_reports.bank_reconciliation_report_label +msgid "Label" +msgstr "بطاقة عنوان" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_lang +msgid "Lang" +msgstr "اللغة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Last Statement balance + Transactions since statement" +msgstr "رصيد آخر كشف حساب + المعاملات منذ إصدار كشف الحساب " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__write_uid +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__write_uid +msgid "Last Updated by" +msgstr "آخر تحديث بواسطة" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget_item__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_file_download_error_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__write_date +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__write_date +msgid "Last Updated on" +msgstr "آخر تحديث في" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.last_statement_balance +msgid "Last statement balance" +msgstr "رصيد آخر كشف حساب " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Later" +msgstr "لاحقاً" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_cost_sales0 +msgid "Less Costs of Revenue" +msgstr "تكاليف إيرادات أقل " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_expense0 +msgid "Less Operating Expenses" +msgstr "نفقات تشغيلية أقل " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_depreciation0 +msgid "Less Other Expenses" +msgstr "نفقات أخرى أقل " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__line_id +msgid "Line" +msgstr "البند " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Line '%(child)s' is configured to appear before its parent '%(parent)s'. " +"This is not allowed." +msgstr "" +"تم تهيئة البند '%(child)s' ليظهر قبل البند الأصلي '%(parent)s'. لا يُسمح " +"بذلك. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Lines" +msgstr "البنود" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Load more..." +msgstr "تحميل المزيد... " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_attachments_widget +msgid "Mail Attachments Widget" +msgstr "أداة مرفقات البريد الإلكتروني " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__main_company_id +msgid "Main Company" +msgstr "الشركة الرئيسية " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__main_company_id +msgid "" +"Main company of this unit; the one actually reporting and paying the taxes." +msgstr "" +"الشركة الرئيسية لهذه الوحدة؛ الشركة التي تقوم بإصدار التقارير ودفع الضرائب. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard +msgid "Make Adjustment Entry" +msgstr "إنشاء قيد تعديل " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_report_file_download_error_wizard +msgid "Manage the file generation errors from report exports." +msgstr "قم بإدارة أخطاء إنشاء الملفات من عمليات تصدير التقارير. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Manual value" +msgstr "القيمة اليدوية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Manual values" +msgstr "القيم اليدوية " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.partner_ledger_report_matching_number +msgid "Matching" +msgstr "مطابقة" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__company_ids +msgid "Members of this unit" +msgstr "أعضاء هذه الوحدة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Method '%(method_name)s' must start with the '%(prefix)s' prefix." +msgstr "يجب أن تبدأ الطريقة '%(method_name)s' بالبادئة '%(prefix)s'. " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.misc_operations +msgid "Misc. operations" +msgstr "العمليات المتنوعة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mode +msgid "Mode" +msgstr "الوضع" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group_rule__res_model_name +msgid "Model" +msgstr "النموذج " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Month" +msgstr "الشهر" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_budget_form +msgid "Months" +msgstr "شهور" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Multi-Ledger:" +msgstr "دفتر الأستاذ المتعدد " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Multi-ledger" +msgstr "دفتر الأستاذ المتعدد " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_multicurrency_revaluation_report_handler +msgid "Multicurrency Revaluation Report Custom Handler" +msgstr "المعالج المخصص لتقرير إعادة التقييم متعدد العملات " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_multicurrency_revaluation_wizard +msgid "Multicurrency Revaluation Wizard" +msgstr "مُعالج إعادة التقييم متعدد العملات " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__account_report_send__mode__multi +msgid "Multiple Recipients" +msgstr "عدة مستلمين " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "" +"Multiple draft tax closing entries exist for fiscal position %(position)s " +"after %(period_start)s. There should be at most one. \n" +" %(closing_entries)s" +msgstr "" +"توجد عدة قيود إقفال الضريبة بحالة المسودة في الوضع المالي %(position)s بعد %" +"(period_start)s. يجب أن يكون هناك واحد فقط كحد أقصى. \n" +" %(closing_entries)s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "" +"Multiple draft tax closing entries exist for your domestic region after %" +"(period_start)s. There should be at most one. \n" +" %(closing_entries)s" +msgstr "" +"توجد عدة قيود إقفال للضريبة بحالة المسودة في منطقتك بعد%(period_start)s. يجب " +"أن يكون هناك واحد فقط كحد أقصى. \n" +" %(closing_entries)s " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model:account.report.line,name:odex30_account_reports.journal_report_line +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__name +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form +msgid "Name" +msgstr "الاسم" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_reports_export_wizard__doc_name +msgid "Name to give to the generated documents." +msgstr "الاسم لمنحه للمستندات المنشأة. " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_profit0 +#: model:account.report.line,name:odex30_account_reports.account_financial_report_net_profit0 +msgid "Net Profit" +msgstr "صافي الربح" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_net_assets0 +msgid "Net assets" +msgstr "صافي الأصول" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_cash_flow_report.py:0 +msgid "Net increase in cash and cash equivalents" +msgstr "صافي الزيادة في النقد وما يعادل النقد " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_npmargin0 +msgid "Net profit margin (net profit / revenue)" +msgstr "صافي هامش الربح (صافي الربح / الإيرادات) " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Never miss a tax deadline." +msgstr "لا تفوت أي موعد نهائي بعد الآن. " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "No" +msgstr "لا" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "No Comparison" +msgstr "بلا مقارنة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "No Journal" +msgstr "لا يوجد دفتر يومية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "No VAT number associated with your company. Please define one." +msgstr "لا يوجد رقم ضريبة مرتبط بشركتك. الرجاء تحديد واحد. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "No adjustment needed" +msgstr "لا حاجة لإجراء أي تعديل " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0 +msgid "No data to display !" +msgstr "لا توجد بيانات لعرضها! " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/chart_template.py:0 +msgid "No default miscellaneous journal could be found for the active company" +msgstr "لم يتم العثور على دفتر يومية افتراضي للمتفرقات للشركة الفعالة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "No entry to generate." +msgstr "لا يوجد قيد لإنشائه. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "No provision needed was found." +msgstr "لم يتم العثور على أي أحكام مطلوبة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Non Trade Partners" +msgstr "الشركاء غير التجاريين. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Non Trade Payable" +msgstr "حساب الدائنين غير التجاري " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Non Trade Receivable" +msgstr "حساب المدينين غير التجاري " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Non-Deductible" +msgstr "غير قابلة للاقتطاع " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_horizontal_groups.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/sales_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "None" +msgstr "لا شيء" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Not Started" +msgstr "لم يبدأ بعد " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Number of periods cannot be smaller than 1" +msgstr "لا يمكن أن يكون عدد الفترات أقل من 1 " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_off_sheet +msgid "OFF BALANCE SHEET ACCOUNTS" +msgstr "الحسابات خارج الميزانية العمومية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filters.js:0 +msgid "Odoo Warning" +msgstr "تحذير من أودو" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period5 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period5 +msgid "Older" +msgstr "أقدم" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/report_export_wizard.py:0 +msgid "One of the formats chosen can not be exported in the DMS" +msgstr "إحدى الصيغ التي اخترتها لا يمكن تصديرها في برنامج إدارة المستندات " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Only Billing Administrators are allowed to change lock dates!" +msgstr "مديرو الفوترة وحدهم المصرح لهم بتغيير تواريخ الإقفال! " + +#. module: odex30_account_reports +#: model:ir.actions.server,name:odex30_account_reports.action_account_reports_customer_statements +msgid "Open Customer Statements" +msgstr "فتح كشوفات العملاء " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_financial_year_op +msgid "Opening Balance of Financial Year" +msgstr "الرصيد الافتتاحي للسنة المالية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_operating_income0 +msgid "Operating Income (or Loss)" +msgstr "الدخل التشغيلي (أو الخسائر التشغيلية) " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_expression_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Options" +msgstr "الخيارات" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template +msgid "Options:" +msgstr "الخيارات: " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.outstanding +msgid "Outstanding Receipts/Payments" +msgstr "المدفوعات/الإيصالات المستحقة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_followup_report.py:0 +msgid "Overdue" +msgstr "متأخر" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "PDF" +msgstr "PDF" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard__report_id +msgid "Parent Report Id" +msgstr "معرف التقرير الأساسي " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_reports_export_wizard_format__export_wizard_id +msgid "Parent Wizard" +msgstr "المعالج الأساسي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +#: model:account.report.column,name:odex30_account_reports.general_ledger_report_partner_name +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__partner_ids +msgid "Partner" +msgstr "الشريك" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Partner Categories" +msgstr "فئات الشركاء " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.partner_ledger_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_partner_ledger +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_partner_ledger +msgid "Partner Ledger" +msgstr "دفتر الأستاذ العام للشركاء" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_partner_ledger_report_handler +msgid "Partner Ledger Custom Handler" +msgstr "المعالج المخصص لدفتر الأستاذ العام الخاص بالشريك " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Partner is bad" +msgstr "الشريك سيء " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +#: code:addons/odex30_account_reports/static/src/components/partner_ledger/line_name.xml:0 +msgid "Partner is good" +msgstr "الشريك جيد " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Partner(s) should have an email address." +msgstr "يجب أن يكون للوكلاء عنوان بريد إلكتروني. " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0 +msgid "Partners" +msgstr "الشركاء" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Partners Categories:" +msgstr "فئات الشركاء: " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Partners with duplicated VAT numbers" +msgstr "الشركاء الذين يملكون أرقام ضريبية مكررة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filters +msgid "Partners:" +msgstr "الشركاء:" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_to_pay +msgid "Pay Tax" +msgstr "دفع الضريبة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Pay tax: %s" +msgstr "دفع الضريبة: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Payable" +msgstr "الدائن" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Payable tax amount" +msgstr "مبلغ الضريبة الدائن " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_current_liabilities_payable +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_creditors0 +msgid "Payables" +msgstr "الدائنون" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_performance0 +msgid "Performance" +msgstr "الأداء" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Period" +msgstr "الفترة" + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period1 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period1 +msgid "Period 1" +msgstr "الفترة 1 " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period2 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period2 +msgid "Period 2" +msgstr "الفترة 2 " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period3 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period3 +msgid "Period 3" +msgstr "الفترة 3 " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_period4 +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_period4 +msgid "Period 4" +msgstr "الفترة 4 " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Period order" +msgstr "طلب المدة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity +#: model:ir.model.fields,help:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity +#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_tax_periodicity +#: model:ir.model.fields,help:odex30_account_reports.field_res_config_settings__account_tax_periodicity +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Periodicity" +msgstr "الوتيرة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity +msgid "Periodicity in month" +msgstr "الوتيرة في الشهر " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Periods" +msgstr "الفترات " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml:0 +msgid "Plans" +msgstr "الخطط " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/budget.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Please enter a valid budget name." +msgstr "يُرجى إدخال اسم ميزانية صالح. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/filters/filters.js:0 +msgid "Please enter a valid number." +msgstr "يُرجى إدخال رقم صالح. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Please select a mail template to send multiple statements." +msgstr "يرجى تحديد قالب بريد لإرسال عدة كشوفات. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Please select the main company and its branches in the company selector to " +"proceed." +msgstr "يرجى تحديد الشركة الرئيسية وفروعها في أداة اختيار الشركة للمتابعة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Please set the deferred accounts in the accounting settings." +msgstr "يرجى تعيين الحسابات المؤجلة في إعدادات المحاسبة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Please set the deferred journal in the accounting settings." +msgstr "يرجى إعداد دفتر اليومية المؤجل في إعدادات المحاسبة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Please specify the accounts necessary for the Tax Closing Entry." +msgstr "يرجى تحديد الحسابات الضرورية للقيد الختامي للضريبة. " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_fixed_assets_view0 +msgid "Plus Fixed Assets" +msgstr "بالإضافة الى الأصول الثابتة" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_non_current_assets_view0 +msgid "Plus Non-current Assets" +msgstr "بالإضافة للأصول غير المتداولة" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_non_current_liabilities0 +msgid "Plus Non-current Liabilities" +msgstr "بالإضافة للالتزامات غير الجارية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_other_income0 +msgid "Plus Other Income" +msgstr "بالإضافة إلى دخل آخر " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_position0 +msgid "Position" +msgstr "المنصب الوظيفي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "Post" +msgstr "منشور " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Posted Entries" +msgstr "القيود المُرحّلة " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_prepayements0 +msgid "Prepayments" +msgstr "المدفوعات المسددة مقدمًا " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__preview_data +msgid "Preview Data" +msgstr "معاينة البيانات " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Period" +msgstr "الفترة السابقة" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Periods" +msgstr "الفترات السابقة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Year" +msgstr "العام الماضي" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Previous Years" +msgstr "السنوات الماضية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings_line_2 +msgid "Previous Years Retained Earnings" +msgstr "الأرباح المحتجزة للسنة الماضية " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_previous_year_earnings0 +msgid "Previous Years Unallocated Earnings" +msgstr "أرباح السنين الماضية غير المخصصة" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "Print & Send" +msgstr "طباعة وإرسال " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Proceed" +msgstr "استمرار " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_multicurrency_revaluation_wizard +msgid "" +"Proceed with caution as there might be an existing adjustment for this " +"period (" +msgstr "الاستمرار بحذر لأنه قد تكون هناك تعديلات لهذه الفترة (" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Product" +msgstr "المنتج " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/groupby.xml:0 +msgid "Product Category" +msgstr "فئة المنتج " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.profit_and_loss +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_pl +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_profit_and_loss +msgid "Profit and Loss" +msgstr "الربح والخسارة " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_profitability0 +msgid "Profitability" +msgstr "الربحية " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return +msgid "Proposition of tax closing journal entry." +msgstr "مقترح قيد يومية إقفال الضريبة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Provision for %(for_cur)s (1 %(comp_cur)s = %(rate)s %(for_cur)s)" +msgstr "الحكل لـ %(for_cur)s (1 %(comp_cur)s = %(rate)s %(for_cur)s) " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Quarter" +msgstr "ربع السنة" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/line_name.xml:0 +msgid "Rates" +msgstr "الأسعار" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_aged_partner_balance.py:0 +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Receivable" +msgstr "المدين" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Receivable tax amount" +msgstr "مبلغ الضريبة المدين " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_debtors0 +#: model:account.report.line,name:odex30_account_reports.account_financial_report_receivable0 +msgid "Receivables" +msgstr "المدينين" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_partner_ids +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "Recipients" +msgstr "المستلمين" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_reports_journal_dashboard_kanban_view +msgid "Reconciliation Report" +msgstr "تقرير التسوية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_financial_year_op__account_tax_periodicity_reminder_day +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_config_settings__account_tax_periodicity_reminder_day +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form +msgid "Reminder" +msgstr "تذكير" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__report_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__account_report_id +msgid "Report" +msgstr "التقرير" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form +msgid "Report Line" +msgstr "بند التقرير" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Report Name" +msgstr "اسم التقرير" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__report_options +msgid "Report Options" +msgstr "خيارات التقرير" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Report lines mentioning the account code" +msgstr "بنود التقرير التي تحتوي على كود الحساب " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_variant.xml:0 +msgid "Report:" +msgstr "التقرير:" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Reporting" +msgstr "إعداد التقارير " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__report_ids +msgid "Reports" +msgstr "التقارير" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form +msgid "Reset to Standard" +msgstr "إعادة التعيين إلى الوضع القياسي " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_retained_earnings0 +msgid "Retained Earnings" +msgstr "الأرباح المحتجزة" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_return_investment0 +msgid "Return on investments (net profit / assets)" +msgstr "العائد على الاستثمار (صافي الربح / الأصول)" + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_income0 +#: model:account.report.line,name:odex30_account_reports.account_financial_report_revenue0 +msgid "Revenue" +msgstr "الإيرادات " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__reversal_date +msgid "Reversal Date" +msgstr "تاريخ الانعكاس" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "Reversal of Grouped Deferral Entry of %s" +msgstr "عكس القيد المؤجل المجمع لـ %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/multicurrency_revaluation.py:0 +msgid "Reversal of: %s" +msgstr "عكس: %s" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_account_report_search +msgid "Root Report" +msgstr "تقرير الجذر " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_horizontal_group__rule_ids +msgid "Rules" +msgstr "القواعد" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +msgid "Same Period Last Year" +msgstr "نفس الفترة العام الماضي" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/search_bar/search_bar.xml:0 +msgid "Search..." +msgstr "بحث..." + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "Sections" +msgstr "الأقسام " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_customer_statement.py:0 +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send" +msgstr "إرسال" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send %s Statement" +msgstr "إرسال كشف حساب %s " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__send_and_print_values +msgid "Send And Print Values" +msgstr "إرسال وطباعة القيم " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__send_mail_readonly +msgid "Send Mail Readonly" +msgstr "إرسال بريد إلكتروني للقراءة فقط " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Send Partner Ledgers" +msgstr "إرسال دفاتر الأستاذ العام للشركاء " + +#. module: odex30_account_reports +#: model:ir.actions.server,name:odex30_account_reports.ir_cron_account_report_send_ir_actions_server +msgid "Send account reports automatically" +msgstr "إرسال تقارير الحسابات تلقائياً " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Send tax report: %s" +msgstr "إرسال التقرير الضريبي: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Sending statements" +msgstr "جاري إرسال كشوفات الحساب " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_budget__sequence +msgid "Sequence" +msgstr "تسلسل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Services" +msgstr "الخدمات" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree +msgid "Set as Checked" +msgstr "التعيين كتمّ التحقق منه " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.duplicated_vat_partner_tree_view +msgid "Set as main" +msgstr "تعيين كالرئيسي " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_report_executivesummary_st_cash_forecast0 +msgid "Short term cash forecast" +msgstr "توقعات النقد قصيرة الأجل" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0 +msgid "Show Account" +msgstr "إظهار الحساب " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml:0 +msgid "Show All Accounts" +msgstr "إظهار كافة الحسابات " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/filter_extra_options.xml:0 +msgid "Show Currency" +msgstr "إظهار العملة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_multicurrency_revaluation_wizard__show_warning_move_id +msgid "Show Warning Move" +msgstr "إظهار حركة تحذير " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/deferred_reports/warnings.xml:0 +msgid "Show already generated deferrals." +msgstr "إظهار التأجيلات التي تم إنشاؤها بالفعل. " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__account_report_send__mode__single +msgid "Single Recipient" +msgstr "مستلم واحد " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "Some" +msgstr "بعض " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "Some journal items appear to point to obsolete report lines." +msgstr "يبدو أن بعض عناصر دفتر اليومية تشير إلى بنود تقرير قديمة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "" +"Some lines in the report involve partners that share the same VAT number.\n" +"\n" +" Please review the" +msgstr "" +"تتضمن بعض بنود التقرير شركاء لهم نفس رقم ضريبة القيمة المضافة.\n" +"\n" +" يُرجى مراجعة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Specific Date" +msgstr "تاريخ محدد" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_representative_id +msgid "" +"Specify an Accounting Firm that will act as a representative when exporting " +"reports." +msgstr "" +"قم بتحديد منشأة محاسبية التي سوف تؤدي دور الوكيل أو الممثل عند تصدير " +"التقارير. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Split Horizontally" +msgstr "التقسيم أفقياَ " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report__tax_closing_start_date +msgid "Start Date" +msgstr "تاريخ البدء " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_periodicity_reminder_day +msgid "Start from" +msgstr "البدء من " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Starting Balance" +msgstr "الرصيد الافتتاحي" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/aged_partner_balance/line_name/line_name.xml:0 +msgid "Statement" +msgstr "كشف الحساب" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "Statements are being sent in the background." +msgstr "يتم إرسال كشوف الحسابات في الخلفية. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Subformula" +msgstr "صيغة فرعية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__mail_subject +msgid "Subject" +msgstr "الموضوع " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_send_form +msgid "Subject..." +msgstr "الموضوع..." + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "T: %s" +msgstr "T: %s" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_partner.xml:0 +msgid "Tags" +msgstr "علامات التصنيف " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Tax Amount" +msgstr "مبلغ الضريبة " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_journal_report_taxes_summary +msgid "Tax Applied" +msgstr "تم تطبيق الضريبة " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_bank_statement_line__tax_closing_alert +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move__tax_closing_alert +msgid "Tax Closing Alert" +msgstr "تنبيه الإقفال الضريبي " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_bank_statement_line__tax_closing_report_id +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_move__tax_closing_report_id +msgid "Tax Closing Report" +msgstr "تقرير الإقفال الضريبي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Tax Declaration" +msgstr "الإقرار الضريبي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Tax Grids" +msgstr "شبكات الضرائب" + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_tax_unit__vat +msgid "Tax ID" +msgstr "معرف الضريبة" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.company_information +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.tax_information_customer_report +msgid "Tax ID:" +msgstr "معرف الضريبة: " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Tax Paid Adjustment" +msgstr "تعديل الضريبة المدفوعة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/filters/filter_date.xml:0 +msgid "Tax Period" +msgstr "الفترة الضريبية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "Tax Received Adjustment" +msgstr "تعديل الضريبة المتلقاة " + +#. module: odex30_account_reports +#: model:mail.activity.type,name:odex30_account_reports.tax_closing_activity_type +#: model:mail.activity.type,summary:odex30_account_reports.tax_closing_activity_type +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return +msgid "Tax Report" +msgstr "التقرير الضريبي " + +#. module: odex30_account_reports +#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_error +msgid "Tax Report - Error" +msgstr "" + +#. module: odex30_account_reports +#: model:mail.activity.type,name:odex30_account_reports.mail_activity_type_tax_report_to_be_sent +msgid "Tax Report Ready" +msgstr "التقرير الضريبي جاهز " + +#. module: odex30_account_reports +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_gt +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_gt +msgid "Tax Return" +msgstr "الإقرار الضريبي " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "Tax Return Periodicity" +msgstr "مدى دورية الإقرار الضريبي " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_tax_unit +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form +msgid "Tax Unit" +msgstr "الوحدة الضريبية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_tax_unit.xml:0 +msgid "Tax Unit:" +msgstr "وحدة الضريبة: " + +#. module: odex30_account_reports +#: model:ir.actions.act_window,name:odex30_account_reports.action_view_tax_units +#: model:ir.model.fields,field_description:odex30_account_reports.field_res_company__account_tax_unit_ids +#: model:ir.ui.menu,name:odex30_account_reports.menu_view_tax_units +msgid "Tax Units" +msgstr "الوحدات الضريبية " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_mail_activity__account_tax_closing_params +msgid "Tax closing additional params" +msgstr "المعايير الإضافية للإقفال الضريبي " + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_to_pay +msgid "Tax is ready to be paid" +msgstr "الضريبة جاهزة ليتم دفعها " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__mail_activity_type__category__tax_report +msgid "Tax report" +msgstr "التقرير الضريبي " + +#. module: odex30_account_reports +#: model:mail.activity.type,summary:odex30_account_reports.mail_activity_type_tax_report_to_be_sent +msgid "Tax report is ready to be sent to the administration" +msgstr "التقرير الضريبي جاهز لإرساله إلى الإدارة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/res_company.py:0 +msgid "Tax return" +msgstr "الإقرار الضريبي " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +msgid "Taxes" +msgstr "الضرائب" + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/journal_report/line/line.xml:0 +msgid "Taxes Applied" +msgstr "تم تطبيق الضرائب" + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__fpos_synced +msgid "" +"Technical field indicating whether Fiscal Positions exist for all companies " +"in the unit" +msgstr "" +"حقل تقني يشير إلى ما إذا كانت الأوضاع المالية موجودة لكافة الشركات في الوحدة " +"أم لا " + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_ir_actions_account_report_download +msgid "Technical model for accounting report downloads" +msgstr "نموذج تقني لتنزيلات التقارير المحاسبية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js:0 +msgid "Text copied" +msgstr "تم نسخ النص " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "The Accounts Coverage Report is not available for this report." +msgstr "تقرير تغطية الحسابات غير متاح لهذا التقرير. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_name/popover_line/annotation_popover_line.js:0 +msgid "The annotation shouldn't have an empty value." +msgstr "يجب ألا يحتوي الشرح على قيمة فارغة. " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_annotation__text +msgid "The annotation's content." +msgstr "محتوى الشرح. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"The attachments of the tax report can be found on the %(link_start)sclosing " +"entry%(link_end)s of the representative company." +msgstr "" +"يمكن العثور على مرفقات التقرير الضريبي في %(link_start)sقيد الإقفال%" +"(link_end)s للشركة الممثلة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "The column '%s' is not available for this report." +msgstr "العمود '%s' غير متاح لهذا التقرير. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "" +"The country detected for this VAT number does not match the one set on this " +"Tax Unit." +msgstr "" +"لا تطابق الدولة التي تم رصدها لرقم الضريبة الدولة التي تم تعيينها لهذا الوضع " +"الضريبي. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__country_id +msgid "" +"The country in which this tax unit is used to group your companies' tax " +"reports declaration." +msgstr "" +"الدولة التي تُستخدَم فيها هذه الوحدة الضريبية لتجميع إقرارات التقارير الضريبية " +"لشركتك. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "The currency rate cannot be equal to zero" +msgstr "لا يمكن أن يكون سعر صرف العملة مساوياً لصفر " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "The current balance in the" +msgstr "الرصيد الحالي في " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "" +"The currently selected dates don't match a tax period. The closing entry " +"will be created for the closest-matching period according to your " +"periodicity setup." +msgstr "" +"لا تتطابق التواريخ المحددة حالياً مع إحدى الفترات الضريبية. سيتم إنشاء القيد " +"الختامي لأقرب فترة مطابقة وفقاً لإعدادات معدل التكرار والدورية الخاصة بك. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__fiscal_position_id +msgid "The fiscal position used while annotating." +msgstr "الوضع المالي المُستَخدَم عند الشرح. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__line_id +msgid "The id of the annotated line." +msgstr "معرِّف البند الذي تم شرحه. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_report_annotation__report_id +msgid "The id of the annotated report." +msgstr "معرِّف التقرير الذي تم شرحه. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_tax_unit__vat +msgid "The identifier to be used when submitting a report for this unit." +msgstr "المعرف لاستخدامه عند تسليم تقرير لهذه الوحدة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "The main company of a tax unit has to be part of it." +msgstr "يجب أن تكون الشركة الرئيسية لوحدة ضريبية جزءاً منها. " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_res_company__account_tax_unit_ids +msgid "The tax units this company belongs to." +msgstr "الوحدة الضريبية التي تنتمي إليها هذه الشركة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "The used operator is not supported for this expression." +msgstr "المشغل المستخدَم غير مدعوم لهذا التعبير. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "There are" +msgstr "هناك " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "There are currently reports waiting to be sent, please try again later." +msgstr "هناك تقارير بانتظار إرسالها حالياً. يُرجى إعادة المحاولة لاحقاً. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/account_report.xml:0 +msgid "There is no data to display for the given filters." +msgstr "لا توجد بيانات لعرضها لعناصر التصفية المحددة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This account exists in the Chart of Accounts but is not mentioned in any " +"line of the report" +msgstr "" +"الحساب موجود في شجرة الحسابات ولكنه غير مذكور في أي بند من بنود التقرير " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This account is reported in a line of the report but does not exist in the " +"Chart of Accounts" +msgstr "" +"تم إعداد تقرير حول هذا الحساب في بند من التقرير ولكنه غير موجود في شجرة " +"الحسابات " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This account is reported in multiple lines of the report" +msgstr "توجد عدة تقارير حول هذا الحساب في عدة بنود من التقرير " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This account is reported multiple times on the same line of the report" +msgstr "توجد عدة تقارير حول هذا الحساب في نفس البند من التقرير " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "" +"This allows you to choose the position of totals in your financial reports." +msgstr "يسمح لك هذا باختيار مكان الإجمالي في تقاريرك المالية." + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "" +"This company is part of a tax unit. You're currently not viewing the whole " +"unit." +msgstr "هذه الشركة هي جزء من وحدة ضريبية. لا تعرض حالياً الوحدة بأكملها. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.js:0 +msgid "" +"This line and all its children will be deleted. Are you sure you want to " +"proceed?" +msgstr "سيتم حذف هذا البند وكافة توابعه. هل أنت متأكد من أنك ترغب بالاستمرار؟ " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "This line is out of sequence." +msgstr "هذا البند خارج التسلسل. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/widgets/account_report_x2many/account_report_x2many.xml:0 +msgid "" +"This line is placed before its parent, which is not allowed. You can fix it " +"by dragging it to the proper position." +msgstr "" +"تم وضع هذا البند قبل البند الأصلي، وهو أمر غير مسموح به. يمكنك إصلاحه عن " +"طريق سحبه إلى المكان المناسب. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_line_form +msgid "This line uses a custom user-defined 'Group By' value." +msgstr "" +"يستخدم هذا البند قيمة \"التجميع حسب\" المخصصة المعرفة من قِبَل المستخدم. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "This option hides lines with a value of 0" +msgstr "يقوم هذا الخيار بإخفاء البنود التي قيمتها 0 " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This report already has a menuitem." +msgstr "هذا التقرير يحتوي على عنصر قائمة بالفعل. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "" +"This report contains inconsistencies. The affected lines are marked with a " +"warning." +msgstr "" +"يحتوي التقرير على بيانات غير متسقة. تم وضع علامة تحذير على البنود المتأثرة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "This report only displays the data of the active company." +msgstr "هذا التقرير يعرض فقط بيانات الشركة النشطة. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.account_report_form +msgid "" +"This report uses report-specific code.\n" +" You can customize it manually, but any change in the " +"parameters used for its computation could lead to errors." +msgstr "" +"يستخدم هذا التقرير كود خاص بالتقرير.\n" +" يمكنك تخصيصه يدوياً، ولكن قد يؤدي أي تغيير في العوامل " +"المستخدمة في حسابه إلى حدوث أخطاء. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "" +"This report uses the CTA conversion method to consolidate multiple companies " +"using different currencies,\n" +" which can lead the report to be unbalanced." +msgstr "" +"يستخدم هذا التقرير طريقة التحويل CTA لدمج عدة شركات تستخدم عملات مختلفة،\n" +" مما قد يؤدي إلى اختلال توازن التقرير. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "This subformula references an unknown expression: %s" +msgstr "تشير الصيغة الفرعية إلى تعبير غير معروف: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"This tag is reported in a line of the report but is not linked to any " +"account of the Chart of Accounts" +msgstr "" +"تم إعداد تقرير حول علامة التصنيف هذه في بند من التقرير ولكنه غير مرتبط بأي " +"حساب في شجرة الحسابات " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_move_form_vat_return +msgid "" +"This tax closing entry is posted, but the tax lock date is earlier than the " +"covered period's last day. You might need to reset it to draft and refresh " +"its content, in case other entries using taxes have been posted in the " +"meantime." +msgstr "" +"تم ترحيل قيد الإقفال الضريبي هذا، ولكن تاريخ قفل الضريبة يسبق اليوم الأخير " +"للفترة المشمولة. قد تحتاج إلى إعادة تعيينه إلى حالة المسودة لتحديث محتواه، " +"في حال تم ترحيل قيود أخرى تستخدم الضرائب في هذه الأثناء. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "Today" +msgstr "اليوم " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_journal_report.py:0 +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +#: model:account.report.column,name:odex30_account_reports.aged_payable_report_total +#: model:account.report.column,name:odex30_account_reports.aged_receivable_report_total +msgid "Total" +msgstr "الإجمالي" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Total %s" +msgstr "الإجمالي %s" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Trade Partners" +msgstr "شركاء تجاريون " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.transaction_without_statement +msgid "Transactions without statement" +msgstr "المعاملات التي ليس لها كشف حساب " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.trial_balance_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_coa +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_coa +msgid "Trial Balance" +msgstr "ميزان المراجعة" + +#. module: odex30_account_reports +#: model:ir.model,name:odex30_account_reports.model_account_trial_balance_report_handler +msgid "Trial Balance Custom Handler" +msgstr "معالج مخصص لميزان المراجعة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Triangular" +msgstr "مثلث الشكل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Trying to dispatch an action on a report not compatible with the provided " +"options." +msgstr "جاري محاولة إرسال إجراء في تقرير غير متوافق مع الخيارات المتوفرة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "" +"Trying to expand a group for a line which was not generated by a report " +"line: %s" +msgstr "نحاول تفصيل مجموعة لبند لم يتم إنشاؤه بواسطة بند تقرير: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Trying to expand a line without an expansion function." +msgstr "نحاول تفصيل بند دون استخدام خاصية التفصيل. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Trying to expand groupby results on lines without a groupby value." +msgstr "نحاول تفصيل نتائج خاصية groupby دون قيمة groupby. " + +#. module: odex30_account_reports +#: model:account.report.line,name:odex30_account_reports.account_financial_unaffected_earnings0 +msgid "Unallocated Earnings" +msgstr "أرباح غير مخصصة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Undefined" +msgstr "غير محدد" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +msgid "Unfold All" +msgstr "كشف الكل" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown" +msgstr "غير معروف" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +msgid "Unknown Partner" +msgstr "شريك مجهول " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown bound criterium: %s" +msgstr "فئة ربط غير معروفة: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Unknown date scope: %s" +msgstr "نطاق البيانات غير معروف: %s " + +#. module: odex30_account_reports +#: model:account.report,name:odex30_account_reports.multicurrency_revaluation_report +#: model:ir.actions.client,name:odex30_account_reports.action_account_report_multicurrency_revaluation +#: model:ir.ui.menu,name:odex30_account_reports.menu_action_account_report_multicurrency_revaluation +msgid "Unrealized Currency Gains/Losses" +msgstr "الأرباح/الخسائر غير المُدرَكة للعملة " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_extra_options.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template +msgid "Unreconciled Entries" +msgstr "القيود غير المسواة " + +#. module: odex30_account_reports +#: model:account.report.column,name:odex30_account_reports.account_financial_report_ec_sales_vat +msgid "VAT Number" +msgstr "رقم ضريبة القيمة المضافة" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form +msgid "VAT Periodicity" +msgstr "وتيرة ضريبة القيمة المضافة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line/popover/debug_popover.xml:0 +msgid "Value" +msgstr "القيمة" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "Vat closing from %(date_from)s to %(date_to)s" +msgstr "إغلاق ضريبة القيمة المضافة من %(date_from)s إلى %(date_to)s " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree +msgid "View" +msgstr "أداة العرض" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Bank Statement" +msgstr "عرض كشف الحساب البنكي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "View Carryover Lines" +msgstr "عرض بنود الترحيل " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Journal Entry" +msgstr "عرض قيد اليومية" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/models/account_sales_report.py:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_journal_report_audit_move_line_tree +msgid "View Partner" +msgstr "عرض الشريك" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/wizard/account_report_send.py:0 +msgid "View Partner(s)" +msgstr "View Partner(s)" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "View Payment" +msgstr "عرض الدفع " + +#. module: odex30_account_reports +#: model:ir.model.fields,field_description:odex30_account_reports.field_account_report_send__warnings +msgid "Warnings" +msgstr "تحذيرات" + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +msgid "" +"When ticked, totals and subtotals appear below the sections of the report" +msgstr "عند التحديد، سوف تظهر المجاميع الكلية والفرعية تحت أقسام التقرير " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_res_company__totals_below_sections +#: model:ir.model.fields,help:odex30_account_reports.field_res_config_settings__totals_below_sections +msgid "" +"When ticked, totals and subtotals appear below the sections of the report." +msgstr "عند التحديد، سوف تظهر المجاميع الكلية والفرعية تحت أقسام التقرير " + +#. module: odex30_account_reports +#: model:ir.model.fields,help:odex30_account_reports.field_account_account__exclude_provision_currency_ids +msgid "" +"Whether or not we have to make provisions for the selected foreign " +"currencies." +msgstr "ما إذا كان علينا إنشاء أحكام للعملات الأجنبية المختارة أم لا. " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_filter_extra_options_template +msgid "With Draft Entries" +msgstr "مع القيود بحالة المسودة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_general_ledger.py:0 +msgid "Wrong ID for general ledger line to expand: %s" +msgstr "المعرّف غير صحيح لبند دفتر الأستاذ العام لتفصيله: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_partner_ledger.py:0 +msgid "Wrong ID for partner ledger line to expand: %s" +msgstr "المعرّف غير صحيح لبند دفتر الأستاذ الخاص بالشريك لتفصيله: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "Wrong format for if_other_expr_above/if_other_expr_below formula: %s" +msgstr "الصيغة غير صحيحة لمعادلة if_other_expr_above/if_other_expr_below: %s " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "XLSX" +msgstr "XLSX" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filters.js:0 +msgid "Year" +msgstr "السنة " + +#. module: odex30_account_reports +#. odoo-javascript +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/edit_popover.xml:0 +msgid "Yes" +msgstr "نعم" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "You are using custom exchange rates." +msgstr "أنت تستخدم أسعار صرف مخصصة " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "You can't open a tax report from a move without a VAT closing date." +msgstr "" +"لا يمكنك فتح تقرير ضريبي من حركة دون تاريخ إقفال ضريبة القيمة المضافة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move_line.py:0 +msgid "You cannot add taxes on a tax closing move line." +msgstr "لا يمكنك إضافة ضرائب في بند حركة إقفال ضريبي. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "" +"You cannot generate entries for a period that does not end at the end of the " +"month." +msgstr "لا يمكنك إنشاء قيود لفترة لا تنتهي بنهاية الشهر. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_deferred_reports.py:0 +msgid "You cannot generate entries for a period that is locked." +msgstr "لا يمكنك إنشاء قيود لفترة مقفلة. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"You cannot reset this closing entry to draft, as another closing entry has " +"been posted at a later date." +msgstr "" +"لا يمكنك إعادة تعيين قيد الإقفال هذا إلى حالة المسودة، لأنه قد تم ترحيل قيد " +"إقفال آخر بتاريخ لاحق. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_move.py:0 +msgid "" +"You cannot reset this closing entry to draft, as it would delete carryover " +"values impacting the tax report of a locked period. Please change the " +"following lock dates to proceed: %(lock_date_info)s." +msgstr "" +"لا يمكنك إعادة تعيين قيد الإقفال هذا إلى حالة المسودة، لأن ذلك سيؤدي إلى حذف " +"القيم المرحّلة التي تؤثر على تقرير الضرائب لفترة مقفلة. يرجى تغيير تواريخ " +"القفل التالية للمتابعة: %(lock_date_info)s." + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "You cannot update this value as it's locked by: %s" +msgstr "" + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_multicurrency_revaluation_report.py:0 +msgid "You need to activate more than one currency to access this report." +msgstr "عليك تفعيل أكثر من عملة واحدة للوصول إلى هذا التقرير. " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_generic_tax_report.py:0 +msgid "" +"You're about the generate the closing entries of multiple companies at once. " +"Each of them will be created in accordance with its company tax periodicity." +msgstr "" +"أنت على وشك إنشاء القيود الختامية لعدة شركات في آنٍ واحد. سيتم إنشاء كل منها " +"وفقاً لمعدل تكرار ضريبة الشركة الخاصة بها. " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.journal_report_pdf_export_main +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_main +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.pdf_export_main_customer_report +msgid "[Draft]" +msgstr "[Draft]" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "addressed to" +msgstr "موجهة إلى " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "affected partners" +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/tax_report/warnings.xml:0 +msgid "and correct their tax tags if necessary." +msgstr "وتصحيح علامات تصنيف الضريبة إذا لزم الأمر. " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__year +msgid "annually" +msgstr "سنوياً " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.setup_financial_year_opening_form +msgid "days after period" +msgstr "أيام بعد الفترة " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "doesn't match the balance of your" +msgstr "لا يطابق رصيد " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__2_months +msgid "every 2 months" +msgstr "كل شهرين " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__4_months +msgid "every 4 months" +msgstr "كل 4 شهور " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "have a starting balance different from the previous ending balance." +msgstr "أن يكون الرصيد الافتتاحي مختلفاً عن الرصيد الختامي السابق " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "in the next period." +msgstr "في الفترة القادمة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "invoices" +msgstr "فواتير العملاء " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "journal items" +msgstr "عناصر اليومية " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "last bank statement" +msgstr "آخر كشف حساب بنكي " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__monthly +msgid "monthly" +msgstr "شهرياً " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_report.py:0 +msgid "n/a" +msgstr "غير منطبق " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "partners" +msgstr "الشركاء " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "prior or included in this period." +msgstr "قبل هذه الفترة أو مشمول فيها. " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__trimester +msgid "quarterly" +msgstr "ربع سنوي " + +#. module: odex30_account_reports +#: model:ir.model.fields.selection,name:odex30_account_reports.selection__res_company__account_tax_periodicity__semester +msgid "semi-annually" +msgstr "شبه سنوي " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "statements" +msgstr "كشوفات الحسابات " + +#. module: odex30_account_reports +#: model_terms:ir.ui.view,arch_db:odex30_account_reports.view_tax_unit_form +msgid "synchronize fiscal positions" +msgstr "مزامنة الأوضاع المالية " + +#. module: odex30_account_reports +#. odoo-python +#: code:addons/odex30_account_reports/models/account_tax.py:0 +msgid "tax unit [%s]" +msgstr "وحدة الضريبة [%s] " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "that are not established abroad." +msgstr "التي لم يتم إنشاؤها في الخارج. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_comparison.xml:0 +#: code:addons/odex30_account_reports/static/src/components/account_report/filters/filter_date.xml:0 +msgid "to" +msgstr "إلى" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "to resolve the duplication." +msgstr "" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/warnings.xml:0 +msgid "unposted Journal Entries" +msgstr "قيود يومية غير مُرحلة" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "were carried over to this line from previous period." +msgstr "تم ترحيلها إلى هذا البند من الفترة السابقة. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/bank_reconciliation_report/warnings.xml:0 +msgid "which don't originate from a bank statement nor payment." +msgstr "والذي لا ينتج عن كشف حساب بنكي أو عملية دفع. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "who are not established in any of the EC countries." +msgstr "غير المنشئين في أي من دول الاتحاد الأوروبي. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "will be carried over to" +msgstr "سيتم ترحيلها إلى " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/account_report/line_cell/popover/carryover_popover.xml:0 +msgid "will be carried over to this line in the next period." +msgstr "سيتم ترحيلها إلى هذا البند في الفترة التالية. " + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/sales_report/warnings.xml:0 +msgid "without a valid intra-community VAT number." +msgstr "دون رقم ضريبي صالح لبين المجتمعات. " + +#. module: odex30_account_reports +#: model:mail.template,subject:odex30_account_reports.email_template_customer_statement +msgid "" +"{{ (object.company_id or " +"object._get_followup_responsible().company_id).name }} Statement - " +"{{ object.commercial_company_name }}" +msgstr "" +"{{ (object.company_id or " +"object._get_followup_responsible().company_id).name }} Statement - " +"{{ object.commercial_company_name }}" + +#. module: odex30_account_reports +#. odoo-javascript +#: code:addons/odex30_account_reports/static/src/components/multicurrency_revaluation_report/warnings.xml:0 +msgid "⇒ Reset to Odoo’s Rate" +msgstr "-> إعادة التعيين لأسعار أودو " diff --git a/dev_odex30_accounting/odex30_account_reports/models/__init__.py b/dev_odex30_accounting/odex30_account_reports/models/__init__.py new file mode 100644 index 0000000..2d52b6b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/__init__.py @@ -0,0 +1,32 @@ + +from . import res_partner +from . import res_company +from . import account +from . import account_report +from . import account_analytic_report +from . import bank_reconciliation_report +from . import account_general_ledger +from . import account_generic_tax_report +from . import account_journal_report +from . import account_cash_flow_report +from . import account_deferred_reports +from . import account_multicurrency_revaluation_report +from . import account_move_line +from . import account_trial_balance_report +from . import account_aged_partner_balance +from . import account_partner_ledger +from . import mail_activity +from . import mail_activity_type +from . import res_config_settings +from . import chart_template +from . import account_journal_dashboard +from . import ir_actions +from . import account_sales_report +from . import account_move +from . import account_tax +from . import executive_summary_report +from . import budget +from . import balance_sheet +from . import account_fiscal_position +from . import account_customer_statement +from . import account_followup_report diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e71859a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc new file mode 100644 index 0000000..b70bdaf Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc new file mode 100644 index 0000000..c9b60c4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_aged_partner_balance.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc new file mode 100644 index 0000000..c8ae24c Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_analytic_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc new file mode 100644 index 0000000..f2495e2 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_cash_flow_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc new file mode 100644 index 0000000..cfc9213 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_customer_statement.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc new file mode 100644 index 0000000..cd4db1b Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_deferred_reports.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc new file mode 100644 index 0000000..50f95f4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_fiscal_position.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc new file mode 100644 index 0000000..5726de4 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_followup_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc new file mode 100644 index 0000000..41cd339 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_general_ledger.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc new file mode 100644 index 0000000..b4119e6 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_generic_tax_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc new file mode 100644 index 0000000..712531f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_dashboard.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc new file mode 100644 index 0000000..cee7be1 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_journal_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc new file mode 100644 index 0000000..ab37b9a Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc new file mode 100644 index 0000000..715c077 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_move_line.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc new file mode 100644 index 0000000..4b6504e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_multicurrency_revaluation_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc new file mode 100644 index 0000000..617f41c Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_partner_ledger.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc new file mode 100644 index 0000000..9089e42 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc new file mode 100644 index 0000000..8553fcc Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_sales_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc new file mode 100644 index 0000000..307603f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_tax.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc new file mode 100644 index 0000000..906b37c Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/account_trial_balance_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc new file mode 100644 index 0000000..35feb17 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/balance_sheet.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc new file mode 100644 index 0000000..606e2fd Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/bank_reconciliation_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc new file mode 100644 index 0000000..98683bb Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/budget.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc new file mode 100644 index 0000000..770eec7 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/chart_template.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc new file mode 100644 index 0000000..2ab0b5d Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/executive_summary_report.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc new file mode 100644 index 0000000..8f4b74f Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/ir_actions.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc new file mode 100644 index 0000000..012be9e Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc new file mode 100644 index 0000000..592f6a9 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/mail_activity_type.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc new file mode 100644 index 0000000..f77f8e8 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_company.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc new file mode 100644 index 0000000..09fbda3 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_config_settings.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc new file mode 100644 index 0000000..2c75e49 Binary files /dev/null and b/dev_odex30_accounting/odex30_account_reports/models/__pycache__/res_partner.cpython-311.pyc differ diff --git a/dev_odex30_accounting/odex30_account_reports/models/account.py b/dev_odex30_accounting/odex30_account_reports/models/account.py new file mode 100644 index 0000000..63c5a14 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account.py @@ -0,0 +1,9 @@ + +from odoo import api, fields, models + + +class AccountAccount(models.Model): + _inherit = "account.account" + + exclude_provision_currency_ids = fields.Many2many('res.currency', relation='account_account_exclude_res_currency_provision', help="Whether or not we have to make provisions for the selected foreign currencies.") + budget_item_ids = fields.One2many(comodel_name='account.report.budget.item', inverse_name='account_id') # To use it in the domain when adding accounts from the report diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py b/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py new file mode 100644 index 0000000..35b0547 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_aged_partner_balance.py @@ -0,0 +1,447 @@ + +import datetime + +from odoo import models, fields, _ +from odoo.tools import SQL +from odoo.tools.misc import format_date + +from dateutil.relativedelta import relativedelta +from itertools import chain + + +class AgedPartnerBalanceCustomHandler(models.AbstractModel): + _name = 'account.aged.partner.balance.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Aged Partner Balance Custom Handler' + + def _get_custom_display_config(self): + return { + 'css_custom_class': 'aged_partner_balance', + 'templates': { + 'AccountReportLineName': 'odex30_account_reports.AgedPartnerBalanceLineName', + }, + 'components': { + 'AccountReportFilters': 'odex30_account_reports.AgedPartnerBalanceFilters', + }, + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + hidden_columns = set() + + options['multi_currency'] = report.env.user.has_group('base.group_multi_currency') + options['show_currency'] = options['multi_currency'] and (previous_options or {}).get('show_currency', False) + options['no_xlsx_currency_code_columns'] = True + if not options['show_currency']: + hidden_columns.update(['amount_currency', 'currency']) + + options['show_account'] = (previous_options or {}).get('show_account', False) + if not options['show_account']: + hidden_columns.add('account_name') + + options['columns'] = [ + column for column in options['columns'] + if column['expression_label'] not in hidden_columns + ] + + default_order_column = { + 'expression_label': 'invoice_date', + 'direction': 'ASC', + } + + options['order_column'] = previous_options.get('order_column') or default_order_column + options['aging_based_on'] = previous_options.get('aging_based_on') or 'base_on_maturity_date' + options['aging_interval'] = previous_options.get('aging_interval') or 30 + + # Set aging column names + interval = options['aging_interval'] + for column in options['columns']: + if column['expression_label'].startswith('period'): + period_number = int(column['expression_label'].replace('period', '')) - 1 + if 0 <= period_number < 4: + column['name'] = f'{interval * period_number + 1}-{interval * (period_number + 1)}' + + def _custom_line_postprocessor(self, report, options, lines): + partner_lines_map = {} + + # Sort line dicts by partner + for line in lines: + model, model_id = report._get_model_info_from_id(line['id']) + if model == 'res.partner': + partner_lines_map[model_id] = line + + if partner_lines_map: + for partner, line_dict in zip( + self.env['res.partner'].browse(partner_lines_map), + partner_lines_map.values() + ): + line_dict['trust'] = partner.with_company(partner.company_id or self.env.company).trust + + return lines + + def _report_custom_engine_aged_receivable(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._aged_partner_report_custom_engine_common(options, 'asset_receivable', current_groupby, next_groupby, offset=offset, limit=limit) + + def _report_custom_engine_aged_payable(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._aged_partner_report_custom_engine_common(options, 'liability_payable', current_groupby, next_groupby, offset=offset, limit=limit) + + def _aged_partner_report_custom_engine_common(self, options, internal_type, current_groupby, next_groupby, offset=0, limit=None): + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + def minus_days(date_obj, days): + return fields.Date.to_string(date_obj - relativedelta(days=days)) + + aging_date_field = SQL.identifier('invoice_date') if options['aging_based_on'] == 'base_on_invoice_date' else SQL.identifier('date_maturity') + date_to = fields.Date.from_string(options['date']['date_to']) + interval = options['aging_interval'] + periods = [(False, fields.Date.to_string(date_to))] + # Since we added the first period in the list we have to do one less iteration + nb_periods = len([column for column in options['columns'] if column['expression_label'].startswith('period')]) - 1 + for i in range(nb_periods): + start_date = minus_days(date_to, (interval * i) + 1) + # The last element of the list will have False for the end date + end_date = minus_days(date_to, interval * (i + 1)) if i < nb_periods - 1 else False + periods.append((start_date, end_date)) + + def build_result_dict(report, query_res_lines): + rslt = {f'period{i}': 0 for i in range(len(periods))} + + for query_res in query_res_lines: + for i in range(len(periods)): + period_key = f'period{i}' + rslt[period_key] += query_res[period_key] + + if current_groupby == 'id': + query_res = query_res_lines[0] # We're grouping by id, so there is only 1 element in query_res_lines anyway + currency = self.env['res.currency'].browse(query_res['currency_id'][0]) if len(query_res['currency_id']) == 1 else None + rslt.update({ + 'invoice_date': query_res['invoice_date'][0] if len(query_res['invoice_date']) == 1 else None, + 'due_date': query_res['due_date'][0] if len(query_res['due_date']) == 1 else None, + 'amount_currency': query_res['amount_currency'], + 'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None, + 'currency': currency.display_name if currency else None, + 'account_name': query_res['account_name'][0] if len(query_res['account_name']) == 1 else None, + 'total': None, + 'has_sublines': query_res['aml_count'] > 0, + + # Needed by the custom_unfold_all_batch_data_generator, to speed-up unfold_all + 'partner_id': query_res['partner_id'][0] if query_res['partner_id'] else None, + }) + else: + rslt.update({ + 'invoice_date': None, + 'due_date': None, + 'amount_currency': None, + 'currency_id': None, + 'currency': None, + 'account_name': None, + 'total': sum(rslt[f'period{i}'] for i in range(len(periods))), + 'has_sublines': False, + }) + + return rslt + + # Build period table + period_table_format = ('(VALUES %s)' % ','.join("(%s, %s, %s)" for period in periods)) + params = list(chain.from_iterable( + (period[0] or None, period[1] or None, i) + for i, period in enumerate(periods) + )) + period_table = SQL(period_table_format, *params) + + # Build query + query = report._get_report_query(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)]) + account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + + always_present_groupby = SQL("period_table.period_index") + if current_groupby: + groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) + select_from_groupby = SQL("%s AS grouping_key,", groupby_field_sql) + groupby_clause = SQL("%s, %s", groupby_field_sql, always_present_groupby) + else: + select_from_groupby = SQL() + groupby_clause = always_present_groupby + multiplicator = -1 if internal_type == 'liability_payable' else 1 + select_period_query = SQL(',').join( + SQL(""" + CASE WHEN period_table.period_index = %(period_index)s + THEN %(multiplicator)s * SUM(%(balance_select)s) + ELSE 0 END AS %(column_name)s + """, + period_index=i, + multiplicator=multiplicator, + column_name=SQL.identifier(f"period{i}"), + balance_select=report._currency_table_apply_rate(SQL( + "account_move_line.balance - COALESCE(part_debit.amount, 0) + COALESCE(part_credit.amount, 0)" + )), + ) + for i in range(len(periods)) + ) + + tail_query = report._get_engine_query_tail(offset, limit) + query = SQL( + """ + WITH period_table(date_start, date_stop, period_index) AS (%(period_table)s) + + SELECT + %(select_from_groupby)s + %(multiplicator)s * ( + SUM(account_move_line.amount_currency) + - COALESCE(SUM(part_debit.debit_amount_currency), 0) + + COALESCE(SUM(part_credit.credit_amount_currency), 0) + ) AS amount_currency, + ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id, + ARRAY_AGG(account_move_line.payment_id) AS payment_id, + ARRAY_AGG(DISTINCT account_move_line.invoice_date) AS invoice_date, + ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date)) AS report_date, + ARRAY_AGG(DISTINCT %(account_code)s) AS account_name, + ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date)) AS due_date, + ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id, + COUNT(account_move_line.id) AS aml_count, + ARRAY_AGG(%(account_code)s) AS account_code, + %(select_period_query)s + + FROM %(table_references)s + + JOIN account_journal journal ON journal.id = account_move_line.journal_id + %(currency_table_join)s + + LEFT JOIN LATERAL ( + SELECT + SUM(part.amount) AS amount, + SUM(part.debit_amount_currency) AS debit_amount_currency, + part.debit_move_id + FROM account_partial_reconcile part + WHERE part.max_date <= %(date_to)s AND part.debit_move_id = account_move_line.id + GROUP BY part.debit_move_id + ) part_debit ON TRUE + + LEFT JOIN LATERAL ( + SELECT + SUM(part.amount) AS amount, + SUM(part.credit_amount_currency) AS credit_amount_currency, + part.credit_move_id + FROM account_partial_reconcile part + WHERE part.max_date <= %(date_to)s AND part.credit_move_id = account_move_line.id + GROUP BY part.credit_move_id + ) part_credit ON TRUE + + JOIN period_table ON + ( + period_table.date_start IS NULL + OR COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date) <= DATE(period_table.date_start) + ) + AND + ( + period_table.date_stop IS NULL + OR COALESCE(account_move_line.%(aging_date_field)s, account_move_line.date) >= DATE(period_table.date_stop) + ) + + WHERE %(search_condition)s + + GROUP BY %(groupby_clause)s + + HAVING + ROUND(SUM(%(having_debit)s), %(currency_precision)s) != 0 + OR ROUND(SUM(%(having_credit)s), %(currency_precision)s) != 0 + + ORDER BY %(groupby_clause)s + + %(tail_query)s + """, + account_code=account_code, + period_table=period_table, + select_from_groupby=select_from_groupby, + select_period_query=select_period_query, + multiplicator=multiplicator, + aging_date_field=aging_date_field, + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(options), + date_to=date_to, + search_condition=query.where_clause, + groupby_clause=groupby_clause, + having_debit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance else 0 END - COALESCE(part_debit.amount, 0)")), + having_credit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance else 0 END - COALESCE(part_credit.amount, 0)")), + currency_precision=self.env.company.currency_id.decimal_places, + tail_query=tail_query, + ) + + self._cr.execute(query) + query_res_lines = self._cr.dictfetchall() + + if not current_groupby: + return build_result_dict(report, query_res_lines) + else: + rslt = [] + + all_res_per_grouping_key = {} + for query_res in query_res_lines: + grouping_key = query_res['grouping_key'] + all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res) + + for grouping_key, query_res_lines in all_res_per_grouping_key.items(): + rslt.append((grouping_key, build_result_dict(report, query_res_lines))) + + return rslt + + def open_journal_items(self, options, params): + params['view_ref'] = 'account.view_move_line_tree_grouped_partner' + options_for_audit = {**options, 'date': {**options['date'], 'date_from': None}} + report = self.env['account.report'].browse(options['report_id']) + action = report.open_journal_items(options=options_for_audit, params=params) + action.get('context', {}).update({'search_default_group_by_account': 0, 'search_default_group_by_partner': 1}) + return action + + def open_customer_statement(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + record_model, record_id = report._get_model_info_from_id(params.get('line_id')) + if self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False): + return self.env[record_model].browse(record_id).open_customer_statement() + return self.env[record_model].browse(record_id).open_partner_ledger() + + def _common_custom_unfold_all_batch_data_generator(self, internal_type, report, options, lines_to_expand_by_function): + rslt = {} # In the form {full_sub_groupby_key: all_column_group_expression_totals for this groupby computation} + report_periods = 6 # The report has 6 periods + + for expand_function_name, lines_to_expand in lines_to_expand_by_function.items(): + for line_to_expand in lines_to_expand: # In standard, this loop will execute only once + if expand_function_name == '_report_expand_unfoldable_line_with_groupby': + report_line_id = report._get_res_id_from_line_id(line_to_expand['id'], 'account.report.line') + expressions_to_evaluate = report.line_ids.expression_ids.filtered(lambda x: x.report_line_id.id == report_line_id and x.engine == 'custom') + + if not expressions_to_evaluate: + continue + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + # Get all aml results by partner + aml_data_by_partner = {} + for aml_id, aml_result in self._aged_partner_report_custom_engine_common(column_group_options, internal_type, 'id', None): + aml_result['aml_id'] = aml_id + aml_data_by_partner.setdefault(aml_result['partner_id'], []).append(aml_result) + + # Iterate on results by partner to generate the content of the column group + partner_expression_totals = rslt.setdefault(f"[{report_line_id}]=>partner_id", {})\ + .setdefault(column_group_key, {expression: {'value': []} for expression in expressions_to_evaluate}) + for partner_id, aml_data_list in aml_data_by_partner.items(): + partner_values = self._prepare_partner_values() + for i in range(report_periods): + partner_values[f'period{i}'] = 0 + + # Build expression totals under the right key + partner_aml_expression_totals = rslt.setdefault(f"[{report_line_id}]partner_id:{partner_id}=>id", {})\ + .setdefault(column_group_key, {expression: {'value': []} for expression in expressions_to_evaluate}) + for aml_data in aml_data_list: + for i in range(report_periods): + period_value = aml_data[f'period{i}'] + partner_values[f'period{i}'] += period_value + partner_values['total'] += period_value + + for expression in expressions_to_evaluate: + partner_aml_expression_totals[expression]['value'].append( + (aml_data['aml_id'], aml_data[expression.subformula]) + ) + + for expression in expressions_to_evaluate: + partner_expression_totals[expression]['value'].append( + (partner_id, partner_values[expression.subformula]) + ) + + return rslt + + def _prepare_partner_values(self): + return { + 'invoice_date': None, + 'due_date': None, + 'amount_currency': None, + 'currency_id': None, + 'currency': None, + 'account_name': None, + 'total': 0, + } + + def aged_partner_balance_audit(self, options, params, journal_type): + """ Open a list of invoices/bills and/or deferral entries for the clicked cell + :param dict options: the report's `options` + :param dict params: a dict containing: + `calling_line_dict_id`: line id containing the optional account of the cell + `expression_label`: the expression label of the cell + """ + report = self.env['account.report'].browse(options['report_id']) + action = self.env['ir.actions.actions']._for_xml_id('account.action_amounts_to_settle') + journal_type_to_exclude = {'purchase': 'sale', 'sale': 'purchase'} + if options: + domain = [ + ('account_id.reconcile', '=', True), + ('journal_id.type', '!=', journal_type_to_exclude.get(journal_type)), + *self._build_domain_from_period(options, params['expression_label']), + *report._get_options_domain(options, 'from_beginning'), + *report._get_audit_line_groupby_domain(params['calling_line_dict_id']), + ] + action['domain'] = domain + return action + + def _build_domain_from_period(self, options, period): + if period != "total" and period[-1].isdigit(): + period_number = int(period[-1]) + if period_number == 0: + domain = [('date_maturity', '>=', options['date']['date_to'])] + else: + options_date_to = datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d') + period_end = options_date_to - datetime.timedelta(30*(period_number-1)+1) + period_start = options_date_to - datetime.timedelta(30*(period_number)) + domain = [('date_maturity', '>=', period_start), ('date_maturity', '<=', period_end)] + if period_number == 5: + domain = [('date_maturity', '<=', period_end)] + else: + domain = [] + return domain + +class AgedPayableCustomHandler(models.AbstractModel): + _name = 'account.aged.payable.report.handler' + _inherit = 'account.aged.partner.balance.report.handler' + _description = 'Aged Payable Custom Handler' + + def open_journal_items(self, options, params): + payable_account_type = {'id': 'trade_payable', 'name': _("Payable"), 'selected': True} + + if 'account_type' in options: + options['account_type'].append(payable_account_type) + else: + options['account_type'] = [payable_account_type] + + return super().open_journal_items(options, params) + + def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): + # We only optimize the unfold all if the groupby value of the report has not been customized. Else, we'll just run the full computation + if self.env.ref('odex30_account_reports.aged_payable_line').groupby.replace(' ', '') == 'partner_id,id': + return self._common_custom_unfold_all_batch_data_generator('liability_payable', report, options, lines_to_expand_by_function) + return {} + + def action_audit_cell(self, options, params): + return super().aged_partner_balance_audit(options, params, 'purchase') + +class AgedReceivableCustomHandler(models.AbstractModel): + _name = 'account.aged.receivable.report.handler' + _inherit = 'account.aged.partner.balance.report.handler' + _description = 'Aged Receivable Custom Handler' + + def open_journal_items(self, options, params): + receivable_account_type = {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True} + + if 'account_type' in options: + options['account_type'].append(receivable_account_type) + else: + options['account_type'] = [receivable_account_type] + + return super().open_journal_items(options, params) + + def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): + # We only optimize the unfold all if the groupby value of the report has not been customized. Else, we'll just run the full computation + if self.env.ref('odex30_account_reports.aged_receivable_line').groupby.replace(' ', '') == 'partner_id,id': + return self._common_custom_unfold_all_batch_data_generator('asset_receivable', report, options, lines_to_expand_by_function) + return {} + + def action_audit_cell(self, options, params): + return super().aged_partner_balance_audit(options, params, 'sale') diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py new file mode 100644 index 0000000..f972be1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_analytic_report.py @@ -0,0 +1,257 @@ + +from odoo import models, fields, api, osv +from odoo.addons.web.controllers.utils import clean_action +from odoo.tools import SQL, Query + + +class AccountReport(models.AbstractModel): + _inherit = 'account.report' + + filter_analytic_groupby = fields.Boolean( + string="Analytic Group By", + compute=lambda x: x._compute_report_option_filter('filter_analytic_groupby'), readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'], + ) + + def _get_options_initializers_forced_sequence_map(self): + """ Force the sequence for the init_options so columns headers are already generated but not the columns + So, between _init_options_column_headers and _init_options_columns""" + sequence_map = super(AccountReport, self)._get_options_initializers_forced_sequence_map() + sequence_map[self._init_options_analytic_groupby] = 995 + return sequence_map + + def _init_options_analytic_groupby(self, options, previous_options): + if not self.filter_analytic_groupby: + return + enable_analytic_accounts = self.env.user.has_group('analytic.group_analytic_accounting') + if not enable_analytic_accounts: + return + + options['display_analytic_groupby'] = True + options['display_analytic_plan_groupby'] = True + + options['include_analytic_without_aml'] = previous_options.get('include_analytic_without_aml', False) + previous_analytic_accounts = previous_options.get('analytic_accounts_groupby', []) + analytic_account_ids = [int(x) for x in previous_analytic_accounts] + selected_analytic_accounts = self.env['account.analytic.account'].with_context(active_test=False).search( + [('id', 'in', analytic_account_ids)]) + options['analytic_accounts_groupby'] = selected_analytic_accounts.ids + options['selected_analytic_account_groupby_names'] = selected_analytic_accounts.mapped('name') + + previous_analytic_plans = previous_options.get('analytic_plans_groupby', []) + analytic_plan_ids = [int(x) for x in previous_analytic_plans] + selected_analytic_plans = self.env['account.analytic.plan'].search([('id', 'in', analytic_plan_ids)]) + options['analytic_plans_groupby'] = selected_analytic_plans.ids + options['selected_analytic_plan_groupby_names'] = selected_analytic_plans.mapped('name') + + self._create_column_analytic(options) + + def _init_options_readonly_query(self, options, previous_options): + super()._init_options_readonly_query(options, previous_options) + options['readonly_query'] = options['readonly_query'] and not options.get('analytic_groupby_option') + + def _create_column_analytic(self, options): + """ Creates the analytic columns for each plan or account in the filters. + This will duplicate all previous columns and adding the analytic accounts in the domain of the added columns. + + The analytic_groupby_option is used so the table used is the shadowed table. + The domain on analytic_distribution can just use simple comparison as the column of the shadowed + table will simply be filled with analytic_account_ids. + """ + analytic_headers = [] + plans = self.env['account.analytic.plan'].browse(options.get('analytic_plans_groupby')) + for plan in plans: + account_list = [] + accounts = self.env['account.analytic.account'].search([('plan_id', 'child_of', plan.id)]) + for account in accounts: + account_list.append(account.id) + analytic_headers.append({ + 'name': plan.name, + 'forced_options': { + 'analytic_groupby_option': True, + 'analytic_accounts_list': tuple(account_list), # Analytic accounts used in the domain to filter the lines. + 'analytic_plan_id': plan.id, + } + }) + + accounts = self.env['account.analytic.account'].browse(options.get('analytic_accounts_groupby')) + for account in accounts: + analytic_headers.append({ + 'name': account.name, + 'forced_options': { + 'analytic_groupby_option': True, + 'analytic_accounts_list': (account.id,), + } + }) + if analytic_headers: + has_selected_budgets = any([budget for budget in options.get('budgets', []) if budget['selected']]) + if has_selected_budgets: + # if budget is selected, then analytic headers are placed on the same header level + options['column_headers'][-1] = analytic_headers + options['column_headers'][-1] + else: + # We add the analytic layer to the column_headers before creating the columns + analytic_headers.append({'name': ''}) + + options['column_headers'] = [ + *options['column_headers'], + analytic_headers, + ] + + @api.model + def _prepare_lines_for_analytic_groupby(self): + """Prepare the analytic_temp_account_move_line + + This method should be used once before all the SQL queries using the + table account_move_line for the analytic columns for the financial reports. + It will create a new table with the schema of account_move_line table, but with + the data from account_analytic_line. + + We inherit the schema of account_move_line, make the correspondence between + account_move_line fields and account_analytic_line fields and put NULL for those + who don't exist in account_analytic_line. + We also drop the NOT NULL constraints for fields who are not required in account_analytic_line. + """ + self.env.cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='analytic_temp_account_move_line'") + if self.env.cr.fetchone(): + return + + project_plan, other_plans = self.env['account.analytic.plan']._get_all_plans() + analytic_cols = SQL(", ").join(SQL('"account_analytic_line".%s', SQL.identifier(n._column_name())) for n in (project_plan + other_plans)) + analytic_distribution_equivalent = SQL('to_jsonb(UNNEST(ARRAY_REMOVE(ARRAY[%s], NULL)))', analytic_cols) + + change_equivalence_dict = { + 'id': SQL("account_analytic_line.id"), + 'balance': SQL("-amount"), + 'display_type': 'product', + 'parent_state': 'posted', + 'account_id': SQL.identifier("general_account_id"), + 'debit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"), + 'credit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"), + 'analytic_distribution': analytic_distribution_equivalent, + 'date': SQL("account_analytic_line.date"), + 'company_id': SQL("account_analytic_line.company_id"), + } + + all_stored_aml_fields = { + field + for field, attrs in self.env['account.move.line'].fields_get().items() + if attrs['type'] not in ['many2many', 'one2many'] and attrs.get('store') + } + + for aml_field in all_stored_aml_fields: + if aml_field not in change_equivalence_dict: + change_equivalence_dict[aml_field] = SQL('"account_move_line".%s', SQL.identifier(aml_field)) + + stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report(change_equivalence_dict) + + query = SQL(""" + CREATE OR REPLACE TEMPORARY VIEW analytic_temp_account_move_line (%(stored_aml_fields)s) AS + SELECT %(fields_to_insert)s + FROM account_analytic_line + LEFT JOIN account_move_line + ON account_analytic_line.move_line_id = account_move_line.id + WHERE + account_analytic_line.general_account_id IS NOT NULL; + """, stored_aml_fields=stored_aml_fields, fields_to_insert=fields_to_insert) + + self.env.cr.execute(query) + + def _get_report_query(self, options, date_scope, domain=None) -> Query: + # Override to add the context key which will eventually trigger the shadowing of the table + context_self = self.with_context(account_report_analytic_groupby=options.get('analytic_groupby_option')) + + # We add the domain filter for analytic_distribution here, as the search is not available + query = super(AccountReport, context_self)._get_report_query(options, date_scope, domain) + if options.get('analytic_accounts'): + if 'analytic_accounts_list' in options: + # the table will be `analytic_temp_account_move_line` and thus analytic_distribution will be a single ID + analytic_account_ids = tuple(str(account_id) for account_id in options['analytic_accounts']) + query.add_where(SQL("""account_move_line.analytic_distribution IN %s""", analytic_account_ids)) + else: + # Real `account_move_line` table so real JSON with percentage + analytic_account_ids = [[str(account_id) for account_id in options['analytic_accounts']]] + query.add_where(SQL('%s && %s', analytic_account_ids, self.env['account.move.line']._query_analytic_accounts())) + + return query + + def action_audit_cell(self, options, params): + column_group_options = self._get_column_group_options(options, params['column_group_key']) + + if not column_group_options.get('analytic_groupby_option'): + return super(AccountReport, self).action_audit_cell(options, params) + else: + # Start by getting the domain from the options. Note that this domain is targeting account.move.line + report_line = self.env['account.report.line'].browse(params['report_line_id']) + expression = report_line.expression_ids.filtered(lambda x: x.label == params['expression_label']) + line_domain = self._get_audit_line_domain(column_group_options, expression, params) + # The line domain is made for move lines, so we need some postprocessing to have it work with analytic lines. + domain = [] + AccountAnalyticLine = self.env['account.analytic.line'] + for expression in line_domain: + if len(expression) == 1: # For operators such as '&' or '|' we can juste add them again. + domain.append(expression) + continue + + field, operator, right_term = expression + # On analytic lines, the account.account field is named general_account_id and not account_id. + if field.split('.')[0] == 'account_id': + field = field.replace('account_id', 'general_account_id') + expression = [(field, operator, right_term)] + # Replace the 'analytic_distribution' by the account_id domain as we expect for analytic lines. + elif field == 'analytic_distribution': + expression = [('auto_account_id', 'in', right_term)] + # For other fields not present in on the analytic line model, map them to get the info from the move_line. + # Or ignore these conditions if there is no move lines. + elif field.split('.')[0] not in AccountAnalyticLine._fields: + expression = [(f'move_line_id.{field}', operator, right_term)] + if options.get('include_analytic_without_aml'): + expression = osv.expression.OR([ + [('move_line_id', '=', False)], + expression, + ]) + else: + expression = [expression] # just for the extend + domain.extend(expression) + + action = clean_action(self.env.ref('analytic.account_analytic_line_action_entries')._get_action_dict(), env=self.env) + action['domain'] = domain + return action + + @api.model + def _get_options_journals_domain(self, options): + domain = super(AccountReport, self)._get_options_journals_domain(options) + # Add False to the domain in order to select lines without journals for analytics columns. + if options.get('include_analytic_without_aml'): + domain = osv.expression.OR([ + domain, + [('journal_id', '=', False)], + ]) + return domain + + def _get_options_domain(self, options, date_scope): + self.ensure_one() + domain = super()._get_options_domain(options, date_scope) + + # Get the analytic accounts that we need to filter on from the options and add a domain for them. + if 'analytic_accounts_list' in options: + domain = osv.expression.AND([ + domain, + [('analytic_distribution', 'in', options.get('analytic_accounts_list', []))], + ]) + + return domain + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _where_calc(self, domain, active_test=True): + """ In case we need an analytic column in an account_report, we shadow the account_move_line table + with a temp table filled with analytic data, that will be used for the analytic columns. + We do it in this function to only create and fill it once for all computations of a report. + The following analytic columns and computations will just query the shadowed table instead of the real one. + """ + query = super()._where_calc(domain, active_test) + if self.env.context.get('account_report_analytic_groupby') and not self.env.context.get('account_report_cash_basis'): + self.env['account.report']._prepare_lines_for_analytic_groupby() + query._tables['account_move_line'] = SQL.identifier('analytic_temp_account_move_line') + return query diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py new file mode 100644 index 0000000..555a497 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_cash_flow_report.py @@ -0,0 +1,711 @@ +from odoo import models, _ +from odoo.tools import SQL, Query + + +class CashFlowReportCustomHandler(models.AbstractModel): + _name = 'account.cash.flow.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Cash Flow Report Custom Handler' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + lines = [] + + layout_data = self._get_layout_data() + report_data = self._get_report_data(report, options, layout_data) + + for layout_line_id, layout_line_data in layout_data.items(): + lines.append((0, self._get_layout_line(report, options, layout_line_id, layout_line_data, report_data))) + + if layout_line_id in report_data and 'aml_groupby_account' in report_data[layout_line_id]: + aml_data_values = report_data[layout_line_id]['aml_groupby_account'].values() + + aml_data_values_with_account_code = [] + aml_data_values_without_account_code = [] + + for aml_data in aml_data_values: + if aml_data['account_code'] is not None: + aml_data_values_with_account_code.append(aml_data) + else: + aml_data_values_without_account_code.append(aml_data) + + for aml_data in (sorted(aml_data_values_with_account_code, key=lambda x: x['account_code']) + + aml_data_values_without_account_code): + lines.append((0, self._get_aml_line(report, options, aml_data))) + + unexplained_difference_line = self._get_unexplained_difference_line(report, options, report_data) + + if unexplained_difference_line: + lines.append((0, unexplained_difference_line)) + + return lines + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + report._init_options_journals(options, previous_options=previous_options, additional_journals_domain=[('type', 'in', ('bank', 'cash', 'general'))]) + + def _get_report_data(self, report, options, layout_data): + report_data = {} + + payment_account_ids = self._get_account_ids(report, options) + if not payment_account_ids: + return report_data + + # Compute 'Cash and cash equivalents, beginning of period' + for aml_data in self._compute_liquidity_balance(report, options, payment_account_ids, 'to_beginning_of_period'): + self._add_report_data('opening_balance', aml_data, layout_data, report_data) + self._add_report_data('closing_balance', aml_data, layout_data, report_data) + + # Compute 'Cash and cash equivalents, closing balance' + for aml_data in self._compute_liquidity_balance(report, options, payment_account_ids, 'strict_range'): + self._add_report_data('closing_balance', aml_data, layout_data, report_data) + + tags_ids = self._get_tags_ids() + cashflow_tag_ids = self._get_cashflow_tag_ids() + + # Process liquidity moves + for aml_groupby_account in self._get_liquidity_moves(report, options, payment_account_ids, cashflow_tag_ids): + for aml_data in aml_groupby_account.values(): + self._dispatch_aml_data(tags_ids, aml_data, layout_data, report_data) + + # Process reconciled moves + for aml_groupby_account in self._get_reconciled_moves(report, options, payment_account_ids, cashflow_tag_ids): + for aml_data in aml_groupby_account.values(): + self._dispatch_aml_data(tags_ids, aml_data, layout_data, report_data) + + return report_data + + def _add_report_data(self, layout_line_id, aml_data, layout_data, report_data): + """ + Add or update the report_data dictionnary with aml_data. + + report_data is a dictionnary where the keys are keys from _cash_flow_report_get_layout_data() (used for mapping) + and the values can contain 2 dictionnaries: + * (required) 'balance' where the key is the column_group_key and the value is the balance of the line + * (optional) 'aml_groupby_account' where the key is an account_id and the values are the aml data + """ + def _report_update_parent(layout_line_id, aml_column_group_key, aml_balance, layout_data, report_data): + # Update the balance in report_data of the parent of the layout_line_id recursively (Stops when the line has no parent) + if 'parent_line_id' in layout_data[layout_line_id]: + parent_line_id = layout_data[layout_line_id]['parent_line_id'] + + report_data.setdefault(parent_line_id, {'balance': {}}) + report_data[parent_line_id]['balance'].setdefault(aml_column_group_key, 0.0) + report_data[parent_line_id]['balance'][aml_column_group_key] += aml_balance + + _report_update_parent(parent_line_id, aml_column_group_key, aml_balance, layout_data, report_data) + + aml_column_group_key = aml_data['column_group_key'] + aml_account_id = aml_data['account_id'] + aml_account_code = aml_data['account_code'] + aml_account_name = aml_data['account_name'] + aml_balance = aml_data['balance'] + aml_account_tag = aml_data.get('account_tag_id', None) + + if self.env.company.currency_id.is_zero(aml_balance): + return + + report_data.setdefault(layout_line_id, { + 'balance': {}, + 'aml_groupby_account': {}, + }) + + report_data[layout_line_id]['aml_groupby_account'].setdefault(aml_account_id, { + 'parent_line_id': layout_line_id, + 'account_id': aml_account_id, + 'account_code': aml_account_code, + 'account_name': aml_account_name, + 'account_tag_id': aml_account_tag, + 'level': layout_data[layout_line_id]['level'] + 1, + 'balance': {}, + }) + + report_data[layout_line_id]['balance'].setdefault(aml_column_group_key, 0.0) + report_data[layout_line_id]['balance'][aml_column_group_key] += aml_balance + + report_data[layout_line_id]['aml_groupby_account'][aml_account_id]['balance'].setdefault(aml_column_group_key, 0.0) + report_data[layout_line_id]['aml_groupby_account'][aml_account_id]['balance'][aml_column_group_key] += aml_balance + + _report_update_parent(layout_line_id, aml_column_group_key, aml_balance, layout_data, report_data) + + def _get_tags_ids(self): + ''' Get a dict to pass on to _dispatch_aml_data containing information mapping account tags to report lines. ''' + return { + 'operating': self.env.ref('account.account_tag_operating').id, + 'investing': self.env.ref('account.account_tag_investing').id, + 'financing': self.env.ref('account.account_tag_financing').id, + } + + def _get_cashflow_tag_ids(self): + ''' Get the list of account tags that are relevant for the cash flow report. ''' + return self._get_tags_ids().values() + + def _dispatch_aml_data(self, tags_ids, aml_data, layout_data, report_data): + # Dispatch the aml_data in the correct layout_line + if aml_data['account_account_type'] == 'asset_receivable': + self._add_report_data('advance_payments_customer', aml_data, layout_data, report_data) + elif aml_data['account_account_type'] == 'liability_payable': + self._add_report_data('advance_payments_suppliers', aml_data, layout_data, report_data) + elif aml_data['balance'] < 0: + if aml_data['account_tag_id'] == tags_ids['operating']: + self._add_report_data('paid_operating_activities', aml_data, layout_data, report_data) + elif aml_data['account_tag_id'] == tags_ids['investing']: + self._add_report_data('investing_activities_cash_out', aml_data, layout_data, report_data) + elif aml_data['account_tag_id'] == tags_ids['financing']: + self._add_report_data('financing_activities_cash_out', aml_data, layout_data, report_data) + else: + self._add_report_data('unclassified_activities_cash_out', aml_data, layout_data, report_data) + elif aml_data['balance'] > 0: + if aml_data['account_tag_id'] == tags_ids['operating']: + self._add_report_data('received_operating_activities', aml_data, layout_data, report_data) + elif aml_data['account_tag_id'] == tags_ids['investing']: + self._add_report_data('investing_activities_cash_in', aml_data, layout_data, report_data) + elif aml_data['account_tag_id'] == tags_ids['financing']: + self._add_report_data('financing_activities_cash_in', aml_data, layout_data, report_data) + else: + self._add_report_data('unclassified_activities_cash_in', aml_data, layout_data, report_data) + + # ------------------------------------------------------------------------- + # QUERIES + # ------------------------------------------------------------------------- + def _get_account_ids(self, report, options): + ''' Retrieve all accounts to be part of the cash flow statement and also the accounts making them. + + :param options: The report options. + :return: payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal. + ''' + # Fetch liquidity accounts: + # Accounts being used by at least one bank/cash journal. + selected_journal_ids = [j['id'] for j in report._get_options_journals(options)] + + where_clause = "account_journal.id IN %s" if selected_journal_ids else "account_journal.type IN ('bank', 'cash', 'general')" + where_params = [tuple(selected_journal_ids)] if selected_journal_ids else [] + + self._cr.execute(f''' + SELECT + array_remove(ARRAY_AGG(DISTINCT account_account.id), NULL), + array_remove(ARRAY_AGG(DISTINCT account_payment_method_line.payment_account_id), NULL) + FROM account_journal + JOIN res_company + ON account_journal.company_id = res_company.id + LEFT JOIN account_payment_method_line + ON account_journal.id = account_payment_method_line.journal_id + LEFT JOIN account_account + ON account_journal.default_account_id = account_account.id + AND account_account.account_type IN ('asset_cash', 'liability_credit_card') + WHERE {where_clause} + ''', where_params) + + res = self._cr.fetchall()[0] + payment_account_ids = set((res[0] or []) + (res[1] or [])) + + if not payment_account_ids: + return () + + return tuple(payment_account_ids) + + def _get_move_ids_query(self, report, payment_account_ids, column_group_options) -> SQL: + ''' Get all liquidity moves to be part of the cash flow statement. + :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal. + :return: query: The SQL query to retrieve the move IDs. + ''' + + query = report._get_report_query(column_group_options, 'strict_range', [('account_id', 'in', list(payment_account_ids))]) + return SQL( + ''' + SELECT + array_agg(DISTINCT account_move_line.move_id) AS move_id + FROM %(table_references)s + WHERE %(search_condition)s + ''', + table_references=query.from_clause, + search_condition=query.where_clause, + ) + + def _compute_liquidity_balance(self, report, options, payment_account_ids, date_scope): + ''' Compute the balance of all liquidity accounts to populate the following sections: + 'Cash and cash equivalents, beginning of period' and 'Cash and cash equivalents, closing balance'. + + :param options: The report options. + :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal. + :return: A list of tuple (account_id, account_code, account_name, balance). + ''' + queries = [] + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + query = report._get_report_query(column_group_options, date_scope, domain=[('account_id', 'in', payment_account_ids)]) + account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + + queries.append(SQL( + ''' + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.account_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.account_id, %(account_code)s, %(account_name)s + ''', + column_group_key=column_group_key, + account_code=account_code, + account_name=account_name, + table_references=query.from_clause, + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=report._currency_table_aml_join(column_group_options), + search_condition=query.where_clause, + )) + + self._cr.execute(SQL(' UNION ALL ').join(queries)) + + return self._cr.dictfetchall() + + def _get_liquidity_moves(self, report, options, payment_account_ids, cash_flow_tag_ids): + ''' Fetch all information needed to compute lines from liquidity moves. + The difficulty is to represent only the not-reconciled part of balance. + + :param options: The report options. + :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal. + :return: A list of tuple (account_id, account_code, account_name, account_type, amount). + ''' + + reconciled_aml_groupby_account = {} + + queries = [] + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + move_ids_query = self._get_move_ids_query(report, payment_account_ids, column_group_options) + query = Query(self.env, 'account_move_line') + account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_type = SQL.identifier(account_alias, 'account_type') + + queries.append(SQL( + ''' + (WITH payment_move_ids AS (%(move_ids_query)s) + -- Credit amount of each account + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.account_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_type)s AS account_account_type, + account_account_account_tag.account_account_tag_id AS account_tag_id, + SUM(%(partial_amount_select)s) AS balance + FROM %(from_clause)s + %(currency_table_join)s + LEFT JOIN account_partial_reconcile + ON account_partial_reconcile.credit_move_id = account_move_line.id + LEFT JOIN account_account_account_tag + ON account_account_account_tag.account_account_id = account_move_line.account_id + AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s + WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND account_move_line.account_id NOT IN %(payment_account_ids)s + AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s + GROUP BY account_move_line.company_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id + + UNION ALL + + -- Debit amount of each account + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.account_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_type)s AS account_account_type, + account_account_account_tag.account_account_tag_id AS account_tag_id, + -SUM(%(partial_amount_select)s) AS balance + FROM %(from_clause)s + %(currency_table_join)s + LEFT JOIN account_partial_reconcile + ON account_partial_reconcile.debit_move_id = account_move_line.id + LEFT JOIN account_account_account_tag + ON account_account_account_tag.account_account_id = account_move_line.account_id + AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s + WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND account_move_line.account_id NOT IN %(payment_account_ids)s + AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s + GROUP BY account_move_line.company_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id + + UNION ALL + + -- Total amount of each account + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.account_id AS account_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_type)s AS account_account_type, + account_account_account_tag.account_account_tag_id AS account_tag_id, + SUM(%(aml_balance_select)s) AS balance + FROM %(from_clause)s + %(currency_table_join)s + LEFT JOIN account_account_account_tag + ON account_account_account_tag.account_account_id = account_move_line.account_id + AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s + WHERE account_move_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND account_move_line.account_id NOT IN %(payment_account_ids)s + GROUP BY account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id) + ''', + column_group_key=column_group_key, + move_ids_query=move_ids_query, + account_code=account_code, + account_name=account_name, + account_type=account_type, + from_clause=query.from_clause, + currency_table_join=report._currency_table_aml_join(column_group_options), + partial_amount_select=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")), + aml_balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + cash_flow_tag_ids=tuple(cash_flow_tag_ids), + payment_account_ids=payment_account_ids, + date_from=column_group_options['date']['date_from'], + date_to=column_group_options['date']['date_to'], + )) + + self._cr.execute(SQL(' UNION ALL ').join(queries)) + + for aml_data in self._cr.dictfetchall(): + reconciled_aml_groupby_account.setdefault(aml_data['account_id'], {}) + reconciled_aml_groupby_account[aml_data['account_id']].setdefault(aml_data['column_group_key'], { + 'column_group_key': aml_data['column_group_key'], + 'account_id': aml_data['account_id'], + 'account_code': aml_data['account_code'], + 'account_name': aml_data['account_name'], + 'account_account_type': aml_data['account_account_type'], + 'account_tag_id': aml_data['account_tag_id'], + 'balance': 0.0, + }) + + reconciled_aml_groupby_account[aml_data['account_id']][aml_data['column_group_key']]['balance'] -= aml_data['balance'] + + return list(reconciled_aml_groupby_account.values()) + + def _get_reconciled_moves(self, report, options, payment_account_ids, cash_flow_tag_ids): + ''' Retrieve all moves being not a liquidity move to be shown in the cash flow statement. + Each amount must be valued at the percentage of what is actually paid. + E.g. An invoice of 1000 being paid at 50% must be valued at 500. + + :param options: The report options. + :param payment_account_ids: A tuple containing all account.account's ids being used in a liquidity journal. + :return: A list of tuple (account_id, account_code, account_name, account_type, amount). + ''' + + reconciled_account_ids = {column_group_key: set() for column_group_key in options['column_groups']} + reconciled_percentage_per_move = {column_group_key: {} for column_group_key in options['column_groups']} + currency_table = report._get_currency_table(options) + + queries = [] + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + move_ids_query = self._get_move_ids_query(report, payment_account_ids, column_group_options) + + queries.append(SQL( + ''' + (WITH payment_move_ids AS (%(move_ids_query)s) + SELECT + %(column_group_key)s AS column_group_key, + debit_line.move_id, + debit_line.account_id, + SUM(%(partial_amount)s) AS balance + FROM account_move_line AS credit_line + LEFT JOIN account_partial_reconcile + ON account_partial_reconcile.credit_move_id = credit_line.id + JOIN %(currency_table)s + ON account_currency_table.company_id = account_partial_reconcile.company_id + AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway + INNER JOIN account_move_line AS debit_line + ON debit_line.id = account_partial_reconcile.debit_move_id + WHERE credit_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND credit_line.account_id NOT IN %(payment_account_ids)s + AND credit_line.credit > 0.0 + AND debit_line.move_id NOT IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s + GROUP BY debit_line.move_id, debit_line.account_id + + UNION ALL + + SELECT + %(column_group_key)s AS column_group_key, + credit_line.move_id, + credit_line.account_id, + -SUM(%(partial_amount)s) AS balance + FROM account_move_line AS debit_line + LEFT JOIN account_partial_reconcile + ON account_partial_reconcile.debit_move_id = debit_line.id + JOIN %(currency_table)s + ON account_currency_table.company_id = account_partial_reconcile.company_id + AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway + INNER JOIN account_move_line AS credit_line + ON credit_line.id = account_partial_reconcile.credit_move_id + WHERE debit_line.move_id IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND debit_line.account_id NOT IN %(payment_account_ids)s + AND debit_line.debit > 0.0 + AND credit_line.move_id NOT IN (SELECT unnest(payment_move_ids.move_id) FROM payment_move_ids) + AND account_partial_reconcile.max_date BETWEEN %(date_from)s AND %(date_to)s + GROUP BY credit_line.move_id, credit_line.account_id) + ''', + move_ids_query=move_ids_query, + column_group_key=column_group_key, + payment_account_ids=payment_account_ids, + date_from=column_group_options['date']['date_from'], + date_to=column_group_options['date']['date_to'], + currency_table=currency_table, + partial_amount=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")), + )) + + self._cr.execute(SQL(' UNION ALL ').join(queries)) + + for aml_data in self._cr.dictfetchall(): + reconciled_percentage_per_move[aml_data['column_group_key']].setdefault(aml_data['move_id'], {}) + reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']].setdefault(aml_data['account_id'], [0.0, 0.0]) + reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']][aml_data['account_id']][0] += aml_data['balance'] + + reconciled_account_ids[aml_data['column_group_key']].add(aml_data['account_id']) + + if not reconciled_percentage_per_move: + return [] + + queries = [] + + for column in options['columns']: + queries.append(SQL( + ''' + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.move_id, + account_move_line.account_id, + SUM(%(balance_select)s) AS balance + FROM account_move_line + JOIN %(currency_table)s + ON account_currency_table.company_id = account_move_line.company_id + AND account_currency_table.rate_type = 'current' -- For payable/receivable accounts it'll always be 'current' anyway + WHERE account_move_line.move_id IN %(move_ids)s + AND account_move_line.account_id IN %(account_ids)s + GROUP BY account_move_line.move_id, account_move_line.account_id + ''', + column_group_key=column['column_group_key'], + currency_table=currency_table, + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + move_ids=tuple(reconciled_percentage_per_move[column['column_group_key']].keys()) or (None,), + account_ids=tuple(reconciled_account_ids[column['column_group_key']]) or (None,) + )) + + self._cr.execute(SQL(' UNION ALL ').join(queries)) + + for aml_data in self._cr.dictfetchall(): + if aml_data['account_id'] in reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']]: + reconciled_percentage_per_move[aml_data['column_group_key']][aml_data['move_id']][aml_data['account_id']][1] += aml_data['balance'] + + reconciled_aml_per_account = {} + + queries = [] + + query = Query(self.env, 'account_move_line') + account_alias = query.join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_type = SQL.identifier(account_alias, 'account_type') + + for column in options['columns']: + queries.append(SQL( + ''' + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.move_id, + account_move_line.account_id, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_type)s AS account_account_type, + account_account_account_tag.account_account_tag_id AS account_tag_id, + SUM(%(balance_select)s) AS balance + FROM %(from_clause)s + %(currency_table_join)s + LEFT JOIN account_account_account_tag + ON account_account_account_tag.account_account_id = account_move_line.account_id + AND account_account_account_tag.account_account_tag_id IN %(cash_flow_tag_ids)s + WHERE account_move_line.move_id IN %(move_ids)s + GROUP BY account_move_line.move_id, account_move_line.account_id, %(account_code)s, %(account_name)s, account_account_type, account_account_account_tag.account_account_tag_id + ''', + column_group_key=column['column_group_key'], + account_code=account_code, + account_name=account_name, + account_type=account_type, + from_clause=query.from_clause, + currency_table_join=report._currency_table_aml_join(options), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + cash_flow_tag_ids=tuple(cash_flow_tag_ids), + move_ids=tuple(reconciled_percentage_per_move[column['column_group_key']].keys()) or (None,) + )) + + self._cr.execute(SQL(' UNION ALL ').join(queries)) + + for aml_data in self._cr.dictfetchall(): + aml_column_group_key = aml_data['column_group_key'] + aml_move_id = aml_data['move_id'] + aml_account_id = aml_data['account_id'] + aml_account_code = aml_data['account_code'] + aml_account_name = aml_data['account_name'] + aml_account_account_type = aml_data['account_account_type'] + aml_account_tag_id = aml_data['account_tag_id'] + aml_balance = aml_data['balance'] + + # Compute the total reconciled for the whole move. + total_reconciled_amount = 0.0 + total_amount = 0.0 + + for reconciled_amount, amount in reconciled_percentage_per_move[aml_column_group_key][aml_move_id].values(): + total_reconciled_amount += reconciled_amount + total_amount += amount + + # Compute matched percentage for each account. + if total_amount and aml_account_id not in reconciled_percentage_per_move[aml_column_group_key][aml_move_id]: + # Lines being on reconciled moves but not reconciled with any liquidity move must be valued at the + # percentage of what is actually paid. + reconciled_percentage = total_reconciled_amount / total_amount + aml_balance *= reconciled_percentage + elif not total_amount and aml_account_id in reconciled_percentage_per_move[aml_column_group_key][aml_move_id]: + # The total amount to reconcile is 0. In that case, only add entries being on these accounts. Otherwise, + # this special case will lead to an unexplained difference equivalent to the reconciled amount on this + # account. + # E.g: + # + # Liquidity move: + # Account | Debit | Credit + # -------------------------------------- + # Bank | | 100 + # Receivable | 100 | + # + # Reconciled move: <- reconciled_amount=100, total_amount=0.0 + # Account | Debit | Credit + # -------------------------------------- + # Receivable | | 200 + # Receivable | 200 | <- Only the reconciled part of this entry must be added. + aml_balance = -reconciled_percentage_per_move[aml_column_group_key][aml_move_id][aml_account_id][0] + else: + # Others lines are not considered. + continue + + reconciled_aml_per_account.setdefault(aml_account_id, {}) + reconciled_aml_per_account[aml_account_id].setdefault(aml_column_group_key, { + 'column_group_key': aml_column_group_key, + 'account_id': aml_account_id, + 'account_code': aml_account_code, + 'account_name': aml_account_name, + 'account_account_type': aml_account_account_type, + 'account_tag_id': aml_account_tag_id, + 'balance': 0.0, + }) + + reconciled_aml_per_account[aml_account_id][aml_column_group_key]['balance'] -= aml_balance + + return list(reconciled_aml_per_account.values()) + + # ------------------------------------------------------------------------- + # COLUMNS / LINES + # ------------------------------------------------------------------------- + def _get_layout_data(self): + # Indentation of the following dict reflects the structure of the report. + return { + 'opening_balance': {'name': _('Cash and cash equivalents, beginning of period'), 'level': 0}, + 'net_increase': {'name': _('Net increase in cash and cash equivalents'), 'level': 0, 'unfolded': True}, + 'operating_activities': {'name': _('Cash flows from operating activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True}, + 'advance_payments_customer': {'name': _('Advance Payments received from customers'), 'level': 4, 'parent_line_id': 'operating_activities'}, + 'received_operating_activities': {'name': _('Cash received from operating activities'), 'level': 4, 'parent_line_id': 'operating_activities'}, + 'advance_payments_suppliers': {'name': _('Advance payments made to suppliers'), 'level': 4, 'parent_line_id': 'operating_activities'}, + 'paid_operating_activities': {'name': _('Cash paid for operating activities'), 'level': 4, 'parent_line_id': 'operating_activities'}, + 'investing_activities': {'name': _('Cash flows from investing & extraordinary activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True}, + 'investing_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'investing_activities'}, + 'investing_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'investing_activities'}, + 'financing_activities': {'name': _('Cash flows from financing activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True}, + 'financing_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'financing_activities'}, + 'financing_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'financing_activities'}, + 'unclassified_activities': {'name': _('Cash flows from unclassified activities'), 'level': 2, 'parent_line_id': 'net_increase', 'class': 'fw-bold', 'unfolded': True}, + 'unclassified_activities_cash_in': {'name': _('Cash in'), 'level': 4, 'parent_line_id': 'unclassified_activities'}, + 'unclassified_activities_cash_out': {'name': _('Cash out'), 'level': 4, 'parent_line_id': 'unclassified_activities'}, + 'closing_balance': {'name': _('Cash and cash equivalents, closing balance'), 'level': 0}, + } + + def _get_layout_line(self, report, options, layout_line_id, layout_line_data, report_data): + line_id = report._get_generic_line_id(None, None, markup=layout_line_id) + unfoldable = 'aml_groupby_account' in report_data[layout_line_id] if layout_line_id in report_data else False + + column_values = [] + + for column in options['columns']: + expression_label = column['expression_label'] + column_group_key = column['column_group_key'] + + value = report_data[layout_line_id][expression_label].get(column_group_key, 0.0) if layout_line_id in report_data else 0.0 + + column_values.append(report._build_column_dict(value, column, options=options)) + + return { + 'id': line_id, + 'name': layout_line_data['name'], + 'level': layout_line_data['level'], + 'class': layout_line_data.get('class', ''), + 'columns': column_values, + 'unfoldable': unfoldable, + 'unfolded': line_id in options['unfolded_lines'] or layout_line_data.get('unfolded') or (options.get('unfold_all') and unfoldable), + } + + def _get_aml_line(self, report, options, aml_data): + parent_line_id = report._get_generic_line_id(None, None, aml_data['parent_line_id']) + line_id = report._get_generic_line_id('account.account', aml_data['account_id'], parent_line_id=parent_line_id) + + column_values = [] + + for column in options['columns']: + expression_label = column['expression_label'] + column_group_key = column['column_group_key'] + + value = aml_data[expression_label].get(column_group_key, 0.0) + + column_values.append(report._build_column_dict(value, column, options=options)) + + return { + 'id': line_id, + 'name': f"{aml_data['account_code']} {aml_data['account_name']}" if aml_data['account_code'] else aml_data['account_name'], + 'caret_options': 'account.account', + 'level': aml_data['level'], + 'parent_id': parent_line_id, + 'columns': column_values, + } + + def _get_unexplained_difference_line(self, report, options, report_data): + unexplained_difference = False + column_values = [] + + for column in options['columns']: + expression_label = column['expression_label'] + column_group_key = column['column_group_key'] + + opening_balance = report_data['opening_balance'][expression_label].get(column_group_key, 0.0) if 'opening_balance' in report_data else 0.0 + closing_balance = report_data['closing_balance'][expression_label].get(column_group_key, 0.0) if 'closing_balance' in report_data else 0.0 + net_increase = report_data['net_increase'][expression_label].get(column_group_key, 0.0) if 'net_increase' in report_data else 0.0 + + balance = closing_balance - opening_balance - net_increase + + if not self.env.company.currency_id.is_zero(balance): + unexplained_difference = True + + column_values.append(report._build_column_dict( + balance, + { + 'figure_type': 'monetary', + 'expression_label': 'balance', + }, + options=options, + )) + + if unexplained_difference: + return { + 'id': report._get_generic_line_id(None, None, markup='unexplained_difference'), + 'name': 'Unexplained Difference', + 'level': 1, + 'columns': column_values, + } diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py b/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py new file mode 100644 index 0000000..c53756b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_customer_statement.py @@ -0,0 +1,24 @@ +from odoo import models, _ + + +class CustomerStatementCustomHandler(models.AbstractModel): + _name = 'account.customer.statement.report.handler' + _inherit = 'account.partner.ledger.report.handler' + _description = 'Customer Statement Custom Handler' + + def _get_custom_display_config(self): + display_config = super()._get_custom_display_config() + display_config['css_custom_class'] += ' customer_statement' + if self.env.ref('odex30_account_reports.pdf_export_main_customer_report', raise_if_not_found=False): + display_config.setdefault('pdf_export', {})['pdf_export_main'] = 'odex30_account_reports.pdf_export_main_customer_report' + return display_config + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options) + + options['buttons'].append({ + 'name': _('Send'), + 'action': 'action_send_statements', + 'sequence': 90, + 'always_show': True, + }) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py b/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py new file mode 100644 index 0000000..9732ba6 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_deferred_reports.py @@ -0,0 +1,659 @@ +import calendar +from collections import defaultdict +from dateutil.relativedelta import relativedelta + +from odoo import models, fields, _, api, Command +from odoo.exceptions import UserError +from odoo.tools import groupby, SQL +from odoo.addons.odex30_account_accountant.models.account_move import DEFERRED_DATE_MIN, DEFERRED_DATE_MAX + + +class DeferredReportCustomHandler(models.AbstractModel): + _name = 'account.deferred.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Deferred Expense Report Custom Handler' + + def _get_deferred_report_type(self): + raise NotImplementedError("This method is not implemented in the deferred report handler.") + + + def _get_domain_fully_inside_period(self, options): + return [ # Exclude if entirely inside the period + '!', '&', '&', '&', '&', '&', '&', '&', + ('deferred_start_date', '!=', False), + ('deferred_end_date', '!=', False), + ('deferred_start_date', '>=', options['date']['date_from']), + ('deferred_start_date', '<=', options['date']['date_to']), + ('deferred_end_date', '>=', options['date']['date_from']), + ('deferred_end_date', '<=', options['date']['date_to']), + ('move_id.date', '>=', options['date']['date_from']), + ('move_id.date', '<=', options['date']['date_to']), + ] + + def _get_domain(self, report, options, filter_already_generated=False, filter_not_started=False): + domain = report._get_options_domain(options, "from_beginning") + account_types = ('expense', 'expense_depreciation', 'expense_direct_cost') if self._get_deferred_report_type() == 'expense' else ('income', 'income_other') + domain += [ + ('account_id.account_type', 'in', account_types), + ('deferred_start_date', '!=', False), + ('deferred_end_date', '!=', False), + ('deferred_end_date', '>=', options['date']['date_from']), + ('move_id.date', '<=', options['date']['date_to']), + ] + domain += self._get_domain_fully_inside_period(options) + if filter_already_generated: + # Avoid regenerating already generated deferrals + domain += [ + ('deferred_end_date', '>=', options['date']['date_from']), + '!', + ('move_id.deferred_move_ids', 'any', [ + ('date', '=', options['date']['date_to']), + '|', + ('state', '=', 'posted'), # Either posted + '&', # Or autoposted in the future + ('auto_post', '=', 'at_date'), + ('date', '>=', fields.Date.context_today(self)), + ]) + ] + if filter_not_started: + domain += [('deferred_start_date', '>', options['date']['date_to'])] + return domain + + @api.model + def _get_select(self, options): + account_name = self.env['account.account']._field_to_sql('account_move_line__account_id', 'name') + return [ + SQL("account_move_line.id AS line_id"), + SQL("account_move_line.account_id AS account_id"), + SQL("account_move_line.partner_id AS partner_id"), + SQL("account_move_line.product_id AS product_id"), + SQL("account_move_line__product_template_id.categ_id AS product_category_id"), + SQL("account_move_line.name AS line_name"), + SQL("account_move_line.deferred_start_date AS deferred_start_date"), + SQL("account_move_line.deferred_end_date AS deferred_end_date"), + SQL("account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days"), + SQL("account_move_line.balance AS balance"), + SQL("account_move_line.analytic_distribution AS analytic_distribution"), + SQL("account_move_line__move_id.id as move_id"), + SQL("account_move_line__move_id.name AS move_name"), + SQL(""" + NOT ( + account_move_line.deferred_end_date >= %(report_date_from)s + AND + NOT EXISTS ( + SELECT 1 + FROM account_move_deferred_rel AS amdr + LEFT JOIN account_move AS am ON amdr.deferred_move_id = am.id + WHERE amdr.original_move_id = account_move_line.move_id + AND am.date = %(report_date_to)s + AND ( + am.state = 'posted' + OR (am.auto_post = 'at_date' AND am.date >= %(today)s) + ) + ) + ) AS is_already_generated + """, + report_date_from=options['date']['date_from'], + report_date_to=options['date']['date_to'], + today=fields.Date.context_today(self), + ), + SQL("%s AS account_name", account_name), + ] + + def _get_lines(self, report, options, filter_already_generated=False): + if 'report_deferred_lines' not in self.env.cr.cache: + self._fetch_lines(report, options, filter_already_generated) + + if not filter_already_generated: + # No more filtering needed, we can reuse the cached result + return self.env.cr.cache['report_deferred_lines'].values() + else: + # Filter the cached result to only keep the lines that are not already generated + cached_lines = self.env.cr.cache['report_deferred_lines'].values() + return [cached_line for cached_line in cached_lines if not cached_line['is_already_generated']] + + def _fetch_lines(self, report, options, filter_already_generated): + """Fetch the lines that need to be deferred from the DB and store them in the cache for later reuse""" + domain = self._get_domain(report, options, filter_already_generated) + query = report._get_report_query(options, domain=domain, date_scope='from_beginning') + select_clause = SQL(', ').join(self._get_select(options)) + + query = SQL( + """ + SELECT %(select_clause)s + FROM %(table_references)s + LEFT JOIN product_product AS account_move_line__product_id ON account_move_line.product_id = account_move_line__product_id.id + LEFT JOIN product_template AS account_move_line__product_template_id ON account_move_line__product_id.product_tmpl_id = account_move_line__product_template_id.id + WHERE %(search_condition)s + ORDER BY account_move_line.deferred_start_date, account_move_line.id + """, + select_clause=select_clause, + table_references=query.from_clause, + search_condition=query.where_clause, + ) + + self.env.cr.execute(query) + # Cache the result so that it can be reused to check whether a warning banner should be shown + # only if it's the generic query (so without filtering already generated deferrals) + self.env.cr.cache['report_deferred_lines'] = { + r['line_id']: r for r in self.env.cr.dictfetchall() + } + + @api.model + def _get_grouping_fields_deferred_lines(self, filter_already_generated=False, grouping_field='account_id'): + return (grouping_field,) + + @api.model + def _group_by_deferred_fields(self, line, filter_already_generated=False, grouping_field='account_id'): + return tuple(line[k] for k in self._get_grouping_fields_deferred_lines(filter_already_generated, grouping_field)) + + @api.model + def _get_grouping_fields_deferral_lines(self): + return () + + @api.model + def _group_by_deferral_fields(self, line): + return tuple(line[k] for k in self._get_grouping_fields_deferral_lines()) + + @api.model + def _group_deferred_amounts_by_grouping_field(self, deferred_amounts_by_line, periods, is_reverse, filter_already_generated=False, grouping_field='account_id'): + """ + Groups the deferred amounts by account and computes the totals for each account for each period. + And the total for all accounts for each period. + E.g. (where period1 = (date1, date2, label1), period2 = (date2, date3, label2), ...) + { + self._get_grouping_keys_deferred_lines(): { + 'account_id': account1, 'amount_total': 600, period_1: 200, period_2: 400 + }, + self._get_grouping_keys_deferred_lines(): { + 'account_id': account2, 'amount_total': 700, period_1: 300, period_2: 400 + }, + }, {'totals_aggregated': 1300, period_1: 500, period_2: 800} + """ + deferred_amounts_by_line = groupby(deferred_amounts_by_line, key=lambda x: self._group_by_deferred_fields(x, filter_already_generated, grouping_field)) + totals_per_key = {} # {key: {**self._get_grouping_fields_deferral_lines(), total, before, current, later}} + totals_aggregated_by_period = {period: 0 for period in periods + ['totals_aggregated']} + sign = 1 if is_reverse else -1 + for key, lines_per_key in deferred_amounts_by_line: + lines_per_key = list(lines_per_key) + current_key_totals = self._get_current_key_totals_dict(lines_per_key, sign) + totals_aggregated_by_period['totals_aggregated'] += current_key_totals['amount_total'] + for period in periods: + current_key_totals[period] = sign * sum(line[period] for line in lines_per_key) + totals_aggregated_by_period[period] += self.env.company.currency_id.round(current_key_totals[period]) + totals_per_key[key] = current_key_totals + return totals_per_key, totals_aggregated_by_period + + ########################### + # DEFERRED REPORT DISPLAY # + ########################### + + def _get_custom_display_config(self): + return { + 'templates': { + 'AccountReportFilters': 'odex30_account_reports.DeferredFilters', + }, + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + + options_per_col_group = report._split_options_per_column_group(options) + for column_dict in options['columns']: + column_options = options_per_col_group[column_dict['column_group_key']] + column_dict['name'] = column_options['date']['string'] + column_dict['date_from'] = column_options['date']['date_from'] + column_dict['date_to'] = column_options['date']['date_to'] + + options['columns'] = list(reversed(options['columns'])) + total_column = [{ + **options['columns'][0], + 'name': _('Total'), + 'expression_label': 'total', + 'date_from': DEFERRED_DATE_MIN, + 'date_to': DEFERRED_DATE_MAX, + }] + not_started_column = [{ + **options['columns'][0], + 'name': _('Not Started'), + 'expression_label': 'not_started', + 'date_from': options['columns'][-1]['date_to'], + 'date_to': DEFERRED_DATE_MAX, + }] + before_column = [{ + **options['columns'][0], + 'name': _('Before'), + 'expression_label': 'before', + 'date_from': DEFERRED_DATE_MIN, + 'date_to': fields.Date.to_string(fields.Date.to_date(options['columns'][0]['date_from']) - relativedelta(days=1)), + }] + later_column = [{ + **options['columns'][0], + 'name': _('Later'), + 'expression_label': 'later', + 'date_from': fields.Date.to_string(fields.Date.to_date(options['columns'][-1]['date_to']) + relativedelta(days=1)), + 'date_to': DEFERRED_DATE_MAX, + }] + options['columns'] = total_column + not_started_column + before_column + options['columns'] + later_column + options['column_headers'] = [] + options['deferred_report_type'] = self._get_deferred_report_type() + options['deferred_grouping_field'] = previous_options.get('deferred_grouping_field') or 'account_id' + if ( + self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual' + or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual' + ): + options['buttons'].append({'name': _('Generate entry'), 'action': 'action_generate_entry', 'sequence': 80, 'always_show': True}) + + def action_audit_cell(self, options, params): + """ Open a list of invoices/bills and/or deferral entries for the clicked cell in a deferred report. + + Specifically, we show the following lines, grouped by their journal entry, filtered by the column date bounds: + - Total: Lines of all invoices/bills being deferred in the current period + - Not Started: Lines of all deferral entries for which the original invoice/bill date is before or in the + current period, but the deferral only starts after the current period, as well as the lines of + their original invoices/bills + - Before: Lines of all deferral entries with a date before the current period, created by invoices/bills also + being deferred in the current period, as well as the lines of their original invoices/bills + - Current: Lines of all deferral entries in the current period, as well as these of their original + invoices/bills + - Later: Lines of all deferral entries with a date after the current period, created by invoices/bills also + being deferred in the current period, as well as the lines of their original invoices/bills + + :param dict options: the report's `options` + :param dict params: a dict containing: + `calling_line_dict_id`: line id containing the optional account of the cell + `column_group_id`: the column group id of the cell + `expression_label`: the expression label of the cell + """ + report = self.env['account.report'].browse(options['report_id']) + column_values = next( + (column for column in options['columns'] if ( + column['column_group_key'] == params.get('column_group_key') + and column['expression_label'] == params.get('expression_label') + )), + None + ) + if not column_values: + return + + column_date_from = fields.Date.to_date(column_values['date_from']) + column_date_to = fields.Date.to_date(column_values['date_to']) + report_date_from = fields.Date.to_date(options['date']['date_from']) + report_date_to = fields.Date.to_date(options['date']['date_to']) + + # Corrections for comparisons + if column_values['expression_label'] in ('not_started', 'later'): + # Not Started and Later period start one day after `report_date_to` + column_date_from = report_date_to + relativedelta(days=1) + if column_values['expression_label'] == 'before': + # Before period ends one day before `report_date_from` + column_date_to = report_date_from - relativedelta(days=1) + + # calling_line_dict_id is of the format `~account.report~15|~account.account~25` + _grouping_model, grouping_record_id = report._get_model_info_from_id(params.get('calling_line_dict_id')) + + # Find the original lines to be deferred in the report period + original_move_lines_domain = self._get_domain( + report, options, filter_not_started=column_values['expression_label'] == 'not_started' + ) + if grouping_record_id: + # We're auditing a specific account, so we only want moves containing this account + original_move_lines_domain.append((options['deferred_grouping_field'], '=', grouping_record_id)) + # We're getting all lines from the concerned moves. They are filtered later for flexibility. + original_moves = self.env['account.move.line'].search(original_move_lines_domain).move_id + + domain = [ + # For the Total period only show the original move lines + '&', + ('move_id', 'in', original_moves.ids), + ('deferred_end_date', '>=', report_date_from), + ] + + # Show both the original move lines and deferral move lines for all other periods + if column_values['expression_label'] != 'total' and original_moves.deferred_move_ids: + domain = ['|'] + [('move_id', 'in', original_moves.deferred_move_ids.ids)] + domain + + if column_values['expression_label'] == 'not_started': + domain += [('deferred_start_date', '>=', column_date_from)] + else: + # If in manually & grouped mode, and deferrals have not yet been generated + # so no move with `date` set => instead show the candidates original deferred moves that + # will be deferred upon clicking the button. If totally/partially generated, we'll just + # use the `date` filter which will include both the originals and deferrals. + if not original_moves.deferred_move_ids: + domain += [ + ('deferred_start_date', '<=', column_date_to), + ('deferred_end_date', '>=', column_date_from), + ] + else: + domain += [ + ('date', '>=', column_date_from), + ('date', '<=', column_date_to), + ] + domain += self._get_domain_fully_inside_period(options) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Deferred Entries'), + 'res_model': 'account.move.line', + 'domain': domain, + 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list'), (False, 'pivot'), (False, 'graph'), (False, 'kanban')], + # Most filters are set here to allow auditing flexibility to the user + 'context': { + 'search_default_pl_accounts': True, + f'search_default_{options["deferred_grouping_field"]}': grouping_record_id, + 'expand': True, + }, + } + + def _caret_options_initializer(self): + return { + 'deferred_caret': [ + {'name': _("Journal Items"), 'action': 'open_journal_items'}, + ], + } + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + if ( + self._get_deferred_report_type() == 'expense' and self.env.company.generate_deferred_expense_entries_method == 'manual' + or self._get_deferred_report_type() == 'revenue' and self.env.company.generate_deferred_revenue_entries_method == 'manual' + ): + already_generated = self.env['account.move'].search_count( + report._get_generated_deferral_entries_domain(options) + ) + # This will trigger a second _get_lines call, however the first one was cached, so we just need to filter again on the cache (see _get_lines) + moves_lines_to_generate, __, __, __, __ = self._get_moves_to_defer(options) + if moves_lines_to_generate and already_generated: + warnings['odex30_account_reports.deferred_report_warning_partially_generated'] = {'alert_type': 'warning'} + elif moves_lines_to_generate: + warnings['odex30_account_reports.deferred_report_warning_never_generated'] = {'alert_type': 'warning'} + elif already_generated: + warnings['odex30_account_reports.deferred_report_info_fully_generated'] = {'alert_type': 'info'} + + + def open_journal_items(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + record_model, record_id = report._get_model_info_from_id(params.get('line_id')) + domain = self._get_domain(report, options) + if record_model == 'account.account' and record_id: + domain += [('account_id', '=', record_id)] + elif record_model == 'product.product' and record_id: + domain += [('product_id', '=', record_id)] + elif record_model == 'product.category' and record_id: + domain += [('product_category_id', '=', record_id)] + return { + 'type': 'ir.actions.act_window', + 'name': _("Deferred Entries"), + 'res_model': 'account.move.line', + 'domain': domain, + 'views': [(self.env.ref('odex30_account_accountant.view_deferred_entries_tree').id, 'list')], + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + } + } + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + def get_columns(totals): + return [ + { + **report._build_column_dict( + totals[( + fields.Date.to_date(column['date_from']), + fields.Date.to_date(column['date_to']), + column['expression_label'] + )], + column, + options=options, + currency=self.env.company.currency_id, + ), + 'auditable': True, + } + for column in options['columns'] + ] + + lines = self._get_lines(report, options) + periods = [ + ( + fields.Date.from_string(column['date_from']), + fields.Date.from_string(column['date_to']), + column['expression_label'], + ) + for column in options['columns'] + ] + deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, periods, self._get_deferred_report_type()) + totals_per_grouping_field, totals_all_grouping_field = self._group_deferred_amounts_by_grouping_field( + deferred_amounts_by_line=deferred_amounts_by_line, + periods=periods, + is_reverse=self._get_deferred_report_type() == 'expense', + filter_already_generated=False, + grouping_field=options['deferred_grouping_field'], + ) + + report_lines = [] + grouping_model = self.env['account.move.line'][options['deferred_grouping_field']]._name + for totals_grouping_field in totals_per_grouping_field.values(): + grouping_record = self.env[grouping_model].browse(totals_grouping_field[options['deferred_grouping_field']]) + grouping_field_description = self.env['account.move.line'][options['deferred_grouping_field']]._description + if options['deferred_grouping_field'] == 'product_id': + grouping_field_description = _("Product") + grouping_name = grouping_record.display_name or _("(No %s)", grouping_field_description) + report_lines.append((0, { + 'id': report._get_generic_line_id(grouping_model, grouping_record.id), + 'name': grouping_name, + 'caret_options': 'deferred_caret', + 'level': 1, + 'columns': get_columns(totals_grouping_field), + })) + if totals_per_grouping_field: + report_lines.append((0, { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': 'Total', + 'level': 1, + 'columns': get_columns(totals_all_grouping_field), + })) + + return report_lines + + ####################### + # DEFERRED GENERATION # + ####################### + + def action_generate_entry(self, options): + new_deferred_moves = self._generate_deferral_entry(options) + return { + 'name': _('Deferred Entries'), + 'type': 'ir.actions.act_window', + 'views': [(False, "list"), (False, "form")], + 'domain': [('id', 'in', new_deferred_moves.ids)], + 'res_model': 'account.move', + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + }, + 'target': 'current', + } + + def _get_moves_to_defer(self, options): + date_from = fields.Date.to_date(DEFERRED_DATE_MIN) + date_to = fields.Date.from_string(options['date']['date_to']) + if date_to.day != calendar.monthrange(date_to.year, date_to.month)[1]: + raise UserError(_("You cannot generate entries for a period that does not end at the end of the month.")) + options['all_entries'] = False # We only want to create deferrals for posted moves + report = self.env["account.report"].browse(options["report_id"]) + self.env['account.move.line'].flush_model() + lines = self._get_lines(report, options, filter_already_generated=True) + deferral_entry_period = self.env['account.report']._get_dates_period(date_from, date_to, 'range', period_type='month') + ref = _("Grouped Deferral Entry of %s", deferral_entry_period['string']) + ref_rev = _("Reversal of Grouped Deferral Entry of %s", deferral_entry_period['string']) + deferred_account = self.env.company.deferred_expense_account_id if self._get_deferred_report_type() == 'expense' else self.env.company.deferred_revenue_account_id + move_lines, original_move_ids = self._get_deferred_lines(lines, deferred_account, (date_from, date_to, 'current'), self._get_deferred_report_type() == 'expense', ref) + return move_lines, original_move_ids, ref, ref_rev, date_to + + def _generate_deferral_entry(self, options): + journal = self.env.company.deferred_expense_journal_id if self._get_deferred_report_type() == "expense" else self.env.company.deferred_revenue_journal_id + if not journal: + raise UserError(_("Please set the deferred journal in the accounting settings.")) + move_lines, original_move_ids, ref, ref_rev, date_to = self._get_moves_to_defer(options) + if self.env.company._get_violated_lock_dates(date_to, False, journal): + raise UserError(_("You cannot generate entries for a period that is locked.")) + if not move_lines: + raise UserError(_("No entry to generate.")) + + deferred_move = self.env['account.move'].with_context(skip_account_deprecation_check=True).create({ + 'move_type': 'entry', + 'deferred_original_move_ids': [Command.set(original_move_ids)], + 'journal_id': journal.id, + 'date': date_to, + 'auto_post': 'at_date', + 'ref': ref, + }) + # We write the lines after creation, to make sure the `deferred_original_move_ids` is set. + # This way we can avoid adding taxes for deferred moves. + deferred_move.write({'line_ids': move_lines}) + reverse_move = deferred_move._reverse_moves() + reverse_move.write({ + 'date': deferred_move.date + relativedelta(days=1), + 'ref': ref_rev, + }) + reverse_move.line_ids.name = ref_rev + new_deferred_moves = deferred_move + reverse_move + # We create the relation (original deferred move, deferral entry) + # using SQL. This avoids a MemoryError using the ORM which will + # load huge amounts of moves in memory for nothing + self.env.cr.execute_values(""" + INSERT INTO account_move_deferred_rel(original_move_id, deferred_move_id) + VALUES %s + ON CONFLICT DO NOTHING + """, [ + (original_move_id, deferral_move.id) + for original_move_id in original_move_ids + for deferral_move in new_deferred_moves + ]) + new_deferred_moves.invalidate_recordset() + new_deferred_moves._post(soft=True) + return new_deferred_moves + + @api.model + def _get_current_key_totals_dict(self, lines_per_key, sign): + return { + 'account_id': lines_per_key[0]['account_id'], + 'product_id': lines_per_key[0]['product_id'], + 'product_category_id': lines_per_key[0]['product_category_id'], + 'amount_total': sign * sum(line['balance'] for line in lines_per_key), + 'move_ids': {line['move_id'] for line in lines_per_key}, + } + + @api.model + def _get_deferred_lines(self, lines, deferred_account, period, is_reverse, ref): + """ + Returns a list of Command objects to create the deferred lines of a single given period. + And the move_ids of the original lines that created these deferred + (to keep track of the original invoice in the deferred entries). + """ + if not deferred_account: + raise UserError(_("Please set the deferred accounts in the accounting settings.")) + + deferred_amounts_by_line = self.env['account.move']._get_deferred_amounts_by_line(lines, [period], is_reverse) + deferred_amounts_by_key, deferred_amounts_totals = self._group_deferred_amounts_by_grouping_field(deferred_amounts_by_line, [period], is_reverse, filter_already_generated=True) + totals_aggregated = deferred_amounts_totals['totals_aggregated'] + if totals_aggregated == deferred_amounts_totals[period]: + return [], set() + + # compute analytic distribution to populate on deferred lines + # structure: {self._get_grouping_keys_deferred_lines(): [analytic distribution]} + # dict of keys: self._get_grouping_keys_deferred_lines() + # values: dict of keys: "account.analytic.account.id" (string) + # values: float + anal_dist_by_key = defaultdict(lambda: defaultdict(float)) + # using another var for the analytic distribution of the deferral account + deferred_anal_dist = defaultdict(lambda: defaultdict(float)) + for line in lines: + if not line['analytic_distribution']: + continue + # Analytic distribution should be computed from the lines with the same _get_grouping_keys_deferred_lines(), except for + # the deferred line with the deferral account which will use _get_grouping_fields_deferral_lines() + sign = 1 if is_reverse else -1 + key_amount = deferred_amounts_by_key.get(self._group_by_deferred_fields(line, True)) + total_amount = key_amount.get('amount_total') + key_ratio = sign * line['balance'] / total_amount if total_amount else 0 + full_ratio = sign * line['balance'] / totals_aggregated if totals_aggregated else 0 + + for account_id, distribution in line['analytic_distribution'].items(): + anal_dist_by_key[self._group_by_deferred_fields(line, True)][account_id] += distribution * key_ratio + deferred_anal_dist[self._group_by_deferral_fields(line)][account_id] += distribution * full_ratio + + remaining_balance = 0 + deferred_lines = [] + original_move_ids = set() + for key, line in deferred_amounts_by_key.items(): + for balance in (-line['amount_total'], line[period]): + if balance != 0 and line[period] != line['amount_total']: + original_move_ids |= line['move_ids'] + deferred_balance = self.env.company.currency_id.round((1 if is_reverse else -1) * balance) + deferred_lines.append( + Command.create( + self.env['account.move.line']._get_deferred_lines_values( + account_id=line['account_id'], + balance=deferred_balance, + ref=ref, + analytic_distribution=anal_dist_by_key[key] or False, + line=line, + ) + ) + ) + remaining_balance += deferred_balance + + grouped_by_key = { + key: list(value) + for key, value in groupby( + deferred_amounts_by_key.values(), + key=self._group_by_deferral_fields, + ) + } + deferral_lines = [] + for key, lines_per_key in grouped_by_key.items(): + balance = 0 + for line in lines_per_key: + if line[period] != line['amount_total']: + balance += self.env.company.currency_id.round((1 if is_reverse else -1) * (line['amount_total'] - line[period])) + deferral_lines.append( + Command.create( + self.env['account.move.line']._get_deferred_lines_values( + account_id=deferred_account.id, + balance=balance, + ref=ref, + analytic_distribution=deferred_anal_dist[key] or False, + line=lines_per_key[0], + ) + ) + ) + remaining_balance += balance + + if not self.env.company.currency_id.is_zero(remaining_balance): + deferral_lines.append( + Command.create({ + 'account_id': deferred_account.id, + 'balance': -remaining_balance, + 'name': ref, + }) + ) + return deferred_lines + deferral_lines, original_move_ids + + +class DeferredExpenseCustomHandler(models.AbstractModel): + _name = 'account.deferred.expense.report.handler' + _inherit = 'account.deferred.report.handler' + _description = 'Deferred Expense Custom Handler' + + def _get_deferred_report_type(self): + return 'expense' + + +class DeferredRevenueCustomHandler(models.AbstractModel): + _name = 'account.deferred.revenue.report.handler' + _inherit = 'account.deferred.report.handler' + _description = 'Deferred Revenue Custom Handler' + + def _get_deferred_report_type(self): + return 'revenue' diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py b/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py new file mode 100644 index 0000000..bcba747 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_fiscal_position.py @@ -0,0 +1,19 @@ +from odoo import models + + +class AccountFiscalPosition(models.Model): + _inherit = 'account.fiscal.position' + + def _inverse_foreign_vat(self): + # EXTENDS account + super()._inverse_foreign_vat() + for fpos in self: + if fpos.foreign_vat: + fpos._create_draft_closing_move_for_foreign_vat() + + def _create_draft_closing_move_for_foreign_vat(self): + self.ensure_one() + existing_draft_closings = self.env['account.move'].search([('tax_closing_report_id', '!=', False), ('state', '=', 'draft')]) + for closing_date, entries in existing_draft_closings.grouped('date').items(): + for entry in entries: + self.company_id._get_and_update_tax_closing_moves(closing_date, entry.tax_closing_report_id, fiscal_positions=self) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py new file mode 100644 index 0000000..2b3d59e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_followup_report.py @@ -0,0 +1,109 @@ +from odoo import fields, models, _ +from odoo.tools import SQL + + +class AccountFollowupCustomHandler(models.AbstractModel): + _name = 'account.followup.report.handler' + _inherit = 'account.partner.ledger.report.handler' + _description = 'Follow-Up Report Custom Handler' + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options) + + options['hide_initial_balance'] = True + if len(options['partner_ids']) == 1: + options['ignore_totals_below_sections'] = True + options['hide_partner_totals'] = True + + if options['report_id'] != previous_options.get('report_id'): + options['unreconciled'] = True + # by default, select only the 'sales' journals + for journal in options.get('journals', []): + journal['selected'] = journal.get('type') != 'general' # dividers don't get a type + # Since we forced the selection of some journal, we need to recompute the filter label + report._init_options_journals_names(options, previous_options=previous_options) + + def _get_custom_display_config(self): + display_config = super()._get_custom_display_config() + if self.env.ref('odex30_account_reports.pdf_export_main_customer_report', raise_if_not_found=False): + display_config.setdefault('pdf_export', {})['pdf_export_main'] = 'odex30_account_reports.pdf_export_main_customer_report' + return display_config + + def _filter_overdue_amls_from_results(self, aml_results): + return list(filter(lambda aml: aml['date_maturity'] and aml['date_maturity'] < fields.Date.today(), aml_results)) + + def _filter_due_amls_from_results(self, aml_results): + return list(filter(lambda aml: not aml['date_maturity'] or aml['date_maturity'] >= fields.Date.today(), aml_results)) + + def _get_partner_aml_report_lines(self, report, options, partner_line_id, aml_results, progress, offset=0, level_shift=0): + + def create_status_line(status_name): + return { + 'id': report._get_generic_line_id(None, None, markup=status_name, parent_line_id=partner_line_id), + 'name': status_name, + 'level': 3 + level_shift, + 'parent_id': partner_line_id, + 'columns': [{} for _col in options['columns']], + 'unfolded': True, + } + + def get_aml_lines_with_status_line(status_name, status_line_id, aml_values, treated_results_count, progress): + lines = [] + next_progress = progress + has_more = False + + if not status_line_id or offset == 0: + status_line = create_status_line(status_name) + lines.append(status_line) + status_line_id = status_line['id'] + + for aml_value in aml_values: + if self._is_report_limit_reached(report, options, treated_results_count): + # We loaded one more than the limit on purpose: this way we know we need a "load more" line + has_more = True + break + + aml_report_line = self._get_report_line_move_line(options, aml_value, status_line_id, next_progress, level_shift=level_shift + 1) + lines.append(aml_report_line) + next_progress = self._init_load_more_progress(options, aml_report_line) + treated_results_count += 1 + + return lines, next_progress, treated_results_count, has_more + + lines = [] + next_progress = progress + has_more = False + treated_results_count = 0 + due_line_id, overdue_line_id = self._get_unfolded_partner_status_lines(report, options, partner_line_id) + + overdue_aml_values = self._filter_overdue_amls_from_results(aml_results) + due_aml_values = self._filter_due_amls_from_results(aml_results) + + if overdue_aml_values: + overdue_lines, next_progress, treated_results_count, has_more = get_aml_lines_with_status_line(_('Overdue'), overdue_line_id, overdue_aml_values, treated_results_count, next_progress) + lines.extend(overdue_lines) + # If we reached the limit just before the due line and have already loaded one extra line, we should skip the due line for now and add a "load more" line + if self._is_report_limit_reached(report, options, treated_results_count) and due_aml_values: + has_more = True + + if due_aml_values and not has_more: + due_lines, next_progress, treated_results_count, has_more = get_aml_lines_with_status_line(_('Due'), due_line_id, due_aml_values, treated_results_count, next_progress) + lines.extend(due_lines) + + return lines, next_progress, treated_results_count, has_more + + def _get_unfolded_partner_status_lines(self, report, options, partner_line_id): + _dummy1, _dummy2, partner_id = report._parse_line_id(partner_line_id)[-1] + due_line_id, overdue_line_id = None, None + for line_id in options['unfolded_lines']: + res_ids_map = report._get_res_ids_from_line_id(line_id, ['account.report', 'res.partner']) + if res_ids_map['account.report'] == report.id and res_ids_map['res.partner'] == partner_id: + markup, _dummy1, _dummy2 = report._parse_line_id(line_id)[-1] + if markup == 'Due': + due_line_id = line_id + if markup == 'Overdue': + overdue_line_id = line_id + return due_line_id, overdue_line_id + + def _get_order_by_aml_values(self): + return SQL('account_move_line.date_maturity, %(order_by)s', order_by=super()._get_order_by_aml_values()) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py b/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py new file mode 100644 index 0000000..6c065f7 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_general_ledger.py @@ -0,0 +1,761 @@ +import json + +from odoo import models, fields, api, _ +from odoo.tools.misc import format_date +from odoo.tools import get_lang, SQL +from odoo.exceptions import UserError + +from datetime import timedelta +from collections import defaultdict + + +class GeneralLedgerCustomHandler(models.AbstractModel): + _name = 'account.general.ledger.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'General Ledger Custom Handler' + + def _get_custom_display_config(self): + return { + 'templates': { + 'AccountReportLineName': 'odex30_account_reports.GeneralLedgerLineName', + }, + } + + def _custom_options_initializer(self, report, options, previous_options): + # Remove multi-currency columns if needed + super()._custom_options_initializer(report, options, previous_options=previous_options) + if self.env.user.has_group('base.group_multi_currency'): + options['multi_currency'] = True + else: + options['columns'] = [ + column for column in options['columns'] + if column['expression_label'] != 'amount_currency' + ] + + # Automatically unfold the report when printing it, unless some specific lines have been unfolded + options['unfold_all'] = (options['export_mode'] == 'print' and not options.get('unfolded_lines')) or options['unfold_all'] + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + lines = [] + date_from = fields.Date.from_string(options['date']['date_from']) + company_currency = self.env.company.currency_id + + totals_by_column_group = defaultdict(lambda: {'debit': 0, 'credit': 0, 'balance': 0}) + for account, column_group_results in self._query_values(report, options): + eval_dict = {} + has_lines = False + for column_group_key, results in column_group_results.items(): + account_sum = results.get('sum', {}) + account_un_earn = results.get('unaffected_earnings', {}) + + account_debit = account_sum.get('debit', 0.0) + account_un_earn.get('debit', 0.0) + account_credit = account_sum.get('credit', 0.0) + account_un_earn.get('credit', 0.0) + account_balance = account_sum.get('balance', 0.0) + account_un_earn.get('balance', 0.0) + + eval_dict[column_group_key] = { + 'amount_currency': account_sum.get('amount_currency', 0.0) + account_un_earn.get('amount_currency', 0.0), + 'debit': account_debit, + 'credit': account_credit, + 'balance': account_balance, + } + + max_date = account_sum.get('max_date') + has_lines = has_lines or (max_date and max_date >= date_from) + + totals_by_column_group[column_group_key]['debit'] += account_debit + totals_by_column_group[column_group_key]['credit'] += account_credit + totals_by_column_group[column_group_key]['balance'] += account_balance + + lines.append(self._get_account_title_line(report, options, account, has_lines, eval_dict)) + + # Report total line. + for totals in totals_by_column_group.values(): + totals['balance'] = company_currency.round(totals['balance']) + + # Tax Declaration lines. + journal_options = report._get_options_journals(options) + if len(options['column_groups']) == 1 and len(journal_options) == 1 and journal_options[0]['type'] in ('sale', 'purchase'): + lines += self._tax_declaration_lines(report, options, journal_options[0]['type']) + + # Total line + lines.append(self._get_total_line(report, options, totals_by_column_group)) + + return [(0, line) for line in lines] + + def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): + account_ids_to_expand = [] + for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_general_ledger', []): + model, model_id = report._get_model_info_from_id(line_dict['id']) + if model == 'account.account': + account_ids_to_expand.append(model_id) + + limit_to_load = report.load_more_limit if report.load_more_limit and not options.get('export_mode') else None + has_more_per_account_id = {} + + unlimited_aml_results_per_account_id = self._get_aml_values(report, options, account_ids_to_expand)[0] + if limit_to_load: + # Apply the load_more_limit. + # load_more_limit cannot be passed to the call to _get_aml_values, otherwise it won't be applied per account but on the whole result. + # We gain perf from batching, but load every result ; then we need to filter them. + + aml_results_per_account_id = {} + for account_id, account_aml_results in unlimited_aml_results_per_account_id.items(): + account_values = {} + for key, value in account_aml_results.items(): + if len(account_values) == limit_to_load: + has_more_per_account_id[account_id] = True + break + account_values[key] = value + aml_results_per_account_id[account_id] = account_values + else: + aml_results_per_account_id = unlimited_aml_results_per_account_id + + return { + 'initial_balances': self._get_initial_balance_values(report, account_ids_to_expand, options), + 'aml_results': aml_results_per_account_id, + 'has_more': has_more_per_account_id, + } + + def _tax_declaration_lines(self, report, options, tax_type): + labels_replacement = { + 'debit': _("Base Amount"), + 'credit': _("Tax Amount"), + } + + rslt = [{ + 'id': report._get_generic_line_id(None, None, markup='tax_decl_header_1'), + 'name': _('Tax Declaration'), + 'columns': [{} for column in options['columns']], + 'level': 1, + 'unfoldable': False, + 'unfolded': False, + }, { + 'id': report._get_generic_line_id(None, None, markup='tax_decl_header_2'), + 'name': _('Name'), + 'columns': [{'name': labels_replacement.get(col['expression_label'], '')} for col in options['columns']], + 'level': 3, + 'unfoldable': False, + 'unfolded': False, + }] + + # Call the generic tax report + generic_tax_report = self.env.ref('account.generic_tax_report') + tax_report_options = generic_tax_report.get_options({**options, 'selected_variant_id': generic_tax_report.id, 'forced_domain': [('tax_line_id.type_tax_use', '=', tax_type)]}) + tax_report_lines = generic_tax_report._get_lines(tax_report_options) + tax_type_parent_line_id = generic_tax_report._get_generic_line_id(None, None, markup=tax_type) + + for tax_report_line in tax_report_lines: + if tax_report_line.get('parent_id') == tax_type_parent_line_id: + original_columns = tax_report_line['columns'] + row_column_map = { + 'debit': original_columns[0], + 'credit': original_columns[1], + } + + tax_report_line['columns'] = [row_column_map.get(col['expression_label'], {}) for col in options['columns']] + rslt.append(tax_report_line) + + return rslt + + def _query_values(self, report, options): + """ Executes the queries, and performs all the computations. + + :return: [(record, values_by_column_group), ...], where + - record is an account.account record. + - values_by_column_group is a dict in the form {column_group_key: values, ...} + - column_group_key is a string identifying a column group, as in options['column_groups'] + - values is a list of dictionaries, one per period containing: + - sum: {'debit': float, 'credit': float, 'balance': float} + - (optional) initial_balance: {'debit': float, 'credit': float, 'balance': float} + - (optional) unaffected_earnings: {'debit': float, 'credit': float, 'balance': float} + """ + # Execute the queries and dispatch the results. + query = self._get_query_sums(report, options) + + if not query: + return [] + + groupby_accounts = {} + groupby_companies = {} + + for res in self.env.execute_query_dict(query): + # No result to aggregate. + if res['groupby'] is None: + continue + + column_group_key = res['column_group_key'] + key = res['key'] + if key == 'sum': + groupby_accounts.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']}) + groupby_accounts[res['groupby']][column_group_key][key] = res + + elif key == 'initial_balance': + groupby_accounts.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']}) + groupby_accounts[res['groupby']][column_group_key][key] = res + + elif key == 'unaffected_earnings': + groupby_companies.setdefault(res['groupby'], {col_group_key: {} for col_group_key in options['column_groups']}) + groupby_companies[res['groupby']][column_group_key] = res + + # Affect the unaffected earnings to the first fetched account of type 'account.data_unaffected_earnings'. + # It's less costly to fetch all candidate accounts in a single search and then iterate it. + if groupby_companies: + unaffected_earnings_accounts = self.env['account.account'].search([ + ('display_name', 'ilike', options.get('filter_search_bar')), + *self.env['account.account']._check_company_domain(list(groupby_companies.keys())), + ('account_type', '=', 'equity_unaffected'), + ]) + for company_id, groupby_company in groupby_companies.items(): + if equity_unaffected_account := unaffected_earnings_accounts.filtered(lambda a: self.env['res.company'].browse(company_id).root_id in a.company_ids): + for column_group_key in options['column_groups']: + groupby_accounts.setdefault( + equity_unaffected_account.id, + {col_group_key: {'unaffected_earnings': {}} for col_group_key in options['column_groups']}, + ) + if unaffected_earnings := groupby_company.get(column_group_key): + if groupby_accounts[equity_unaffected_account.id][column_group_key].get('unaffected_earnings'): + for key in ['amount_currency', 'debit', 'credit', 'balance']: + groupby_accounts[equity_unaffected_account.id][column_group_key]['unaffected_earnings'][key] += unaffected_earnings[key] + else: + groupby_accounts[equity_unaffected_account.id][column_group_key]['unaffected_earnings'] = unaffected_earnings + + # Retrieve the accounts to browse. + # groupby_accounts.keys() contains all account ids affected by: + # - the amls in the current period. + # - the amls affecting the initial balance. + # - the unaffected earnings allocation. + # Note a search is done instead of a browse to preserve the table ordering. + if groupby_accounts: + accounts = self.env['account.account'].search([('id', 'in', list(groupby_accounts.keys()))]) + else: + accounts = [] + + return [(account, groupby_accounts[account.id]) for account in accounts] + + def _get_query_sums(self, report, options) -> SQL: + """ Construct a query retrieving all the aggregated sums to build the report. It includes: + - sums for all accounts. + - sums for the initial balances. + - sums for the unaffected earnings. + - sums for the tax declaration. + :return: query as SQL object + """ + options_by_column_group = report._split_options_per_column_group(options) + + queries = [] + + # ============================================ + # 1) Get sums for all accounts. + # ============================================ + for column_group_key, options_group in options_by_column_group.items(): + + # Sum is computed including the initial balance of the accounts configured to do so, unless a special option key is used + # (this is required for trial balance, which is based on general ledger) + sum_date_scope = 'strict_range' if options_group.get('general_ledger_strict_range') else 'from_beginning' + + query_domain = [] + + if not options_group.get('general_ledger_strict_range'): + date_from = fields.Date.from_string(options_group['date']['date_from']) + current_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from) + query_domain += [ + '|', + ('date', '>=', current_fiscalyear_dates['date_from']), + ('account_id.include_initial_balance', '=', True), + ] + + if options_group.get('export_mode') == 'print' and options_group.get('filter_search_bar'): + if options_group.get('hierarchy'): + query_domain += [ + '|', + ('account_id', 'ilike', options_group['filter_search_bar']), + ('account_id.id', 'in', SQL( + """ + /* + JOIN clause: Check if the account_group include the account_account + A group from 10 to 10 include every account with code that begin with 10. + If there is an account with a length of 6, it should be included if it's in the range from 100 000 to 109 999 included + + Where clause: Check if the account_group matches the filter + */ + (SELECT distinct account_account.id + FROM account_account + LEFT JOIN account_group ON + ( + LEFT(account_account.code_store->> '%(company_id)s', LENGTH(code_prefix_start)) BETWEEN + code_prefix_start + AND code_prefix_end + ) + WHERE ( account_group.name->> %(lang)s ILIKE %(filter_search_bar)s + OR account_group.code_prefix_start ILIKE %(filter_search_bar)s) + )""", + lang=self.env.lang, + company_id=self.env.company.id, + filter_search_bar="%" + options_group['filter_search_bar'] + "%")), + ] + else: + query_domain.append(('account_id', 'ilike', options_group['filter_search_bar'])) + + if options_group.get('include_current_year_in_unaff_earnings'): + query_domain += [('account_id.include_initial_balance', '=', True)] + + query = report._get_report_query(options_group, sum_date_scope, domain=query_domain) + queries.append(SQL( + """ + SELECT + account_move_line.account_id AS groupby, + 'sum' AS key, + MAX(account_move_line.date) AS max_date, + %(column_group_key)s AS column_group_key, + COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.account_id + """, + column_group_key=column_group_key, + table_references=query.from_clause, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=report._currency_table_aml_join(options_group), + search_condition=query.where_clause, + )) + + # ============================================ + # 2) Get sums for the unaffected earnings. + # ============================================ + if not options_group.get('general_ledger_strict_range'): + unaff_earnings_domain = [('account_id.include_initial_balance', '=', False)] + + # The period domain is expressed as: + # [ + # ('date' <= fiscalyear['date_from'] - 1), + # ('account_id.include_initial_balance', '=', False), + # ] + + new_options = self._get_options_unaffected_earnings(options_group) + query = report._get_report_query(new_options, 'strict_range', domain=unaff_earnings_domain) + queries.append(SQL( + """ + SELECT + account_move_line.company_id AS groupby, + 'unaffected_earnings' AS key, + NULL AS max_date, + %(column_group_key)s AS column_group_key, + COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.company_id + """, + column_group_key=column_group_key, + table_references=query.from_clause, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=report._currency_table_aml_join(options_group), + search_condition=query.where_clause, + )) + + return SQL(" UNION ALL ").join(queries) + + def _get_options_unaffected_earnings(self, options): + ''' Create options used to compute the unaffected earnings. + The unaffected earnings are the amount of benefits/loss that have not been allocated to + another account in the previous fiscal years. + The resulting dates domain will be: + [ + ('date' <= fiscalyear['date_from'] - 1), + ('account_id.include_initial_balance', '=', False), + ] + :param options: The report options. + :return: A copy of the options. + ''' + new_options = options.copy() + new_options.pop('filter_search_bar', None) + fiscalyear_dates = self.env.company.compute_fiscalyear_dates(fields.Date.from_string(options['date']['date_from'])) + + # Trial balance uses the options key, general ledger does not + new_date_to = fields.Date.from_string(new_options['date']['date_to']) if options.get('include_current_year_in_unaff_earnings') else fiscalyear_dates['date_from'] - timedelta(days=1) + + new_options['date'] = self.env['account.report']._get_dates_period(None, new_date_to, 'single') + + return new_options + + def _get_aml_values(self, report, options, expanded_account_ids, offset=0, limit=None): + rslt = {account_id: {} for account_id in expanded_account_ids} + aml_query = self._get_query_amls(report, options, expanded_account_ids, offset=offset, limit=limit) + self._cr.execute(aml_query) + aml_results_number = 0 + has_more = False + for aml_result in self._cr.dictfetchall(): + aml_results_number += 1 + if aml_results_number == limit: + has_more = True + break + + # For asset_receivable the name will already contains the ref with the _compute_name + if aml_result['ref'] and aml_result['account_type'] != 'asset_receivable': + aml_result['communication'] = f"{aml_result['ref']} - {aml_result['name']}" + else: + aml_result['communication'] = aml_result['name'] + + # The same aml can return multiple results when using account_report_cash_basis module, if the receivable/payable + # is reconciled with multiple payments. In this case, the date shown for the move lines actually corresponds to the + # reconciliation date. In order to keep distinct lines in this case, we include date in the grouping key. + aml_key = (aml_result['id'], aml_result['date']) + + account_result = rslt[aml_result['account_id']] + if not aml_key in account_result: + account_result[aml_key] = {col_group_key: {} for col_group_key in options['column_groups']} + + account_result[aml_key][aml_result['column_group_key']] = aml_result + + return rslt, has_more + + def _get_query_amls(self, report, options, expanded_account_ids, offset=0, limit=None) -> SQL: + """ Construct a query retrieving the account.move.lines when expanding a report line with or without the load + more. + :param options: The report options. + :param expanded_account_ids: The account.account ids corresponding to consider. If None, match every account. + :param offset: The offset of the query (used by the load more). + :param limit: The limit of the query (used by the load more). + :return: (query, params) + """ + additional_domain = [('account_id', 'in', expanded_account_ids)] if expanded_account_ids is not None else None + queries = [] + journal_name = self.env['account.journal']._field_to_sql('journal', 'name') + for column_group_key, group_options in report._split_options_per_column_group(options).items(): + # Get sums for the account move lines. + # period: [('date' <= options['date_to']), ('date', '>=', options['date_from'])] + query = report._get_report_query(group_options, domain=additional_domain, date_scope='strict_range') + account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + account_type = self.env['account.account']._field_to_sql(account_alias, 'account_type') + + query = SQL( + ''' + SELECT + account_move_line.id, + account_move_line.date, + MIN(account_move_line.date_maturity) AS date_maturity, + MIN(account_move_line.name) AS name, + MIN(account_move_line.ref) AS ref, + MIN(account_move_line.company_id) AS company_id, + MIN(account_move_line.account_id) AS account_id, + MIN(account_move_line.payment_id) AS payment_id, + MIN(account_move_line.partner_id) AS partner_id, + MIN(account_move_line.currency_id) AS currency_id, + SUM(account_move_line.amount_currency) AS amount_currency, + MIN(COALESCE(account_move_line.invoice_date, account_move_line.date)) AS invoice_date, + account_move_line.date AS date, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS balance, + MIN(move.name) AS move_name, + MIN(company.currency_id) AS company_currency_id, + MIN(partner.name) AS partner_name, + MIN(move.move_type) AS move_type, + MIN(%(account_code)s) AS account_code, + MIN(%(account_name)s) AS account_name, + MIN(%(account_type)s) AS account_type, + MIN(journal.code) AS journal_code, + MIN(%(journal_name)s) AS journal_name, + MIN(full_rec.id) AS full_rec_name, + %(column_group_key)s AS column_group_key + FROM %(table_references)s + JOIN account_move move ON move.id = account_move_line.move_id + %(currency_table_join)s + LEFT JOIN res_company company ON company.id = account_move_line.company_id + LEFT JOIN res_partner partner ON partner.id = account_move_line.partner_id + LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id + LEFT JOIN account_full_reconcile full_rec ON full_rec.id = account_move_line.full_reconcile_id + WHERE %(search_condition)s + GROUP BY account_move_line.id, account_move_line.date + ORDER BY account_move_line.date, move_name, account_move_line.id + ''', + account_code=account_code, + account_name=account_name, + account_type=account_type, + journal_name=journal_name, + column_group_key=column_group_key, + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(group_options), + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + search_condition=query.where_clause, + ) + queries.append(query) + + full_query = SQL(" UNION ALL ").join(SQL("(%s)", query) for query in queries) + + if offset: + full_query = SQL('%s OFFSET %s ', full_query, offset) + if limit: + full_query = SQL('%s LIMIT %s ', full_query, limit) + + return full_query + + def _get_initial_balance_values(self, report, account_ids, options): + """ + Get sums for the initial balance. + """ + queries = [] + for column_group_key, options_group in report._split_options_per_column_group(options).items(): + new_options = self._get_options_initial_balance(options_group) + domain = [ + ('account_id', 'in', account_ids), + ] + if not new_options.get('general_ledger_strict_range'): + domain += [ + '|', + ('date', '>=', new_options['date']['date_from']), + ('account_id.include_initial_balance', '=', True), + ] + if new_options.get('include_current_year_in_unaff_earnings'): + domain += [('account_id.include_initial_balance', '=', True)] + query = report._get_report_query(new_options, 'from_beginning', domain=domain) + queries.append(SQL( + """ + SELECT + account_move_line.account_id AS groupby, + 'initial_balance' AS key, + NULL AS max_date, + %(column_group_key)s AS column_group_key, + COALESCE(SUM(account_move_line.amount_currency), 0.0) AS amount_currency, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.account_id + """, + column_group_key=column_group_key, + table_references=query.from_clause, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=report._currency_table_aml_join(options_group), + search_condition=query.where_clause, + )) + + self._cr.execute(SQL(" UNION ALL ").join(queries)) + + init_balance_by_col_group = { + account_id: {column_group_key: {} for column_group_key in options['column_groups']} + for account_id in account_ids + } + for result in self._cr.dictfetchall(): + init_balance_by_col_group[result['groupby']][result['column_group_key']] = result + + accounts = self.env['account.account'].browse(account_ids) + return { + account.id: (account, init_balance_by_col_group[account.id]) + for account in accounts + } + + def _get_options_initial_balance(self, options): + """ Create options used to compute the initial balances. + The initial balances depict the current balance of the accounts at the beginning of + the selected period in the report. + The resulting dates domain will be: + [ + ('date' <= options['date_from'] - 1), + '|', + ('date' >= fiscalyear['date_from']), + ('account_id.include_initial_balance', '=', True) + ] + :param options: The report options. + :return: A copy of the options. + """ + #pylint: disable=sql-injection + new_options = options.copy() + date_to = new_options['comparison']['periods'][-1]['date_from'] if new_options.get('comparison', {}).get('periods') else new_options['date']['date_from'] + new_date_to = fields.Date.from_string(date_to) - timedelta(days=1) + + # Date from computation + # We have two case: + # 1) We are choosing a date that starts at the beginning of a fiscal year and we want the initial period to be + # the previous fiscal year + # 2) We are choosing a date that starts in the middle of a fiscal year and in that case we want the initial period + # to be the beginning of the fiscal year + date_from = fields.Date.from_string(new_options['date']['date_from']) + current_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from) + + if date_from == current_fiscalyear_dates['date_from']: + # We want the previous fiscal year + previous_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_from - timedelta(days=1)) + new_date_from = previous_fiscalyear_dates['date_from'] + include_current_year_in_unaff_earnings = True + else: + # We want the current fiscal year + new_date_from = current_fiscalyear_dates['date_from'] + include_current_year_in_unaff_earnings = False + + new_options['date'] = self.env['account.report']._get_dates_period( + new_date_from, + new_date_to, + 'range', + ) + new_options['include_current_year_in_unaff_earnings'] = include_current_year_in_unaff_earnings + + return new_options + + #################################################### + # COLUMN/LINE HELPERS + #################################################### + def _get_account_title_line(self, report, options, account, has_lines, eval_dict): + line_columns = [] + for column in options['columns']: + col_value = eval_dict.get(column['column_group_key'], {}).get(column['expression_label']) + col_expr_label = column['expression_label'] + + value = None if col_value is None or (col_expr_label == 'amount_currency' and not account.currency_id) else col_value + + line_columns.append(report._build_column_dict( + value, + column, + options=options, + currency=account.currency_id if col_expr_label == 'amount_currency' else None, + )) + + line_id = report._get_generic_line_id('account.account', account.id) + is_in_unfolded_lines = any( + report._get_res_id_from_line_id(line_id, 'account.account') == account.id + for line_id in options.get('unfolded_lines') + ) + return { + 'id': line_id, + 'name': account.display_name, + 'columns': line_columns, + 'level': 1, + 'unfoldable': has_lines, + 'unfolded': has_lines and (is_in_unfolded_lines or options.get('unfold_all')), + 'expand_function': '_report_expand_unfoldable_line_general_ledger', + } + + def _get_aml_line(self, report, parent_line_id, options, eval_dict, init_bal_by_col_group): + line_columns = [] + for column in options['columns']: + col_expr_label = column['expression_label'] + col_value = eval_dict[column['column_group_key']].get(col_expr_label) + col_currency = None + + if col_value is not None: + if col_expr_label == 'amount_currency': + col_currency = self.env['res.currency'].browse(eval_dict[column['column_group_key']]['currency_id']) + col_value = None if col_currency == self.env.company.currency_id else col_value + elif col_expr_label == 'balance': + col_value += (init_bal_by_col_group[column['column_group_key']] or 0) + + line_columns.append(report._build_column_dict( + col_value, + column, + options=options, + currency=col_currency, + )) + + aml_id = None + move_name = None + caret_type = None + for column_group_dict in eval_dict.values(): + aml_id = column_group_dict.get('id', '') + if aml_id: + if column_group_dict.get('payment_id'): + caret_type = 'account.payment' + else: + caret_type = 'account.move.line' + move_name = column_group_dict['move_name'] + date = str(column_group_dict.get('date', '')) + break + + return { + 'id': report._get_generic_line_id('account.move.line', aml_id, parent_line_id=parent_line_id, markup=date), + 'caret_options': caret_type, + 'parent_id': parent_line_id, + 'name': move_name or _('Draft Entry'), + 'columns': line_columns, + 'level': 3, + } + + @api.model + def _get_total_line(self, report, options, eval_dict): + line_columns = [] + for column in options['columns']: + col_value = eval_dict[column['column_group_key']].get(column['expression_label']) + col_value = None if col_value is None else col_value + + line_columns.append(report._build_column_dict(col_value, column, options=options)) + + return { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': _('Total'), + 'level': 1, + 'columns': line_columns, + } + + def caret_option_audit_tax(self, options, params): + return self.env['account.generic.tax.report.handler'].caret_option_audit_tax(options, params) + + def _report_expand_unfoldable_line_general_ledger(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + def init_load_more_progress(line_dict): + return { + column['column_group_key']: line_col.get('no_format', 0) + for column, line_col in zip(options['columns'], line_dict['columns']) + if column['expression_label'] == 'balance' + } + + report = self.env.ref('odex30_account_reports.general_ledger_report') + model, model_id = report._get_model_info_from_id(line_dict_id) + + if model != 'account.account': + raise UserError(_("Wrong ID for general ledger line to expand: %s", line_dict_id)) + + lines = [] + + # Get initial balance + if offset == 0: + if unfold_all_batch_data: + account, init_balance_by_col_group = unfold_all_batch_data['initial_balances'][model_id] + else: + account, init_balance_by_col_group = self._get_initial_balance_values(report, [model_id], options)[model_id] + + initial_balance_line = report._get_partner_and_general_ledger_initial_balance_line(options, line_dict_id, init_balance_by_col_group, account.currency_id) + + if initial_balance_line: + lines.append(initial_balance_line) + + # For the first expansion of the line, the initial balance line gives the progress + progress = init_load_more_progress(initial_balance_line) + + # Get move lines + limit_to_load = report.load_more_limit + 1 if report.load_more_limit and options['export_mode'] != 'print' else None + if unfold_all_batch_data: + aml_results = unfold_all_batch_data['aml_results'][model_id] + has_more = unfold_all_batch_data['has_more'].get(model_id, False) + else: + aml_results, has_more = self._get_aml_values(report, options, [model_id], offset=offset, limit=limit_to_load) + aml_results = aml_results[model_id] + + next_progress = progress + for aml_result in aml_results.values(): + new_line = self._get_aml_line(report, line_dict_id, options, aml_result, next_progress) + lines.append(new_line) + next_progress = init_load_more_progress(new_line) + + return { + 'lines': lines, + 'offset_increment': report.load_more_limit, + 'has_more': has_more, + 'progress': next_progress, + } diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py new file mode 100644 index 0000000..b9adb8e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_generic_tax_report.py @@ -0,0 +1,1222 @@ +import ast +from collections import defaultdict + +from odoo import models, api, fields, Command, _ +from odoo.addons.web.controllers.utils import clean_action +from odoo.exceptions import UserError, RedirectWarning +from odoo.osv import expression +from odoo.tools import SQL + + +class AccountTaxReportHandler(models.AbstractModel): + _name = 'account.tax.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Account Report Handler for Tax Reports' + + + def _custom_options_initializer(self, report, options, previous_options): + optional_periods = { + 'monthly': 'month', + 'trimester': 'quarter', + 'year': 'year', + } + + options['buttons'].append({'name': _('Closing Entry'), 'action': 'action_periodic_vat_entries', 'sequence': 110, 'always_show': True}) + options['enable_export_buttons_for_common_vat_in_branches'] = True + + day, month = self.env.company._get_tax_closing_start_date_attributes(report) + periodicity = self.env.company._get_tax_periodicity(report) + options['tax_periodicity'] = { + 'periodicity': periodicity, + 'months_per_period': self.env.company._get_tax_periodicity_months_delay(report), + 'start_day': day, + 'start_month': month, + } + + options['show_tax_period_filter'] = periodicity not in optional_periods or day != 1 or month != 1 + if not options['show_tax_period_filter'] and 'custom' not in options['date']['filter']: + period_type = optional_periods[periodicity] + options['date']['filter'] = options['date']['filter'].replace('tax_period', period_type) + options['date']['period_type'] = options['date']['period_type'].replace('tax_period', period_type) + + def _get_custom_display_config(self): + display_config = defaultdict(dict) + display_config['templates']['AccountReportFilters'] = 'odex30_account_reports.GenericTaxReportFiltersCustomizable' + return display_config + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + if 'odex30_account_reports.common_warning_draft_in_period' in warnings: + # Recompute the warning 'common_warning_draft_in_period' to not include tax closing entries in the banner of unposted moves + if not self.env['account.move'].search_count( + [('state', '=', 'draft'), ('date', '<=', options['date']['date_to']), + ('tax_closing_report_id', '=', False)], + limit=1, + ): + warnings.pop('odex30_account_reports.common_warning_draft_in_period') + + # Chek the use of inactive tags in the period + query = report._get_report_query(options, 'strict_range') + rows = self.env.execute_query(SQL(""" + SELECT 1 + FROM %s + JOIN account_account_tag_account_move_line_rel aml_tag + ON account_move_line.id = aml_tag.account_move_line_id + JOIN account_account_tag tag + ON aml_tag.account_account_tag_id = tag.id + WHERE %s + AND NOT tag.active + LIMIT 1 + """, query.from_clause, query.where_clause)) + if rows: + warnings['odex30_account_reports.tax_report_warning_inactive_tags'] = {} + + + # ------------------------------------------------------------------------- + # TAX CLOSING + # ------------------------------------------------------------------------- + + def _is_period_equal_to_options(self, report, options): + options_date_to = fields.Date.from_string(options['date']['date_to']) + options_date_from = fields.Date.from_string(options['date']['date_from']) + date_from, date_to = self.env.company._get_tax_closing_period_boundaries(options_date_to, report) + return date_from == options_date_from and date_to == options_date_to + + def action_periodic_vat_entries(self, options, from_post=False): + report = self.env['account.report'].browse(options['report_id']) + if ( + options['date']['period_type'] != 'tax_period' + and not self._is_period_equal_to_options(report, options) + and not self.env.context.get('override_tax_closing_warning') + ): + if len(options['companies']) > 1 and (report.filter_multi_company != 'tax_units' or not (report.country_id and options['available_tax_units'])): + message = _( + "You're about the generate the closing entries of multiple companies at once. Each of them will be created in accordance with its company tax periodicity.") + else: + message = _( + "The currently selected dates don't match a tax period. The closing entry will be created for the closest-matching period according to your periodicity setup.") + + return { + 'type': 'ir.actions.client', + 'tag': 'odex30_account_reports.redirect_action', + 'target': 'new', + 'params': { + 'depending_action': self.with_context( + {'override_tax_closing_warning': True}).action_periodic_vat_entries(options), + 'message': message, + 'button_text': _("Proceed"), + }, + 'context': { + 'dialog_size': 'medium', + 'override_tax_closing_warning': True, + }, + } + + moves = self._get_periodic_vat_entries(options, from_post=from_post) + # Make the action for the retrieved move and return it. + action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line") + action = clean_action(action, env=self.env) + action.pop('domain', None) + + if len(moves) == 1: + action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] + action['res_id'] = moves.id + else: + action['domain'] = [('id', 'in', moves.ids)] + action['context'] = dict(ast.literal_eval(action['context'])) + action['context'].pop('search_default_posted', None) + return action + + def _get_periodic_vat_entries(self, options, from_post=False): + report = self.env['account.report'].browse(options['report_id']) + + # When integer_rounding is available, we always want it for tax closing (as it means it's a legal requirement) + if options.get('integer_rounding'): + options['integer_rounding_enabled'] = True + + # Return action to open form view of newly created entry + moves = self.env['account.move'] + + # Get all companies impacting the report. + companies = self.env['res.company'].browse(report.get_report_company_ids(options)) + + companies_moves = self._get_tax_closing_entries_for_closed_period(report, options, companies, posted_only=False) + moves += companies_moves + moves += self._generate_tax_closing_entries(report, options, companies=companies - companies_moves.company_id, from_post=from_post) + + return moves + + def _generate_tax_closing_entries(self, report, options, closing_moves=None, companies=None, from_post=False): + """Generates and/or updates VAT closing entries. + + This method computes the content of the tax closing in the following way: + - Search on all tax lines in the given period, group them by tax_group (each tax group might have its own + tax receivable/payable account). + - Create a move line that balances each tax account and add the difference in the correct receivable/payable + account. Also take into account amounts already paid via advance tax payment account. + + The tax closing is done so that an individual move is created per available VAT number: so, one for each + foreign vat fiscal position (each with fiscal_position_id set to this fiscal position), and one for the domestic + position (with fiscal_position_id = None). The moves created by this function hence depends on the content of the + options dictionary, and what fiscal positions are accepted by it. + + :param options: the tax report options dict to use to make the closing. + :param closing_moves: If provided, closing moves to update the content from. + They need to be compatible with the provided options (if they have a fiscal_position_id, for example). + :param companies: optional params, the companies given will be used instead of taking all the companies impacting + the report. + :return: The closing moves. + """ + if companies is None: + companies = self.env['res.company'].browse(report.get_report_company_ids(options)) + + if closing_moves is None: + closing_moves = self.env['account.move'] + + end_date = fields.Date.from_string(options['date']['date_to']) + + closing_moves_by_company = defaultdict(lambda: self.env['account.move']) + + companies_without_closing = companies.filtered(lambda company: company not in closing_moves.company_id) + if closing_moves: + for move in closing_moves.filtered(lambda x: x.state == 'draft'): + closing_moves_by_company[move.company_id] |= move + + for company in companies_without_closing: + include_domestic, fiscal_positions = self._get_fpos_info_for_tax_closing(company, report, options) + company_closing_moves = company._get_and_update_tax_closing_moves(end_date, report, fiscal_positions=fiscal_positions, include_domestic=include_domestic) + closing_moves_by_company[company] = company_closing_moves + closing_moves += company_closing_moves + + for company, company_closing_moves in closing_moves_by_company.items(): + + # First gather the countries for which the closing is being done + countries = self.env['res.country'] + for move in company_closing_moves: + if move.fiscal_position_id.foreign_vat: + countries |= move.fiscal_position_id.country_id + else: + countries |= company.account_fiscal_country_id + + # Check the tax groups from the company for any misconfiguration in these countries + if self.env['account.tax.group']._check_misconfigured_tax_groups(company, countries): + self._redirect_to_misconfigured_tax_groups(company, countries) + + for move in company_closing_moves: + # When coming from post and that the current move is the closing of the current company we don't want to + # write on it again + if from_post and move == closing_moves_by_company.get(self.env.company): + continue + + # get tax entries by tax_group for the period defined in options + move_options = {**options, 'fiscal_position': move.fiscal_position_id.id if move.fiscal_position_id else 'domestic'} + line_ids_vals, tax_group_subtotal = self._compute_vat_closing_entry(company, move_options) + + line_ids_vals += self._add_tax_group_closing_items(tax_group_subtotal, move) + + if move.line_ids: + line_ids_vals += [Command.delete(aml.id) for aml in move.line_ids] + + move_vals = {} + if line_ids_vals: + move_vals['line_ids'] = line_ids_vals + move.write(move_vals) + + return closing_moves + + def _get_tax_closing_entries_for_closed_period(self, report, options, companies, posted_only=True): + """ Fetch the closing entries related to the given companies for the currently selected tax report period. + Only used when the selected period already has a tax lock date impacting it, and assuming that these periods + all have a tax closing entry. + :param report: The tax report for which we are getting the closing entries. + :param options: the tax report options dict needed to get the period end date and fiscal position info. + :param companies: a recordset of companies for which the period has already been closed. + :return: The closing moves. + """ + closing_moves = self.env['account.move'] + for company in companies: + _dummy, period_end = company._get_tax_closing_period_boundaries(fields.Date.from_string(options['date']['date_to']), report) + include_domestic, fiscal_positions = self._get_fpos_info_for_tax_closing(company, report, options) + fiscal_position_ids = fiscal_positions.ids + ([False] if include_domestic else []) + state_domain = ('state', '=', 'posted') if posted_only else ('state', '!=', 'cancel') + closing_moves += self.env['account.move'].search([ + ('company_id', '=', company.id), + ('fiscal_position_id', 'in', fiscal_position_ids), + ('date', '=', period_end), + ('tax_closing_report_id', '!=', False), + state_domain, + ]) + + return closing_moves + + @api.model + def _compute_vat_closing_entry(self, company, options): + """Compute the VAT closing entry. + + This method returns the one2many commands to balance the tax accounts for the selected period, and + a dictionnary that will help balance the different accounts set per tax group. + """ + self = self.with_company(company) # Needed to handle access to property fields correctly + + # first, for each tax group, gather the tax entries per tax and account + self.env['account.tax'].flush_model(['name', 'tax_group_id']) + self.env['account.tax.repartition.line'].flush_model(['use_in_tax_closing']) + self.env['account.move.line'].flush_model(['account_id', 'debit', 'credit', 'move_id', 'tax_line_id', 'date', 'company_id', 'display_type', 'parent_state']) + self.env['account.move'].flush_model(['state']) + + new_options = { + **options, + 'all_entries': False, + 'date': dict(options['date']), + } + + report = self.env['account.report'].browse(options['report_id']) + period_start, period_end = company._get_tax_closing_period_boundaries(fields.Date.from_string(options['date']['date_to']), report) + new_options['date']['date_from'] = fields.Date.to_string(period_start) + new_options['date']['date_to'] = fields.Date.to_string(period_end) + new_options['date']['period_type'] = 'custom' + new_options['date']['filter'] = 'custom' + new_options = report.with_context(allowed_company_ids=company.ids).get_options(previous_options=new_options) + # Force the use of the fiscal position from the original options (_get_options sets the fiscal + # position to 'all' when the report is the generic tax report) + new_options['fiscal_position'] = options['fiscal_position'] + + query = self.env.ref('account.generic_tax_report')._get_report_query( + new_options, + 'strict_range', + domain=self._get_vat_closing_entry_additional_domain() + ) + + # Check whether it is multilingual, in order to get the translation from the JSON value if present + tax_name = self.env['account.tax']._field_to_sql('tax', 'name') + + query = SQL( + """ + SELECT "account_move_line".tax_line_id as tax_id, + tax.tax_group_id as tax_group_id, + %(tax_name)s as tax_name, + "account_move_line".account_id, + COALESCE(SUM("account_move_line".balance), 0) as amount + FROM account_tax tax, account_tax_repartition_line repartition, %(table_references)s + WHERE %(search_condition)s + AND tax.id = "account_move_line".tax_line_id + AND repartition.id = "account_move_line".tax_repartition_line_id + AND repartition.use_in_tax_closing + GROUP BY tax.tax_group_id, "account_move_line".tax_line_id, tax.name, "account_move_line".account_id + """, + tax_name=tax_name, + table_references=query.from_clause, + search_condition=query.where_clause, + ) + self.env.cr.execute(query) + results = self.env.cr.dictfetchall() + results = self._postprocess_vat_closing_entry_results(company, new_options, results) + + tax_group_ids = [r['tax_group_id'] for r in results] + tax_groups = {} + for tg, result in zip(self.env['account.tax.group'].browse(tax_group_ids), results): + if tg not in tax_groups: + tax_groups[tg] = {} + if result.get('tax_id') not in tax_groups[tg]: + tax_groups[tg][result.get('tax_id')] = [] + tax_groups[tg][result.get('tax_id')].append((result.get('tax_name'), result.get('account_id'), result.get('amount'))) + + # then loop on previous results to + # * add the lines that will balance their sum per account + # * make the total per tax group's account triplet + # (if 2 tax groups share the same 3 accounts, they should consolidate in the vat closing entry) + move_vals_lines = [] + tax_group_subtotal = {} + currency = self.env.company.currency_id + for tg, values in tax_groups.items(): + total = 0 + # ignore line that have no property defined on tax group + if not tg.tax_receivable_account_id or not tg.tax_payable_account_id: + continue + for dummy, value in values.items(): + for v in value: + tax_name, account_id, amt = v + # Line to balance + move_vals_lines.append((0, 0, {'name': tax_name, 'debit': abs(amt) if amt < 0 else 0, 'credit': amt if amt > 0 else 0, 'account_id': account_id})) + total += amt + + if not currency.is_zero(total): + # Add total to correct group + key = (tg.advance_tax_payment_account_id.id or False, tg.tax_receivable_account_id.id, tg.tax_payable_account_id.id) + + if tax_group_subtotal.get(key): + tax_group_subtotal[key] += total + else: + tax_group_subtotal[key] = total + + # If the tax report is completely empty, we add two 0-valued lines, using the first in in and out + # account id we find on the taxes. + if len(move_vals_lines) == 0: + rep_ln_in = self.env['account.tax.repartition.line'].search([ + *self.env['account.tax.repartition.line']._check_company_domain(company), + ('account_id.deprecated', '=', False), + ('repartition_type', '=', 'tax'), + ('document_type', '=', 'invoice'), + ('tax_id.type_tax_use', '=', 'purchase') + ], limit=1) + rep_ln_out = self.env['account.tax.repartition.line'].search([ + *self.env['account.tax.repartition.line']._check_company_domain(company), + ('account_id.deprecated', '=', False), + ('repartition_type', '=', 'tax'), + ('document_type', '=', 'invoice'), + ('tax_id.type_tax_use', '=', 'sale') + ], limit=1) + + if rep_ln_out.account_id and rep_ln_in.account_id: + move_vals_lines = [ + Command.create({ + 'name': _('Tax Received Adjustment'), + 'debit': 0, + 'credit': 0.0, + 'account_id': rep_ln_out.account_id.id + }), + + Command.create({ + 'name': _('Tax Paid Adjustment'), + 'debit': 0.0, + 'credit': 0, + 'account_id': rep_ln_in.account_id.id + }) + ] + + return move_vals_lines, tax_group_subtotal + + def _get_vat_closing_entry_additional_domain(self): + return [] + + def _postprocess_vat_closing_entry_results(self, company, options, results): + # Override this to, for example, apply a rounding to the lines of the closing entry + return results + + def _vat_closing_entry_results_rounding(self, company, options, results, rounding_accounts, vat_results_summary): + """ + Apply the rounding from the tax report by adding a line to the end of the query results + representing the sum of the roundings on each line of the tax report. + """ + # Ignore if the rounding accounts cannot be found + if not rounding_accounts.get('profit') or not rounding_accounts.get('loss'): + return results + + total_amount = 0.0 + tax_group_id = None + + for line in results: + total_amount += line['amount'] + # The accounts on the tax group ids from the results should be uniform, + # but we choose the greatest id so that the line appears last on the entry. + tax_group_id = line['tax_group_id'] + + report = self.env['account.report'].browse(options['report_id']) + + for line in report._get_lines(options): + model, record_id = report._get_model_info_from_id(line['id']) + + if model != 'account.report.line': + continue + + for (operation_type, report_line_id, column_expression_label) in vat_results_summary: + for column in line['columns']: + if record_id != report_line_id or column['expression_label'] != column_expression_label: + continue + + # We accept 3 types of operations: + # 1) due and 2) deductible - This is used for reports that have lines for the payable vat and + # lines for the reclaimable vat. + # 3) total - This is used for reports that have a single line with the payable/reclaimable vat. + if operation_type in {'due', 'total'}: + total_amount += column['no_format'] + elif operation_type == 'deductible': + total_amount -= column['no_format'] + + currency = company.currency_id + total_difference = currency.round(total_amount) + + if not currency.is_zero(total_difference): + results.append({ + 'tax_name': _('Difference from rounding taxes'), + 'amount': total_difference * -1, + 'tax_group_id': tax_group_id, + 'account_id': rounding_accounts['profit'].id if total_difference < 0 else rounding_accounts['loss'].id + }) + + return results + + @api.model + def _add_tax_group_closing_items(self, tax_group_subtotal, closing_move): + """Transform the parameter tax_group_subtotal dictionnary into one2many commands. + + Used to balance the tax group accounts for the creation of the vat closing entry. + """ + def _add_line(account, name, company_currency): + self.env.cr.execute(sql_account, ( + account, + closing_move.date, + closing_move.company_id.id, + )) + result = self.env.cr.dictfetchone() + advance_balance = result.get('balance') or 0 + # Deduct/Add advance payment + if not company_currency.is_zero(advance_balance): + line_ids_vals.append((0, 0, { + 'name': name, + 'debit': abs(advance_balance) if advance_balance < 0 else 0, + 'credit': abs(advance_balance) if advance_balance > 0 else 0, + 'account_id': account + })) + return advance_balance + + currency = closing_move.company_id.currency_id + sql_account = ''' + SELECT SUM(aml.balance) AS balance + FROM account_move_line aml + LEFT JOIN account_move move ON move.id = aml.move_id + WHERE aml.account_id = %s + AND aml.date <= %s + AND move.state = 'posted' + AND aml.company_id = %s + ''' + line_ids_vals = [] + # keep track of already balanced account, as one can be used in several tax group + account_already_balanced = [] + for key, value in tax_group_subtotal.items(): + total = value + # Search if any advance payment done for that configuration + if key[0] and key[0] not in account_already_balanced: + total += _add_line(key[0], _('Balance tax advance payment account'), currency) + account_already_balanced.append(key[0]) + if key[1] and key[1] not in account_already_balanced: + total += _add_line(key[1], _('Balance tax current account (receivable)'), currency) + account_already_balanced.append(key[1]) + if key[2] and key[2] not in account_already_balanced: + total += _add_line(key[2], _('Balance tax current account (payable)'), currency) + account_already_balanced.append(key[2]) + # Balance on the receivable/payable tax account + if not currency.is_zero(total): + line_ids_vals.append(Command.create({ + 'name': _('Payable tax amount') if total < 0 else _('Receivable tax amount'), + 'debit': total if total > 0 else 0, + 'credit': abs(total) if total < 0 else 0, + 'account_id': key[2] if total < 0 else key[1] + })) + return line_ids_vals + + @api.model + def _redirect_to_misconfigured_tax_groups(self, company, countries): + """ Raises a RedirectWarning informing the user his tax groups are missing configuration + for a given company, redirecting him to the list view of account.tax.group, filtered + accordingly to the provided countries. + """ + need_config_action = { + 'type': 'ir.actions.act_window', + 'name': 'Tax groups', + 'res_model': 'account.tax.group', + 'view_mode': 'list', + 'views': [[False, 'list']], + 'domain': ['|', ('country_id', 'in', countries.ids), ('country_id', '=', False)] + } + + raise RedirectWarning( + _('Please specify the accounts necessary for the Tax Closing Entry.'), + need_config_action, + _('Configure your TAX accounts - %s', company.display_name), + ) + + def _get_fpos_info_for_tax_closing(self, company, report, options): + """ Returns the fiscal positions information to use to generate the tax closing + for this company, with the provided options. + + :return: (include_domestic, fiscal_positions), where fiscal positions is a recordset + and include_domestic is a boolean telling whether or not the domestic closing + (i.e. the one without any fiscal position) must also be performed + """ + if options['fiscal_position'] == 'domestic': + fiscal_positions = self.env['account.fiscal.position'] + elif options['fiscal_position'] == 'all': + fiscal_positions = self.env['account.fiscal.position'].search([ + *self.env['account.fiscal.position']._check_company_domain(company), + ('foreign_vat', '!=', False), + ]) + else: + fpos_ids = [options['fiscal_position']] + fiscal_positions = self.env['account.fiscal.position'].browse(fpos_ids) + + if options['fiscal_position'] == 'all': + fiscal_country = company.account_fiscal_country_id + include_domestic = not fiscal_positions \ + or not report.country_id \ + or fiscal_country == fiscal_positions[0].country_id + else: + include_domestic = options['fiscal_position'] == 'domestic' + + return include_domestic, fiscal_positions + + def _get_amls_with_archived_tags_domain(self, options): + domain = [ + ('tax_tag_ids.active', '=', False), + ('parent_state', '=', 'posted'), + ('date', '>=', options['date']['date_from']), + ] + if options['date']['mode'] == 'single': + domain.append(('date', '<=', options['date']['date_to'])) + return domain + + def action_open_amls_with_archived_tags(self, options, params=None): + return { + 'name': _("Journal items with archived tax tags"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move.line', + 'domain': self._get_amls_with_archived_tags_domain(options), + 'context': {'active_test': False}, + 'views': [(self.env.ref('odex30_account_reports.view_archived_tag_move_tree').id, 'list')], + } + + +class GenericTaxReportCustomHandler(models.AbstractModel): + _name = 'account.generic.tax.report.handler' + _inherit = 'account.tax.report.handler' + _description = 'Generic Tax Report Custom Handler' + + def _get_custom_display_config(self): + parent_config = super()._get_custom_display_config() + parent_config['css_custom_class'] = 'generic_tax_report' + parent_config['templates']['AccountReportLineName'] = 'odex30_account_reports.TaxReportLineName' + + return parent_config + + def _custom_options_initializer(self, report, options, previous_options=None): + super()._custom_options_initializer(report, options, previous_options=previous_options) + + # We are on the generic tax report (no country) and the user can not change the fiscal position so we show them all. + if not report.country_id and len(options['available_vat_fiscal_positions']) <= (0 if options['allow_domestic'] else 1) and len(options['companies']) <= 1: + options['allow_domestic'] = False + options['fiscal_position'] = 'all' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + return self._get_dynamic_lines(report, options, 'default', warnings) + + def _caret_options_initializer(self): + return { + 'generic_tax_report': [ + {'name': _("Audit"), 'action': 'caret_option_audit_tax'}, + ] + } + + def _get_dynamic_lines(self, report, options, grouping, warnings=None): + """ Compute the report lines for the generic tax report. + + :param options: The report options. + :return: A list of lines, each one being a python dictionary. + """ + options_by_column_group = report._split_options_per_column_group(options) + + # Compute tax_base_amount / tax_amount for each selected groupby. + if grouping == 'tax_account': + groupby_fields = [('src_tax', 'type_tax_use'), ('src_tax', 'id'), ('account', 'id')] + comodels = [None, 'account.tax', 'account.account'] + elif grouping == 'account_tax': + groupby_fields = [('src_tax', 'type_tax_use'), ('account', 'id'), ('src_tax', 'id')] + comodels = [None, 'account.account', 'account.tax'] + else: + groupby_fields = [('src_tax', 'type_tax_use'), ('src_tax', 'id')] + comodels = [None, 'account.tax'] + + if grouping in ('tax_account', 'account_tax'): + tax_amount_hierarchy = self._read_generic_tax_report_amounts(report, options_by_column_group, groupby_fields) + else: + tax_amount_hierarchy = self._read_generic_tax_report_amounts_no_tax_details(report, options, options_by_column_group) + + + # Fetch involved records in order to ensure all lines are sorted according the comodel order. + # To do so, we compute 'sorting_map_list' allowing to retrieve each record by id and the order + # to be used. + record_ids_gb = [set() for dummy in groupby_fields] + + def populate_record_ids_gb_recursively(node, level=0): + for k, v in node.items(): + if k: + record_ids_gb[level].add(k) + if v.get('children'): + populate_record_ids_gb_recursively(v['children'], level=level + 1) + + populate_record_ids_gb_recursively(tax_amount_hierarchy) + + sorting_map_list = [] + for i, comodel in enumerate(comodels): + if comodel: + # Relational records. + records = self.env[comodel].with_context(active_test=False).search([('id', 'in', tuple(record_ids_gb[i]))]) + sorting_map = {r.id: (r, j) for j, r in enumerate(records)} + sorting_map_list.append(sorting_map) + else: + # src_tax_type_tax_use. + selection = self.env['account.tax']._fields['type_tax_use']._description_selection(self.env) + sorting_map_list.append({v[0]: (v, j) for j, v in enumerate(selection) if v[0] in record_ids_gb[i]}) + + # Compute report lines. + lines = [] + self._populate_lines_recursively( + report, + options, + lines, + sorting_map_list, + groupby_fields, + tax_amount_hierarchy, + warnings=warnings, + ) + return lines + + + # ------------------------------------------------------------------------- + # GENERIC TAX REPORT COMPUTATION (DYNAMIC LINES) + # ------------------------------------------------------------------------- + + @api.model + def _read_generic_tax_report_amounts_no_tax_details(self, report, options, options_by_column_group): + # Fetch the group of taxes. + # If all child taxes have a 'none' type_tax_use, all amounts are aggregated and only the group appears on the report. + company_ids = report.get_report_company_ids(options) + company_domain = self.env['account.tax']._check_company_domain(company_ids) + company_where_query = self.env['account.tax'].with_context(active_test=False)._where_calc(company_domain) + self._cr.execute(SQL( + ''' + SELECT + account_tax.id, + account_tax.type_tax_use, + ARRAY_AGG(child_tax.id) AS child_tax_ids, + ARRAY_AGG(DISTINCT child_tax.type_tax_use) AS child_types + FROM account_tax_filiation_rel account_tax_rel + JOIN account_tax ON account_tax.id = account_tax_rel.parent_tax + JOIN account_tax child_tax ON child_tax.id = account_tax_rel.child_tax + WHERE account_tax.amount_type = 'group' + AND %s + GROUP BY account_tax.id + ''', company_where_query.where_clause or SQL("TRUE") + )) + group_of_taxes_info = {} + child_to_group_of_taxes = {} + for row in self._cr.dictfetchall(): + row['to_expand'] = row['child_types'] != ['none'] + group_of_taxes_info[row['id']] = row + for child_id in row['child_tax_ids']: + child_to_group_of_taxes[child_id] = row['id'] + + results = defaultdict(lambda: { # key: type_tax_use + 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_non_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_due': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'children': defaultdict(lambda: { # key: tax_id + 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_non_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_deductible': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_due': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + }), + }) + + for column_group_key, options in options_by_column_group.items(): + query = report._get_report_query(options, 'strict_range') + # make sure account_move is always joined + if 'account_move_line__move_id' not in query._joins: + query.join('account_move_line', 'move_id', 'account_move', 'id', 'move_id') + + # Fetch the base amounts. + self._cr.execute(SQL( + ''' + SELECT + tax.id AS tax_id, + tax.type_tax_use AS tax_type_tax_use, + src_group_tax.id AS src_group_tax_id, + src_group_tax.type_tax_use AS src_group_tax_type_tax_use, + src_tax.id AS src_tax_id, + src_tax.type_tax_use AS src_tax_type_tax_use, + SUM(account_move_line.balance) AS base_amount + FROM %(table_references)s + JOIN account_move_line_account_tax_rel tax_rel ON account_move_line.id = tax_rel.account_move_line_id + JOIN account_tax tax ON tax.id = tax_rel.account_tax_id + LEFT JOIN account_tax src_tax ON src_tax.id = account_move_line.tax_line_id + LEFT JOIN account_tax src_group_tax ON src_group_tax.id = account_move_line.group_tax_id + WHERE %(search_condition)s + AND ( + /* CABA */ + account_move_line__move_id.always_tax_exigible + OR account_move_line__move_id.tax_cash_basis_rec_id IS NOT NULL + OR tax.tax_exigibility != 'on_payment' + ) + AND ( + ( + /* Tax lines affecting the base of others. */ + account_move_line.tax_line_id IS NOT NULL + AND ( + src_tax.type_tax_use IN ('sale', 'purchase') + OR src_group_tax.type_tax_use IN ('sale', 'purchase') + ) + ) + OR + ( + /* For regular base lines. */ + account_move_line.tax_line_id IS NULL + AND tax.type_tax_use IN ('sale', 'purchase') + ) + ) + GROUP BY tax.id, src_group_tax.id, src_tax.id + ORDER BY src_group_tax.sequence, src_group_tax.id, src_tax.sequence, src_tax.id, tax.sequence, tax.id + ''', + table_references=query.from_clause, + search_condition=query.where_clause, + )) + + group_of_taxes_with_extra_base_amount = set() + for row in self._cr.dictfetchall(): + is_tax_line = bool(row['src_tax_id']) + if is_tax_line: + if row['src_group_tax_id'] \ + and not group_of_taxes_info[row['src_group_tax_id']]['to_expand'] \ + and row['tax_id'] in group_of_taxes_info[row['src_group_tax_id']]['child_tax_ids']: + # Suppose a base of 1000 with a group of taxes 20% affect + 10%. + # The base of the group of taxes must be 1000, not 1200 because the group of taxes is not + # expanded. So the tax lines affecting the base of its own group of taxes are ignored. + pass + elif row['tax_type_tax_use'] == 'none' and child_to_group_of_taxes.get(row['tax_id']): + # The tax line is affecting the base of a 'none' tax belonging to a group of taxes. + # In that case, the amount is accounted as an extra base for that group. However, we need to + # account it only once. + # For example, suppose a tax 10% affect base of subsequent followed by a group of taxes + # 20% + 30%. On a base of 1000.0, the tax line for 10% will affect the base of 20% + 30%. + # However, this extra base must be accounted only once since the base of the group of taxes + # must be 1100.0 and not 1200.0. + group_tax_id = child_to_group_of_taxes[row['tax_id']] + if group_tax_id not in group_of_taxes_with_extra_base_amount: + group_tax_info = group_of_taxes_info[group_tax_id] + results[group_tax_info['type_tax_use']]['children'][group_tax_id]['base_amount'][column_group_key] += row['base_amount'] + group_of_taxes_with_extra_base_amount.add(group_tax_id) + else: + tax_type_tax_use = row['src_group_tax_type_tax_use'] or row['src_tax_type_tax_use'] + results[tax_type_tax_use]['children'][row['tax_id']]['base_amount'][column_group_key] += row['base_amount'] + else: + if row['tax_id'] in group_of_taxes_info and group_of_taxes_info[row['tax_id']]['to_expand']: + # Expand the group of taxes since it contains at least one tax with a type != 'none'. + group_info = group_of_taxes_info[row['tax_id']] + for child_tax_id in group_info['child_tax_ids']: + results[group_info['type_tax_use']]['children'][child_tax_id]['base_amount'][column_group_key] += row['base_amount'] + else: + results[row['tax_type_tax_use']]['children'][row['tax_id']]['base_amount'][column_group_key] += row['base_amount'] + + # Fetch the tax amounts. + + select_deductible = join_deductible = group_by_deductible = SQL() + if options.get('account_journal_report_tax_deductibility_columns'): + select_deductible = SQL(""", repartition.use_in_tax_closing AS trl_tax_closing + , SIGN(repartition.factor_percent) AS trl_factor""") + join_deductible = SQL("""JOIN account_tax_repartition_line repartition + ON account_move_line.tax_repartition_line_id = repartition.id""") + group_by_deductible = SQL(', repartition.use_in_tax_closing, SIGN(repartition.factor_percent)') + + self._cr.execute(SQL( + ''' + SELECT + tax.id AS tax_id, + tax.type_tax_use AS tax_type_tax_use, + group_tax.id AS group_tax_id, + group_tax.type_tax_use AS group_tax_type_tax_use, + SUM(account_move_line.balance) AS tax_amount + %(select_deductible)s + FROM %(table_references)s + JOIN account_tax tax ON tax.id = account_move_line.tax_line_id + %(join_deductible)s + LEFT JOIN account_tax group_tax ON group_tax.id = account_move_line.group_tax_id + WHERE %(search_condition)s + AND ( + /* CABA */ + account_move_line__move_id.always_tax_exigible + OR account_move_line__move_id.tax_cash_basis_rec_id IS NOT NULL + OR tax.tax_exigibility != 'on_payment' + ) + AND ( + (group_tax.id IS NULL AND tax.type_tax_use IN ('sale', 'purchase')) + OR + (group_tax.id IS NOT NULL AND group_tax.type_tax_use IN ('sale', 'purchase')) + ) + GROUP BY tax.id, group_tax.id %(group_by_deductible)s + ''', + select_deductible=select_deductible, + table_references=query.from_clause, + join_deductible=join_deductible, + search_condition=query.where_clause, + group_by_deductible=group_by_deductible, + )) + + for row in self._cr.dictfetchall(): + # Manage group of taxes. + # In case the group of taxes is mixing multiple taxes having a type_tax_use != 'none', consider + # them instead of the group. + tax_id = row['tax_id'] + if row['group_tax_id']: + tax_type_tax_use = row['group_tax_type_tax_use'] + if not group_of_taxes_info[row['group_tax_id']]['to_expand']: + tax_id = row['group_tax_id'] + else: + tax_type_tax_use = row['group_tax_type_tax_use'] or row['tax_type_tax_use'] + + results[tax_type_tax_use]['tax_amount'][column_group_key] += row['tax_amount'] + results[tax_type_tax_use]['children'][tax_id]['tax_amount'][column_group_key] += row['tax_amount'] + + if options.get('account_journal_report_tax_deductibility_columns'): + tax_detail_label = False + if row['trl_factor'] > 0 and tax_type_tax_use == 'purchase': + tax_detail_label = 'tax_deductible' if row['trl_tax_closing'] else 'tax_non_deductible' + elif row['trl_tax_closing'] and (row['trl_factor'] > 0, tax_type_tax_use) in ((False, 'purchase'), (True, 'sale')): + tax_detail_label = 'tax_due' + + if tax_detail_label: + results[tax_type_tax_use][tax_detail_label][column_group_key] += row['tax_amount'] * row['trl_factor'] + results[tax_type_tax_use]['children'][tax_id][tax_detail_label][column_group_key] += row['tax_amount'] * row['trl_factor'] + + return results + + def _read_generic_tax_report_amounts(self, report, options_by_column_group, groupby_fields): + """ Read the tax details to compute the tax amounts. + + :param options_list: The list of report options, one for each period. + :param groupby_fields: A list of tuple (alias, field) representing the way the amounts must be grouped. + :return: A dictionary mapping each groupby key (e.g. a tax_id) to a sub dictionary containing: + + base_amount: The tax base amount expressed in company's currency. + tax_amount The tax amount expressed in company's currency. + children: The children nodes following the same pattern as the current dictionary. + """ + fetch_group_of_taxes = False + + select_clause_list = [] + groupby_query_list = [] + for alias, field in groupby_fields: + select_clause_list.append(SQL("%s AS %s", SQL.identifier(alias, field), SQL.identifier(f'{alias}_{field}'))) + groupby_query_list.append(SQL.identifier(alias, field)) + + # Fetch both info from the originator tax and the child tax to manage the group of taxes. + if alias == 'src_tax': + select_clause_list.append(SQL("%s AS %s", SQL.identifier('tax', field), SQL.identifier(f'tax_{field}'))) + groupby_query_list.append(SQL.identifier('tax', field)) + fetch_group_of_taxes = True + + # Fetch the group of taxes. + # If all children taxes are 'none', all amounts are aggregated and only the group will appear on the report. + # If some children taxes are not 'none', the children are displayed. + group_of_taxes_to_expand = set() + if fetch_group_of_taxes: + group_of_taxes = self.env['account.tax'].with_context(active_test=False).search([('amount_type', '=', 'group')]) + for group in group_of_taxes: + if set(group.children_tax_ids.mapped('type_tax_use')) != {'none'}: + group_of_taxes_to_expand.add(group.id) + + res = {} + for column_group_key, options in options_by_column_group.items(): + query = report._get_report_query(options, 'strict_range') + tax_details_query = self.env['account.move.line']._get_query_tax_details(query.from_clause, query.where_clause) + + # Avoid adding multiple times the same base amount sharing the same grouping_key. + # It could happen when dealing with group of taxes for example. + row_keys = set() + + self._cr.execute(SQL( + ''' + SELECT + %(select_clause)s, + trl.document_type = 'refund' AS is_refund, + SUM(CASE WHEN tdr.display_type = 'rounding' THEN 0 ELSE tdr.base_amount END) AS base_amount, + SUM(tdr.tax_amount) AS tax_amount + FROM (%(tax_details_query)s) AS tdr + JOIN account_tax_repartition_line trl ON trl.id = tdr.tax_repartition_line_id + JOIN account_tax tax ON tax.id = tdr.tax_id + JOIN account_tax src_tax ON + src_tax.id = COALESCE(tdr.group_tax_id, tdr.tax_id) + AND src_tax.type_tax_use IN ('sale', 'purchase') + JOIN account_account account ON account.id = tdr.base_account_id + WHERE tdr.tax_exigible + GROUP BY tdr.tax_repartition_line_id, trl.document_type, %(groupby_query)s + ORDER BY src_tax.sequence, src_tax.id, tax.sequence, tax.id + ''', + select_clause=SQL(',').join(select_clause_list), + tax_details_query=tax_details_query, + groupby_query=SQL(',').join(groupby_query_list), + )) + + for row in self._cr.dictfetchall(): + node = res + + # tuple of values used to prevent adding multiple times the same base amount. + cumulated_row_key = [row['is_refund']] + + for alias, field in groupby_fields: + grouping_key = f'{alias}_{field}' + + # Manage group of taxes. + # In case the group of taxes is mixing multiple taxes having a type_tax_use != 'none', consider + # them instead of the group. + if grouping_key == 'src_tax_id' and row['src_tax_id'] in group_of_taxes_to_expand: + # Add the originator group to the grouping key, to make sure that its base amount is not + # treated twice, for hybrid cases where a tax is both used in a group and independently. + cumulated_row_key.append(row[grouping_key]) + + # Ensure the child tax is used instead of the group. + grouping_key = 'tax_id' + + row_key = row[grouping_key] + cumulated_row_key.append(row_key) + cumulated_row_key_tuple = tuple(cumulated_row_key) + + node.setdefault(row_key, { + 'base_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'tax_amount': {column_group_key: 0.0 for column_group_key in options['column_groups']}, + 'children': {}, + }) + sub_node = node[row_key] + + # Add amounts. + if cumulated_row_key_tuple not in row_keys: + sub_node['base_amount'][column_group_key] += row['base_amount'] + sub_node['tax_amount'][column_group_key] += row['tax_amount'] + + node = sub_node['children'] + row_keys.add(cumulated_row_key_tuple) + + return res + + def _populate_lines_recursively(self, report, options, lines, sorting_map_list, groupby_fields, values_node, index=0, type_tax_use=None, parent_line_id=None, warnings=None): + ''' Populate the list of report lines passed as parameter recursively. At this point, every amounts is already + fetched for every periods and every groupby. + + :param options: The report options. + :param lines: The list of report lines to populate. + :param sorting_map_list: A list of dictionary mapping each encountered key with a weight to sort the results. + :param index: The index of the current element to process (also equals to the level into the hierarchy). + :param groupby_fields: A list of tuple defining in which way tax amounts should be grouped. + :param values_node: The node containing the amounts and children into the hierarchy. + :param type_tax_use: The type_tax_use of the tax. + :param parent_line_id: The line id of the parent line (if any) + :param warnings The warnings dictionnary. + ''' + if index == len(groupby_fields): + return + + alias, field = groupby_fields[index] + groupby_key = f'{alias}_{field}' + + # Sort the keys in order to add the lines in the same order as the records. + sorting_map = sorting_map_list[index] + sorted_keys = sorted(list(values_node.keys()), key=lambda x: sorting_map[x][1]) + + for key in sorted_keys: + + # Compute 'type_tax_use' with the first grouping since 'src_tax_type_tax_use' is always + # the first one. + if groupby_key == 'src_tax_type_tax_use': + type_tax_use = key + sign = -1 if type_tax_use == 'sale' else 1 + + # Prepare columns. + tax_amount_dict = values_node[key] + columns = [] + tax_base_amounts = tax_amount_dict['base_amount'] + tax_amounts = tax_amount_dict['tax_amount'] + + for column in options['columns']: + tax_base_amount = tax_base_amounts[column['column_group_key']] + tax_amount = tax_amounts[column['column_group_key']] + + expr_label = column.get('expression_label') + col_value = '' + + if expr_label == 'net' and index == len(groupby_fields) - 1: + col_value = sign * tax_base_amount + + if expr_label == 'tax': + col_value = sign * tax_amount + + columns.append(report._build_column_dict(col_value, column, options=options)) + + # Add the non-deductible, deductible and due tax amounts. + if expr_label == 'tax' and options.get('account_journal_report_tax_deductibility_columns'): + for deduct_type in ('tax_non_deductible', 'tax_deductible', 'tax_due'): + columns.append(report._build_column_dict( + col_value=sign * tax_amount_dict[deduct_type][column['column_group_key']], + col_data={ + 'figure_type': 'monetary', + 'column_group_key': column['column_group_key'], + 'expression_label': deduct_type, + }, + options=options, + )) + + # Prepare line. + default_vals = { + 'columns': columns, + 'level': index if index == 0 else index + 1, + 'unfoldable': False, + } + report_line = self._build_report_line(report, options, default_vals, groupby_key, sorting_map[key][0], parent_line_id, warnings) + + if groupby_key == 'src_tax_id': + report_line['caret_options'] = 'generic_tax_report' + + lines.append((0, report_line)) + + # Process children recursively. + self._populate_lines_recursively( + report, + options, + lines, + sorting_map_list, + groupby_fields, + tax_amount_dict.get('children'), + index=index + 1, + type_tax_use=type_tax_use, + parent_line_id=report_line['id'], + warnings=warnings, + ) + + def _build_report_line(self, report, options, default_vals, groupby_key, value, parent_line_id, warnings=None): + """ Build the report line accordingly to its type. + :param options: The report options. + :param default_vals: The pre-computed report line values. + :param groupby_key: The grouping_key record. + :param value: The value that could be a record. + :param parent_line_id The line id of the parent line (if any, can be None otherwise) + :param warnings: The warnings dictionary. + :return: A python dictionary. + """ + report_line = dict(default_vals) + if parent_line_id is not None: + report_line['parent_id'] = parent_line_id + + if groupby_key == 'src_tax_type_tax_use': + type_tax_use_option = value + report_line['id'] = report._get_generic_line_id(None, None, markup=type_tax_use_option[0], parent_line_id=parent_line_id) + report_line['name'] = type_tax_use_option[1] + + elif groupby_key == 'src_tax_id': + tax = value + report_line['id'] = report._get_generic_line_id(tax._name, tax.id, parent_line_id=parent_line_id) + + if tax.amount_type == 'percent': + report_line['name'] = f"{tax.name} ({tax.amount}%)" + + if warnings is not None: + self._check_line_consistency(report, options, report_line, tax, warnings) + elif tax.amount_type == 'fixed': + report_line['name'] = f"{tax.name} ({tax.amount})" + else: + report_line['name'] = tax.name + + if options.get('multi-company'): + report_line['name'] = f"{report_line['name']} - {tax.company_id.display_name}" + + elif groupby_key == 'account_id': + account = value + report_line['id'] = report._get_generic_line_id(account._name, account.id, parent_line_id=parent_line_id) + + if options.get('multi-company'): + report_line['name'] = f"{account.display_name} - {account.company_id.display_name}" + else: + report_line['name'] = account.display_name + + return report_line + + def _check_line_consistency(self, report, options, report_line, tax, warnings=None): + tax_applied = tax.amount * sum(tax.invoice_repartition_line_ids.filtered(lambda tax_rep: tax_rep.repartition_type == 'tax').mapped('factor')) / 100 + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + net_value = next((col['no_format'] for col in report_line['columns'] if col['column_group_key'] == column_group_key and col['expression_label'] == 'net'), 0) + tax_value = next((col['no_format'] for col in report_line['columns'] if col['column_group_key'] == column_group_key and col['expression_label'] == 'tax'), 0) + + if net_value == '': # noqa: PLC1901 + continue + + currency = self.env.company.currency_id + computed_tax_amount = float(net_value or 0) * tax_applied + is_inconsistent = currency.compare_amounts(computed_tax_amount, tax_value) + + if is_inconsistent: + error = abs(abs(tax_value) - abs(computed_tax_amount)) / float(net_value or 1) + + # Error is bigger than 0.1%. We can not ignore it. + if error > 0.001: + report_line['alert'] = True + warnings['odex30_account_reports.tax_report_warning_lines_consistency'] = {'alert_type': 'danger'} + + return + + # ------------------------------------------------------------------------- + # BUTTONS & CARET OPTIONS + # ------------------------------------------------------------------------- + + def caret_option_audit_tax(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + model, tax_id = report._get_model_info_from_id(params['line_id']) + + if model != 'account.tax': + raise UserError(_("Cannot audit tax from another model than account.tax.")) + + tax = self.env['account.tax'].browse(tax_id) + + if tax.amount_type == 'group': + tax_affecting_base_domain = [ + ('tax_ids', 'in', tax.children_tax_ids.ids), + ('tax_repartition_line_id', '!=', False), + ] + else: + tax_affecting_base_domain = [ + ('tax_ids', '=', tax.id), + ('tax_ids.type_tax_use', '=', tax.type_tax_use), + ('tax_repartition_line_id', '!=', False), + ] + + domain = report._get_options_domain(options, 'strict_range') + expression.OR(( + # Base lines + [ + ('tax_ids', 'in', tax.ids), + ('tax_ids.type_tax_use', '=', tax.type_tax_use), + ('tax_repartition_line_id', '=', False), + ], + # Tax lines + [ + ('group_tax_id', '=', tax.id) if tax.amount_type == 'group' else ('tax_line_id', '=', tax.id), + ], + # Tax lines acting as base lines + tax_affecting_base_domain, + )) + + ctx = self._context.copy() + ctx.update({'search_default_group_by_account': 2, 'expand': 1}) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Journal Items for Tax Audit'), + 'res_model': 'account.move.line', + 'views': [[self.env.ref('account.view_move_line_tax_audit_tree').id, 'list']], + 'domain': domain, + 'context': ctx, + } + + +class GenericTaxReportCustomHandlerAT(models.AbstractModel): + _name = 'account.generic.tax.report.handler.account.tax' + _inherit = 'account.generic.tax.report.handler' + _description = 'Generic Tax Report Custom Handler (Account -> Tax)' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + return super()._get_dynamic_lines(report, options, 'account_tax', warnings) + + +class GenericTaxReportCustomHandlerTA(models.AbstractModel): + _name = 'account.generic.tax.report.handler.tax.account' + _inherit = 'account.generic.tax.report.handler' + _description = 'Generic Tax Report Custom Handler (Tax -> Account)' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + return super()._get_dynamic_lines(report, options, 'tax_account', warnings) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py b/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py new file mode 100644 index 0000000..0b93785 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_journal_dashboard.py @@ -0,0 +1,28 @@ +from odoo import models + +import ast + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + def _fill_general_dashboard_data(self, dashboard_data): + super()._fill_general_dashboard_data(dashboard_data) + for journal in self.filtered(lambda journal: journal.type == 'general'): + dashboard_data[journal.id]['is_account_tax_periodicity_journal'] = journal == journal.company_id._get_tax_closing_journal() + + def action_open_bank_balance_in_gl(self): + + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_general_ledger") + + action['context'] = dict(ast.literal_eval(action['context']), default_filter_accounts=self.default_account_id.code) + + return action + + def _transform_activity_dict(self, activity_data): + error_type_id = self.env['ir.model.data']._xmlid_to_res_id('odex30_account_reports.mail_activity_type_tax_report_error', raise_if_not_found=False) + return { + **super()._transform_activity_dict(activity_data), + 'is_tax_report_error': error_type_id and activity_data['act_type_id'] == error_type_id, + } diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py new file mode 100644 index 0000000..14e6594 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_journal_report.py @@ -0,0 +1,1385 @@ +import io +import datetime + +from PIL import ImageFont +from markupsafe import Markup + +from odoo import models, _ +from odoo.tools import SQL +from odoo.tools.misc import xlsxwriter, file_path +from collections import defaultdict +from itertools import chain + +XLSX_GRAY_200 = '#EEEEEE' +XLSX_BORDER_COLOR = '#B4B4B4' +XLSX_FONT_SIZE_DEFAULT = 8 +XLSX_FONT_SIZE_HEADING = 11 + + +class JournalReportCustomHandler(models.AbstractModel): + _name = "account.journal.report.handler" + _inherit = "account.report.custom.handler" + _description = "Journal Report Custom Handler" + + def _custom_options_initializer(self, report, options, previous_options): + + # Initialise the custom option for this report. + options['ignore_totals_below_sections'] = True + options['show_payment_lines'] = previous_options.get('show_payment_lines', True) + + def _get_custom_display_config(self): + return { + 'css_custom_class': 'journal_report', + 'pdf_css_custom_class': 'journal_report_pdf', + 'components': { + 'AccountReportLine': 'odex30_account_reports.JournalReportLine', + }, + 'templates': { + 'AccountReportFilters': 'odex30_account_reports.JournalReportFilters', + 'AccountReportLineName': 'odex30_account_reports.JournalReportLineName', + }, + 'pdf_export': { + 'pdf_export_main': 'odex30_account_reports.journal_report_pdf_export_main', + }, + } + + + def _report_custom_engine_journal_report(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + + def build_result_dict(current_groupby, query_line): + """ + Creates a line entry used by the custom engine + """ + if current_groupby == 'account_id': + code = query_line['account_code'][0] + elif current_groupby == 'journal_id': + code = query_line['journal_code'][0] + else: + code = None + + result_line_dict = { + 'code': code, + 'credit': query_line['credit'], + 'debit': query_line['debit'], + 'balance': query_line['balance'] if current_groupby == 'account_id' else None + } + return query_line['grouping_key'], result_line_dict + + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + # If it is the first line, we want to render our column label + # Since we don't use the one from the base report + if not current_groupby: + return { + 'code': None, + 'debit': None, + 'credit': None, + 'balance': None + } + + query = report._get_report_query(options, 'strict_range') + account_alias = query.join( + lhs_alias='account_move_line', + lhs_column='account_id', + rhs_table='account_account', + rhs_column='id', + link='account_id_for_code', # Custom link name to avoid potential alias clash with what is generated by _field_to_sql for the groupby below + ) + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + + groupby_clause = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) + select_from_groupby = SQL('%s AS grouping_key', groupby_clause) + + query = SQL( + """ + SELECT + %(select_from_groupby)s, + ARRAY_AGG(DISTINCT %(account_code)s) AS account_code, + ARRAY_AGG(DISTINCT j.code) AS journal_code, + SUM("account_move_line".debit) AS debit, + SUM("account_move_line".credit) AS credit, + SUM("account_move_line".balance) AS balance + FROM %(table)s + JOIN account_move am ON am.id = account_move_line.move_id + JOIN account_journal j ON j.id = am.journal_id + JOIN res_company cp ON cp.id = am.company_id + WHERE %(case_statement)s AND %(search_conditions)s + GROUP BY %(groupby_clause)s + ORDER BY %(groupby_clause)s + """, + select_from_groupby=select_from_groupby, + account_code=account_code, + table=query.from_clause, + search_conditions=query.where_clause, + case_statement=self._get_payment_lines_filter_case_statement(options), + groupby_clause=groupby_clause + ) + self._cr.execute(query) + query_lines = self._cr.dictfetchall() + result_lines = [] + + for query_line in query_lines: + result_lines.append(build_result_dict(current_groupby, query_line)) + + return result_lines + + def _custom_line_postprocessor(self, report, options, lines): + + new_lines = [] + + if not lines: + return new_lines + + for i, line in enumerate(lines): + new_lines.append(line) + line_id = line['id'] + + line_model, res_id = report._get_model_info_from_id(line_id) + if line_model == 'account.journal': + line['journal_id'] = res_id + elif line_model == 'account.account': + res_ids_map = report._get_res_ids_from_line_id(line_id, ['account.journal', 'account.account']) + line['journal_id'] = res_ids_map['account.journal'] + line['account_id'] = res_ids_map['account.account'] + line['date'] = options['date'] + + journal = self.env['account.journal'].browse(line['journal_id']) + + # If it is the last line of the journal section + # Check if the journal has taxes and if so, add the tax summaries + if (i + 1 == len(lines) or (i + 1 < len(lines) and report._get_model_info_from_id(lines[i + 1]['id'])[0] != 'account.account')) and self._section_has_tax(options, journal.id): + tax_summary_line = { + 'id': report._get_generic_line_id(False, False, parent_line_id=line['parent_id'], markup='tax_report_section'), + 'name': '', + 'parent_id': line['parent_id'], + 'journal_id': journal.id, + 'is_tax_section_line': True, + 'columns': [], + 'colspan': len(options['columns']) + 1, + 'level': 4, + **self._get_tax_summary_section(options, {'id': journal.id, 'type': journal.type}) + } + new_lines.append(tax_summary_line) + + # If we render the first level it means that we need to render + # the global tax summary lines + if report._get_model_info_from_id(lines[0]['id'])[0] == 'account.report.line': + if self._section_has_tax(options, False): + # We only add the global summary line if it has taxes + new_lines.append({ + 'id': report._get_generic_line_id(False, False, markup='tax_report_section_heading'), + 'name': _('Global Tax Summary'), + 'level': 0, + 'columns': [], + 'unfoldable': False, + 'colspan': len(options['columns']) + 1 + # We want it to take the whole line. It makes it easier to unfold it. + }) + summary_line = { + 'id': report._get_generic_line_id(False, False, markup='tax_report_section'), + 'name': '', + 'is_tax_section_line': True, + 'columns': [], + 'colspan': len(options['columns']) + 1, + 'level': 4, + 'class': 'o_odex30_account_reports_ja_subtable', + **self._get_tax_summary_section(options) + } + new_lines.append(summary_line) + + return new_lines + + def format_column_values_from_client(self, options, lines): + """ + Format column values for journal reports, including tax summary sections. + Called via dispatch_report_action when rounding unit changes on client side. + """ + report = self.env['account.report'].browse(options['report_id']) + for line_dict in lines: + if line_dict.get('is_tax_section_line'): + self._format_tax_summary_line(report, options, line_dict) + + return report.format_column_values_from_client(options, lines) + + def _format_tax_summary_line(self, report, options, line_dict): + """ Apply formatting to tax summary monetary values based on current options. """ + # Format tax_report_lines (individual tax details) + tax_report_lines = line_dict.get('tax_report_lines') + if tax_report_lines: + monetary_fields = ['base_amount', 'tax_amount', 'tax_non_deductible', 'tax_deductible', 'tax_due'] + for tax_line in chain.from_iterable(tax_report_lines.values()): + for field in monetary_fields: + no_format_field = f'{field}_no_format' + no_format_value = tax_line.get(no_format_field) + if no_format_value is not None: + tax_line[field] = report.format_value(options, no_format_value, figure_type='monetary') + + # Format tax_grid_summary_lines (tax grid summaries) + tax_grid_lines = line_dict.get('tax_grid_summary_lines') + if tax_grid_lines: + for country_grids in tax_grid_lines.values(): + for grid_line in country_grids.values(): + plus = grid_line.get('+_no_format', 0) + minus = grid_line.get('-_no_format', 0) + grid_line['+'] = report.format_value(options, plus, figure_type='monetary') + grid_line['-'] = report.format_value(options, minus, figure_type='monetary') + grid_line['impact'] = report.format_value(options, plus - minus, figure_type='monetary') + + ########################################################################## + # PDF Export + ########################################################################## + + def export_to_pdf(self, options): + """ + Overrides the default export_to_pdf function from account.report to + not use the default lines system since we make a different report + from the UI + """ + report = self.env['account.report'].browse(options['report_id']) + base_url = report.get_base_url() + print_options = { + **report.get_options(previous_options={**options, 'export_mode': 'print'}), + 'css_custom_class': self._get_custom_display_config().get('pdf_css_custom_class', 'journal_report_pdf') + } + rcontext = { + 'mode': 'print', + 'base_url': base_url, + 'company': self.env.company, + } + + footer = self.env['ir.actions.report']._render_template('odex30_account_reports.internal_layout', values=rcontext) + footer = self.env['ir.actions.report']._render_template('web.minimal_layout', values=dict(rcontext, subst=True, body=Markup(footer.decode()))) + + document_data = self._generate_document_data_for_export(report, print_options, 'pdf') + render_values = { + 'report': report, + 'options': print_options, + 'base_url': base_url, + 'document_data': document_data + } + body = self.env['ir.qweb']._render(self._get_custom_display_config()['pdf_export']['pdf_export_main'], render_values) + + action_report = self.env['ir.actions.report'] + pdf_file_stream = io.BytesIO(action_report._run_wkhtmltopdf( + [body], + footer=footer.decode(), + landscape=False, + specific_paperformat_args={ + 'data-report-margin-top': 10, + 'data-report-header-spacing': 10, + 'data-report-margin-bottom': 15, + } + )) + + pdf_result = pdf_file_stream.getvalue() + pdf_file_stream.close() + + return { + 'file_name': report.get_default_report_filename(print_options, 'pdf'), + 'file_content': pdf_result, + 'file_type': 'pdf', + } + + ########################################################################## + # XLSX Export + ########################################################################## + + def export_to_xlsx(self, options, response=None): + """ + Overrides the default XLSX Generation from account.repor to use a custom one. + """ + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, { + 'in_memory': True, + 'strings_to_formulas': False, + }) + report = self.env['account.report'].search([('id', '=', options['report_id'])], limit=1) + print_options = report.get_options(previous_options={**options, 'export_mode': 'print'}) + document_data = self._generate_document_data_for_export(report, print_options, 'xlsx') + + # We need to use fonts to calculate column width otherwise column width would be ugly + # Using Lato as reference font is a hack and is not recommended. Customer computers don't have this font by default and so + # the generated xlsx wouldn't have this font. Since it is not by default, we preferred using Arial font as default and keep + # Lato as reference for columns width calculations. + fonts = {} + for font_size in (XLSX_FONT_SIZE_HEADING, XLSX_FONT_SIZE_DEFAULT): + fonts[font_size] = defaultdict() + for font_type in ('Reg', 'Bol', 'RegIta', 'BolIta'): + try: + lato_path = f'web/static/fonts/lato/Lato-{font_type}-webfont.ttf' + fonts[font_size][font_type] = ImageFont.truetype(file_path(lato_path), font_size) + except (OSError, FileNotFoundError): + # This won't give great result, but it will work. + fonts[font_size][font_type] = ImageFont.load_default() + + for journal_vals in document_data['journals_vals']: + cursor_x = 0 + cursor_y = 0 + + # Default sheet properties + sheet = workbook.add_worksheet(journal_vals['name'][:31]) + columns = journal_vals['columns'] + + for column in columns: + align = 'left' + if 'o_right_alignment' in column.get('class', ''): + align = 'right' + self._write_cell(cursor_x, cursor_y, column['name'], 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_HEADING, + True, XLSX_GRAY_200, align, 2, 2) + cursor_x = cursor_x + 1 + + # Set cursor coordinates for the table generation + cursor_y += 1 + cursor_x = 0 + for line in journal_vals['lines'][:-1]: + is_first_aml_line = False + for column in columns: + border_top = 0 if not is_first_aml_line else 1 + align = 'left' + + if line.get(column['label'], {}).get('data'): + data = line[column['label']]['data'] + is_date = isinstance(data, datetime.date) + bold = False + + if 'o_right_alignment' in column.get('class', ''): + align = 'right' + + if line[column['label']].get('class') and 'o_bold' in line[column['label']]['class']: + # if the cell has bold styling, should only be on the first line of each aml + is_first_aml_line = True + border_top = 1 + bold = True + + self._write_cell(cursor_x, cursor_y, data, 1, is_date, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, + bold, 'white', align, 0, border_top, XLSX_BORDER_COLOR) + + else: + # Empty value + self._write_cell(cursor_x, cursor_y, '', 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, False, + 'white', align, 0, border_top, XLSX_BORDER_COLOR) + + cursor_x += 1 + cursor_x = 0 + cursor_y += 1 + + # Draw total line + total_line = journal_vals['lines'][-1] + for column in columns: + data = '' + align = 'left' + + if total_line.get(column['label'], {}).get('data'): + data = total_line[column['label']]['data'] + + if 'o_right_alignment' in column.get('class', ''): + align = 'right' + + self._write_cell(cursor_x, cursor_y, data, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True, + XLSX_GRAY_200, align, 2, 2) + cursor_x += 1 + + cursor_x = 0 + + sheet.set_default_row(20) + sheet.set_row(0, 30) + + # Tax tables drawing + if journal_vals.get('tax_summary'): + self._write_tax_summaries_to_sheet(report, workbook, sheet, fonts, len(columns) + 1, 1, journal_vals['tax_summary']) + + if document_data.get('global_tax_summary'): + self._write_tax_summaries_to_sheet( + report, + workbook, + workbook.add_worksheet(_('Global Tax Summary')[:31]), + fonts, + 0, + 0, + document_data['global_tax_summary'] + ) + + report._add_options_xlsx_sheet(workbook, [print_options]) + workbook.close() + output.seek(0) + generated_file = output.read() + output.close() + + return { + 'file_name': report.get_default_report_filename(options, 'xlsx'), + 'file_content': generated_file, + 'file_type': 'xlsx', + } + + def _write_cell(self, x, y, value, colspan, datetime, report, fonts, workbook, sheet, font_size, bold=False, + bg_color='white', align='left', border_bottom=0, border_top=0, border_color='0x000000'): + """ + Write a value to a specific cell in the sheet with specific styling + + This helps to not create style format for every use case + + :param x: The x coordinate of the cell to write in + :param y: The y coordinate of the cell to write in + :param value: The value to write + :param colspan: The number of columns to extend + :param datetime: True if the value is a date else False + :param report: The current report + :param fonts: The fonts used to calculate the size of each cells. We use Lato because we cannot get Arial but, we write in Arial since we cannot embed Lato on the worksheet + :param workbook: The workbook currently using + :param sheet: The sheet from the workbook to write on + :param font_size: The font size to write with + :param bold: True if the written value should be bold default: False + :param bg_color: The background color of the cell in hex or string ex: '#fff' default: 'white' + :param align: The alignement of the text ex: 'left', 'right', 'center' default: 'left' + :param border_bottom: The width of the bottom border default: 0 + :param border_top: The width of the top border default: 0 + :param border_color: The color of the borders in hex or string default: '0x000' + """ + style = workbook.add_format({ + 'font_name': 'Arial', + 'font_size': font_size, + 'bold': bold, + 'bg_color': bg_color, + 'align': align, + 'bottom': border_bottom, + 'top': border_top, + 'border_color': border_color, + }) + + if colspan == 1: + if datetime: + style.set_num_format('yyyy-mm-dd') + sheet.write_datetime(y, x, value, style) + else: + # Some account_move_lines cells can have multiple lines: one for the title then some additional lines for text. + # On Xlsx it's better to keep everything on one line so when you click on cell, all the value is shown and not juste the title + if isinstance(value, str): + value = value.replace('\n', ' ') + report._set_xlsx_cell_sizes(sheet, fonts[font_size], x, y, value, style, colspan > 1) + sheet.write(y, x, value, style) + else: + sheet.merge_range(y, x, y, x + colspan - 1, value, style) + + def _write_tax_summaries_to_sheet(self, report, workbook, sheet, fonts, start_x, start_y, tax_summary): + cursor_x = start_x + cursor_y = start_y + + # Tax applied + columns = [] + taxes = tax_summary.get('tax_report_lines') + if taxes: + start_align_right = start_x + 1 + + if len(taxes) > 1: + start_align_right += 1 + columns.append(_('Country')) + + columns += [_('Name'), _('Base Amount'), _('Tax Amount')] + if tax_summary.get('tax_non_deductible_column'): + columns.append(_('Non-Deductible')) + if tax_summary.get('tax_deductible_column'): + columns.append(_('Deductible')) + if tax_summary.get('tax_due_column'): + columns.append(_('Due')) + + # Draw Tax Applied Table + # Write tax applied header amd columns + self._write_cell(cursor_x, cursor_y, _('Taxes Applied'), len(columns), False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_HEADING, True, 'white', 'left', 2) + cursor_y += 1 + for column in columns: + align = 'left' + if cursor_x >= start_align_right: + align = 'right' + self._write_cell(cursor_x, cursor_y, column, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True, + XLSX_GRAY_200, align, 2) + cursor_x += 1 + + cursor_x = start_x + cursor_y += 1 + + for country in taxes: + is_country_first_line = True + for tax in taxes[country]: + if len(taxes) > 1: + if is_country_first_line: + is_country_first_line = not is_country_first_line + self._write_cell(cursor_x, cursor_y, country, 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR) + + cursor_x += 1 + + self._write_cell(cursor_x, cursor_y, tax['name'], 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, + True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR) + self._write_cell(cursor_x + 1, cursor_y, tax['base_amount'], 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + self._write_cell(cursor_x + 2, cursor_y, tax['tax_amount'], 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + cursor_x += 3 + + if tax_summary.get('tax_non_deductible_column'): + self._write_cell(cursor_x, cursor_y, tax['tax_non_deductible'], 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + cursor_x += 1 + + if tax_summary.get('tax_deductible_column'): + self._write_cell(cursor_x, cursor_y, tax['tax_deductible'], 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + cursor_x += 1 + + if tax_summary.get('tax_due_column'): + self._write_cell(cursor_x, cursor_y, tax['tax_due'], 1, False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + + cursor_x = start_x + cursor_y += 1 + + cursor_x = start_x + cursor_y += 2 + + # Tax grids + columns = [] + grids = tax_summary.get('tax_grid_summary_lines') + if grids: + start_align_right = start_x + 1 + if len(grids) > 1: + start_align_right += 1 + columns.append(_('Country')) + + columns += [_('Grid'), _('+'), _('-'), _('Impact On Grid')] + + # Draw Tax Applied Table + # Write tax applied columns and header + self._write_cell(cursor_x, cursor_y, _('Impact On Grid'), len(columns), False, report, fonts, workbook, sheet, + XLSX_FONT_SIZE_HEADING, True, 'white', 'left', 2) + + cursor_y += 1 + for column in columns: + align = 'left' + if cursor_x >= start_align_right: + align = 'right' + self._write_cell(cursor_x, cursor_y, column, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True, + XLSX_GRAY_200, align, 2) + cursor_x += 1 + + cursor_x = start_x + cursor_y += 1 + + for country in grids: + is_country_first_line = True + for grid_name in grids[country]: + if len(grids) > 1: + if is_country_first_line: + is_country_first_line = not is_country_first_line + self._write_cell(cursor_x, cursor_y, country, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, + True, 'white', 'left', 1, 0, XLSX_BORDER_COLOR) + + cursor_x += 1 + + self._write_cell(cursor_x, cursor_y, grid_name, 1, False, report, fonts, workbook, sheet, XLSX_FONT_SIZE_DEFAULT, True, + 'white', 'left', 1, 0, XLSX_BORDER_COLOR) + self._write_cell(cursor_x + 1, cursor_y, grids[country][grid_name].get('+', 0), 1, False, report, fonts, workbook, + sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + self._write_cell(cursor_x + 2, cursor_y, grids[country][grid_name].get('-', 0), 1, False, report, fonts, workbook, + sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + self._write_cell(cursor_x + 3, cursor_y, grids[country][grid_name]['impact'], 1, False, report, fonts, workbook, + sheet, XLSX_FONT_SIZE_DEFAULT, False, 'white', 'right', 1, 0, XLSX_BORDER_COLOR) + + cursor_x = start_x + cursor_y += 1 + + ########################################################################## + # Document Data Generation + ########################################################################## + + def _generate_document_data_for_export(self, report, options, export_type='pdf'): + """ + Used to generate all the data needed for the rendering of the export + + :param export_type: The export type the generation need to use can be ('pdf' or 'xslx') + + :return: a dictionnary containing a list of all lines grouped by journals and a dictionnay with the global tax summary lines + - journals_vals (mandatory): List of dictionary containing all the lines, columns, and tax summaries + - lines (mandatory): A list of dict containing all tha data for each lines in format returned by _get_lines_for_journal + - columns (mandatory): A list of columns for this journal returned in the format returned by _get_columns_for_journal + - tax_summary (optional): A dict of data for the tax summaries inside journals in the format returned by _get_tax_summary_section + - global_tax_summary: A dict with the global tax summaries data in the format returned by _get_tax_summary_section + """ + # Ensure that all the data is synchronized with the database before we read it + self.env.flush_all() + query = report._get_report_query(options, 'strict_range') + account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + + query = SQL( + """ + SELECT + account_move_line.id AS move_line_id, + account_move_line.name, + account_move_line.date, + account_move_line.invoice_date, + account_move_line.amount_currency, + account_move_line.tax_base_amount, + account_move_line.currency_id AS move_line_currency, + account_move_line.display_type AS display_type, + am.id AS move_id, + am.name AS move_name, + am.journal_id, + am.currency_id AS move_currency, + am.amount_total_in_currency_signed AS amount_currency_total, + am.currency_id != cp.currency_id AS is_multicurrency, + p.name AS partner_name, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + %(account_alias)s.account_type AS account_type, + COALESCE(account_move_line.debit, 0) AS debit, + COALESCE(account_move_line.credit, 0) AS credit, + COALESCE(account_move_line.balance, 0) AS balance, + %(j_name)s AS journal_name, + j.code AS journal_code, + j.type AS journal_type, + cp.currency_id AS company_currency, + CASE WHEN j.type = 'sale' THEN am.payment_reference WHEN j.type = 'purchase' THEN am.ref END AS reference, + array_remove(array_agg(DISTINCT %(tax_name)s), NULL) AS taxes, + array_remove(array_agg(DISTINCT %(tag_name)s), NULL) AS tax_grids + FROM %(table)s + JOIN account_move am ON am.id = account_move_line.move_id + LEFT JOIN res_partner p ON p.id = account_move_line.partner_id + JOIN account_journal j ON j.id = am.journal_id + JOIN res_company cp ON cp.id = am.company_id + LEFT JOIN account_move_line_account_tax_rel aml_at_rel ON aml_at_rel.account_move_line_id = account_move_line.id + LEFT JOIN account_tax parent_tax ON parent_tax.id = aml_at_rel.account_tax_id and parent_tax.amount_type = 'group' + LEFT JOIN account_tax_filiation_rel tax_filiation_rel ON tax_filiation_rel.parent_tax = parent_tax.id + LEFT JOIN account_tax tax ON (tax.id = aml_at_rel.account_tax_id and tax.amount_type != 'group') or tax.id = tax_filiation_rel.child_tax + LEFT JOIN account_account_tag_account_move_line_rel tag_rel ON tag_rel.account_move_line_id = account_move_line.id + LEFT JOIN account_account_tag tag ON tag_rel.account_account_tag_id = tag.id + LEFT JOIN res_currency journal_curr ON journal_curr.id = j.currency_id + WHERE %(case_statement)s AND %(search_conditions)s + GROUP BY "account_move_line".id, am.id, p.id, %(account_alias)s.id, j.id, cp.id, journal_curr.id, account_code, account_name + ORDER BY + CASE j.type + WHEN 'sale' THEN 1 + WHEN 'purchase' THEN 2 + WHEN 'general' THEN 3 + WHEN 'bank' THEN 4 + ELSE 5 + END, + j.sequence, + CASE WHEN am.name = '/' THEN 1 ELSE 0 END, am.date, am.name, am.id, + CASE %(account_alias)s.account_type + WHEN 'liability_payable' THEN 1 + WHEN 'asset_receivable' THEN 1 + WHEN 'liability_credit_card' THEN 5 + WHEN 'asset_cash' THEN 5 + ELSE 2 + END, + account_move_line.tax_line_id NULLS FIRST + """, + table=query.from_clause, + case_statement=self._get_payment_lines_filter_case_statement(options), + search_conditions=query.where_clause, + account_code=account_code, + account_name=account_name, + account_alias=SQL.identifier(account_alias), + j_name=self.env['account.journal']._field_to_sql('j', 'name'), + tax_name=self.env['account.tax']._field_to_sql('tax', 'name'), + tag_name=self.env['account.account.tag']._field_to_sql('tag', 'name') + ) + + self._cr.execute(query) + result = {} + + # Grouping by journal_id then move_id + for entry in self._cr.dictfetchall(): + result.setdefault(entry['journal_id'], {}) + result[entry['journal_id']].setdefault(entry['move_id'], []) + result[entry['journal_id']][entry['move_id']].append(entry) + + journals_vals = [] + any_journal_group_has_taxes = False + + for journal_entry_dict in result.values(): + account_move_vals_list = list(journal_entry_dict.values()) + journal_vals = { + 'id': account_move_vals_list[0][0]['journal_id'], + 'name': account_move_vals_list[0][0]['journal_name'], + 'code': account_move_vals_list[0][0]['journal_code'], + 'type': account_move_vals_list[0][0]['journal_type'] + } + + if self._section_has_tax(options, journal_vals['id']): + journal_vals['tax_summary'] = self._get_tax_summary_section(options, journal_vals) + any_journal_group_has_taxes = True + + journal_vals['lines'] = self._get_export_lines_for_journal(report, options, export_type, journal_vals, account_move_vals_list) + journal_vals['columns'] = self._get_columns_for_journal(journal_vals, export_type) + journals_vals.append(journal_vals) + + return { + 'journals_vals': journals_vals, + 'global_tax_summary': self._get_tax_summary_section(options) if any_journal_group_has_taxes else False + } + + def _get_columns_for_journal(self, journal, export_type='pdf'): + """ + Creates a columns list that will be used in this journal for the pdf report + + :return: A list of the columns as dict each having: + - name (mandatory): A string that will be displayed + - label (mandatory): A string used to link lines with the column + - class (optional): A string with css classes that need to be applied to all that column + """ + columns = [ + {'name': _('Document'), 'label': 'document'}, + ] + + # We have different columns regarding we are exporting to a PDF file or an XLSX document + if export_type == 'pdf': + columns.append({'name': _('Account'), 'label': 'account_label'}) + else: + columns.extend([ + {'name': _('Account Code'), 'label': 'account_code'}, + {'name': _('Account Label'), 'label': 'account_label'} + ]) + + columns.extend([ + {'name': _('Name'), 'label': 'name'}, + {'name': _('Debit'), 'label': 'debit', 'class': 'o_right_alignment '}, + {'name': _('Credit'), 'label': 'credit', 'class': 'o_right_alignment '}, + ]) + + if journal.get('tax_summary'): + columns.append( + {'name': _('Taxes'), 'label': 'taxes'}, + ) + if journal['tax_summary'].get('tax_grid_summary_lines'): + columns.append({'name': _('Tax Grids'), 'label': 'tax_grids'}) + + if self._should_use_bank_journal_export(journal): + columns.append({ + 'name': _('Balance'), + 'label': 'balance', + 'class': 'o_right_alignment ' + }) + + if journal.get('multicurrency_column'): + columns.append({ + 'name': _('Amount Currency'), + 'label': 'amount_currency', + 'class': 'o_right_alignment ' + }) + + return columns + + def _should_use_bank_journal_export(self, journal_vals): + """Returns True if the journal requires bank-specific export logic.""" + return journal_vals.get('type') == 'bank' + + def _get_export_lines_for_journal(self, report, options, export_type, journal_vals, account_move_vals_list): + """ + Default document lines generation it will generate a list of lines in a format valid for the pdf and xlsx + + If it is a bank journal it will be redirected to _get_lines_for_bank_journal since this type of journals + require more complexity + We want to be as lightweight as possible and not at unnecessary calculations + + :return: A list of lines. Each line is a dict having: + - 'column_label': A dict containing the values for a cell with a key that links to the label of a column + - data (mandatory): The formatted cell value + - class (optional): Additional css classes to apply to the current cell + - line_class (optional): Additional css classes that applies to the entire line + """ + lines = [] + + if self._should_use_bank_journal_export(journal_vals): + return self._get_export_lines_for_bank_journal(report, options, export_type, journal_vals, account_move_vals_list) + + total_credit = 0 + total_debit = 0 + + for i, account_move_line_vals_list in enumerate(account_move_vals_list): + for j, move_line_entry_vals in enumerate(account_move_line_vals_list): + document = False + if j == 0: + document = move_line_entry_vals['move_name'] + elif j == 1: + document = move_line_entry_vals['date'] + + line = self._get_base_line(report, options, export_type, document, move_line_entry_vals, j, i % 2 != 0, journal_vals.get('tax_summary')) + + total_credit += move_line_entry_vals['credit'] + total_debit += move_line_entry_vals['debit'] + + lines.append(line) + + # Add other currency amout if this move is using multiple currencies + move_vals_entry = account_move_line_vals_list[0] + if move_vals_entry['is_multicurrency']: + amount_currency_name = _( + 'Amount in currency: %s', + report._format_value( + options, + move_vals_entry['amount_currency_total'], + 'monetary', + format_params={'currency_id': move_vals_entry['move_currency']}, + ), + ) + if len(account_move_line_vals_list) <= 2: + lines.append({ + 'document': {'data': amount_currency_name}, + 'line_class': 'o_even ' if i % 2 == 0 else 'o_odd ', + 'amount': {'data': move_vals_entry['amount_currency_total']}, + 'currency_id': {'data': move_vals_entry['move_currency']} + }) + else: + lines[-1]['document'] = {'data': amount_currency_name} + lines[-1]['amount'] = {'data': move_vals_entry['amount_currency_total']} + lines[-1]['currency_id'] = {'data': move_vals_entry['move_currency']} + + # Add an empty line to add a separation between the total section and the data section + lines.append({}) + + total_line = { + 'name': {'data': _('Total')}, + 'debit': {'data': report._format_value(options, total_debit, 'monetary')}, + 'credit': {'data': report._format_value(options, total_credit, 'monetary')}, + } + + lines.append(total_line) + + return lines + + def _get_export_lines_for_bank_journal(self, report, options, export_type, journal_vals, account_moves_vals_list): + """ + Bank journals are more complex and should be calculated separately from other journal types + + :return: A list of lines. Each line is a dict having: + - 'column_label': A dict containing the values for a cell with a key that links to the label of a column + - data (mandatory): The formatted cell value + - class (optional): Additional css classes to apply to the current cell + - line_class (optional): Additional css classes that applies to the entire line + """ + lines = [] + + # Initial balance + current_balance = self._query_bank_journal_initial_balance(options, journal_vals['id']) + lines.append({ + 'name': {'data': _('Starting Balance')}, + 'balance': {'data': report._format_value(options, current_balance, 'monetary')}, + }) + + # Debit and credit accumulators + total_credit = 0 + total_debit = 0 + + for i, account_move_line_vals_list in enumerate(account_moves_vals_list): + is_unreconciled_payment = not any( + line for line in account_move_line_vals_list if line['account_type'] in ('liability_credit_card', 'asset_cash') + ) + + for j, move_line_entry_vals in enumerate(account_move_line_vals_list): + # Do not display bank account lines for bank journals + if move_line_entry_vals['account_type'] not in ('liability_credit_card', 'asset_cash'): + document = '' + if j == 0: + document = f'{move_line_entry_vals["move_name"]} ({move_line_entry_vals["date"]})' + line = self._get_base_line(report, options, export_type, document, move_line_entry_vals, j, i % 2 != 0, journal_vals.get('tax_summary')) + + total_credit += move_line_entry_vals['credit'] + total_debit += move_line_entry_vals['debit'] + + if not is_unreconciled_payment: + # We need to invert the balance since it is a bank journal + line_balance = -move_line_entry_vals['balance'] + current_balance += line_balance + line.update({ + 'balance': { + 'data': report._format_value(options, current_balance, 'monetary'), + 'class': 'o_muted ' if self.env.company.currency_id.is_zero(line_balance) else '' + }, + }) + + if self.env.user.has_group('base.group_multi_currency') and move_line_entry_vals['move_line_currency'] != move_line_entry_vals['company_currency']: + journal_vals['multicurrency_column'] = True + amount_currency = -move_line_entry_vals['amount_currency'] if not is_unreconciled_payment else move_line_entry_vals['amount_currency'] + move_line_currency = self.env['res.currency'].browse(move_line_entry_vals['move_line_currency']) + line.update({ + 'amount_currency': { + 'data': report._format_value( + options, + amount_currency, + 'monetary', + format_params={'currency_id': move_line_currency.id}, + ), + 'class': 'o_muted ' if move_line_currency.is_zero(amount_currency) else '', + } + }) + lines.append(line) + + # Add an empty line to add a separation between the total section and the data section + lines.append({}) + + total_line = { + 'name': {'data': _('Total')}, + 'balance': {'data': report._format_value(options, current_balance, 'monetary')}, + } + lines.append(total_line) + + return lines + + def _get_base_line(self, report, options, export_type, document, line_entry, line_index, even, has_taxes): + """ + Returns the generic part of a line that is used by both '_get_lines_for_journal' and '_get_lines_for_bank_journal' + + :return: A dict with base values for the line + - line_class (mandatory): Css classes that applies to this whole line + - document (mandatory): A dict containing the cell data for the column document + - data (mandatory): The value of the cell formatted + - class (mandatory): css class for this cell + - account (mandatory): A dict containing the cell data for the column account + - data (mandatory): The value of the cell formatted + - account_code (mandatory): A dict containing the cell data for the column account_code + - data (mandatory): The value of the cell formatted + - account_label (mandatory): A dict containing the cell data for the column account_label + - data (mandatory): The value of the cell formatted + - name (mandatory): A dict containing the cell data for the column name + - data (mandatory): The value of the cell formatted + - debit (mandatory): A dict containing the cell data for the column debit + - data (mandatory): The value of the cell formatted + - class (mandatory): css class for this cell + - credit (mandatory): A dict containing the cell data for the column credit + - data (mandatory): The value of the cell formatted + - class (mandatory): css class for this cell + + - taxes(optional): A dict containing the cell data for the column taxes + - data (mandatory): The value of the cell formatted + - tax_grids(optional): A dict containing the cell data for the column taxes + - data (mandatory): The value of the cell formatted + """ + company_currency = self.env.company.currency_id + + name = line_entry['name'] or line_entry['reference'] + account_label = line_entry['partner_name'] or line_entry['account_name'] + + if line_entry['account_type'] not in ('asset_receivable', 'liability_payable'): + account_label = line_entry['account_name'] + elif line_entry['partner_name'] and line_entry['account_type'] in ('asset_receivable', 'liability_payable'): + name = f"{line_entry['partner_name']} {name or ''}" + + line = { + 'line_class': 'o_even ' if even else 'o_odd ', + 'document': {'data': document, 'class': 'o_bold ' if line_index == 0 else ''}, + 'account_code': {'data': line_entry['account_code']}, + 'account_label': { + 'data': ( + account_label + if export_type != 'pdf' + else ( + f"{line_entry['account_code']} " + + ( + line_entry['account_name'][:35] + '...' + if len(line_entry['account_name']) > 35 + else line_entry['account_name'] + ) + ) + ) + }, + 'name': {'data': name}, + 'debit': { + 'data': report._format_value(options, line_entry['debit'], 'monetary'), + 'class': 'o_muted ' if company_currency.is_zero(line_entry['debit']) else '' + }, + 'credit': { + 'data': report._format_value(options, line_entry['credit'], 'monetary'), + 'class': 'o_muted ' if company_currency.is_zero(line_entry['credit']) else '' + }, + } + + if has_taxes: + tax_val = '' + if line_entry['taxes']: + tax_val = _('T: %s', ', '.join(line_entry['taxes'])) + elif line_entry['tax_base_amount'] is not None: + tax_val = _('B: %s', report._format_value(options, line_entry['tax_base_amount'], 'monetary')) + + line.update({ + 'taxes': {'data': tax_val}, + 'tax_grids': {'data': ', '.join(line_entry['tax_grids'])}, + }) + + return line + + ########################################################################## + # Queries + ########################################################################## + + def _get_payment_lines_filter_case_statement(self, options): + if not options.get('show_payment_lines'): + return SQL( + """ + (j.type != 'bank' OR EXISTS( + SELECT + 1 + FROM account_move_line + JOIN account_account acc ON acc.id = account_move_line.account_id + WHERE account_move_line.move_id = am.id + AND acc.account_type IN ('liability_credit_card', 'asset_cash') + )) + """ + ) + else: + return SQL('TRUE') + + def _query_bank_journal_initial_balance(self, options, journal_id): + report = self.env.ref('odex30_account_reports.journal_report') + query = report._get_report_query(options, 'to_beginning_of_period', domain=[('journal_id', '=', journal_id)]) + query = SQL( + """ + SELECT + COALESCE(SUM(account_move_line.balance), 0) AS balance + FROM %(table)s + JOIN account_journal journal ON journal.id = "account_move_line".journal_id AND account_move_line.account_id = journal.default_account_id + WHERE %(search_conditions)s + GROUP BY journal.id + """, + table=query.from_clause, + search_conditions=query.where_clause, + ) + self._cr.execute(query) + result = self._cr.dictfetchall() + init_balance = result[0]['balance'] if len(result) >= 1 else 0 + return init_balance + + ########################################################################## + # Tax Grids + ########################################################################## + + def _section_has_tax(self, options, journal_id): + report = self.env['account.report'].browse(options.get('report_id')) + aml_has_tax_domain = [('tax_ids', '!=', False)] + if journal_id: + aml_has_tax_domain.append(('journal_id', '=', journal_id)) + aml_has_tax_domain += report._get_options_domain(options, 'strict_range') + return bool(self.env['account.move.line'].search_count(aml_has_tax_domain, limit=1)) + + def _get_tax_summary_section(self, options, journal_vals=None): + """ + Get the journal tax summary if it is passed as parameter. + In case no journal is passed, it will return the global tax summary data + """ + tax_data = { + 'date_from': options.get('date', {}).get('date_from'), + 'date_to': options.get('date', {}).get('date_to'), + } + + if journal_vals: + tax_data['journal_id'] = journal_vals['id'] + tax_data['journal_type'] = journal_vals['type'] + + tax_report_lines = self._get_generic_tax_summary_for_sections(options, tax_data) + tax_non_deductible_column = any(line.get('tax_non_deductible_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list) + tax_deductible_column = any(line.get('tax_deductible_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list) + tax_due_column = any(line.get('tax_due_no_format') for country_vals_list in tax_report_lines.values() for line in country_vals_list) + extra_columns = int(tax_non_deductible_column) + int(tax_deductible_column) + int(tax_due_column) + + tax_grid_summary_lines = self._get_tax_grids_summary(options, tax_data) + + return { + 'tax_report_lines': tax_report_lines, + 'tax_non_deductible_column': tax_non_deductible_column, + 'tax_deductible_column': tax_deductible_column, + 'tax_due_column': tax_due_column, + 'extra_columns': extra_columns, + 'tax_grid_summary_lines': tax_grid_summary_lines, + } + + def _get_generic_tax_report_options(self, options, data): + """ + Return an option dictionnary set to fetch the reports with the parameters needed for this journal. + The important bits are the journals, date, and fetch the generic tax reports that contains all taxes. + We also provide the information about wether to take all entries or only posted ones. + """ + generic_tax_report = self.env.ref('account.generic_tax_report') + previous_option = options.copy() + # Force the dates to the selected ones. Allows to get it correctly when grouped by months + previous_option.update({ + 'selected_variant_id': generic_tax_report.id, + 'date_from': data.get('date_from'), + 'date_to': data.get('date_to'), + }) + tax_report_options = generic_tax_report.get_options(previous_option) + journal_report = self.env['account.report'].browse(options['report_id']) + tax_report_options['forced_domain'] = tax_report_options.get('forced_domain', []) + journal_report._get_options_domain(options, 'strict_range') + + # Even though it doesn't have a journal selector, we can force a journal in the options to only get the lines for a specific journal. + if data.get('journal_id') or data.get('journal_type'): + tax_report_options['journals'] = [{ + 'id': data.get('journal_id'), + 'model': 'account.journal', + 'type': data.get('journal_type'), + 'selected': True, + }] + + return tax_report_options + + def _get_tax_grids_summary(self, options, data): + """ + Fetches the details of all grids that have been used in the provided journal. + The result is grouped by the country in which the tag exists in case of multivat environment. + Returns a dictionary with the following structure: + { + Country : { + tag_name: {+, -, impact}, + tag_name: {+, -, impact}, + tag_name: {+, -, impact}, + ... + }, + Country : [ + tag_name: {+, -, impact}, + tag_name: {+, -, impact}, + tag_name: {+, -, impact}, + ... + ], + ... + } + """ + report = self.env.ref('odex30_account_reports.journal_report') + # Use the same option as we use to get the tax details, but this time to generate the query used to fetch the + # grid information + tax_report_options = self._get_generic_tax_report_options(options, data) + query = report._get_report_query(tax_report_options, 'strict_range') + country_name = self.env['res.country']._field_to_sql('country', 'name') + tag_name = self.env['account.account.tag']._field_to_sql('tag', 'name') + query = SQL(""" + WITH tag_info (country_name, tag_id, tag_name, tag_sign, balance) AS ( + SELECT + %(country_name)s AS country_name, + tag.id, + %(tag_name)s AS name, + CASE WHEN tag.tax_negate IS TRUE THEN '-' ELSE '+' END, + SUM(COALESCE("account_move_line".balance, 0) + * CASE WHEN "account_move_line".tax_tag_invert THEN -1 ELSE 1 END + ) AS balance + FROM account_account_tag tag + JOIN account_account_tag_account_move_line_rel rel ON tag.id = rel.account_account_tag_id + JOIN res_country country ON country.id = tag.country_id + , %(table_references)s + WHERE %(search_condition)s + AND applicability = 'taxes' + AND "account_move_line".id = rel.account_move_line_id + GROUP BY country_name, tag.id + ) + SELECT + country_name, + tag_id, + REGEXP_REPLACE(tag_name, '^[+-]', '') AS name, -- Remove the sign from the grid name + balance, + tag_sign AS sign + FROM tag_info + ORDER BY country_name, name + """, country_name=country_name, tag_name=tag_name, table_references=query.from_clause, search_condition=query.where_clause) + self._cr.execute(query) + query_res = self.env.cr.fetchall() + + res = {} + opposite = {'+': '-', '-': '+'} + for country_name, tag_id, name, balance, sign in query_res: + res.setdefault(country_name, {}).setdefault(name, {}) + res[country_name][name].setdefault('tag_ids', []).append(tag_id) + res[country_name][name][sign] = report._format_value(options, balance, 'monetary') + + # We need them formatted, to ensure they are displayed correctly in the report. (E.g. 0.0, not 0) + if not opposite[sign] in res[country_name][name]: + res[country_name][name][opposite[sign]] = report._format_value(options, 0, 'monetary') + + res[country_name][name][sign + '_no_format'] = balance + res[country_name][name]['impact'] = report._format_value(options, res[country_name][name].get('+_no_format', 0) - res[country_name][name].get('-_no_format', 0), 'monetary') + + return res + + def _get_generic_tax_summary_for_sections(self, options, data): + """ + Overridden to make use of the generic tax report computation + Works by forcing specific options into the tax report to only get the lines we need. + The result is grouped by the country in which the tag exists in case of multivat environment. + Returns a dictionary with the following structure: + { + Country : [ + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + ... + ], + Country : [ + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + {name, base_amount{_no_format}, tax_amount{_no_format}, tax_non_deductible{_no_format}, tax_deductible{_no_format}, tax_due{_no_format}}, + ... + ], + ... + } + """ + report = self.env['account.report'].browse(options['report_id']) + tax_report_options = self._get_generic_tax_report_options(options, data) + tax_report_options['account_journal_report_tax_deductibility_columns'] = True + tax_report = self.env.ref('account.generic_tax_report') + tax_report_lines = tax_report._get_lines(tax_report_options) + + tax_values = {} + for tax_report_line in tax_report_lines: + model, line_id = report._parse_line_id(tax_report_line.get('id'))[-1][1:] + if model == 'account.tax': + tax_values[line_id] = { + 'base_amount': tax_report_line['columns'][0]['no_format'], + 'tax_amount': tax_report_line['columns'][1]['no_format'], + 'tax_non_deductible': tax_report_line['columns'][2]['no_format'], + 'tax_deductible': tax_report_line['columns'][3]['no_format'], + 'tax_due': tax_report_line['columns'][4]['no_format'], + } + + # Make the final data dict that will be used by the template, using the taxes information. + taxes = self.env['account.tax'].browse(tax_values.keys()) + res = {} + for tax in taxes: + res.setdefault(tax.country_id.name, []).append({ + 'base_amount': report._format_value(options, tax_values[tax.id]['base_amount'], 'monetary'), + 'base_amount_no_format': tax_values[tax.id]['base_amount'], + 'tax_amount': report._format_value(options, tax_values[tax.id]['tax_amount'], 'monetary'), + 'tax_amount_no_format': tax_values[tax.id]['tax_amount'], + 'tax_non_deductible': report._format_value(options, tax_values[tax.id]['tax_non_deductible'], 'monetary'), + 'tax_non_deductible_no_format': tax_values[tax.id]['tax_non_deductible'], + 'tax_deductible': report._format_value(options, tax_values[tax.id]['tax_deductible'], 'monetary'), + 'tax_deductible_no_format': tax_values[tax.id]['tax_deductible'], + 'tax_due': report._format_value(options, tax_values[tax.id]['tax_due'], 'monetary'), + 'tax_due_no_format': tax_values[tax.id]['tax_due'], + 'name': tax.name, + 'line_id': report._get_generic_line_id('account.tax', tax.id) + }) + + # Return the result, ordered by country name + return dict(sorted(res.items())) + + ########################################################################## + # Actions + ########################################################################## + + def journal_report_tax_tag_template_open_aml(self, options, params=None): + """ returns an action to open a list view of the account.move.line having the selected tax tag """ + tag_ids = params.get('tag_ids') + domain = ( + self.env['account.report'].browse(options['report_id'])._get_options_domain(options, 'strict_range') + + [('tax_tag_ids', 'in', tag_ids)] + + self.env['account.move.line']._get_tax_exigible_domain() + ) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Journal Items for Tax Audit'), + 'res_model': 'account.move.line', + 'views': [[self.env.ref('account.view_move_line_tax_audit_tree').id, 'list']], + 'domain': domain, + 'context': self.env.context, + } + + def journal_report_action_dropdown_audit_default_tax_report(self, options, params): + return self.env['account.generic.tax.report.handler'].caret_option_audit_tax(options, params) + + def journal_report_action_open_tax_journal_items(self, options, params): + """ + Open the journal items related to the tax on this line. + Take into account the given/options date and group by taxes then account. + :param options: the report options. + :param params: a dict containing the line params. (Dates, name, journal_id, tax_type) + :return: act_window on journal items grouped by tax or tags and accounts. + """ + ctx = { + 'search_default_posted': 0 if options.get('all_entries') else 1, + 'search_default_date_between': 1, + 'date_from': params and params.get('date_from') or options.get('date', {}).get('date_from'), + 'date_to': params and params.get('date_to') or options.get('date', {}).get('date_to'), + 'search_default_journal_id': params.get('journal_id'), + 'expand': 1, + } + if params and params.get('tax_type') == 'tag': + ctx.update({ + 'search_default_group_by_tax_tags': 1, + 'search_default_group_by_account': 2, + }) + elif params and params.get('tax_type') == 'tax': + ctx.update({ + 'search_default_group_by_taxes': 1, + 'search_default_group_by_account': 2, + }) + + if params and 'journal_id' in params: + ctx.update({ + 'search_default_journal_id': [params['journal_id']], + }) + + if options and options.get('journals') and not ctx.get('search_default_journal_id'): + selected_journals = [journal['id'] for journal in options['journals'] if journal.get('selected') and journal['model'] == 'account.journal'] + if len(selected_journals) == 1: + ctx['search_default_journal_id'] = selected_journals + + return { + 'name': params.get('name'), + 'view_mode': 'list,pivot,graph,kanban', + 'res_model': 'account.move.line', + 'views': [(self.env.ref('account.view_move_line_tree').id, 'list')], + 'type': 'ir.actions.act_window', + 'domain': [('display_type', 'not in', ('line_section', 'line_note'))], + 'context': ctx, + } + + def journal_report_action_open_account_move_lines_by_account(self, options, params): + """ + Open a list view of the journal account move lines + corresponding to the date filter and the current account line clicked + :param options: The current options of the report + :param params: The params given from the report UI (journal_id, account_id, date) + :return: act_window on journal items filtered on the current journal and the current account within a date. + """ + report = self.env['account.report'].browse(options['report_id']) + journal = self.env['account.journal'].browse(params['journal_id']) + account = self.env['account.account'].browse(params['account_id']) + + domain = [ + ('journal_id.id', '=', journal.id), + ('account_id.id', '=', account.id), + ] + domain += report._get_options_domain(options, 'strict_range') + + return { + 'type': 'ir.actions.act_window', + 'name': _("%(journal)s - %(account)s", journal=journal.name, account=account.name), + 'res_model': 'account.move.line', + 'views': [[False, 'list']], + 'domain': domain + } + + def journal_report_open_aml_by_move(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + journal = self.env['account.journal'].browse(params['journal_id']) + + context_update = { + 'search_default_group_by_account': 0, + 'show_more_partner_info': 1, + } + + if journal.type in ('bank', 'credit'): + params['view_ref'] = 'odex30_account_reports.view_journal_report_audit_bank_move_line_tree' + # context_update['search_default_exclude_bank_lines'] = 1 + else: + params['view_ref'] = 'odex30_account_reports.view_journal_report_audit_move_line_tree' + context_update.update({ + 'search_default_group_by_partner': 1, + 'search_default_group_by_move': 2, + }) + if journal.type in ('sale', 'purchase'): + context_update['search_default_invoices_lines'] = 1 + + action = report.open_journal_items(options=options, params=params) + action.get('context', {}).update(context_update) + return action diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_move.py b/dev_odex30_accounting/odex30_account_reports/models/account_move.py new file mode 100644 index 0000000..6262e70 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_move.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +import ast + +from odoo.addons.account.models.exceptions import TaxClosingNonPostedDependingMovesError +from odoo import api, models, fields, _ +from odoo.exceptions import UserError +from odoo.tools.misc import format_date +from odoo.tools import date_utils +from odoo.addons.web.controllers.utils import clean_action + +from dateutil.relativedelta import relativedelta +from markupsafe import Markup + + +class AccountMove(models.Model): + _inherit = "account.move" + + # used for VAT closing, containing the end date of the period this entry closes + tax_closing_report_id = fields.Many2one(comodel_name='account.report') + # technical field used to know whether to show the tax closing alert or not + tax_closing_alert = fields.Boolean(compute='_compute_tax_closing_alert') + + def _post(self, soft=True): + # Overridden to create carryover external values and join the pdf of the report when posting the tax closing + for move in self.filtered(lambda m: m.tax_closing_report_id): + report = move.tax_closing_report_id + options = move._get_tax_closing_report_options(move.company_id, move.fiscal_position_id, report, move.date) + move._close_tax_period(report, options) + + return super()._post(soft) + + def action_post(self): + + try: + res = super().action_post() + except TaxClosingNonPostedDependingMovesError as exception: + return { + "type": "ir.actions.client", + "tag": "odex30_account_reports.redirect_action", + "target": "new", + "name": "Depending Action", + "params": { + "depending_action": exception.args[0], + "message": _("It seems there is some depending closing move to be posted"), + "button_text": _("Depending moves"), + }, + 'context': { + 'dialog_size': 'medium', + } + } + return res + + def button_draft(self): + super().button_draft() + for closing_move in self.filtered(lambda m: m.tax_closing_report_id): + report = closing_move.tax_closing_report_id + options = closing_move._get_tax_closing_report_options(closing_move.company_id, closing_move.fiscal_position_id, report, closing_move.date) + closing_months_delay = closing_move.company_id._get_tax_periodicity_months_delay(report) + + carryover_values = self.env['account.report.external.value'].search([ + ('carryover_origin_report_line_id', 'in', report.line_ids.ids), + ('date', '=', options['date']['date_to']), + ]) + + carryover_impacted_period_end = fields.Date.from_string(options['date']['date_to']) + relativedelta(months=closing_months_delay) + violated_lock_dates = closing_move.company_id._get_lock_date_violations( + carryover_impacted_period_end, fiscalyear=False, sale=False, purchase=False, tax=True, hard=True, + ) if carryover_values else None + + if violated_lock_dates: + raise UserError(_("You cannot reset this closing entry to draft, as it would delete carryover values impacting the tax report of a locked period. " + "Please change the following lock dates to proceed: %(lock_date_info)s.", + lock_date_info=self.env['res.company']._format_lock_dates(violated_lock_dates))) + + if self._has_subsequent_posted_closing_moves(): + raise UserError(_("You cannot reset this closing entry to draft, as another closing entry has been posted at a later date.")) + + carryover_values.unlink() + + def _has_subsequent_posted_closing_moves(self): + self.ensure_one() + closing_domains = [ + ('company_id', '=', self.company_id.id), + ('tax_closing_report_id', '!=', False), + ('state', '=', 'posted'), + ('date', '>', self.date), + ('fiscal_position_id', '=', self.fiscal_position_id.id) + ] + return bool(self.env['account.move'].search_count(closing_domains, limit=1)) + + def _get_tax_to_pay_on_closing(self): + self.ensure_one() + tax_payable_accounts = self.env['account.tax.group'].search([ + ('company_id', '=', self.company_id.id), + ]).tax_payable_account_id + payable_lines = self.line_ids.filtered(lambda line: line.account_id in tax_payable_accounts) + return self.currency_id.round(-sum(payable_lines.mapped('balance'))) + + def _action_tax_to_pay_wizard(self): + # hook for l10n tax payment wizard + return self.action_open_tax_report() + + def _action_tax_to_send(self): + return self.action_open_tax_report() + + def _action_tax_report_error(self): + # hook for l10n tax report errors + return self.action_open_tax_report() + + def action_open_tax_report(self): + action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_gt") + if not self.tax_closing_report_id: + raise UserError(_("You can't open a tax report from a move without a VAT closing date.")) + options = self._get_tax_closing_report_options(self.company_id, self.fiscal_position_id, self.tax_closing_report_id, self.date) + # Pass options in context and set ignore_session: true to prevent using session options + action.update({'params': {'options': options, 'ignore_session': True}}) + return action + + def _close_tax_period(self, report, options): + + self.ensure_one() + if not self.env.user.has_group('account.group_account_manager'): + raise UserError(_('Only Billing Administrators are allowed to change lock dates!')) + report = self.tax_closing_report_id + options = self._get_tax_closing_report_options(self.company_id, self.fiscal_position_id, report, self.date) + + sender_company = report._get_sender_company_for_export(options) + company_ids = report.get_report_company_ids(options) + if sender_company == self.company_id: + depending_closings = self.env['account.tax.report.handler']._get_tax_closing_entries_for_closed_period(report, options, self.env['res.company'].browse(company_ids), posted_only=False) - self + depending_closings_to_post = depending_closings.filtered(lambda x: x.state == 'draft') + if depending_closings_to_post: + depending_action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line") + depending_action = clean_action(depending_action, env=self.env) + + if len(depending_closings_to_post) == 1: + depending_action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] + depending_action['res_id'] = depending_closings_to_post.id + else: + depending_action['domain'] = [('id', 'in', depending_closings_to_post.ids)] + depending_action['context'] = dict(ast.literal_eval(depending_action['context'])) + depending_action['context'].pop('search_default_posted', None) + + + raise TaxClosingNonPostedDependingMovesError(depending_action) + + report.with_context(allowed_company_ids=company_ids)._generate_carryover_external_values(options) + + attachments = self._get_vat_report_attachments(report, options) + subject = _( + "Vat closing from %(date_from)s to %(date_to)s", + date_from=format_date(self.env, options['date']['date_from']), + date_to=format_date(self.env, options['date']['date_to']), + ) + self.with_context(no_new_invoice=True).message_post(body=self.ref, subject=subject, attachments=attachments) + + # Log a note on depending closings, redirecting to the main one + for closing_move in depending_closings: + closing_move.message_post(body=_( + "The attachments of the tax report can be found on the %(link_start)sclosing entry%(link_end)s of the representative company.", + link_start=Markup('') % self.id, + link_end=Markup(""), + )) + + # End activity + activity = self.company_id._get_tax_closing_reminder_activity(report.id, self.date, self.fiscal_position_id.id) + if activity: + activity.action_done() + + # Generate next activity + self.company_id._generate_tax_closing_reminder_activity(self.tax_closing_report_id, self.date + relativedelta(days=1), self.fiscal_position_id if self.fiscal_position_id.foreign_vat else None) + + if not self.fiscal_position_id and (not self.company_id.tax_lock_date or self.date > self.company_id.tax_lock_date): + self.env['account.report']._generate_default_external_values(options['date']['date_from'], options['date']['date_to'], True) + self.company_id.sudo().tax_lock_date = self.date + + self._close_tax_period_create_activities() + + def _get_tax_period_description(self): + self.ensure_one() + period_start, period_end = self.company_id._get_tax_closing_period_boundaries(self.date, self.tax_closing_report_id) + return self.company_id._get_tax_closing_move_description(self.company_id._get_tax_periodicity(self.tax_closing_report_id), period_start, period_end, self.fiscal_position_id, self.tax_closing_report_id) + + def _close_tax_period_create_activities(self): + mat_to_send_xml_id = 'odex30_account_reports.mail_activity_type_tax_report_to_be_sent' + mat_to_send = self.env.ref(mat_to_send_xml_id, raise_if_not_found=False) + if not mat_to_send: + # As this is introduced in stable, we ensure data exists by creating them on the fly if needed + mat_to_send = self.env['mail.activity.type'].sudo()._load_records([{ + 'xml_id': mat_to_send_xml_id, + 'noupdate': False, + 'values': { + 'name': 'Tax Report Ready', + 'summary': 'Tax report is ready to be sent to the administration', + 'category': 'tax_report', + 'delay_count': '0', + 'delay_unit': 'days', + 'delay_from': 'current_date', + 'res_model': 'account.move', + 'chaining_type': 'suggest', + } + }]) + mat_to_pay_xml_id = 'odex30_account_reports.mail_activity_type_tax_report_to_pay' + mat_to_pay = self.env.ref(mat_to_pay_xml_id, raise_if_not_found=False) + + act_user = mat_to_send.default_user_id + if act_user and not (self.company_id in act_user.company_ids and act_user.has_group('account.group_account_manager')): + act_user = self.env['res.users'] + + moves_without_send_activity = self.filtered_domain([ + '|', + ('activity_ids', '=', False), + ('activity_ids', 'not any', [('activity_type_id.id', '=', mat_to_send.id)]), + ]) + + for move in moves_without_send_activity: + period_desc = move._get_tax_period_description() + move.with_context(mail_activity_quick_update=True).activity_schedule( + act_type_xmlid=mat_to_send_xml_id, + summary=_("Send tax report: %s", period_desc), + date_deadline=fields.Date.context_today(move), + user_id=act_user.id or self.env.user.id, + ) + + if mat_to_pay and mat_to_pay not in move.activity_ids.activity_type_id and move._get_tax_to_pay_on_closing() > 0: + move.with_context(mail_activity_quick_update=True).activity_schedule( + act_type_xmlid=mat_to_pay_xml_id, + summary=_("Pay tax: %s", period_desc), + date_deadline=fields.Date.context_today(move), + user_id=act_user.id or self.env.user.id, + ) + + def refresh_tax_entry(self): + for move in self.filtered(lambda m: m.tax_closing_report_id and m.state == 'draft'): + report = move.tax_closing_report_id + options = move._get_tax_closing_report_options(move.company_id, move.fiscal_position_id, report, move.date) + self.env[report.custom_handler_model_name or 'account.generic.tax.report.handler']._generate_tax_closing_entries(report, options, closing_moves=move) + + @api.model + def _get_tax_closing_report_options(self, company, fiscal_position, report, date_inside_period): + _dummy, date_to = company._get_tax_closing_period_boundaries(date_inside_period, report) + + # In case the company submits its report in different regions, a closing entry + # is made for each fiscal position defining a foreign VAT. + # We hence need to make sure to select a tax report in the right country when opening + # the report (in case there are many, we pick the first one available; it doesn't impact the closing) + if fiscal_position and fiscal_position.foreign_vat: + fpos_option = fiscal_position.id + report_country = fiscal_position.country_id + else: + fpos_option = 'domestic' + report_country = company.account_fiscal_country_id + + options = { + 'date': { + 'date_to': fields.Date.to_string(date_to), + 'filter': 'custom_tax_period', + 'mode': 'range', + }, + 'selected_variant_id': report.id, + 'sections_source_id': report.id, + 'fiscal_position': fpos_option, + 'tax_unit': 'company_only', + } + + if report.filter_multi_company == 'tax_units': + # Enforce multicompany if the closing is done for a tax unit + candidate_tax_unit = company.account_tax_unit_ids.filtered(lambda x: x.country_id == report_country) + if candidate_tax_unit: + options['tax_unit'] = candidate_tax_unit.id + company_ids = candidate_tax_unit.company_ids.ids + else: + same_vat_branches = self.env.company._get_branches_with_same_vat() + # Consider the one with the least number of parents (highest in hierarchy) as the active company, coming first + company_ids = same_vat_branches.sorted(lambda x: len(x.parent_ids)).ids + else: + company_ids = self.env.company.ids + + return report.with_context(allowed_company_ids=company_ids).get_options(previous_options=options) + + def _get_vat_report_attachments(self, report, options): + # Fetch pdf + pdf_data = report.export_to_pdf(options) + return [(pdf_data['file_name'], pdf_data['file_content'])] + + def _compute_tax_closing_alert(self): + for move in self: + move.tax_closing_alert = ( + move.state == 'posted' + and move.tax_closing_report_id + and move.company_id.tax_lock_date + and move.company_id.tax_lock_date < move.date + ) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py b/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py new file mode 100644 index 0000000..b1a4d9f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_move_line.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, fields, _ + +from odoo.exceptions import UserError +from odoo.tools import SQL + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + exclude_bank_lines = fields.Boolean(compute='_compute_exclude_bank_lines', store=True) + + @api.depends('journal_id') + def _compute_exclude_bank_lines(self): + for move_line in self: + move_line.exclude_bank_lines = move_line.account_id != move_line.journal_id.default_account_id + + @api.constrains('tax_ids', 'tax_tag_ids') + def _check_taxes_on_closing_entries(self): + for aml in self: + if aml.move_id.tax_closing_report_id and (aml.tax_ids or aml.tax_tag_ids): + raise UserError(_("You cannot add taxes on a tax closing move line.")) + + @api.depends('product_id', 'product_uom_id', 'move_id.tax_closing_report_id') + def _compute_tax_ids(self): + """ Some special cases may see accounts used in tax closing having default taxes. + They would trigger the constrains above, which we don't want. Instead, we don't trigger + the tax computation in this case. + """ + # EXTEND account + lines_to_compute = self.filtered(lambda line: not line.move_id.tax_closing_report_id) + (self - lines_to_compute).tax_ids = False + super(AccountMoveLine, lines_to_compute)._compute_tax_ids() + + @api.model + def _prepare_aml_shadowing_for_report(self, change_equivalence_dict): + """ Prepares the fields lists for creating a temporary table shadowing the account_move_line one. + This is used to switch the computation mode of the reports, with analytics or financial budgets, for example. + + :param change_equivalence_dict: A dict, in the form {aml_field: sql_equivalence}, where: + - aml_field: is a string containing the name of field of account.move.line + - sql_equivalence: is the value to use to shadow aml_field. It can be an SQL object; if + it's not, it'll be escaped in the query. + + :return: A tuple of 2 SQL objects, so that: + - The first one is the fields list to pass into the INSERT TO part of the query filling up the temporary table + - The second one contains the field values to insert into the SELECT clause of the same query, in the same order + as in the first element of the returned tuple. + """ + line_fields = self.env['account.move.line'].fields_get() + self.env.cr.execute("SELECT column_name FROM information_schema.columns WHERE table_name='account_move_line'") + stored_fields = {f[0] for f in self.env.cr.fetchall() if f[0] in line_fields} + + fields_to_insert = [] + for fname in stored_fields: + if fname in change_equivalence_dict: + fields_to_insert.append(SQL( + "%(original)s AS %(asname)s", + original=change_equivalence_dict[fname], + asname=SQL('"account_move_line.%s"', SQL(fname)), + )) + else: + line_field = line_fields[fname] + if line_field.get("translate"): + typecast = SQL('jsonb') + else: + typecast = SQL(self.env['account.move.line']._fields[fname].column_type[0]) + + fields_to_insert.append(SQL( + "CAST(NULL AS %(typecast)s) AS %(fname)s", + typecast=typecast, + fname=SQL('"account_move_line.%s"', SQL(fname)), + )) + + return SQL(', ').join(SQL.identifier(fname) for fname in stored_fields), SQL(', ').join(fields_to_insert) diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py new file mode 100644 index 0000000..5670b89 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_multicurrency_revaluation_report.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.tools import float_is_zero, SQL +from odoo.exceptions import UserError + +from itertools import chain + + +class MulticurrencyRevaluationReportCustomHandler(models.AbstractModel): + """Manage Unrealized Gains/Losses. + + In multi-currencies environments, we need a way to control the risk related + to currencies (in case some are higthly fluctuating) and, in some countries, + some laws also require to create journal entries to record the provisionning + of a probable future expense related to currencies. Hence, people need to + create a journal entry at the beginning of a period, to make visible the + probable expense in reports (and revert it at the end of the period, to + recon the real gain/loss. + """ + _name = 'account.multicurrency.revaluation.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Multicurrency Revaluation Report Custom Handler' + + def _get_custom_display_config(self): + return { + 'components': { + 'AccountReportFilters': 'odex30_account_reports.MulticurrencyRevaluationReportFilters', + }, + 'templates': { + 'AccountReportLineName': 'odex30_account_reports.MulticurrencyRevaluationReportLineName', + }, + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + active_currencies = self.env['res.currency'].search([('active', '=', True)]) + if len(active_currencies) < 2: + raise UserError(_("You need to activate more than one currency to access this report.")) + rates = active_currencies._get_rates(self.env.company, options.get('date').get('date_to')) + # Normalize the rates to the company's currency + company_rate = rates[self.env.company.currency_id.id] + for key in rates.keys(): + rates[key] /= company_rate + + options['currency_rates'] = { + str(currency_id.id): { + 'currency_id': currency_id.id, + 'currency_name': currency_id.name, + 'currency_main': self.env.company.currency_id.name, + 'rate': (rates[currency_id.id] + if not previous_options.get('currency_rates', {}).get(str(currency_id.id), {}).get('rate') else + float(previous_options['currency_rates'][str(currency_id.id)]['rate'])), + } for currency_id in active_currencies + } + + for currency_rates in options['currency_rates'].values(): + if currency_rates['rate'] == 0: + raise UserError(_("The currency rate cannot be equal to zero")) + + options['company_currency'] = options['currency_rates'].pop(str(self.env.company.currency_id.id)) + options['custom_rate'] = any( + not float_is_zero(cr['rate'] - rates[cr['currency_id']], 20) + for cr in options['currency_rates'].values() + ) + + options['multi_currency'] = True + options['buttons'].append({'name': _('Adjustment Entry'), 'sequence': 30, 'action': 'action_multi_currency_revaluation_open_revaluation_wizard', 'always_show': True}) + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + if len(self.env.companies) > 1: + warnings['odex30_account_reports.multi_currency_revaluation_report_warning_multicompany'] = {'alert_type': 'warning'} + if options['custom_rate']: + warnings['odex30_account_reports.multi_currency_revaluation_report_warning_custom_rate'] = {'alert_type': 'warning'} + + def _custom_line_postprocessor(self, report, options, lines): + line_to_adjust_id = self.env.ref('odex30_account_reports.multicurrency_revaluation_to_adjust').id + line_excluded_id = self.env.ref('odex30_account_reports.multicurrency_revaluation_excluded').id + + rslt = [] + for index, line in enumerate(lines): + res_model_name, res_id = report._get_model_info_from_id(line['id']) + + if res_model_name == 'account.report.line' and ( + (res_id == line_to_adjust_id and report._get_model_info_from_id(lines[index + 1]['id']) == ('account.report.line', line_excluded_id)) or + (res_id == line_excluded_id and index == len(lines) - 1) + ): + # 'To Adjust' and 'Excluded' lines need to be hidden if they have no child + continue + + elif res_model_name == 'res.currency': + # Include the rate in the currency_id group lines + line['name'] = '{for_cur} (1 {comp_cur} = {rate:.6} {for_cur})'.format( + for_cur=line['name'], + comp_cur=self.env.company.currency_id.display_name, + rate=float(options['currency_rates'][str(res_id)]['rate']), + ) + + elif res_model_name == 'account.account': + # Mark the included/excluded lines, so that the custom component templates knows what label to put on them + line['is_included_line'] = report._get_res_id_from_line_id(line['id'], 'account.account') == line_to_adjust_id + + # Inject the related model into the line dict in order to use it on the custom component template on js side to display buttons + line['cur_revaluation_line_model'] = res_model_name + + rslt.append(line) + + return rslt + + def _custom_groupby_line_completer(self, report, options, line_dict): + model_info_from_id = report._get_model_info_from_id(line_dict['id']) + if model_info_from_id[0] == 'res.currency': + line_dict['unfolded'] = True + line_dict['unfoldable'] = False + + def action_multi_currency_revaluation_open_revaluation_wizard(self, options): + """Open the revaluation wizard.""" + form = self.env.ref('odex30_account_reports.view_account_multicurrency_revaluation_wizard', False) + return { + 'name': _("Make Adjustment Entry"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.multicurrency.revaluation.wizard', + 'view_mode': 'form', + 'view_id': form.id, + 'views': [(form.id, 'form')], + 'multi': 'True', + 'target': 'new', + 'context': { + **self._context, + 'multicurrency_revaluation_report_options': options, + }, + } + + # ACTIONS + def action_multi_currency_revaluation_open_general_ledger(self, options, params): + report = self.env['account.report'].browse(options['report_id']) + account_id = report._get_res_id_from_line_id(params['line_id'], 'account.account') + account_line_id = report._get_generic_line_id('account.account', account_id) + general_ledger_options = self.env.ref('odex30_account_reports.general_ledger_report').get_options(options) + general_ledger_options['unfolded_lines'] = [account_line_id] + + general_ledger_action = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger') + general_ledger_action['params'] = { + 'options': general_ledger_options, + 'ignore_session': True, + } + + return general_ledger_action + + def action_multi_currency_revaluation_toggle_provision(self, options, params): + """ Include/exclude an account from the provision. """ + res_ids_map = self.env['account.report']._get_res_ids_from_line_id(params['line_id'], ['res.currency', 'account.account']) + account = self.env['account.account'].browse(res_ids_map['account.account']) + currency = self.env['res.currency'].browse(res_ids_map['res.currency']) + if currency in account.exclude_provision_currency_ids: + account.exclude_provision_currency_ids -= currency + else: + account.exclude_provision_currency_ids += currency + return { + 'type': 'ir.actions.client', + 'tag': 'reload', + } + + def action_multi_currency_revaluation_open_currency_rates(self, options, params=None): + """ Open the currency rate list. """ + currency_id = self.env['account.report']._get_res_id_from_line_id(params['line_id'], 'res.currency') + return { + 'type': 'ir.actions.act_window', + 'name': _('Currency Rates (%s)', self.env['res.currency'].browse(currency_id).display_name), + 'views': [(False, 'list')], + 'res_model': 'res.currency.rate', + 'context': {**self.env.context, **{'default_currency_id': currency_id, 'active_id': currency_id}}, + 'domain': [('currency_id', '=', currency_id)], + } + + def _report_custom_engine_multi_currency_revaluation_to_adjust(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._multi_currency_revaluation_get_custom_lines(options, 'to_adjust', current_groupby, next_groupby, offset=offset, limit=limit) + + def _report_custom_engine_multi_currency_revaluation_excluded(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._multi_currency_revaluation_get_custom_lines(options, 'excluded', current_groupby, next_groupby, offset=offset, limit=limit) + + def _multi_currency_revaluation_get_custom_lines(self, options, line_code, current_groupby, next_groupby, offset=0, limit=None): + def build_result_dict(report, query_res): + return { + 'balance_currency': query_res['balance_currency'] if len(query_res['currency_id']) == 1 else None, + 'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None, + 'balance_operation': query_res['balance_operation'], + 'balance_current': query_res['balance_current'], + 'adjustment': query_res['adjustment'], + 'has_sublines': query_res['aml_count'] > 0, + } + + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + # No need to run any SQL if we're computing the main line: it does not display any total + if not current_groupby: + return { + 'balance_currency': None, + 'currency_id': None, + 'balance_operation': None, + 'balance_current': None, + 'adjustment': None, + 'has_sublines': False, + } + + query = "(VALUES {})".format(', '.join("(%s, %s)" for rate in options['currency_rates'])) + params = list(chain.from_iterable((cur['currency_id'], cur['rate']) for cur in options['currency_rates'].values())) + custom_currency_table_query = SQL(query, *params) + date_to = options['date']['date_to'] + select_part_not_an_exchange_move_id = SQL( + """ + NOT EXISTS ( + SELECT 1 + FROM account_partial_reconcile part_exch + WHERE part_exch.exchange_move_id = account_move_line.move_id + AND part_exch.max_date <= %s + ) + """, + date_to + ) + + query = report._get_report_query(options, 'strict_range') + groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) + tail_query = report._get_engine_query_tail(offset, limit) + full_query = SQL( + """ + WITH custom_currency_table(currency_id, rate) AS (%(custom_currency_table_query)s) + + -- Final select that gets the following lines: + -- (where there is a change in the rates of currency between the creation of the move and the full payments) + -- - Moves that don't have a payment yet at a certain date + -- - Moves that have a partial but are not fully paid at a certain date + SELECT + subquery.grouping_key, + ARRAY_AGG(DISTINCT(subquery.currency_id)) AS currency_id, + SUM(subquery.balance_currency) AS balance_currency, + SUM(subquery.balance_operation) AS balance_operation, + SUM(subquery.balance_current) AS balance_current, + SUM(subquery.adjustment) AS adjustment, + COUNT(subquery.aml_id) AS aml_count + FROM ( + -- Get moves that have at least one partial at a certain date and are not fully paid at that date + SELECT + %(groupby_field_sql)s AS grouping_key, + ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places) AS balance_operation, + ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) AS balance_currency, + ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) / custom_currency_table.rate AS balance_current, + ( + -- adjustment is computed as: balance_current - balance_operation + ROUND( account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) / custom_currency_table.rate + - ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places) + ) AS adjustment, + account_move_line.currency_id AS currency_id, + account_move_line.id AS aml_id + FROM %(table_references)s, + account_account AS account, + res_currency AS aml_currency, + res_currency AS aml_comp_currency, + custom_currency_table, + + -- Get for each move line the amount residual and amount_residual currency + -- both for matched "debit" and matched "credit" the same way as account.move.line + -- '_compute_amount_residual()' method does + -- (using LATERAL greatly reduce the number of lines for which we have to compute it) + LATERAL ( + -- Get sum of matched "debit" amount and amount in currency for related move line at date + SELECT COALESCE(SUM(part.amount), 0.0) AS amount_debit, + ROUND( + SUM(part.debit_amount_currency), + curr.decimal_places + ) AS amount_debit_currency, + 0.0 AS amount_credit, + 0.0 AS amount_credit_currency, + account_move_line.currency_id AS currency_id, + account_move_line.id AS aml_id + FROM account_partial_reconcile part + JOIN res_currency curr ON curr.id = part.debit_currency_id + WHERE account_move_line.id = part.debit_move_id + AND part.max_date <= %(date_to)s + GROUP BY aml_id, + curr.decimal_places + UNION + -- Get sum of matched "credit" amount and amount in currency for related move line at date + SELECT 0.0 AS amount_debit, + 0.0 AS amount_debit_currency, + COALESCE(SUM(part.amount), 0.0) AS amount_credit, + ROUND( + SUM(part.credit_amount_currency), + curr.decimal_places + ) AS amount_credit_currency, + account_move_line.currency_id AS currency_id, + account_move_line.id AS aml_id + FROM account_partial_reconcile part + JOIN res_currency curr ON curr.id = part.credit_currency_id + WHERE account_move_line.id = part.credit_move_id + AND part.max_date <= %(date_to)s + GROUP BY aml_id, + curr.decimal_places + ) AS ara + WHERE %(search_condition)s + AND account_move_line.account_id = account.id + AND account_move_line.currency_id = aml_currency.id + AND account_move_line.company_currency_id = aml_comp_currency.id + AND account_move_line.currency_id = custom_currency_table.currency_id + AND account.account_type NOT IN ('income', 'income_other', 'expense', 'expense_depreciation', 'expense_direct_cost', 'off_balance') + AND ( + account.currency_id != account_move_line.company_currency_id + OR ( + account.account_type IN ('asset_receivable', 'liability_payable') + AND (account_move_line.currency_id != account_move_line.company_currency_id) + ) + ) + AND %(exist_condition)s ( + SELECT 1 + FROM account_account_exclude_res_currency_provision + WHERE account_account_id = account_move_line.account_id + AND res_currency_id = account_move_line.currency_id + ) + AND (%(select_part_not_an_exchange_move_id)s) + GROUP BY account_move_line.id, grouping_key, aml_comp_currency.decimal_places, aml_currency.decimal_places, custom_currency_table.rate + HAVING ROUND(account_move_line.balance - SUM(ara.amount_debit) + SUM(ara.amount_credit), aml_comp_currency.decimal_places) != 0 + OR ROUND(account_move_line.amount_currency - SUM(ara.amount_debit_currency) + SUM(ara.amount_credit_currency), aml_currency.decimal_places) != 0.0 + + UNION + -- Moves that don't have a payment yet at a certain date + SELECT + %(groupby_field_sql)s AS grouping_key, + account_move_line.balance AS balance_operation, + account_move_line.amount_currency AS balance_currency, + account_move_line.amount_currency / custom_currency_table.rate AS balance_current, + account_move_line.amount_currency / custom_currency_table.rate - account_move_line.balance AS adjustment, + account_move_line.currency_id AS currency_id, + account_move_line.id AS aml_id + FROM %(table_references)s + JOIN account_account account ON account_move_line.account_id = account.id + JOIN custom_currency_table ON custom_currency_table.currency_id = account_move_line.currency_id + WHERE %(search_condition)s + AND account.account_type NOT IN ('income', 'income_other', 'expense', 'expense_depreciation', 'expense_direct_cost', 'off_balance') + AND ( + account.currency_id != account_move_line.company_currency_id + OR ( + account.account_type IN ('asset_receivable', 'liability_payable') + AND (account_move_line.currency_id != account_move_line.company_currency_id) + ) + ) + AND %(exist_condition)s ( + SELECT 1 + FROM account_account_exclude_res_currency_provision + WHERE account_account_id = account_id + AND res_currency_id = account_move_line.currency_id + ) + AND (%(select_part_not_an_exchange_move_id)s) + AND NOT EXISTS ( + SELECT 1 FROM account_partial_reconcile part + WHERE (part.debit_move_id = account_move_line.id OR part.credit_move_id = account_move_line.id) + AND part.max_date <= %(date_to)s + ) + AND (account_move_line.balance != 0.0 OR account_move_line.amount_currency != 0.0) + + ) subquery + + GROUP BY grouping_key + ORDER BY grouping_key + %(tail_query)s + """, + groupby_field_sql=groupby_field_sql, + custom_currency_table_query=custom_currency_table_query, + exist_condition=SQL('NOT EXISTS') if line_code == 'to_adjust' else SQL('EXISTS'), + table_references=query.from_clause, + date_to=date_to, + tail_query=tail_query, + search_condition=query.where_clause, + select_part_not_an_exchange_move_id=select_part_not_an_exchange_move_id, + ) + self._cr.execute(full_query) + query_res_lines = self._cr.dictfetchall() + + if not current_groupby: + return build_result_dict(report, query_res_lines and query_res_lines[0] or {}) + else: + rslt = [] + for query_res in query_res_lines: + grouping_key = query_res['grouping_key'] + rslt.append((grouping_key, build_result_dict(report, query_res))) + return rslt diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py b/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py new file mode 100644 index 0000000..d6ea0db --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_partner_ledger.py @@ -0,0 +1,815 @@ + +from odoo import api, models, _, fields +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools import SQL + +from datetime import timedelta +from collections import defaultdict +from copy import deepcopy + + +class PartnerLedgerCustomHandler(models.AbstractModel): + _name = 'account.partner.ledger.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Partner Ledger Custom Handler' + + def _get_custom_display_config(self): + return { + 'css_custom_class': 'partner_ledger', + 'components': { + 'AccountReportLineCell': 'odex30_account_reports.PartnerLedgerLineCell', + }, + 'templates': { + 'AccountReportFilters': 'odex30_account_reports.PartnerLedgerFilters', + 'AccountReportLineName': 'odex30_account_reports.PartnerLedgerLineName', + }, + } + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + partner_lines, totals_by_column_group = self._build_partner_lines(report, options) + lines = report._regroup_lines_by_name_prefix(options, partner_lines, '_report_expand_unfoldable_line_partner_ledger_prefix_group', 0) + + # Inject sequence on dynamic lines + lines = [(0, line) for line in lines] + + # Report total line. + lines.append((0, self._get_report_line_total(options, totals_by_column_group))) + + return lines + + def _build_partner_lines(self, report, options, level_shift=0): + lines = [] + + totals_by_column_group = { + column_group_key: { + total: 0.0 + for total in ['debit', 'credit', 'amount', 'balance'] + } + for column_group_key in options['column_groups'] + } + + partners_results = self._query_partners(report, options) + + search_filter = options.get('filter_search_bar', '') + accept_unknown_in_filter = search_filter.lower() in self._get_no_partner_line_label().lower() + for partner, results in partners_results: + if options['export_mode'] == 'print' and search_filter and not partner and not accept_unknown_in_filter: + # When printing and searching for a specific partner, make it so we only show its lines, not the 'Unknown Partner' one, that would be + # shown in case a misc entry with no partner was reconciled with one of the target partner's entries. + continue + + partner_values = defaultdict(dict) + for column_group_key in options['column_groups']: + partner_sum = results.get(column_group_key, {}) + + partner_values[column_group_key]['debit'] = partner_sum.get('debit', 0.0) + partner_values[column_group_key]['credit'] = partner_sum.get('credit', 0.0) + partner_values[column_group_key]['amount'] = partner_sum.get('amount', 0.0) + partner_values[column_group_key]['balance'] = partner_sum.get('balance', 0.0) + + totals_by_column_group[column_group_key]['debit'] += partner_values[column_group_key]['debit'] + totals_by_column_group[column_group_key]['credit'] += partner_values[column_group_key]['credit'] + totals_by_column_group[column_group_key]['amount'] += partner_values[column_group_key]['amount'] + totals_by_column_group[column_group_key]['balance'] += partner_values[column_group_key]['balance'] + + lines.append(self._get_report_line_partners(options, partner, partner_values, level_shift=level_shift)) + + return lines, totals_by_column_group + + def _report_expand_unfoldable_line_partner_ledger_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + report = self.env['account.report'].browse(options['report_id']) + matched_prefix = report._get_prefix_groups_matched_prefix_from_line_id(line_dict_id) + + prefix_domain = [('partner_id.name', '=ilike', f'{matched_prefix}%')] + if self._get_no_partner_line_label().upper().startswith(matched_prefix): + prefix_domain = expression.OR([prefix_domain, [('partner_id', '=', None)]]) + + expand_options = { + **options, + 'forced_domain': options.get('forced_domain', []) + prefix_domain + } + parent_level = len(matched_prefix) * 2 + partner_lines, dummy = self._build_partner_lines(report, expand_options, level_shift=parent_level) + + for partner_line in partner_lines: + partner_line['id'] = report._build_subline_id(line_dict_id, partner_line['id']) + partner_line['parent_id'] = line_dict_id + + lines = report._regroup_lines_by_name_prefix( + options, + partner_lines, + '_report_expand_unfoldable_line_partner_ledger_prefix_group', + parent_level, + matched_prefix=matched_prefix, + parent_line_dict_id=line_dict_id, + ) + + return { + 'lines': lines, + 'offset_increment': len(lines), + 'has_more': False, + } + + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + domain = [] + + company_ids = report.get_report_company_ids(options) + exch_code = self.env['res.company'].browse(company_ids).mapped('currency_exchange_journal_id') + if exch_code: + domain += ['!', '&', '&', '&', ('credit', '=', 0.0), ('debit', '=', 0.0), ('amount_currency', '!=', 0.0), ('journal_id', 'in', exch_code.ids)] + + if options['export_mode'] == 'print' and options.get('filter_search_bar'): + domain += [ + '|', ('matched_debit_ids.debit_move_id.partner_id.name', 'ilike', options['filter_search_bar']), + '|', ('matched_credit_ids.credit_move_id.partner_id.name', 'ilike', options['filter_search_bar']), + '|', ('partner_id.name', 'ilike', options['filter_search_bar']), + ('partner_id', '=', False), + ] + + options['forced_domain'] = options.get('forced_domain', []) + domain + + if self.env.user.has_group('base.group_multi_currency'): + options['multi_currency'] = True + else: + options['columns'] = [col for col in options['columns'] if col['expression_label'] != 'amount_currency'] + + if not self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False): + # Deprecated, will be removed in master + columns_to_hide = [] + options['hide_account'] = (previous_options or {}).get('hide_account', False) + columns_to_hide += ['journal_code', 'account_code', 'matching_number'] if options['hide_account'] else [] + + options['hide_debit_credit'] = (previous_options or {}).get('hide_debit_credit', False) + columns_to_hide += ['debit', 'credit'] if options['hide_debit_credit'] else ['amount'] + + options['columns'] = [col for col in options['columns'] if col['expression_label'] not in columns_to_hide] + + options['buttons'].append({ + 'name': _('Send'), + 'action': 'action_send_statements', + 'sequence': 90, + 'always_show': True, + }) + + def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): + partner_ids_to_expand = [] + + # Regular case + for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_partner_ledger', []): + markup, model, model_id = self.env['account.report']._parse_line_id(line_dict['id'])[-1] + if model == 'res.partner': + partner_ids_to_expand.append(model_id) + elif markup == 'no_partner': + partner_ids_to_expand.append(None) + + # In case prefix groups are used + no_partner_line_label = self._get_no_partner_line_label().upper() + partner_prefix_domains = [] + for line_dict in lines_to_expand_by_function.get('_report_expand_unfoldable_line_partner_ledger_prefix_group', []): + prefix = report._get_prefix_groups_matched_prefix_from_line_id(line_dict['id']) + partner_prefix_domains.append([('name', '=ilike', f'{prefix}%')]) + + # amls without partners are regrouped "Unknown Partner", which is also used to create prefix groups + if no_partner_line_label.startswith(prefix): + partner_ids_to_expand.append(None) + + if partner_prefix_domains: + partner_ids_to_expand += self.env['res.partner'].with_context(active_test=False).search(expression.OR(partner_prefix_domains)).ids + + return { + 'initial_balances': self._get_initial_balance_values(partner_ids_to_expand, options) if partner_ids_to_expand else {}, + + # load_more_limit cannot be passed to this call, otherwise it won't be applied per partner but on the whole result. + # We gain perf from batching, but load every result, even if the limit restricts them later. + 'aml_values': self._get_aml_values(options, partner_ids_to_expand) if partner_ids_to_expand else {}, + } + + def _get_report_send_recipients(self, options): + # Deprecated, to be moved to customer statement handler in master + partners = options.get('partner_ids', []) + if not partners: + report = self.env['account.report'].browse(options['report_id']) + self._cr.execute(self._get_query_sums(report, options)) + partners = [row['groupby'] for row in self._cr.dictfetchall() if row['groupby']] + return self.env['res.partner'].browse(partners) + + def action_send_statements(self, options): + # Deprecated, to be moved to customer statement handler in master + template = self.env.ref('odex30_account_reports.email_template_customer_statement', False) + partners = self.env['res.partner'].browse(options.get('partner_ids', [])) + return { + 'name': _("Send %s Statement", partners.name) if len(partners) == 1 else _("Send Partner Ledgers"), + 'type': 'ir.actions.act_window', + 'views': [[False, 'form']], + 'res_model': 'account.report.send', + 'target': 'new', + 'context': { + 'default_mail_template_id': template.id if template else False, + 'default_report_options': options, + }, + } + + @api.model + def action_open_partner(self, options, params): + dummy, record_id = self.env['account.report']._get_model_info_from_id(params['id']) + return { + 'type': 'ir.actions.act_window', + 'res_model': 'res.partner', + 'res_id': record_id, + 'views': [[False, 'form']], + 'view_mode': 'form', + 'target': 'current', + } + + def _query_partners(self, report, options): + """ Executes the queries and performs all the computation. + :return: A list of tuple (partner, column_group_values) sorted by the table's model _order: + - partner is a res.parter record. + - column_group_values is a dict(column_group_key, fetched_values), where + - column_group_key is a string identifying a column group, like in options['column_groups'] + - fetched_values is a dictionary containing: + - sum: {'debit': float, 'credit': float, 'balance': float} + - (optional) initial_balance: {'debit': float, 'credit': float, 'balance': float} + - (optional) lines: [line_vals_1, line_vals_2, ...] + """ + def assign_sum(row): + fields_to_assign = ['balance', 'debit', 'credit', 'amount'] + if any(not company_currency.is_zero(row[field]) for field in fields_to_assign): + groupby_partners.setdefault(row['groupby'], defaultdict(lambda: defaultdict(float))) + for field in fields_to_assign: + groupby_partners[row['groupby']][row['column_group_key']][field] += row[field] + + company_currency = self.env.company.currency_id + + # Execute the queries and dispatch the results. + query = self._get_query_sums(report, options) + + groupby_partners = {} + + self._cr.execute(query) + for res in self._cr.dictfetchall(): + assign_sum(res) + + # Correct the sums per partner, for the lines without partner reconciled with a line having a partner + self._add_sums_of_lines_without_partners(options, groupby_partners) + + # Retrieve the partners to browse. + # groupby_partners.keys() contains all account ids affected by: + # - the amls in the current period. + # - the amls affecting the initial balance. + if groupby_partners: + # Note a search is done instead of a browse to preserve the table ordering. + partners = self.env['res.partner'].with_context(active_test=False).search_fetch([('id', 'in', list(groupby_partners.keys()))], ["id", "name", "trust", "company_registry", "vat"]) + else: + partners = [] + + # Add 'Partner Unknown' if needed + if None in groupby_partners.keys(): + partners = [p for p in partners] + [None] + + return [(partner, groupby_partners[partner.id if partner else None]) for partner in partners] + + def _get_query_sums(self, report, options) -> SQL: + """ Construct a query retrieving all the aggregated sums to build the report. It includes: + - sums for all partners. + - sums for the initial balances. + :param options: The report options. + :return: query as SQL object + """ + queries = [] + + # Create the currency table. + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + query = report._get_report_query(column_group_options, 'from_beginning') + date_from = options['date']['date_from'] + queries.append(SQL( + """ + (WITH partner_sums AS ( + SELECT + account_move_line.partner_id AS groupby, + %(column_group_key)s AS column_group_key, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS amount, + SUM(%(balance_select)s) AS balance, + BOOL_AND(account_move_line.reconciled) AS all_reconciled, + MAX(account_move_line.date) AS latest_date + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.partner_id + ) + SELECT * + FROM partner_sums + WHERE partner_sums.balance != 0 + OR partner_sums.all_reconciled = FALSE + OR partner_sums.latest_date >= %(date_from)s + )""", + column_group_key=column_group_key, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(column_group_options), + search_condition=query.where_clause, + date_from=date_from, + )) + + return SQL(' UNION ALL ').join(queries) + + def _get_initial_balance_values(self, partner_ids, options): + queries = [] + report = self.env.ref('odex30_account_reports.partner_ledger_report') + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + # Get sums for the initial balance. + # period: [('date' <= options['date_from'] - 1)] + new_options = self._get_options_initial_balance(column_group_options) + query = report._get_report_query(new_options, 'from_beginning', domain=[('partner_id', 'in', partner_ids)]) + queries.append(SQL( + """ + SELECT + account_move_line.partner_id, + %(column_group_key)s AS column_group_key, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS amount, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + GROUP BY account_move_line.partner_id + """, + column_group_key=column_group_key, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(column_group_options), + search_condition=query.where_clause, + )) + + self._cr.execute(SQL(" UNION ALL ").join(queries)) + + init_balance_by_col_group = { + partner_id: {column_group_key: defaultdict(float) for column_group_key in options['column_groups']} + for partner_id in partner_ids + } + for result in self._cr.dictfetchall(): + init_balance_by_col_group[result['partner_id']][result['column_group_key']] = result + + # Correct the sums per partner, for the lines without partner reconciled with a line having a partner + new_options = self._get_options_initial_balance(options) + self._add_sums_of_lines_without_partners(new_options, init_balance_by_col_group) + + return init_balance_by_col_group + + def _get_options_initial_balance(self, options): + """ Create options used to compute the initial balances for each partner. + The resulting dates domain will be: + [('date' <= options['date_from'] - 1)] + :param options: The report options. + :return: A copy of the options, modified to match the dates to use to get the initial balances. + """ + new_date_to = fields.Date.from_string(options['date']['date_from']) - timedelta(days=1) + new_options = deepcopy(options) + new_options['date']['date_from'] = False + new_options['date']['date_to'] = fields.Date.to_string(new_date_to) + for column_group in new_options['column_groups'].values(): + column_group['forced_options']['date'] = new_options['date'] + return new_options + + def _add_sums_of_lines_without_partners(self, options, result_dict): + fields2inverse = { + 'balance': ('balance', -1), + 'debit': ('credit', 1), + 'amount': ('amount', 1), + 'credit': ('debit', 1), + } + query = self._get_sums_without_partner(options) + self._cr.execute(query) + rows = self._cr.dictfetchall() + for row in rows: + for field, (inverse_field, inverse_sign) in fields2inverse.items(): + if partner_vals := result_dict.get(row['groupby']): + partner_vals[row['column_group_key']][field] += row[field] + if no_partner_vals := result_dict.get(None): + # Debit/credit are inverted for the unknown partner as the computation is made regarding the balance of the known partner + no_partner_vals[row['column_group_key']][inverse_field] += inverse_sign * row[field] + + def _get_sums_without_partner(self, options): + """ Get the sum of lines without partner reconciled with a line with a partner, grouped by partner. Those lines + should be considered as belonging to the partner for the reconciled amount as it may clear some of the partner + invoice/bill and they have to be accounted in the partner balance.""" + queries = [] + report = self.env.ref('odex30_account_reports.partner_ledger_report') + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + query = report._get_report_query(column_group_options, 'from_beginning') + queries.append(SQL( + """ + SELECT + %(column_group_key)s AS column_group_key, + aml_with_partner.partner_id AS groupby, + SUM(%(debit_select)s) AS debit, + SUM(%(credit_select)s) AS credit, + SUM(%(balance_select)s) AS amount, + SUM(%(balance_select)s) AS balance + FROM %(table_references)s + JOIN account_partial_reconcile partial + ON account_move_line.id = partial.debit_move_id OR account_move_line.id = partial.credit_move_id + JOIN account_move_line aml_with_partner ON + (aml_with_partner.id = partial.debit_move_id OR aml_with_partner.id = partial.credit_move_id) + AND aml_with_partner.partner_id IS NOT NULL + %(currency_table_join)s + WHERE partial.max_date <= %(date_to)s AND %(search_condition)s + AND account_move_line.partner_id IS NULL + GROUP BY aml_with_partner.partner_id + """, + column_group_key=column_group_key, + debit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance > 0 THEN 0 ELSE partial.amount END")), + credit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance < 0 THEN 0 ELSE partial.amount END")), + balance_select=report._currency_table_apply_rate(SQL("-SIGN(aml_with_partner.balance) * partial.amount")), + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(column_group_options, aml_alias=SQL("aml_with_partner")), + date_to=column_group_options['date']['date_to'], + search_condition=query.where_clause, + )) + + return SQL(" UNION ALL ").join(queries) + + def _report_expand_unfoldable_line_partner_ledger(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + report = self.env['account.report'].browse(options['report_id']) + markup, model, record_id = report._parse_line_id(line_dict_id)[-1] + + if model != 'res.partner': + raise UserError(_("Wrong ID for partner ledger line to expand: %s", line_dict_id)) + + prefix_groups_count = 0 + for markup, dummy1, dummy2 in report._parse_line_id(line_dict_id): + if isinstance(markup, dict) and 'groupby_prefix_group' in markup: + prefix_groups_count += 1 + level_shift = prefix_groups_count * 2 + + lines = [] + + # Get initial balance + if offset == 0 and not options.get('hide_initial_balance'): + if unfold_all_batch_data: + init_balance_by_col_group = unfold_all_batch_data['initial_balances'][record_id] + else: + init_balance_by_col_group = self._get_initial_balance_values([record_id], options)[record_id] + initial_balance_line = report._get_partner_and_general_ledger_initial_balance_line(options, line_dict_id, init_balance_by_col_group, level_shift=level_shift) + if initial_balance_line: + lines.append(initial_balance_line) + + # For the first expansion of the line, the initial balance line gives the progress + progress = self._init_load_more_progress(options, initial_balance_line) + + limit_to_load = report.load_more_limit + 1 if report.load_more_limit and options['export_mode'] != 'print' else None + + if unfold_all_batch_data: + aml_results = unfold_all_batch_data['aml_values'][record_id] + else: + aml_results = self._get_aml_values(options, [record_id], offset=offset, limit=limit_to_load)[record_id] + + aml_report_lines, next_progress, treated_results_count, has_more = self._get_partner_aml_report_lines(report, options, line_dict_id, aml_results, progress, offset, level_shift=level_shift) + lines.extend(aml_report_lines) + + return { + 'lines': lines, + 'offset_increment': treated_results_count, + 'has_more': has_more, + 'progress': next_progress + } + + def _init_load_more_progress(self, options, line_dict): + return { + column['column_group_key']: line_col.get('no_format', 0) + for column, line_col in zip(options['columns'], line_dict['columns']) + if column['expression_label'] == 'balance' + } + + def _get_partner_aml_report_lines(self, report, options, partner_line_id, aml_results, progress, offset=0, level_shift=0): + lines = [] + has_more = False + treated_results_count = 0 + next_progress = progress + for result in aml_results: + if self._is_report_limit_reached(report, options, treated_results_count): + # We loaded one more than the limit on purpose: this way we know we need a "load more" line + has_more = True + break + + new_line = self._get_report_line_move_line(options, result, partner_line_id, next_progress, level_shift=level_shift) + lines.append(new_line) + next_progress = self._init_load_more_progress(options, new_line) + treated_results_count += 1 + return lines, next_progress, treated_results_count, has_more + + def _is_report_limit_reached(self, report, options, results_count): + return options['export_mode'] != 'print' and report.load_more_limit and results_count == report.load_more_limit + + def _get_additional_column_aml_values(self): + """ + Allows customization of additional fields in the partner ledger query. + + This method is intended to be overridden by other modules to add custom fields + to the partner ledger query, e.g. SQL("account_move_line.date AS date,"). + + By default, it returns an empty SQL object. + """ + return SQL() + + def _get_order_by_aml_values(self): + return SQL('account_move_line.date, account_move_line.id') + + def _get_aml_values(self, options, partner_ids, offset=0, limit=None): + rslt = {partner_id: [] for partner_id in partner_ids} + + partner_ids_wo_none = [x for x in partner_ids if x] + directly_linked_aml_partner_clauses = [] + indirectly_linked_aml_partner_clause = SQL('aml_with_partner.partner_id IS NOT NULL') + if None in partner_ids: + directly_linked_aml_partner_clauses.append(SQL('account_move_line.partner_id IS NULL')) + if partner_ids_wo_none: + directly_linked_aml_partner_clauses.append(SQL('account_move_line.partner_id IN %s', tuple(partner_ids_wo_none))) + indirectly_linked_aml_partner_clause = SQL('aml_with_partner.partner_id IN %s', tuple(partner_ids_wo_none)) + directly_linked_aml_partner_clause = SQL('(%s)', SQL(' OR ').join(directly_linked_aml_partner_clauses)) + + queries = [] + journal_name = self.env['account.journal']._field_to_sql('journal', 'name') + report = self.env.ref('odex30_account_reports.partner_ledger_report') + additional_columns = self._get_additional_column_aml_values() + order_by = self._get_order_by_aml_values() + for column_group_key, group_options in report._split_options_per_column_group(options).items(): + query = report._get_report_query(group_options, 'strict_range') + account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id') + account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query) + account_name = self.env['account.account']._field_to_sql(account_alias, 'name') + + # For the move lines directly linked to this partner + # ruff: noqa: FURB113 + queries.append(SQL( + ''' + SELECT + account_move_line.id, + COALESCE(account_move_line.date_maturity, account_move_line.date) AS date_maturity, + account_move_line.name, + account_move_line.ref, + account_move_line.company_id, + account_move_line.account_id, + account_move_line.payment_id, + account_move_line.partner_id, + account_move_line.currency_id, + account_move_line.amount_currency, + account_move_line.matching_number, + %(additional_columns)s + COALESCE(account_move_line.invoice_date, account_move_line.date) AS invoice_date, + %(debit_select)s AS debit, + %(credit_select)s AS credit, + %(balance_select)s AS amount, + %(balance_select)s AS balance, + account_move.name AS move_name, + account_move.move_type AS move_type, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + journal.code AS journal_code, + %(journal_name)s AS journal_name, + %(column_group_key)s AS column_group_key, + 'directly_linked_aml' AS key, + 0 AS partial_id + %(extra_select)s + FROM %(table_references)s + JOIN account_move ON account_move.id = account_move_line.move_id + %(currency_table_join)s + LEFT JOIN res_company company ON company.id = account_move_line.company_id + LEFT JOIN res_partner partner ON partner.id = account_move_line.partner_id + LEFT JOIN account_journal journal ON journal.id = account_move_line.journal_id + WHERE %(search_condition)s AND %(directly_linked_aml_partner_clause)s + ORDER BY %(order_by)s + ''', + additional_columns=additional_columns, + debit_select=report._currency_table_apply_rate(SQL("account_move_line.debit")), + credit_select=report._currency_table_apply_rate(SQL("account_move_line.credit")), + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + account_code=account_code, + account_name=account_name, + journal_name=journal_name, + column_group_key=column_group_key, + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(group_options), + search_condition=query.where_clause, + directly_linked_aml_partner_clause=directly_linked_aml_partner_clause, + order_by=order_by, + extra_select=SQL(' ').join(self._get_aml_value_extra_select()), + )) + + # For the move lines linked to no partner, but reconciled with this partner. They will appear in grey in the report + queries.append(SQL( + ''' + SELECT + account_move_line.id, + COALESCE(account_move_line.date_maturity, account_move_line.date) AS date_maturity, + account_move_line.name, + account_move_line.ref, + account_move_line.company_id, + account_move_line.account_id, + account_move_line.payment_id, + aml_with_partner.partner_id, + account_move_line.currency_id, + account_move_line.amount_currency, + account_move_line.matching_number, + %(additional_columns)s + COALESCE(account_move_line.invoice_date, account_move_line.date) AS invoice_date, + %(debit_select)s AS debit, + %(credit_select)s AS credit, + %(balance_select)s AS amount, + %(balance_select)s AS balance, + account_move.name AS move_name, + account_move.move_type AS move_type, + %(account_code)s AS account_code, + %(account_name)s AS account_name, + journal.code AS journal_code, + %(journal_name)s AS journal_name, + %(column_group_key)s AS column_group_key, + 'indirectly_linked_aml' AS key, + partial.id AS partial_id + %(extra_select)s + FROM %(table_references)s + %(currency_table_join)s, + account_partial_reconcile partial, + account_move, + account_move_line aml_with_partner, + account_journal journal + WHERE + (account_move_line.id = partial.debit_move_id OR account_move_line.id = partial.credit_move_id) + AND account_move_line.partner_id IS NULL + AND account_move.id = account_move_line.move_id + AND (aml_with_partner.id = partial.debit_move_id OR aml_with_partner.id = partial.credit_move_id) + AND %(indirectly_linked_aml_partner_clause)s + AND journal.id = account_move_line.journal_id + AND %(account_alias)s.id = account_move_line.account_id + AND %(search_condition)s + AND partial.max_date BETWEEN %(date_from)s AND %(date_to)s + ORDER BY %(order_by)s + ''', + additional_columns=additional_columns, + debit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance > 0 THEN 0 ELSE partial.amount END")), + credit_select=report._currency_table_apply_rate(SQL("CASE WHEN aml_with_partner.balance < 0 THEN 0 ELSE partial.amount END")), + balance_select=report._currency_table_apply_rate(SQL("-SIGN(aml_with_partner.balance) * partial.amount")), + account_code=account_code, + account_name=account_name, + journal_name=journal_name, + column_group_key=column_group_key, + table_references=query.from_clause, + currency_table_join=report._currency_table_aml_join(group_options), + indirectly_linked_aml_partner_clause=indirectly_linked_aml_partner_clause, + account_alias=SQL.identifier(account_alias), + search_condition=query.where_clause, + date_from=group_options['date']['date_from'], + date_to=group_options['date']['date_to'], + order_by=order_by, + extra_select=SQL(' ').join(self._get_aml_value_extra_select()), + )) + + query = SQL(" UNION ALL ").join(SQL("(%s)", query) for query in queries) + + if offset: + query = SQL('%s OFFSET %s ', query, offset) + + if limit: + query = SQL('%s LIMIT %s ', query, limit) + + self._cr.execute(query) + for aml_result in self._cr.dictfetchall(): + if aml_result['key'] == 'indirectly_linked_aml': + + # Append the line to the partner found through the reconciliation. + if aml_result['partner_id'] in rslt: + rslt[aml_result['partner_id']].append(aml_result) + + # Balance it with an additional line in the Unknown Partner section but having reversed amounts. + if None in rslt: + rslt[None].append({ + **aml_result, + 'debit': aml_result['credit'], + 'credit': aml_result['debit'], + 'amount': aml_result['credit'] - aml_result['debit'], + 'balance': -aml_result['balance'], + }) + else: + rslt[aml_result['partner_id']].append(aml_result) + + return rslt + + def _get_aml_value_extra_select(self): + """ Hook method to add extra select fields to the aml queries. """ + return [] + + #################################################### + # COLUMNS/LINES + #################################################### + def _get_report_line_partners(self, options, partner, partner_values, level_shift=0): + company_currency = self.env.company.currency_id + + partner_data = next(iter(partner_values.values())) + unfoldable = not company_currency.is_zero(partner_data.get('debit', 0) or partner_data.get('credit', 0)) + column_values = [] + report = self.env['account.report'].browse(options['report_id']) + for column in options['columns']: + col_expr_label = column['expression_label'] + value = None if options.get('hide_partner_totals') else partner_values[column['column_group_key']].get(col_expr_label) + unfoldable = unfoldable or (col_expr_label in ('debit', 'credit', 'amount') and not company_currency.is_zero(value)) + column_values.append(report._build_column_dict(value, column, options=options)) + + + line_id = report._get_generic_line_id('res.partner', partner.id) if partner else report._get_generic_line_id('res.partner', None, markup='no_partner') + + return { + 'id': line_id, + 'name': partner is not None and (partner.name or '')[:128] or self._get_no_partner_line_label(), + 'columns': column_values, + 'level': 1 + level_shift, + 'trust': partner.trust if partner else None, + 'unfoldable': unfoldable, + 'unfolded': line_id in options['unfolded_lines'] or options['unfold_all'], + 'expand_function': '_report_expand_unfoldable_line_partner_ledger', + } + + def _get_no_partner_line_label(self): + return _('Unknown Partner') + + @api.model + def _format_aml_name(self, line_name, move_ref, move_name=None): + ''' Format the display of an account.move.line record. As its very costly to fetch the account.move.line + records, only line_name, move_ref, move_name are passed as parameters to deal with sql-queries more easily. + + :param line_name: The name of the account.move.line record. + :param move_ref: The reference of the account.move record. + :param move_name: The name of the account.move record. + :return: The formatted name of the account.move.line record. + ''' + return self.env['account.move.line']._format_aml_name(line_name, move_ref, move_name=move_name) + + def _get_report_line_move_line(self, options, aml_query_result, partner_line_id, init_bal_by_col_group, level_shift=0): + if aml_query_result['payment_id']: + caret_type = 'account.payment' + else: + caret_type = 'account.move.line' + + columns = [] + report = self.env['account.report'].browse(options['report_id']) + for column in options['columns']: + col_expr_label = column['expression_label'] + + if col_expr_label not in aml_query_result: + raise UserError(_("The column '%s' is not available for this report.", col_expr_label)) + + col_value = aml_query_result[col_expr_label] if column['column_group_key'] == aml_query_result['column_group_key'] else None + + if col_value is None: + columns.append(report._build_column_dict(None, None)) + else: + currency = False + + if col_expr_label == 'balance': + col_value += init_bal_by_col_group[column['column_group_key']] + + if col_expr_label == 'amount_currency': + currency = self.env['res.currency'].browse(aml_query_result['currency_id']) + + if currency == self.env.company.currency_id: + col_value = '' + + columns.append(report._build_column_dict(col_value, column, options=options, currency=currency)) + + return { + 'id': report._get_generic_line_id('account.move.line', aml_query_result['id'], parent_line_id=partner_line_id, markup=aml_query_result['partial_id']), + 'parent_id': partner_line_id, + 'name': self._format_aml_name(aml_query_result['name'], aml_query_result['ref'], aml_query_result['move_name']), + 'columns': columns, + 'caret_options': caret_type, + 'level': 3 + level_shift, + } + + def _get_report_line_total(self, options, totals_by_column_group): + column_values = [] + report = self.env['account.report'].browse(options['report_id']) + for column in options['columns']: + col_value = totals_by_column_group[column['column_group_key']].get(column['expression_label']) + column_values.append(report._build_column_dict(col_value, column, options=options)) + + return { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': _('Total'), + 'level': 1, + 'columns': column_values, + } + + def open_journal_items(self, options, params): + params['view_ref'] = 'account.view_move_line_tree_grouped_partner' + report = self.env['account.report'].browse(options['report_id']) + action = report.open_journal_items(options=options, params=params) + action.get('context', {}).update({'search_default_group_by_account': 0}) + return action diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_report.py new file mode 100644 index 0000000..d743356 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_report.py @@ -0,0 +1,7553 @@ + +import ast +import base64 +import datetime +import io +import json +import logging +import re +from ast import literal_eval +from collections import defaultdict +from functools import cmp_to_key +from itertools import groupby + +import markupsafe +from dateutil.relativedelta import relativedelta +from PIL import ImageFont + +from odoo import models, fields, api, _, osv +from odoo.addons.web.controllers.utils import clean_action +from odoo.exceptions import RedirectWarning, UserError, ValidationError +from odoo.service.model import get_public_method +from odoo.tools import date_utils, get_lang, float_is_zero, float_repr, SQL, parse_version, Query +from odoo.tools.float_utils import float_round, float_compare +from odoo.tools.misc import file_path, format_date, formatLang, split_every, xlsxwriter +from odoo.tools.safe_eval import expr_eval, safe_eval + +_logger = logging.getLogger(__name__) + +ACCOUNT_CODES_ENGINE_SPLIT_REGEX = re.compile(r"(?=[+-])") + +ACCOUNT_CODES_ENGINE_TERM_REGEX = re.compile( + r"^(?P[+-]?)"\ + r"(?P([A-Za-z\d.]*|tag\([\w.]+\))((?=\\)|(?<=[^CD])))"\ + r"(\\\((?P([A-Za-z\d.]+,)*[A-Za-z\d.]*)\))?"\ + r"(?P[DC]?)$" +) + +ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX = re.compile(r"tag\(((?P\d+)|(?P\w+\.\w+))\)") + +# Performance optimisation: those engines always will receive None as their next_groupby, allowing more efficient batching. +NO_NEXT_GROUPBY_ENGINES = {'tax_tags', 'account_codes'} + +NUMBER_FIGURE_TYPES = ('float', 'integer', 'monetary', 'percentage') + +LINE_ID_HIERARCHY_DELIMITER = '|' + +CURRENCIES_USING_LAKH = {'AFN', 'BDT', 'INR', 'MMK', 'NPR', 'PKR', 'LKR'} + + +class AccountReportAnnotation(models.Model): + _name = 'account.report.annotation' + _description = 'Account Report Annotation' + + report_id = fields.Many2one('account.report', help="The id of the annotated report.") + line_id = fields.Char(index=True, help="The id of the annotated line.") + text = fields.Char(string="The annotation's content.") + date = fields.Date(help="Date considered as annotated by the annotation.") + fiscal_position_id = fields.Many2one('account.fiscal.position', help="The fiscal position used while annotating.") + + @api.model_create_multi + def create(self, values): + fiscal_positions_with_foreign_vat = self.env['account.fiscal.position'].search([('foreign_vat', '!=', False)], limit=1) + for annotation in values: + if 'line_id' in annotation: + annotation['line_id'] = self._remove_tax_grouping_from_line_id(annotation['line_id']) + if 'fiscal_position_id' in annotation: + if annotation['fiscal_position_id'] == 'domestic': + del annotation['fiscal_position_id'] + elif annotation['fiscal_position_id'] == 'all': + annotation['fiscal_position_id'] = fiscal_positions_with_foreign_vat.id + else: + annotation['fiscal_position_id'] = int(annotation['fiscal_position_id']) + + return super().create(values) + + def _remove_tax_grouping_from_line_id(self, line_id): + """ + Remove the tax grouping from the line_id. This is needed because the tax grouping is not relevant for the annotation. + Tax grouping are any group using 'account.group' in the line_id. + """ + return self.env['account.report']._build_line_id([ + (markup, model, res_id) + for markup, model, res_id in self.env['account.report']._parse_line_id(line_id, markup_as_string=True) + if model != 'account.group' + ]) + +class AccountReport(models.Model): + _inherit = 'account.report' + + horizontal_group_ids = fields.Many2many(string="Horizontal Groups", comodel_name='account.report.horizontal.group') + annotations_ids = fields.One2many(string="Annotations", comodel_name='account.report.annotation', inverse_name='report_id') + + # Those fields allow case-by-case fine-tuning of the engine, for custom reports. + custom_handler_model_id = fields.Many2one(string='Custom Handler Model', comodel_name='ir.model') + custom_handler_model_name = fields.Char(string='Custom Handler Model Name', related='custom_handler_model_id.model') + + # Account Coverage Report + is_account_coverage_report_available = fields.Boolean(compute='_compute_is_account_coverage_report_available') + + tax_closing_start_date = fields.Date( # the default value is set in _auto_init + string="Start Date", + company_dependent=True + ) + + # Fields used for send reports by cron + send_and_print_values = fields.Json(copy=False) + + def _auto_init(self): + super()._auto_init() + + def precommit(): + self.env['ir.default'].set( + 'account.report', + 'tax_closing_start_date', + fields.Date.context_today(self).replace(month=1, day=1), + ) + self.env.cr.precommit.add(precommit) + + @api.constrains('custom_handler_model_id') + def _validate_custom_handler_model(self): + for report in self: + if report.custom_handler_model_id: + custom_handler_model = self.env.registry['account.report.custom.handler'] + current_model = self.env[report.custom_handler_model_name] + if not isinstance(current_model, custom_handler_model): + raise ValidationError(_( + "Field 'Custom Handler Model' can only reference records inheriting from [%s].", + custom_handler_model._name + )) + + def unlink(self): + for report in self: + action, menuitem = report._get_existing_menuitem() + menuitem.unlink() + action.unlink() + return super().unlink() + + def write(self, vals): + if 'active' in vals: + reports = {r.id: r.name for r in self} + actions = self.env['ir.actions.client'] \ + .search([('name', 'in', list(reports.values())), ('tag', '=', 'account_report')]) \ + .filtered(lambda act: (ast.literal_eval(act.context).get('report_id'), act.name) in reports.items()) + self.env['ir.ui.menu'] \ + .search([ + ('active', '=', not vals['active']), + ('action', 'in', [f'ir.actions.client,{action.id}' for action in actions]), + ])\ + .active = vals['active'] + return super().write(vals) + + #################################################### + # CRON + #################################################### + + @api.model + def _cron_account_report_send(self, job_count=10): + """ Handle Send & Print async processing. + :param job_count: maximum number of jobs to process if specified. + """ + to_process = self.env['account.report'].search( + [('send_and_print_values', '!=', False)], + ) + if not to_process: + return + + processed_count = 0 + need_retrigger = False + + for report in to_process: + if need_retrigger: + break + send_and_print_vals = report.send_and_print_values + report_partner_ids = send_and_print_vals.get('report_options', {}).get('partner_ids', []) + need_retrigger = processed_count + len(report_partner_ids) > job_count + partner_ids = report_partner_ids[:job_count - processed_count] + company = self.env['res.company'].browse(send_and_print_vals['report_options']['companies'][0]['id']) + existing_partner_ids = set(self.env['res.partner'].browse(partner_ids).exists().ids) + for partner_id in partner_ids: + if partner_id in existing_partner_ids: + options = { + **send_and_print_vals['report_options'], + 'partner_ids': [partner_id], + } + self.env['account.report.send']._process_send_and_print(report=report.with_company(company), options=options) + processed_count += 1 + report_partner_ids.remove(partner_id) + if report_partner_ids: + send_and_print_vals['report_options']['partner_ids'] = report_partner_ids + report.send_and_print_values = send_and_print_vals + else: + report.send_and_print_values = False + + if need_retrigger: + self.env.ref('odex30_account_reports.ir_cron_account_report_send')._trigger() + + #################################################### + # MENU MANAGEMENT + #################################################### + + def _get_existing_menuitem(self): + self.ensure_one() + action = self.env['ir.actions.client']\ + .search([('name', '=', self.name), ('tag', '=', 'account_report')])\ + .filtered(lambda act: ast.literal_eval(act.context).get('report_id') == self.id) + menuitem = self.env['ir.ui.menu']\ + .with_context({'active_test': False, 'ir.ui.menu.full_list': True})\ + .search([('action', '=', f'ir.actions.client,{action.id}')]) + return action, menuitem + + def _create_menu_item_for_report(self): + """ Adds a default menu item for this report. This is called by an action on the report, for reports created manually by the user. + """ + self.ensure_one() + + action, menuitem = self._get_existing_menuitem() + + if menuitem: + raise UserError(_("This report already has a menuitem.")) + + if not action: + action = self.env['ir.actions.client'].create({ + 'name': self.name, + 'tag': 'account_report', + 'context': {'report_id': self.id}, + }) + + self.env['ir.ui.menu'].create({ + 'name': self.name, + 'parent_id': self.env['ir.model.data']._xmlid_to_res_id('account.menu_finance_reports'), + 'action': f'ir.actions.client,{action.id}', + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'reload', + } + + #################################################### + # OPTIONS: journals + #################################################### + + def _get_filter_journals(self, options, additional_domain=None): + return self.env['account.journal'].with_context(active_test=False).search([ + *self.env['account.journal']._check_company_domain(self.get_report_company_ids(options)), + *(additional_domain or []), + ], order="company_id, name") + + def _get_filter_journal_groups(self, options): + return self.env['account.journal.group'].search([ + *self.env['account.journal.group']._check_company_domain(self.get_report_company_ids(options)), + ], order='sequence') + + def _init_options_journals(self, options, previous_options, additional_journals_domain=None): + # The additional additional_journals_domain optional parameter allows calling this with an additional restriction on journals, + # to regenerate the journal options accordingly. + def option_value(value, selected=False, group_journals=None): + result = { + 'id': value.id, + 'model': value._name, + 'name': value.display_name, + 'selected': selected, + } + + if value._name == 'account.journal.group': + result.update({ + 'title': value.display_name, + 'journals': group_journals.ids, + 'journal_types': list(set(group_journals.mapped('type'))), + }) + elif value._name == 'account.journal': + result.update({ + 'title': f"{value.name} - {value.code}", + 'type': value.type, + 'visible': True, + }) + + return result + + if not self.filter_journals: + return + + previous_journals = previous_options.get('journals', []) + previous_journal_group_action = previous_options.get('__journal_group_action', {}) + + all_journals = self._get_filter_journals(options, additional_domain=additional_journals_domain) + all_journal_groups = self._get_filter_journal_groups(options) + + options['journals'] = [] + options['selected_journal_groups'] = {} + + groups_journals_selected = set() + options_journal_groups = [] + + # First time opening the report, and make sure it's not specifically stated that we should not reset the filter + is_opening_report = previous_options.get('is_opening_report') # key from JS controller when report is being opened + # a key to prevent the reset of the journals filter even when is_opening_report is True + can_reset_journals_filter = not previous_options.get('not_reset_journals_filter') + + # 1. Handle journal group selection + for group in all_journal_groups: + group_journals = all_journals - group.excluded_journal_ids + selected = False + first_group_already_selected = bool(options['selected_journal_groups']) # only one group should be selected at most + + # select the first group by default when opening the report + if is_opening_report and not first_group_already_selected and can_reset_journals_filter: + selected = True + # Otherwise, select the previous selected group (if any) + elif group.id == previous_journal_group_action.get('id'): + selected = previous_journal_group_action.get('action') == 'add' + + group_option = option_value(group, selected=selected, group_journals=group_journals) + options_journal_groups.append(group_option) + + # Select all the group journals + if selected: + options['selected_journal_groups'] = group_option + groups_journals_selected |= set(group_journals.ids) + + # 2. Handle journals selection + previous_selected_journals_ids = { + journal['id'] + for journal in previous_journals + if journal.get('model') == 'account.journal' and journal.get('selected') + } + + company_journals_map = defaultdict(list) + journals_selected = set() + + for journal in all_journals: + selected = False + + if journal.id in groups_journals_selected: + selected = True + + elif not options['selected_journal_groups'] and previous_journal_group_action.get('action') != 'remove': + if journal.id in previous_selected_journals_ids: + selected = True + + if selected: + journals_selected.add(journal.id) + + company_journals_map[journal.company_id].append(option_value(journal, selected=journal.id in journals_selected)) + + # 3. Recompute selected groups in case the set of selected journals is equal to a group's accepted journals + for group in options_journal_groups: + if journals_selected == set(group['journals']): + group['selected'] = True + options['selected_journal_groups'] = group + + # 4. Unselect all journals if all are selected and no group is specifically selected + if journals_selected == set(all_journals.ids) and not options['selected_journal_groups']: + for company, journals in company_journals_map.items(): + for journal in journals: + journal['selected'] = False + + # 5. Build group options + if all_journal_groups: + options['journals'] = [{ + 'id': 'divider', + 'name': _("Multi-ledger"), + 'model': 'account.journal.group', + }] + options_journal_groups + + if not company_journals_map: + options['name_journal_group'] = _("No Journal") + return + + # 6. Build journals options + if len(company_journals_map) > 1 or all_journal_groups: + for company, journals in company_journals_map.items(): + # users may not have full access to the parent company in case they are in a branch, yet they have to see the company name + company_name = company.sudo().display_name + + # if not is_opening_report, then gets the unfolded attribute of the company from the previous options + unfolded = False if is_opening_report else next( + (entry.get('unfolded') for entry in previous_journals + if entry['model'] == 'res.company' and entry['name'] == company_name), False) + + for journal in journals: + journal['visible'] = unfolded + + options['journals'].append({ + 'id': 'divider', + 'model': 'res.company', + 'name': company_name, + 'unfolded': unfolded, + }) + + options['journals'] += journals + + else: + options['journals'].extend(next(iter(company_journals_map.values()), [])) + + + def _init_options_journals_names(self, options, previous_options, additional_journals_domain=None): + all_journals = [ + journal for journal in options.get('journals', []) + if journal['model'] == 'account.journal' + ] + journals_selected = [j for j in all_journals if j.get('selected')] + # 1. Compute the name to display on the widget + if options.get('selected_journal_groups'): + names_to_display = [options['selected_journal_groups']['name']] + elif len(all_journals) == len(journals_selected) or not journals_selected: + names_to_display = [_("All Journals")] + else: + names_to_display = [] + for journal in options['journals']: + if journal.get('model') == 'account.journal' and journal['selected']: + names_to_display += [journal['name']] + + # 2. Abbreviate the name + max_nb_journals_displayed = 5 + nb_remaining = len(names_to_display) - max_nb_journals_displayed + displayed_names = ', '.join(names_to_display[:max_nb_journals_displayed]) + if nb_remaining == 1: + options['name_journal_group'] = _("%(names)s and one other", names=displayed_names) + elif nb_remaining > 1: + options['name_journal_group'] = _("%(names)s and %(remaining)s others", names=displayed_names, remaining=nb_remaining) + else: + options['name_journal_group'] = displayed_names + + @api.model + def _get_options_journals(self, options): + selected_journals = [ + journal for journal in options.get('journals', []) + if journal['model'] == 'account.journal' and journal['selected'] + ] + if not selected_journals: + # If no journal is specifically selected, we actually want to select them all. + # This is needed, because some reports will not use ALL available journals and filter by type. + # Without getting them from the options, we will use them all, which is wrong. + selected_journals = [ + journal for journal in options.get('journals', []) + if journal['model'] == 'account.journal' + ] + return selected_journals + + @api.model + def _get_options_journals_domain(self, options): + # Make sure to return an empty array when nothing selected to handle archived journals. + selected_journals = self._get_options_journals(options) + return selected_journals and [('journal_id', 'in', [j['id'] for j in selected_journals])] or [] + + # #################################################### + # OPTIONS: USER DEFINED FILTERS ON AML + #################################################### + def _init_options_aml_ir_filters(self, options, previous_options): + options['aml_ir_filters'] = [] + if not self.filter_aml_ir_filters: + return + + ir_filters = self.env['ir.filters'].search([('model_id', '=', 'account.move.line')]) + if not ir_filters: + return + + aml_ir_filters = [{'id': x.id, 'name': x.name, 'selected': False} for x in ir_filters] + previous_options_aml_ir_filters = previous_options.get('aml_ir_filters', []) + previous_options_filters_map = {filter_item['id']: filter_item for filter_item in previous_options_aml_ir_filters} + + for filter_item in aml_ir_filters: + if filter_item['id'] in previous_options_filters_map: + filter_item['selected'] = previous_options_filters_map[filter_item['id']]['selected'] + + options['aml_ir_filters'] = aml_ir_filters + + @api.model + def _get_options_aml_ir_filters(self, options): + selected_filters_ids = [ + filter_item['id'] + for filter_item in options.get('aml_ir_filters', []) + if filter_item['selected'] + ] + + if not selected_filters_ids: + return [] + + selected_ir_filters = self.env['ir.filters'].browse(selected_filters_ids) + return osv.expression.OR([filter_record._get_eval_domain() for filter_record in selected_ir_filters]) + + #################################################### + # OPTIONS: date + comparison + #################################################### + + @api.model + def _get_dates_period(self, date_from, date_to, mode, period_type=None): + '''Compute some information about the period: + * The name to display on the report. + * The period type (e.g. quarter) if not specified explicitly. + :param date_from: The starting date of the period. + :param date_to: The ending date of the period. + :param period_type: The type of the interval date_from -> date_to. + :return: A dictionary containing: + * date_from * date_to * string * period_type * mode * + ''' + def match(dt_from, dt_to): + return (dt_from, dt_to) == (date_from, date_to) + + def get_quarter_name(date_to, date_from): + date_to_quarter_string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy') + date_from_quarter_string = format_date(self.env, fields.Date.to_string(date_from), date_format='MMM') + return f"{date_from_quarter_string} - {date_to_quarter_string}" + + string = None + # If no date_from or not date_to, we are unable to determine a period + if not period_type or period_type == 'custom': + date = date_to or date_from + company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date) + if match(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to']): + period_type = 'fiscalyear' + if company_fiscalyear_dates.get('record'): + string = company_fiscalyear_dates['record'].name + elif match(*date_utils.get_month(date)): + period_type = 'month' + elif match(*date_utils.get_quarter(date)): + period_type = 'quarter' + elif match(*date_utils.get_fiscal_year(date)): + period_type = 'year' + elif match(date_utils.get_month(date)[0], fields.Date.today()): + period_type = 'today' + else: + period_type = 'custom' + elif period_type == 'fiscalyear': + date = date_to or date_from + company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date) + record = company_fiscalyear_dates.get('record') + string = record and record.name + elif period_type == 'tax_period': + day, month = self.env.company._get_tax_closing_start_date_attributes(self) + months_per_period = self.env.company._get_tax_periodicity_months_delay(self) + # We need to format ourselves the date and not switch the period type to the actual period because we do not want to write the actual period in the options but keep tax_period + if day == 1 and month == 1 and months_per_period in (1, 3, 12): + match months_per_period: + case 1: + string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy') + case 3: + string = get_quarter_name(date_to, date_from) + case 12: + string = date_to.strftime('%Y') + else: + dt_from_str = format_date(self.env, fields.Date.to_string(date_from)) + dt_to_str = format_date(self.env, fields.Date.to_string(date_to)) + string = '%s - %s' % (dt_from_str, dt_to_str) + + if not string: + fy_day = self.env.company.fiscalyear_last_day + fy_month = int(self.env.company.fiscalyear_last_month) + if mode == 'single': + string = _('As of %s', format_date(self.env, date_to)) + elif period_type == 'year' or ( + period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to)): + string = date_to.strftime('%Y') + elif period_type == 'fiscalyear' and (date_from, date_to) == date_utils.get_fiscal_year(date_to, day=fy_day, month=fy_month): + string = '%s - %s' % (date_to.year - 1, date_to.year) + elif period_type == 'month': + string = format_date(self.env, fields.Date.to_string(date_to), date_format='MMM yyyy') + elif period_type == 'quarter': + string = get_quarter_name(date_to, date_from) + else: + dt_from_str = format_date(self.env, fields.Date.to_string(date_from)) + dt_to_str = format_date(self.env, fields.Date.to_string(date_to)) + string = _('From %(date_from)s\nto %(date_to)s', date_from=dt_from_str, date_to=dt_to_str) + + return { + 'string': string, + 'period_type': period_type, + 'currency_table_period_key': f"{date_from if mode == 'range' else 'None'}_{date_to}", + 'mode': mode, + 'date_from': date_from and fields.Date.to_string(date_from) or False, + 'date_to': fields.Date.to_string(date_to), + } + + @api.model + def _get_shifted_dates_period(self, options, period_vals, periods, tax_period=False): + '''Shift the period. + :param period_vals: A dictionary generated by the _get_dates_period method. + :param periods: The number of periods we want to move either in the future or the past + :return: A dictionary containing: + * date_from * date_to * string * period_type * + ''' + period_type = period_vals['period_type'] + mode = period_vals['mode'] + date_from = fields.Date.from_string(period_vals['date_from']) + date_to = fields.Date.from_string(period_vals['date_to']) + if period_type == 'month': + date_to = date_from + relativedelta(months=periods) + elif period_type == 'quarter': + date_to = date_from + relativedelta(months=3 * periods) + elif period_type == 'year': + date_to = date_from + relativedelta(years=periods) + elif period_type in {'custom', 'today'}: + date_to = date_from + relativedelta(days=periods) + + if tax_period or 'tax_period' in period_type: + month_per_period = self.env.company._get_tax_periodicity_months_delay(self) + date_from, date_to = self.env.company._get_tax_closing_period_boundaries(date_from + relativedelta(months=month_per_period * periods), self) + return self._get_dates_period(date_from, date_to, mode, period_type='tax_period') + if period_type in ('fiscalyear', 'today'): + # Don't pass the period_type to _get_dates_period to be able to retrieve the account.fiscal.year record if + # necessary. + company_fiscalyear_dates = {} + # This loop is needed because a fiscal year can be a month, quarter, etc + for _ in range(abs(periods)): + date_to = (date_from if periods < 0 else date_to) + relativedelta(days=periods / abs(periods)) + company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(date_to) + if periods < 0: + date_from = company_fiscalyear_dates['date_from'] + else: + date_to = company_fiscalyear_dates['date_to'] + + return self._get_dates_period(company_fiscalyear_dates['date_from'], company_fiscalyear_dates['date_to'], mode) + if period_type in ('month', 'custom'): + return self._get_dates_period(*date_utils.get_month(date_to), mode, period_type='month') + if period_type == 'quarter': + return self._get_dates_period(*date_utils.get_quarter(date_to), mode, period_type='quarter') + if period_type == 'year': + return self._get_dates_period(*date_utils.get_fiscal_year(date_to), mode, period_type='year') + return None + + @api.model + def _get_dates_previous_year(self, options, period_vals): + '''Shift the period to the previous year. + :param options: The report options. + :param period_vals: A dictionary generated by the _get_dates_period method. + :return: A dictionary containing: + * date_from * date_to * string * period_type * + ''' + period_type = period_vals['period_type'] + mode = period_vals['mode'] + date_from = fields.Date.from_string(period_vals['date_from']) + date_from = date_from - relativedelta(years=1) + date_to = fields.Date.from_string(period_vals['date_to']) + date_to = date_to - relativedelta(years=1) + + if period_type == 'month': + date_from, date_to = date_utils.get_month(date_to) + + return self._get_dates_period(date_from, date_to, mode, period_type=period_type) + + def _init_options_date(self, options, previous_options): + """ Initialize the 'date' options key. + + :param options: The current report options to build. + :param previous_options: The previous options coming from another report. + """ + date = previous_options.get('date', {}) + period_date_to = date.get('date_to') + period_date_from = date.get('date_from') + mode = date.get('mode') + date_filter = date.get('filter', 'custom') + + default_filter = self.default_opening_date_filter + options_mode = 'range' if self.filter_date_range else 'single' + date_from = date_to = period_type = False + + if mode == 'single' and options_mode == 'range': + # 'single' date mode to 'range'. + if date_filter: + date_to = fields.Date.from_string(period_date_to or period_date_from) + date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from'] + options_filter = 'custom' + else: + options_filter = default_filter + elif mode == 'range' and options_mode == 'single': + # 'range' date mode to 'single'. + if date_filter == 'custom': + date_to = fields.Date.from_string(period_date_to or period_date_from) + date_from = date_utils.get_month(date_to)[0] + options_filter = 'custom' + elif date_filter: + options_filter = date_filter + else: + options_filter = default_filter + elif (mode is None or mode == options_mode) and date: + # Same date mode. + if date_filter == 'custom': + if options_mode == 'range': + date_from = fields.Date.from_string(period_date_from) + date_to = fields.Date.from_string(period_date_to) + else: + date_to = fields.Date.from_string(period_date_to or period_date_from) + date_from = date_utils.get_month(date_to)[0] + + options_filter = 'custom' + else: + options_filter = date_filter + else: + # Default. + options_filter = default_filter + + # Compute 'date_from' / 'date_to'. + if not date_from or not date_to: + if options_filter == 'today': + date_to = fields.Date.context_today(self) + date_from = self.env.company.compute_fiscalyear_dates(date_to)['date_from'] + period_type = 'today' + elif 'month' in options_filter: + date_from, date_to = date_utils.get_month(fields.Date.context_today(self)) + period_type = 'month' + elif 'quarter' in options_filter: + date_from, date_to = date_utils.get_quarter(fields.Date.context_today(self)) + period_type = 'quarter' + elif 'year' in options_filter: + company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(fields.Date.context_today(self)) + curr_year = fields.Date.context_today(self).year + if company_fiscalyear_dates['date_from'].year < curr_year: + company_fiscalyear_dates = self.env.company.compute_fiscalyear_dates(company_fiscalyear_dates['date_to'] + relativedelta(days=1)) + date_from = company_fiscalyear_dates['date_from'] + date_to = company_fiscalyear_dates['date_to'] + elif 'tax_period' in options_filter: + if 'custom' in options_filter: + base_date = fields.Date.from_string(period_date_to) + else: + base_date = fields.Date.context_today(self) + + date_from, date_to = self.env.company._get_tax_closing_period_boundaries(base_date, self) + period_type = 'tax_period' + start_day, start_month = self.env.company._get_tax_closing_start_date_attributes(self) + if start_day == 1 and start_month == 1: + periods = self.env.company._get_tax_periodicity_months_delay(self) + period_type_map = { + 1: 'month', + 3: 'quarter', + 12: 'year', + } + period_type = period_type_map.get(periods, 'tax_period') + + options['date'] = self._get_dates_period( + date_from, + date_to, + options_mode, + period_type=period_type, + ) + + if any(option in options_filter for option in ['previous', 'next']): + new_period = date.get('period', -1 if 'previous' in options_filter else 1) + options['date'] = self._get_shifted_dates_period(options, options['date'], new_period, tax_period='tax_period' in options_filter) + # This line is useful for the export and tax closing so that the period is set in the options. + options['date']['period'] = new_period + + options['date']['filter'] = options_filter if options_filter != 'custom_tax_period' else 'custom' + + def _init_options_comparison(self, options, previous_options): + """ Initialize the 'comparison' options key. + + This filter must be loaded after the 'date' filter. + + :param options: The current report options to build. + :param previous_options: The previous options coming from another report. + """ + if not self.filter_period_comparison: + return + + previous_comparison = previous_options.get('comparison', {}) + previous_filter = previous_comparison.get('filter') + + period_order = previous_comparison.get('period_order') or 'descending' + if previous_filter == 'custom': + # Try to adapt the previous 'custom' filter. + date_from = previous_comparison.get('date_from') + date_to = previous_comparison.get('date_to') + number_period = 1 + options_filter = 'custom' + else: + # Use the 'date' options. + date_from = options['date']['date_from'] + date_to = options['date']['date_to'] + number_period = max(previous_comparison.get('number_period', 1) or 0, 0) + options_filter = number_period and previous_filter or 'no_comparison' + + options['comparison'] = { + 'filter': options_filter, + 'number_period': number_period, + 'date_from': date_from, + 'date_to': date_to, + 'periods': [], + 'period_order': period_order, + } + + date_from_obj = fields.Date.from_string(date_from) + date_to_obj = fields.Date.from_string(date_to) + + if options_filter == 'custom': + options['comparison']['periods'].append(self._get_dates_period( + date_from_obj, + date_to_obj, + options['date']['mode'], + )) + elif options_filter in ('previous_period', 'same_last_year'): + previous_period = options['date'] + for dummy in range(0, number_period): + if options_filter == 'previous_period': + period_vals = self._get_shifted_dates_period(options, previous_period, -1) + elif options_filter == 'same_last_year': + period_vals = self._get_dates_previous_year(options, previous_period) + else: + date_from_obj = fields.Date.from_string(date_from) + date_to_obj = fields.Date.from_string(date_to) + period_vals = self._get_dates_period(date_from_obj, date_to_obj, previous_period['mode']) + options['comparison']['periods'].append(period_vals) + previous_period = period_vals + + if len(options['comparison']['periods']) > 0: + options['comparison'].update(options['comparison']['periods'][0]) + + def _init_options_column_percent_comparison(self, options, previous_options): + if options['selected_horizontal_group_id'] is None: + if self.filter_growth_comparison and len(options['columns']) == 2 and len(options.get('comparison', {}).get('periods', [])) == 1: + options['column_percent_comparison'] = 'growth' + + if self.filter_budgets and any(budget['selected'] for budget in options.get('budgets', [])): + options['column_percent_comparison'] = 'budget' + + def _get_options_date_domain(self, options, date_scope): + date_from, date_to = self._get_date_bounds_info(options, date_scope) + + scope_domain = [('date', '<=', date_to)] + if date_from: + scope_domain += [('date', '>=', date_from)] + + return scope_domain + + def _get_date_bounds_info(self, options, date_scope): + # Default values (the ones from 'strict_range') + date_to = options['date']['date_to'] + date_from = options['date']['date_from'] if options['date']['mode'] == 'range' else None + + if date_scope == 'from_beginning': + date_from = None + + elif date_scope == 'to_beginning_of_period': + date_tmp = fields.Date.from_string(date_from or date_to) - relativedelta(days=1) + date_to = date_tmp.strftime('%Y-%m-%d') + date_from = None + + elif date_scope == 'from_fiscalyear': + date_tmp = fields.Date.from_string(date_to) + date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from'] + date_from = date_tmp.strftime('%Y-%m-%d') + + elif date_scope == 'to_beginning_of_fiscalyear': + date_tmp = fields.Date.from_string(date_to) + date_tmp = self.env.company.compute_fiscalyear_dates(date_tmp)['date_from'] - relativedelta(days=1) + date_to = date_tmp.strftime('%Y-%m-%d') + date_from = None + + elif date_scope == 'previous_tax_period': + eve_of_date_from = fields.Date.from_string(options['date']['date_from']) - relativedelta(days=1) + date_from, date_to = self.env.company._get_tax_closing_period_boundaries(eve_of_date_from, self) + + return date_from, date_to + + + #################################################### + # OPTIONS: analytic filter + #################################################### + + def _init_options_analytic(self, options, previous_options): + if not self.filter_analytic: + return + + + if self.env.user.has_group('analytic.group_analytic_accounting'): + previous_analytic_accounts = previous_options.get('analytic_accounts', []) + analytic_account_ids = [int(x) for x in previous_analytic_accounts] + selected_analytic_accounts = self.env['account.analytic.account'].with_context(active_test=False).search([('id', 'in', analytic_account_ids)]) + + options['display_analytic'] = True + options['analytic_accounts'] = selected_analytic_accounts.ids + options['selected_analytic_account_names'] = selected_analytic_accounts.mapped('name') + + #################################################### + # OPTIONS: partners + #################################################### + + def _init_options_partner(self, options, previous_options): + if not self.filter_partner: + return + + options['partner'] = True + previous_partner_ids = previous_options.get('partner_ids') or [] + options['partner_categories'] = previous_options.get('partner_categories') or [] + + selected_partner_ids = [int(partner) for partner in previous_partner_ids] + # search instead of browse so that record rules apply and filter out the ones the user does not have access to + selected_partners = selected_partner_ids and self.env['res.partner'].with_context(active_test=False).search([('id', 'in', selected_partner_ids)]) or self.env['res.partner'] + options['selected_partner_ids'] = selected_partners.filtered('name').mapped('name') + options['partner_ids'] = selected_partners.ids + + selected_partner_category_ids = [int(category) for category in options['partner_categories']] + selected_partner_categories = selected_partner_category_ids and self.env['res.partner.category'].browse(selected_partner_category_ids) or self.env['res.partner.category'] + options['selected_partner_categories'] = selected_partner_categories.mapped('name') + + @api.model + def _get_options_partner_domain(self, options): + domain = [] + if options.get('partner_ids'): + partner_ids = [int(partner) for partner in options['partner_ids']] + domain.append(('partner_id', 'in', partner_ids)) + if options.get('partner_categories'): + partner_category_ids = [int(category) for category in options['partner_categories']] + domain.append(('partner_id.category_id', 'in', partner_category_ids)) + return domain + + #################################################### + # OPTIONS: all_entries + #################################################### + + @api.model + def _get_options_all_entries_domain(self, options): + if not options.get('all_entries'): + return [('parent_state', '=', 'posted')] + else: + return [('parent_state', '!=', 'cancel')] + + #################################################### + # OPTIONS: not reconciled entries + #################################################### + def _init_options_reconciled(self, options, previous_options): + if self.filter_unreconciled: + options['unreconciled'] = previous_options.get('unreconciled', False) + else: + options['unreconciled'] = False + + @api.model + def _get_options_unreconciled_domain(self, options): + if options.get('unreconciled'): + return ['&', ('full_reconcile_id', '=', False), ('balance', '!=', '0')] + return [] + + #################################################### + # OPTIONS: account_type + #################################################### + + def _init_options_account_type(self, options, previous_options): + ''' + Initialize a filter based on the account_type of the line (trade/non trade, payable/receivable). + Selects a name to display according to the selections. + The group display name is selected according to the display name of the options selected. + ''' + if self.filter_account_type in ('disabled', False): + return + + account_type_list = [ + {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True}, + {'id': 'non_trade_receivable', 'name': _("Non Trade Receivable"), 'selected': False}, + {'id': 'trade_payable', 'name': _("Payable"), 'selected': True}, + {'id': 'non_trade_payable', 'name': _("Non Trade Payable"), 'selected': False}, + ] + + if self.filter_account_type == 'receivable': + options['account_type'] = account_type_list[:2] + elif self.filter_account_type == 'payable': + options['account_type'] = account_type_list[2:] + else: + options['account_type'] = account_type_list + + if previous_options.get('account_type'): + previously_selected_ids = {x['id'] for x in previous_options['account_type'] if x.get('selected')} + for opt in options['account_type']: + opt['selected'] = opt['id'] in previously_selected_ids + + + @api.model + def _get_options_account_type_domain(self, options): + all_domains = [] + selected_domains = [] + if not options.get('account_type') or len(options.get('account_type')) == 0: + return [] + for opt in options.get('account_type', []): + if opt['id'] == 'trade_receivable': + domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'asset_receivable')] + elif opt['id'] == 'trade_payable': + domain = [('account_id.non_trade', '=', False), ('account_id.account_type', '=', 'liability_payable')] + elif opt['id'] == 'non_trade_receivable': + domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'asset_receivable')] + elif opt['id'] == 'non_trade_payable': + domain = [('account_id.non_trade', '=', True), ('account_id.account_type', '=', 'liability_payable')] + if opt['selected']: + selected_domains.append(domain) + all_domains.append(domain) + return osv.expression.OR(selected_domains or all_domains) + + #################################################### + # OPTIONS: order column + #################################################### + + @api.model + def _init_options_order_column(self, options, previous_options): + # options['order_column'] is in the form {'expression_label': expression label of the column to order, 'direction': the direction order ('ASC' or 'DESC')} + options['order_column'] = None + + previous_value = previous_options and previous_options.get('order_column') + if previous_value: + for col in options['columns']: + if col['sortable'] and col['expression_label'] == previous_value['expression_label']: + options['order_column'] = previous_value + break + + #################################################### + # OPTIONS: hierarchy + #################################################### + + def _init_options_hierarchy(self, options, previous_options): + company_ids = self.get_report_company_ids(options) + if self.filter_hierarchy != 'never' and self.env['account.group'].search_count(self.env['account.group']._check_company_domain(company_ids), limit=1): + options['display_hierarchy_filter'] = True + if 'hierarchy' in previous_options: + options['hierarchy'] = previous_options['hierarchy'] + else: + options['hierarchy'] = self.filter_hierarchy == 'by_default' + else: + options['hierarchy'] = False + options['display_hierarchy_filter'] = False + + @api.model + def _create_hierarchy(self, lines, options): + """Compute the hierarchy based on account groups when the option is activated. + + The option is available only when there are account.group for the company. + It should be called when before returning the lines to the client/templater. + The lines are the result of _get_lines(). If there is a hierarchy, it is left + untouched, only the lines related to an account.account are put in a hierarchy + according to the account.group's and their prefixes. + """ + if not lines: + return lines + + def get_account_group_hierarchy(account): + # Create codes path in the hierarchy based on account. + groups = self.env['account.group'] + if account.group_id: + group = account.group_id + while group: + groups += group + group = group.parent_id + return list(groups.sorted(reverse=True)) + + def create_hierarchy_line(account_group, column_totals, level, parent_id): + line_id = self._get_generic_line_id('account.group', account_group.id if account_group else None, parent_line_id=parent_id) + unfolded = line_id in options.get('unfolded_lines') or options['unfold_all'] + name = account_group.display_name if account_group else _('(No Group)') + columns = [] + for column_total, column in zip(column_totals, options['columns']): + columns.append(self._build_column_dict(column_total, column, options=options)) + return { + 'id': line_id, + 'name': name, + 'title_hover': name, + 'unfoldable': True, + 'unfolded': unfolded, + 'level': level, + 'parent_id': parent_id, + 'columns': columns, + } + + def compute_group_totals(line, group=None): + result = [] + for total, column in zip(hierarchy[group]['totals'], line['columns']): + value = column.get('no_format') + if isinstance(total, float) and isinstance(value, (int, float)): + result.append(total + value) + else: + result.append('') + return result + + def render_lines(account_groups, current_level, parent_line_id, skip_no_group=True): + to_treat = [(current_level, parent_line_id, group) for group in account_groups.sorted()] + + if None in hierarchy and not skip_no_group: + to_treat.append((current_level, parent_line_id, None)) + + while to_treat: + level_to_apply, parent_id, group = to_treat.pop(0) + group_data = hierarchy[group] + hierarchy_line = create_hierarchy_line(group, group_data['totals'], level_to_apply, parent_id) + new_lines.append(hierarchy_line) + treated_child_groups = self.env['account.group'] + + for account_line in group_data['lines']: + for child_group in group_data['child_groups']: + if child_group not in treated_child_groups and child_group['code_prefix_end'] < account_line['name']: + render_lines(child_group, hierarchy_line['level'] + 1, hierarchy_line['id']) + treated_child_groups += child_group + + markup, model, account_id = self._parse_line_id(account_line['id'])[-1] + account_line_id = self._get_generic_line_id(model, account_id, markup=markup, parent_line_id=hierarchy_line['id']) + account_line.update({ + 'id': account_line_id, + 'parent_id': hierarchy_line['id'], + 'level': hierarchy_line['level'] + 1, + }) + new_lines.append(account_line) + + for child_line in account_line_children_map[account_id]: + markup, model, res_id = self._parse_line_id(child_line['id'])[-1] + child_line.update({ + 'id': self._get_generic_line_id(model, res_id, markup=markup, parent_line_id=account_line_id), + 'parent_id': account_line_id, + 'level': account_line['level'] + 1, + }) + new_lines.append(child_line) + + to_treat = [ + (level_to_apply + 1, hierarchy_line['id'], child_group) + for child_group + in group_data['child_groups'].sorted() + if child_group not in treated_child_groups + ] + to_treat + + def create_hierarchy_dict(): + return defaultdict(lambda: { + 'lines': [], + 'totals': [('' if column.get('figure_type') == 'string' else 0.0) for column in options['columns']], + 'child_groups': self.env['account.group'], + }) + + # Precompute the account groups of the accounts in the report + account_ids = [] + for line in lines: + markup, res_model, model_id = self._parse_line_id(line['id'])[-1] + if res_model == 'account.account': + account_ids.append(model_id) + self.env['account.account'].browse(account_ids).group_id + + new_lines, total_lines = [], [] + + # root_line_id is the id of the parent line of the lines we want to render + root_line_id = self._build_parent_line_id(self._parse_line_id(lines[0]['id'])) or None + last_account_line_id = account_id = None + current_level = 0 + account_line_children_map = defaultdict(list) + account_groups = self.env['account.group'] + root_account_groups = self.env['account.group'] + hierarchy = create_hierarchy_dict() + + for line in lines: + markup, res_model, model_id = self._parse_line_id(line['id'])[-1] + + # Account lines are used as the basis for the computation of the hierarchy. + if res_model == 'account.account': + last_account_line_id = line['id'] + current_level = line['level'] + account_id = model_id + account = self.env[res_model].browse(account_id) + account_groups = get_account_group_hierarchy(account) + + if not account_groups: + hierarchy[None]['lines'].append(line) + hierarchy[None]['totals'] = compute_group_totals(line) + else: + for i, group in enumerate(account_groups): + if i == 0: + hierarchy[group]['lines'].append(line) + if i == len(account_groups) - 1 and group not in root_account_groups: + root_account_groups += group + if group.parent_id and group not in hierarchy[group.parent_id]['child_groups']: + hierarchy[group.parent_id]['child_groups'] += group + + hierarchy[group]['totals'] = compute_group_totals(line, group=group) + + # This is not an account line, so we check to see if it is a descendant of the last account line. + # If so, it is added to the mapping of the lines that are related to this account. + elif last_account_line_id and line.get('parent_id', '').startswith(last_account_line_id): + account_line_children_map[account_id].append(line) + + # This is a total line that is not linked to an account. It is saved in order to be added at the end. + elif markup == 'total': + total_lines.append(line) + + # This line ends the scope of the current hierarchy and is (possibly) the root of a new hierarchy. + # We render the current hierarchy and set up to build a new hierarchy + else: + render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False) + + new_lines.append(line) + + # Reset the hierarchy-related variables for a new hierarchy + root_line_id = line['id'] + last_account_line_id = account_id = None + current_level = 0 + account_line_children_map = defaultdict(list) + root_account_groups = self.env['account.group'] + account_groups = self.env['account.group'] + hierarchy = create_hierarchy_dict() + + render_lines(root_account_groups, current_level, root_line_id, skip_no_group=False) + + return new_lines + total_lines + + #################################################### + # OPTIONS: prefix groups threshold + #################################################### + + def _init_options_prefix_groups_threshold(self, options, previous_options): + previous_threshold = previous_options.get('prefix_groups_threshold') + options['prefix_groups_threshold'] = self.prefix_groups_threshold + + #################################################### + # OPTIONS: fiscal position (multi vat) + #################################################### + + def _init_options_fiscal_position(self, options, previous_options): + if self.filter_fiscal_position and self.country_id and len(options['companies']) == 1: + vat_fpos_domain = [ + *self.env['account.fiscal.position']._check_company_domain(next(comp_id for comp_id in self.get_report_company_ids(options))), + ('foreign_vat', '!=', False), + ] + + vat_fiscal_positions = self.env['account.fiscal.position'].search([ + *vat_fpos_domain, + ('country_id', '=', self.country_id.id), + ]) + + options['allow_domestic'] = self.env.company.account_fiscal_country_id == self.country_id + + accepted_prev_vals = {*vat_fiscal_positions.ids} + if options['allow_domestic']: + accepted_prev_vals.add('domestic') + if len(vat_fiscal_positions) > (0 if options['allow_domestic'] else 1) or not accepted_prev_vals: + accepted_prev_vals.add('all') + + if previous_options.get('fiscal_position') in accepted_prev_vals: + # Legit value from previous options; keep it + options['fiscal_position'] = previous_options['fiscal_position'] + elif len(vat_fiscal_positions) == 1 and not options['allow_domestic']: + # Only one foreign fiscal position: always select it, menu will be hidden + options['fiscal_position'] = vat_fiscal_positions.id + else: + # Multiple possible values; by default, show the values of the company's area (if allowed), or everything + options['fiscal_position'] = options['allow_domestic'] and 'domestic' or 'all' + else: + # No country, or we're displaying data from several companies: disable fiscal position filtering + vat_fiscal_positions = [] + options['allow_domestic'] = True + previous_fpos = previous_options.get('fiscal_position') + options['fiscal_position'] = previous_fpos if previous_fpos in ('all', 'domestic') else 'all' + + options['available_vat_fiscal_positions'] = [{ + 'id': fiscal_pos.id, + 'name': fiscal_pos.name, + 'company_id': fiscal_pos.company_id.id, + } for fiscal_pos in vat_fiscal_positions] + + def _get_options_fiscal_position_domain(self, options): + def get_foreign_vat_tax_tag_extra_domain(fiscal_position=None): + # We want to gather any line wearing a tag, whatever its fiscal position. + # Nevertheless, if a country is using the same report for several regions (e.g. India) we need to exclude + # the lines from the other regions to avoid reporting numbers that don't belong to the current region. + fp_ids_to_exclude = self.env['account.fiscal.position'].search([ + ('id', '!=', fiscal_position.id if fiscal_position else False), + ('foreign_vat', '!=', False), + ('country_id', '=', self.country_id.id), + ]).ids + + if fiscal_position and fiscal_position.country_id == self.env.company.account_fiscal_country_id: + # We are looking for a fiscal position inside our country which means we need to exclude + # the local fiscal position which is represented by `False`. + fp_ids_to_exclude.append(False) + + return [ + ('tax_tag_ids.country_id', '=', self.country_id.id), + ('move_id.fiscal_position_id', 'not in', fp_ids_to_exclude), + ] + + fiscal_position_opt = options.get('fiscal_position') + + if fiscal_position_opt == 'domestic': + domain = [ + '|', + ('move_id.fiscal_position_id', '=', False), + ('move_id.fiscal_position_id.foreign_vat', '=', False), + ] + tax_tag_domain = get_foreign_vat_tax_tag_extra_domain() + return osv.expression.OR([domain, tax_tag_domain]) + + if isinstance(fiscal_position_opt, int): + # It's a fiscal position id + domain = [('move_id.fiscal_position_id', '=', fiscal_position_opt)] + fiscal_position = self.env['account.fiscal.position'].browse(fiscal_position_opt) + tax_tag_domain = get_foreign_vat_tax_tag_extra_domain(fiscal_position) + return osv.expression.OR([domain, tax_tag_domain]) + + # 'all', or option isn't specified + return [] + + #################################################### + # OPTIONS: MULTI COMPANY + #################################################### + + def _init_options_companies(self, options, previous_options): + if previous_options.get('forced_companies'): + options['forced_companies'] = previous_options['forced_companies'] + companies = self.env.company.browse(previous_options['forced_companies']) + elif self.filter_multi_company == 'selector': + companies = self.env.companies + elif self.filter_multi_company == 'tax_units': + companies = self._multi_company_tax_units_init_options(options, previous_options=previous_options) + else: + # Multi-company is disabled for this report ; only accept the sub-branches of the current company from the selector + companies = self.env.company._accessible_branches() + + options['companies'] = [{'name': c.name, 'id': c.id, 'currency_id': c.currency_id.id} for c in companies] + + def _multi_company_tax_units_init_options(self, options, previous_options): + """ Initializes the companies option for reports configured to compute it from tax units. + """ + tax_units_domain = [('company_ids', 'in', self.env.company.id)] + + if self.country_id: + tax_units_domain.append(('country_id', '=', self.country_id.id)) + + available_tax_units = self.env['account.tax.unit'].search(tax_units_domain) + + # Filter available units to only consider the ones whose companies are all accessible to the user + available_tax_units = available_tax_units.filtered( + lambda x: all(unit_company in self.env.user.company_ids for unit_company in x.sudo().company_ids) + # sudo() to avoid bypassing companies the current user does not have access to + ) + + options['available_tax_units'] = [{ + 'id': tax_unit.id, + 'name': tax_unit.name, + 'company_ids': tax_unit.company_ids.ids + } for tax_unit in available_tax_units] + + # Available tax_unit option values that are currently allowed by the company selector + # A js hack ensures the page is reloaded and the selected companies modified + # when clicking on a tax unit option in the UI, so we don't need to worry about that here. + companies_authorized_tax_unit_opt = { + *(available_tax_units.filtered(lambda x: set(self.env.companies) == set(x.company_ids)).ids), + 'company_only' + } + + if previous_options.get('tax_unit') in companies_authorized_tax_unit_opt: + options['tax_unit'] = previous_options['tax_unit'] + + else: + # No tax_unit gotten from previous options; initialize it + # A tax_unit will be set by default if only one tax unit is available for the report + # (which should always be true for non-generic reports, which have a country), and the companies of + # the unit are the only ones currently selected. + if companies_authorized_tax_unit_opt == {'company_only'}: + options['tax_unit'] = 'company_only' + elif len(available_tax_units) == 1 and available_tax_units[0].id in companies_authorized_tax_unit_opt: + options['tax_unit'] = available_tax_units[0].id + else: + options['tax_unit'] = 'company_only' + + # Finally initialize multi_company filter + if options['tax_unit'] == 'company_only': + companies = self.env.company._get_branches_with_same_vat(accessible_only=True) + else: + tax_unit = available_tax_units.filtered(lambda x: x.id == options['tax_unit']) + companies = tax_unit.company_ids + + return companies + + #################################################### + # OPTIONS: MULTI CURRENCY + #################################################### + def _init_options_multi_currency(self, options, previous_options): + options['multi_currency'] = ( + any([company.get('currency_id') != options['companies'][0].get('currency_id') for company in options['companies']]) + or any([column.figure_type != 'monetary' for column in self.column_ids]) + or any(expression.figure_type and expression.figure_type != 'monetary' for expression in self.line_ids.expression_ids) + ) + + #################################################### + # OPTIONS: CURRENCY TABLE + #################################################### + def _init_options_currency_table(self, options, previous_options): + companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + table_type = 'monocurrency' if self.env['res.currency']._check_currency_table_monocurrency(companies) else self.currency_translation + + periods = {} + for col_group in options['column_groups'].values(): + if col_group['forced_options'].get('no_impact_on_currency_table'): + # This key is used to ignore the colum group in the creation of the periods list for + # the currency table. This way, its dates won't influence. It's useful for groups corresponding + # to an initial balance of some sorts, like on the Trial Balance. + continue + + col_group_date = col_group['forced_options'].get('date', options['date']) + + col_group_date_from = col_group_date['date_from'] if col_group_date['mode'] == 'range' else None + col_group_date_to = col_group_date['date_to'] + period_key = col_group_date['currency_table_period_key'] + + already_present_period = periods.get(period_key) + if already_present_period: + # This can happen for custom reports, needing to enforce the same rates on multiple column groups with + # different dates (e.g. Trial Balance). In that case, the date_from and date_to of the currency table period must respectively + # be the lowest and highest among those groups. + if col_group_date_from and already_present_period['from'] > col_group_date_from: + already_present_period['from'] = col_group_date_from + + if already_present_period['to'] < col_group_date_to: + already_present_period['to'] = col_group_date_to + else: + periods[period_key] = { + 'from': col_group_date_from, + 'to': col_group_date_to, + } + + options['currency_table'] = {'type': table_type, 'periods': periods} + + @api.model + def _currency_table_apply_rate(self, value: SQL) -> SQL: + """ Returns an SQL term to use in a SELECT statement converting the value passed as parameter into the current company's currency, using the + currency table (which must be joined in the query as well ; using _currency_table_aml_join for account.move.line, or _get_currency_table for + other more specific uses). + """ + return SQL("(%(value)s) * COALESCE(account_currency_table.rate, 1)", value=value) + + @api.model + def _currency_table_aml_join(self, options, aml_alias=SQL('account_move_line')) -> SQL: + """ Returns the JOIN condition to the currency table in a query needing to use it to convert aml balances from one currency to another. + """ + if options['currency_table']['type'] == 'cta': + return SQL( + """ + JOIN account_account aml_ct_account + ON aml_ct_account.id = %(aml_table)s.account_id + LEFT JOIN %(currency_table)s + ON %(aml_table)s.company_id = account_currency_table.company_id + AND ( + account_currency_table.rate_type = CASE + WHEN aml_ct_account.account_type LIKE %(equity_prefix)s THEN 'historical' + WHEN aml_ct_account.account_type LIKE ANY (ARRAY[%(income_prefix)s, %(expense_prefix)s, 'equity_unaffected']) THEN 'average' + ELSE 'current' + END + ) + AND (account_currency_table.date_from IS NULL OR account_currency_table.date_from <= %(aml_table)s.date) + AND (account_currency_table.date_next IS NULL OR account_currency_table.date_next > %(aml_table)s.date) + AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL) + """, + aml_table=aml_alias, + equity_prefix='equity%', + income_prefix='income%', + expense_prefix='expense%', + currency_table=self._get_currency_table(options), + period_key=options['date']['currency_table_period_key'], + ) + + return SQL( + """ + JOIN %(currency_table)s + ON %(aml_table)s.company_id = account_currency_table.company_id + AND (account_currency_table.period_key = %(period_key)s OR account_currency_table.period_key IS NULL) + """, + aml_table=aml_alias, + currency_table=self._get_currency_table(options), + period_key=options['date']['currency_table_period_key'], + ) + + @api.model + def _get_currency_table(self, options) -> SQL: + """ Returns the currency table table definition to be injected in the JOIN condition of an SQL query needing to use it. + """ + if options['currency_table']['type'] == 'monocurrency': + companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + return self.env['res.currency']._get_monocurrency_currency_table_sql(companies, use_cta_rates=options['currency_table']['type'] == 'cta') + + return SQL('account_currency_table') + + def _init_currency_table(self, options): + """ Creates the currency table temporary table if necessary, using the provided options to compute its periods. + This function should always be called before any query invovlving the currency table is run. + """ + if options['currency_table']['type'] != 'monocurrency': + companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + + self.env['res.currency']._create_currency_table( + companies, + [(period_key, period['from'], period['to']) for period_key, period in options['currency_table']['periods'].items()], + use_cta_rates=options['currency_table']['type'] == 'cta', + ) + + #################################################### + # OPTIONS: ROUNDING UNIT + #################################################### + def _init_options_rounding_unit(self, options, previous_options): + default = 'decimals' + options['rounding_unit'] = previous_options.get('rounding_unit', default) + options['rounding_unit_names'] = self._get_rounding_unit_names() + + def _get_rounding_unit_names(self): + currency_symbol = self.env.company.currency_id.symbol + currency_name = self.env.company.currency_id.name + + rounding_unit_names = [ + ('decimals', (f'.{currency_symbol}', '')), + ('units', (f'{currency_symbol}', '')), + ('thousands', (f'K{currency_symbol}', _('Amounts in Thousands'))), + ('millions', (f'M{currency_symbol}', _('Amounts in Millions'))), + ] + + if currency_name in CURRENCIES_USING_LAKH: + rounding_unit_names.insert(3, ('lakhs', (f'L{currency_symbol}', _('Amounts in Lakhs')))) + + return dict(rounding_unit_names) + + # #################################################### + # OPTIONS: ALL ENTRIES + #################################################### + def _init_options_all_entries(self, options, previous_options): + if self.filter_show_draft: + options['all_entries'] = previous_options.get('all_entries', False) + else: + options['all_entries'] = False + + #################################################### + # OPTIONS: UNFOLDED LINES + #################################################### + def _init_options_unfolded(self, options, previous_options): + options['unfold_all'] = self.filter_unfold_all and previous_options.get('unfold_all', False) + + previous_section_source_id = previous_options.get('sections_source_id') + if not previous_section_source_id or previous_section_source_id == options['sections_source_id']: + # Only keep the unfolded lines if they belong to the same report or a section of the same report + options['unfolded_lines'] = previous_options.get('unfolded_lines', []) + else: + options['unfolded_lines'] = [] + + #################################################### + # OPTIONS: HIDE LINE AT 0 + #################################################### + def _init_options_hide_0_lines(self, options, previous_options): + if self.filter_hide_0_lines != 'never': + previous_val = previous_options.get('hide_0_lines') + if previous_val is not None: + options['hide_0_lines'] = previous_val + else: + options['hide_0_lines'] = self.filter_hide_0_lines == 'by_default' + else: + options['hide_0_lines'] = False + + def _filter_out_0_lines(self, lines): + """ Returns a list containing all lines that are not zero or that are parent to non-zero lines. + Can be used to ensure printed report does not include 0 lines, when hide_0_lines is toggled. + """ + lines_to_hide = set() # contain line ids to remove from lines + has_visible_children = set() # contain parent line ids + # Traverse lines in reverse to keep track of visible parent lines required by children lines + for line in reversed(lines): + is_zero_line = all(col.get('figure_type') not in NUMBER_FIGURE_TYPES or col.get('is_zero', True) for col in line['columns']) + if is_zero_line and line['id'] not in has_visible_children: + lines_to_hide.add(line['id']) + if line.get('parent_id') and line['id'] not in lines_to_hide: + has_visible_children.add(line['parent_id']) + return list(filter(lambda x: x['id'] not in lines_to_hide, lines)) + + #################################################### + # OPTIONS: HORIZONTAL GROUP + #################################################### + def _init_options_horizontal_groups(self, options, previous_options): + options['available_horizontal_groups'] = [ + { + 'id': horizontal_group.id, + 'name': horizontal_group.name, + } + for horizontal_group in self.horizontal_group_ids + ] + previous_selected = previous_options.get('selected_horizontal_group_id') + options['selected_horizontal_group_id'] = previous_selected if previous_selected in self.horizontal_group_ids.ids else None + + #################################################### + # OPTIONS: SEARCH BAR + #################################################### + def _init_options_search_bar(self, options, previous_options): + if self.search_bar: + options['search_bar'] = True + if 'default_filter_accounts' not in self._context and 'filter_search_bar' in previous_options: + options['filter_search_bar'] = previous_options['filter_search_bar'] + + #################################################### + # OPTIONS: COLUMN HEADERS + #################################################### + + def _init_options_column_headers(self, options, previous_options): + # Prepare column headers, in case the order of the comparison is ascending we reverse the order of the columns + all_comparison_date_vals = ([options['date']] + options.get('comparison', {}).get('periods', [])) + if options.get('comparison') and options['comparison']['period_order'] == 'ascending': + all_comparison_date_vals = all_comparison_date_vals[::-1] + + column_headers = [ + [ + { + 'name': comparison_date_vals['string'], + 'forced_options': {'date': comparison_date_vals}, + } + for comparison_date_vals in all_comparison_date_vals + ], # First level always consists of date comparison. Horizontal groupby are done on following levels. + ] + + # Handle horizontal groups + selected_horizontal_group_id = options.get('selected_horizontal_group_id') + if selected_horizontal_group_id: + horizontal_group = self.env['account.report.horizontal.group'].browse(selected_horizontal_group_id) + + for field_name, records in horizontal_group._get_header_levels_data(): + header_level = [ + { + 'name': record.display_name, + 'horizontal_groupby_element': {field_name: record.id}, + } + for record in records + ] + column_headers.append(header_level) + else: + # Insert budget column headers if needed + selected_budgets = [budget for budget in options.get('budgets', []) if budget['selected']] + if selected_budgets: + budget_headers = [{ + 'name': '', + 'forced_options': { + 'budget_base': True, + } + }] + + for budget in selected_budgets: + # Add budget amount column + budget_headers.append({ + 'name': budget['name'], + 'forced_options': { + 'compute_budget': budget['id'], + }, + 'colspan': 1, + }) + if len(self.column_ids.filtered(lambda column: column.figure_type == 'monetary')) == 1: + # Add budget percentage column (only if one column in the report) + budget_headers.append({ + 'name': "%", + 'forced_options': { + 'budget_percentage': budget['id'], + }, + 'colspan': 1, + }) + + column_headers.append(budget_headers) + + options['column_headers'] = column_headers + + #################################################### + # OPTIONS: COLUMNS + #################################################### + def _init_options_columns(self, options, previous_options): + default_group_vals = {'horizontal_groupby_element': {}, 'forced_options': {}} + all_column_group_vals_in_order = self._generate_columns_group_vals_recursively(options['column_headers'], default_group_vals) + + columns, column_groups = self._build_columns_from_column_group_vals(options, all_column_group_vals_in_order) + + options['columns'] = columns + options['column_groups'] = column_groups + + # Debug column is only shown when there is a single column group, so that we can display all the subtotals of the line in a clear way + options['show_debug_column'] = options['export_mode'] != 'print' \ + and self.env.user.has_group('base.group_no_one') \ + and len(options['column_groups']) == 1 \ + and len(self.line_ids) > 0 # No debug column on fully dynamic reports by default (they can customize this) + + # Show an additional column summing all the horizontal groups if there is no comparison and only one level of horizontal group + options['show_horizontal_group_total'] = options.get('selected_horizontal_group_id') \ + and options.get('comparison', {}).get('filter') == 'no_comparison' \ + and len(self.column_ids) == 1 \ + and len(options['column_headers']) == 2 + + def _generate_columns_group_vals_recursively(self, next_levels_headers, previous_levels_group_vals): + if next_levels_headers: + rslt = [] + for header_element in next_levels_headers[0]: + current_level_group_vals = {} + for key in previous_levels_group_vals: + current_level_group_vals[key] = {**previous_levels_group_vals.get(key, {}), **header_element.get(key, {})} + + rslt += self._generate_columns_group_vals_recursively(next_levels_headers[1:], current_level_group_vals) + return rslt + else: + return [previous_levels_group_vals] + + def _build_columns_from_column_group_vals(self, options, all_column_group_vals_in_order): + def _generate_domain_from_horizontal_group_hash_key_tuple(group_hash_key): + domain = [] + for field_name, field_value in group_hash_key: + domain.append((field_name, '=', field_value)) + return domain + + columns = [] + column_groups = {} + for column_group_val in all_column_group_vals_in_order: + horizontal_group_key_tuple = self._get_dict_hashable_key_tuple(column_group_val['horizontal_groupby_element']) # Empty tuple if no grouping + column_group_key = str(self._get_dict_hashable_key_tuple(column_group_val)) # Unique identifier for the column group + + column_groups[column_group_key] = { + 'forced_options': column_group_val['forced_options'], + 'forced_domain': _generate_domain_from_horizontal_group_hash_key_tuple(horizontal_group_key_tuple), + } + + # for budget, only one column in needed, regardless of the number of columns in the report + if any(budget_key in column_group_val['forced_options'] for budget_key in ('compute_budget', 'budget_percentage')): + columns.append({ + 'name': "", + 'column_group_key': column_group_key, + 'expression_label': 'balance', + 'sortable': False, + 'figure_type': 'monetary', + 'blank_if_zero': False, + 'style': "text-align: center; white-space: nowrap;", + }) + + else: + for report_column in self.column_ids: + columns.append({ + 'name': report_column.name, + 'column_group_key': column_group_key, + 'expression_label': report_column.expression_label, + 'sortable': report_column.sortable, + 'figure_type': report_column.figure_type, + 'blank_if_zero': report_column.blank_if_zero, + 'style': "text-align: center; white-space: nowrap;", + }) + + return columns, column_groups + + def _get_dict_hashable_key_tuple(self, dict_to_convert): + rslt = [] + for key, value in sorted(dict_to_convert.items()): + if isinstance(value, dict): + value = self._get_dict_hashable_key_tuple(value) + rslt.append((key, value)) + return tuple(rslt) + + #################################################### + # OPTIONS: BUTTONS + #################################################### + + def action_open_report_form(self, options, params): + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.report', + 'view_mode': 'form', + 'views': [(False, 'form')], + 'res_id': self.id, + } + + def _init_options_buttons(self, options, previous_options): + options['buttons'] = [ + {'name': _('PDF'), 'sequence': 10, 'action': 'export_file', 'action_param': 'export_to_pdf', 'file_export_type': _('PDF'), 'branch_allowed': True, 'always_show': True}, + {'name': _('XLSX'), 'sequence': 20, 'action': 'export_file', 'action_param': 'export_to_xlsx', 'file_export_type': _('XLSX'), 'branch_allowed': True, 'always_show': True}, + ] + + def open_account_report_file_download_error_wizard(self, errors, content): + self.ensure_one() + + model = 'account.report.file.download.error.wizard' + vals = {'actionable_errors': errors} + + if content: + vals['file_name'] = content['file_name'] + vals['file_content'] = base64.b64encode(re.sub(r'\n\s*\n', '\n', content['file_content']).encode()) + + return { + 'type': 'ir.actions.act_window', + 'res_model': model, + 'res_id': self.env[model].create(vals).id, + 'target': 'new', + 'views': [(False, 'form')], + } + + def get_export_mime_type(self, file_type): + """ Returns the MIME type associated with a report export file type, + for attachment generation. + """ + type_mapping = { + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'pdf': 'application/pdf', + 'xml': 'application/xml', + 'xaf': 'application/vnd.sun.xml.writer', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'zip': 'application/zip', + } + return type_mapping.get(file_type, False) + + def _init_options_section_buttons(self, options, previous_options): + """ In case we're displaying a section, we want to replace its buttons by its source report's. This needs to be done last, after calling the + custom handler, to avoid its _custom_options_initializer function to generate additional buttons. + """ + if options['sections_source_id'] != self.id: + # We need to re-call a full get_options in case a custom options initializer adds new buttons depending on other options. + # This way, we're sure we always get all buttons that are needed. + sections_source = self.env['account.report'].browse(options['sections_source_id']) + options['buttons'] = sections_source.get_options(previous_options={**options, 'no_report_reroute': True})['buttons'] + + #################################################### + # OPTIONS: VARIANTS + #################################################### + def _init_options_variants(self, options, previous_options): + allowed_variant_ids = set() + + previous_section_source_id = previous_options.get('sections_source_id') + if previous_section_source_id: + previous_section_source = self.env['account.report'].browse(previous_section_source_id) + if self in previous_section_source.section_report_ids: + options['variants_source_id'] = (previous_section_source.root_report_id or previous_section_source).id + allowed_variant_ids.add(previous_section_source_id) + + if 'variants_source_id' not in options: + options['variants_source_id'] = (self.root_report_id or self).id + + available_variants = self.env['account.report'] + options['has_inactive_variants'] = False + allowed_country_variant_ids = {} + all_variants = self._get_variants(options['variants_source_id']) + for variant in all_variants.filtered(lambda x: x._is_available_for(options)): + if not self.root_report_id and variant != self and variant.active: # Non-route reports don't reroute the variant when computing their options + allowed_variant_ids.add(variant.id) + if variant.country_id: + allowed_country_variant_ids.setdefault(variant.country_id.id, []).append(variant.id) + + if variant.active: + available_variants += variant + else: + options['has_inactive_variants'] = True + + options['available_variants'] = [ + { + 'id': variant.id, + 'name': variant.display_name, + 'country_id': variant.country_id.id, # To ease selection of default variant to open, without needing browsing again + } + for variant in sorted(available_variants, key=lambda x: (x.country_id and 1 or 0, x.sequence, x.id)) + ] + + previous_opt_report_id = previous_options.get('selected_variant_id') + if previous_opt_report_id in allowed_variant_ids or previous_opt_report_id == self.id: + options['selected_variant_id'] = previous_opt_report_id + elif allowed_country_variant_ids: + country_id = self.env.company.account_fiscal_country_id.id + report_id = (allowed_country_variant_ids.get(country_id) or next(iter(allowed_country_variant_ids.values())))[0] + options['selected_variant_id'] = report_id + else: + options['selected_variant_id'] = self.id + + def _get_variants(self, report_id): + source_report = self.env['account.report'].browse(report_id) + if source_report.root_report_id: + # We need to get the root report in order to get all variants + source_report = source_report.root_report_id + return source_report + source_report.with_context(active_test=False).variant_report_ids + + #################################################### + # OPTIONS: SECTIONS + #################################################### + def _init_options_sections(self, options, previous_options): + if options.get('selected_variant_id'): + options['sections_source_id'] = options['selected_variant_id'] + else: + options['sections_source_id'] = self.id + + source_report = self.env['account.report'].browse(options['sections_source_id']) + + available_sections = source_report.section_report_ids if source_report.use_sections else self.env['account.report'] + options['sections'] = [{'name': section.name, 'id': section.id} for section in available_sections] + + if available_sections: + section_id = previous_options.get('selected_section_id') + if not section_id or section_id not in available_sections.ids: + section_id = available_sections[0].id + + options['selected_section_id'] = section_id + + options['has_inactive_sections'] = bool(self.env['account.report'].with_context(active_test=False).search_count([ + ('section_main_report_ids', 'in', options['sections_source_id']), + ('active', '=', False) + ])) + + #################################################### + # OPTIONS: REPORT_ID + #################################################### + def _init_options_report_id(self, options, previous_options): + if previous_options.get('no_report_reroute'): + # Used for exports + options['report_id'] = self.id + else: + options['report_id'] = options.get('selected_section_id') or options.get('selected_variant_id') or self.id + + #################################################### + # OPTIONS: EXPORT MODE + #################################################### + def _init_options_export_mode(self, options, previous_options): + options['export_mode'] = previous_options.get('export_mode') + + #################################################### + # OPTIONS: HORIZONTAL SPLIT + #################################################### + def _init_options_horizontal_split(self, options, previous_options): + if any(line.horizontal_split_side for line in self.line_ids): + options['horizontal_split'] = previous_options.get('horizontal_split', False) + + #################################################### + # OPTIONS: CUSTOM + #################################################### + def _init_options_custom(self, options, previous_options): + custom_handler_model = self._get_custom_handler_model() + if custom_handler_model: + self.env[custom_handler_model]._custom_options_initializer(self, options, previous_options) + + #################################################### + # OPTIONS: INTEGER ROUNDING + #################################################### + def _init_options_integer_rounding(self, options, previous_options): + if self.integer_rounding: + options['integer_rounding'] = self.integer_rounding + if options.get('export_mode') == 'file': + options['integer_rounding_enabled'] = True + else: + options['integer_rounding_enabled'] = previous_options.get('integer_rounding_enabled', True) + return options + + #################################################### + # OPTIONS: BUDGETS + #################################################### + def _init_options_budgets(self, options, previous_options): + if self.filter_budgets: + previous_selection = {budget_option['id'] for budget_option in previous_options.get('budgets', []) if budget_option.get('selected')} + + options['budgets'] = [ + { + 'id': budget.id, + 'name': budget.name, + 'selected': budget.id in previous_selection, + 'company_id': budget.company_id.id, + } + for budget in self.env['account.report.budget'].search([('company_id', '=', self.env.company.id)]) + ] + options['show_all_accounts'] = previous_options.get('show_all_accounts') or False + + #################################################### + # OPTIONS: LOADING CALL + #################################################### + def _init_options_loading_call(self, options, previous_options): + """ Used by the js to know if it needs to reload the options (to not overwrite new options from the js) """ + options['loading_call_number'] = previous_options.get('loading_call_number') or 0 + return options + + #################################################### + # OPTIONS: READONLY QUERY + #################################################### + def _init_options_readonly_query(self, options, previous_options): + options['readonly_query'] = ( + options['currency_table']['type'] == 'monocurrency' + and not any(budget_opt['selected'] for budget_opt in options.get('budgets', [])) + ) + + #################################################### + # OPTIONS: CORE + #################################################### + + @api.readonly + def get_options(self, previous_options): + self.ensure_one() + + initializers_in_sequence = self._get_options_initializers_in_sequence() + + options = {} + + if previous_options.get('_running_export_test'): + options['_running_export_test'] = True + + # We need report_id to be initialized. Compute the necessary options to check for reroute. + for reroute_initializer_index, initializer in enumerate(initializers_in_sequence): + initializer(options, previous_options=previous_options) + + # pylint: disable=W0143 + if initializer == self._init_options_report_id: + break + + # Stop the computation to check for reroute once we have computed the necessary information + if (not self.root_report_id or (self.use_sections and self.section_report_ids)) and options['report_id'] != self.id: + # Load the variant/section instead of the root report + variant_options = {**previous_options} + for reroute_opt_key in ('selected_variant_id', 'selected_section_id', 'variants_source_id', 'sections_source_id'): + opt_val = options.get(reroute_opt_key) + if opt_val: + variant_options[reroute_opt_key] = opt_val + + return self.env['account.report'].browse(options['report_id']).get_options(variant_options) + + # No reroute; keep on and compute the other options + for initializer_index in range(reroute_initializer_index + 1, len(initializers_in_sequence)): + initializer = initializers_in_sequence[initializer_index] + initializer(options, previous_options=previous_options) + + options_companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + # Set export buttons to 'branch_allowed' if the currently selected company branches all share the same VAT + # number and no unselected sub-branch of the active company has the same VAT number. Companies with an empty VAT + # field will be considered as having the same VAT number as their closest parent with a non-empty VAT. + if options.get('enable_export_buttons_for_common_vat_in_branches'): + report_accepted_company_ids = set(options_companies.ids) + same_vat_branch_ids = set(self.env.company._get_branches_with_same_vat().ids) + if report_accepted_company_ids == same_vat_branch_ids: + options['buttons'] = [{**button, 'branch_allowed': button.get('branch_allowed', True)} for button in options['buttons']] + + # Disable buttons without branch_allowed = True if not all branches are selected + if not options_companies._all_branches_selected(): + for button in filter(lambda x: not x.get('branch_allowed'), options['buttons']): + button['error_action'] = 'show_error_branch_allowed' + + # Sort the buttons list by sequence, for rendering + options['buttons'] = sorted(options['buttons'], key=lambda x: x.get('sequence', 90)) + + # Sanitizing date_from and date_to since they need to be JSON-serializable when exporting the report + # on the server side, since the ORM converts them to strings automatically when sending them to the client. + for date_dict in ( + [options.get('date', {})] + + [group_data['forced_options']['date'] for group_data in options['column_groups'].values() if group_data.get('forced_options', {}).get('date')] + ): + if (date_from := date_dict.get('date_from')) and not isinstance(date_from, str): + date_dict['date_from'] = fields.Date.to_string(date_from) + + if (date_to := date_dict.get('date_to')) and not isinstance(date_to, str): + date_dict['date_to'] = fields.Date.to_string(date_to) + + return options + + def _get_options_initializers_in_sequence(self): + """ Gets all filters in the right order to initialize them, so that each filter is + guaranteed to be after all of its dependencies in the resulting list. + + :return: a list of initializer functions, each accepting two parameters: + - options (mandatory): The options dictionary to be modified by this initializer to include its related option's data + + - previous_options (optional, defaults to None): A dict with default options values, coming from a previous call to the report. + These values can be considered or ignored on a case-by-case basis by the initializer, + depending on functional needs. + """ + initializer_prefix = '_init_options_' + initializers = [ + getattr(self, attr) for attr in dir(self) + if attr.startswith(initializer_prefix) + ] + + # Order them in a dependency-compliant way + forced_sequence_map = self._get_options_initializers_forced_sequence_map() + initializers.sort(key=lambda x: forced_sequence_map.get(x, forced_sequence_map.get('default'))) + + return initializers + + def _get_options_initializers_forced_sequence_map(self): + """ By default, not specific order is ensured for the filters when calling _get_options_initializers_in_sequence. + This function allows giving them a sequence number. It can be overridden + to make filters depend on each other. + + :return: dict(str, int): str is the filter name, int is its sequence (lowest = first). + Multiple filters may share the same sequence, their relative order is then not guaranteed. + """ + return { + self._init_options_companies: 10, + self._init_options_variants: 15, + self._init_options_sections: 16, + self._init_options_report_id: 17, + self._init_options_fiscal_position: 20, + self._init_options_date: 30, + self._init_options_horizontal_groups: 40, + self._init_options_comparison: 50, + self._init_options_export_mode: 60, + self._init_options_integer_rounding: 70, + self._init_options_journals: 80, + self._init_options_journals_names: 90, + + 'default': 200, + + self._init_options_column_headers: 990, + self._init_options_columns: 1000, + self._init_options_column_percent_comparison: 1010, + self._init_options_order_column: 1020, + self._init_options_hierarchy: 1030, + self._init_options_prefix_groups_threshold: 1040, + self._init_options_custom: 1050, + self._init_options_currency_table: 1055, + self._init_options_section_buttons: 1060, + self._init_options_readonly_query: 1070, + } + + def _get_options_domain(self, options, date_scope): + self.ensure_one() + + available_scopes = dict(self.env['account.report.expression']._fields['date_scope'].selection) + if date_scope and date_scope not in available_scopes: # date_scope can be passed to None explicitly to ignore the dates + raise UserError(_("Unknown date scope: %s", date_scope)) + + domain = [ + ('display_type', 'not in', ('line_section', 'line_note')), + ('company_id', 'in', self.get_report_company_ids(options)), + ] + if not options.get('compute_budget'): + domain += self._get_options_journals_domain(options) + if date_scope: + domain += self._get_options_date_domain(options, date_scope) + domain += self._get_options_partner_domain(options) + domain += self._get_options_all_entries_domain(options) + domain += self._get_options_unreconciled_domain(options) + domain += self._get_options_fiscal_position_domain(options) + domain += self._get_options_account_type_domain(options) + domain += self._get_options_aml_ir_filters(options) + + if self.only_tax_exigible: + domain += self.env['account.move.line']._get_tax_exigible_domain() + + if options.get('forced_domain'): + # That option key is set when splitting options between column groups + domain += options['forced_domain'] + + return domain + + #################################################### + # QUERIES + #################################################### + + def _get_report_query(self, options, date_scope, domain=None) -> Query: + """ Get a Query object that references the records needed for this report. """ + domain = self._get_options_domain(options, date_scope) + (domain or []) + + self.env['account.move.line'].check_access('read') + + query = self.env['account.move.line']._where_calc(domain) + + if options.get('compute_budget'): + self._create_report_budget_temp_table(options) + query._tables['account_move_line'] = SQL.identifier('account_report_budget_temp_aml') + query.add_where(SQL( + "%s AND budget_id = %s", + query.where_clause, + options['compute_budget'], + )) + + # Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights. + self.env['account.move.line']._apply_ir_rules(query) + + return query + + def _create_report_budget_temp_table(self, options): + self._cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='account_report_budget_temp_aml'") + if self._cr.fetchone(): + return + + stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({ + 'id': SQL.identifier("id"), + 'balance': SQL.identifier('amount'), + 'company_id': self.env.company.id, + 'parent_state': 'posted', + 'date': SQL.identifier('date'), + 'account_id': SQL.identifier("account_id"), + 'debit': SQL("CASE WHEN (amount > 0) THEN amount else 0 END"), + 'credit': SQL("CASE WHEN (amount < 0) THEN -amount else 0 END"), + }) + + self._cr.execute(SQL( + """ + -- Create a temporary table, dropping not null constraints because we're not filling those columns + CREATE TEMPORARY TABLE IF NOT EXISTS account_report_budget_temp_aml () inherits (account_move_line) ON COMMIT DROP; + ALTER TABLE account_report_budget_temp_aml NO INHERIT account_move_line; + ALTER TABLE account_report_budget_temp_aml ALTER COLUMN move_id DROP NOT NULL; + ALTER TABLE account_report_budget_temp_aml ALTER COLUMN currency_id DROP NOT NULL; + ALTER TABLE account_report_budget_temp_aml ALTER COLUMN journal_id DROP NOT NULL; + ALTER TABLE account_report_budget_temp_aml ALTER COLUMN display_type DROP NOT NULL; + ALTER TABLE account_report_budget_temp_aml ADD budget_id INTEGER NOT NULL; + + INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id) + SELECT %(fields_to_insert)s, budget_id + FROM account_report_budget_item + WHERE budget_id IN %(available_budget_ids)s; + + -- Create a supporting index to avoid seq.scans + CREATE INDEX IF NOT EXISTS account_report_budget_temp_aml__composite_idx ON account_report_budget_temp_aml (account_id, journal_id, date, company_id); + -- Update statistics for correct planning + ANALYZE account_report_budget_temp_aml + """, + stored_aml_fields=stored_aml_fields, + fields_to_insert=fields_to_insert, + available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']), + )) + + if options.get('show_all_accounts'): + stored_aml_fields, fields_to_insert = self.env['account.move.line']._prepare_aml_shadowing_for_report({ + # Using nextval will consume a sequence number, we decide to do it to avoid comparing apples and oranges + 'id': SQL("(SELECT nextval('account_report_budget_item_id_seq'))"), + 'balance': SQL("0"), + 'company_id': self.env.company.id, + 'parent_state': 'posted', + 'date': SQL("%s", options['date']['date_from']), + 'account_id': SQL.identifier("accounts", "id"), + 'debit': SQL("0"), + 'credit': SQL("0"), + }) + accounts_subquery = self.env['account.account']._where_calc([ + ('company_ids', 'in', self.get_report_company_ids(options)), + ('internal_group', 'in', ['income', 'expense']), + ]) + self._cr.execute(SQL( + """ + -- Insert dynamic combinations of account_id and budget_id into the temporary table + INSERT INTO account_report_budget_temp_aml (%(stored_aml_fields)s, budget_id) + SELECT %(fields_to_insert)s, budgets.id AS budget_id + FROM (%(accounts_subquery)s) AS accounts + CROSS JOIN ( + SELECT id + FROM account_report_budget + WHERE id IN %(available_budget_ids)s + ) AS budgets + """, + stored_aml_fields=stored_aml_fields, + fields_to_insert=fields_to_insert, + accounts_subquery=accounts_subquery.select(), + available_budget_ids=tuple(budget_option['id'] for budget_option in options['budgets']), + income='income%', + expense='expense%', + company_ids=tuple(), + )) + + #################################################### + # LINE IDS MANAGEMENT HELPERS + #################################################### + def _get_generic_line_id(self, model_name, value, markup=None, parent_line_id=None): + """ Generates a generic line id from the provided parameters. + + Such a generic id consists of a string repeating 1 to n times the following pattern: + markup-model-value, each occurence separated by a LINE_ID_HIERARCHY_DELIMITER character from the previous one. + + Each pattern corresponds to a level of hierarchy in the report, so that + the n-1 patterns starting the id of a line actually form the id of its generator line. + EX: a~b~c|d~e~f|g~h~i => This line is a subline generated by a~b~c|d~e~f where | is the LINE_ID_HIERARCHY_DELIMITER. + + Each pattern consists of the three following elements: + - markup: a (possibly empty) free string or json-formatted dict allowing finer identification of the line + (like the name of the field for account.accounting.reports) + + - model: the model this line has been generated for, or an empty string if there is none + + - value: the groupby value for this line (typically the id of a record + or the value of a field), or an empty string if there isn't any. + """ + self.ensure_one() + + if parent_line_id: + parent_id_list = self._parse_line_id(parent_line_id, markup_as_string=True) + else: + parent_id_list = [(None, 'account.report', self.id)] + + # In case the markup is a dict, it must be converted to a string, but in a way such that the keys are ordered alphabetically. + # This is useful, notably for annotations where the ids of the lines are stored, therefore requiring a consistent ordering + if isinstance(markup, dict): + markup = json.dumps(markup, sort_keys=True) + + new_line = self._build_line_id(parent_id_list + [(markup, model_name, value)]) + return new_line + + @api.model + def _get_line_from_xml_id(self, lines, xml_id): + """ Helper function to get a specific account report line from the xmlid """ + report_line = self.env.ref(xml_id, raise_if_not_found=False) + return next( + line for line in lines + if self._get_model_info_from_id(line['id']) == ('account.report.line', report_line.id) + ) + + @api.model + def _get_model_info_from_id(self, line_id): + """ Parse the provided generic report line id. + + :param line_id: the report line id (i.e. markup~model~value|markup2~model2~value2 where | is the LINE_ID_HIERARCHY_DELIMITER) + :return: tuple(model, id) of the report line. Each of those values can be None if the id contains no information about them. + """ + last_id_tuple = self._parse_line_id(line_id)[-1] + return last_id_tuple[-2:] + + @api.model + def _build_line_id(self, current): + """ Build a generic line id string from its list representation, converting + the None values for model and value to empty strings. + :param current (list): list of tuple(markup, model, value) + """ + def convert_none(x): + return x if x is not None and x is not False else '' + return LINE_ID_HIERARCHY_DELIMITER.join(f'{convert_none(markup)}~{convert_none(model)}~{convert_none(value)}' for markup, model, value in current) + + @api.model + def _build_parent_line_id(self, current): + """Build the parent_line id based on the current position in the report. + + For instance, if current is [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)], it will return + markup1~account.account~5 + :param current (list): list of tuple(markup, model, value) + """ + to_process = [(json.dumps(markup) if isinstance(markup, dict) else markup, model, value) for markup, model, value in current[:-1]] + return self._build_line_id(to_process) + + @api.model + def _parse_markup(self, markup): + if not markup: + return markup + try: + result = json.loads(markup) + except json.JSONDecodeError: # the markup is not a JSON object + return markup + if isinstance(result, dict): + return result + + return markup + + @api.model + def _parse_line_id(self, line_id, markup_as_string=False): + """Parse the provided string line id and convert it to its list representation. + Empty strings for model and value will be converted to None. + + For instance if line_id is markup1~account.account~5|markup2~res.partner~8 (where | is the LINE_ID_HIERARCHY_DELIMITER), + it will return [('markup1', 'account.account', 5), ('markup2', 'res.partner', 8)] + :param line_id (str): the generic line id to parse + """ + return line_id and [ + # When there is a model, value is an id, so we cast it to and int. Else, we keep the original value (for groupby lines on + # non-relational fields, for example). + (self._parse_markup(markup) if not markup_as_string else markup, model or None, int(value) if model and value else (value or None)) + for markup, model, value in (key.rsplit('~', 2) for key in line_id.split(LINE_ID_HIERARCHY_DELIMITER)) + ] or [] + + @api.model + def _get_unfolded_lines(self, lines, parent_line_id): + """ Return a list of all children lines for specified parent_line_id. + NB: It will return the parent_line itself! + + For instance if parent_line_ids is '~account.report.line~84|{"groupby": "currency_id"}~res.currency~174' + (where | is the LINE_ID_HIERARCHY_DELIMITER), it will return every subline for this currency. + :param lines: list of report lines + :param parent_line_id: id of a specified line + :return: A list of all children lines for a specified parent_line_id + """ + return [ + line for line in lines + if line['id'].startswith(parent_line_id) + ] + + @api.model + def _get_res_id_from_line_id(self, line_id, target_model_name): + """ Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record id it contains which + corresponds to the provided model name. If line_id does not contain anything related to target_model_name, None will be returned. + + For example, parsing ~account.move~1|~res.partner~2|~account.move~3 (where | is the LINE_ID_HIERARCHY_DELIMITER) + with target_model_name='account.move' will return 3. + """ + dict_result = self._get_res_ids_from_line_id(line_id, [target_model_name]) + return dict_result[target_model_name] if dict_result else None + + + @api.model + def _get_res_ids_from_line_id(self, line_id, target_model_names): + """ Parses the provided generic line id and returns the most local (i.e. the furthest on the right) record ids it contains which + correspond to the provided model names, in the form {model_name: res_id}. If a model is not present in line_id, its model will be absent + from the resulting dict. + + For example, parsing ~account.move~1|~res.partner~2|~account.move~3 with target_model_names=['account.move', 'res.partner'] will return + {'account.move': 3, 'res.partner': 2}. + """ + result = {} + models_to_find = set(target_model_names) + for dummy, model, value in reversed(self._parse_line_id(line_id)): + if model in models_to_find: + result[model] = value + models_to_find.remove(model) + + return result + + @api.model + def _get_markup(self, line_id): + """ Directly returns the markup associated with the provided line_id. + """ + return self._parse_line_id(line_id)[-1][0] if line_id else None + + def _build_subline_id(self, parent_line_id, subline_id_postfix): + """ Creates a new subline id by concatanating parent_line_id with the provided id postfix. + """ + return f"{parent_line_id}{LINE_ID_HIERARCHY_DELIMITER}{subline_id_postfix}" + + #################################################### + # CARET OPTIONS MANAGEMENT + #################################################### + + def _get_caret_options(self): + return { + **self._caret_options_initializer_default(), + **(self.env[self.custom_handler_model_name]._caret_options_initializer() if self.custom_handler_model_id else {}), + } + + def _caret_options_initializer_default(self): + return { + 'account.account': [ + {'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'}, + ], + + 'account.move': [ + {'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form'}, + ], + + 'account.move.line': [ + {'name': _("View Journal Entry"), 'action': 'caret_option_open_record_form', 'action_param': 'move_id'}, + ], + + 'account.payment': [ + {'name': _("View Payment"), 'action': 'caret_option_open_record_form', 'action_param': 'payment_id'}, + ], + + 'account.bank.statement': [ + {'name': _("View Bank Statement"), 'action': 'caret_option_open_statement_line_reco_widget'}, + ], + + 'res.partner': [ + {'name': _("View Partner"), 'action': 'caret_option_open_record_form'}, + ], + } + + def caret_option_open_record_form(self, options, params): + model, record_id = self._get_model_info_from_id(params['line_id']) + record = self.env[model].browse(record_id) + target_record = record[params['action_param']] if 'action_param' in params else record + + view_id = self._resolve_caret_option_view(target_record) + + action = { + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'views': [(view_id, 'form')], # view_id will be False in case the default view is needed + 'res_model': target_record._name, + 'res_id': target_record.id, + 'context': self._context, + } + + if view_id is not None: + action['view_id'] = view_id + + return action + + def _get_caret_option_view_map(self): + return { + 'account.payment': 'account.view_account_payment_form', + 'res.partner': 'base.view_partner_form', + 'account.move': 'account.view_move_form', + } + + def _resolve_caret_option_view(self, target): + '''Retrieve the target view of the caret option. + + :param target: The target record of the redirection. + :return: The id of the target view. + ''' + view_map = self._get_caret_option_view_map() + + view_xmlid = view_map.get(target._name) + if not view_xmlid: + return None + + return self.env['ir.model.data']._xmlid_lookup(view_xmlid)[1] + + def caret_option_open_general_ledger(self, options, params): + # When coming from a specific account, the unfold must only be retained + # on the specified account. Better performance and more ergonomic + # as it opens what client asked. And "Unfold All" is 1 clic away. + options["unfold_all"] = False + + records_to_unfold = [] + for _dummy, model, record_id in self._parse_line_id(params['line_id']): + if model in ('account.group', 'account.account'): + records_to_unfold.append((model, record_id)) + + if not records_to_unfold or records_to_unfold[-1][0] != 'account.account': + raise UserError(_("'Open General Ledger' caret option is only available form report lines targetting accounts.")) + + general_ledger = self.env.ref('odex30_account_reports.general_ledger_report') + lines_to_unfold = [] + for model, record_id in records_to_unfold: + parent_line_id = lines_to_unfold[-1] if lines_to_unfold else None + # Re-create the hierarchy of account groups that should be unfolded in GL + generic_line_id = general_ledger._get_generic_line_id(model, record_id, parent_line_id=parent_line_id) + lines_to_unfold.append(generic_line_id) + + options['not_reset_journals_filter'] = True # prevents resetting the default journal group + gl_options = general_ledger.get_options(options) + gl_options['not_reset_journals_filter'] = True # prevents resetting the default journal group + gl_options['unfolded_lines'] = lines_to_unfold + + account_id = self.env['account.account'].browse(records_to_unfold[-1][1]) + action_vals = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger') + action_vals['params'] = { + 'options': gl_options, + 'ignore_session': True, + } + action_vals['context'] = dict(ast.literal_eval(action_vals['context']), default_filter_accounts=account_id.code) + + return action_vals + + def caret_option_open_statement_line_reco_widget(self, options, params): + model, record_id = self._get_model_info_from_id(params['line_id']) + record = self.env[model].browse(record_id) + if record._name == 'account.bank.statement.line': + return record.action_open_recon_st_line() + elif record._name == 'account.bank.statement': + return record.action_open_bank_reconcile_widget() + raise UserError(_("'View Bank Statement' caret option is only available for report lines targeting bank statements.")) + + #################################################### + # MISC + #################################################### + + def _get_custom_handler_model(self): + """ Check whether the current report has a custom handler and if it does, return its name. + Otherwise, try to fall back on the root report. + """ + return self.custom_handler_model_name or self.root_report_id.custom_handler_model_name or None + + def dispatch_report_action(self, options, action, action_param=None, on_sections_source=False): + """ Dispatches calls made by the client to either the report itself, or its custom handler if it exists. + The action should be a public method, by definition, but a check is made to make sure + it is not trying to call a private method. + """ + self.ensure_one() + + if on_sections_source: + report_to_call = self.env['account.report'].browse(options['sections_source_id']) + options["report_id"] = report_to_call.id + return report_to_call.dispatch_report_action(options, action, action_param=action_param, on_sections_source=False) + + if self.id not in (options['report_id'], options.get('sections_source_id')): + raise UserError(_("Trying to dispatch an action on a report not compatible with the provided options.")) + + args = [options, action_param] if action_param is not None else [options] + model = self + custom_handler_model = self._get_custom_handler_model() + if custom_handler_model and hasattr(self.env[custom_handler_model], action): + model = self.env[custom_handler_model] + report_method = get_public_method(model, action) + return report_method(model, *args) + + def _get_custom_report_function(self, function_name, prefix): + """ Returns a report function from its name, first checking it to ensure it's private (and raising if it isn't). + This helper is used by custom report fields containing function names. + The function will be called on the report's custom handler if it exists, or on the report itself otherwise. + """ + self.ensure_one() + function_name_prefix = f'_report_{prefix}_' + if not function_name.startswith(function_name_prefix): + raise UserError(_("Method '%(method_name)s' must start with the '%(prefix)s' prefix.", method_name=function_name, prefix=function_name_prefix)) + + if self.custom_handler_model_id: + handler = self.env[self.custom_handler_model_name] + if hasattr(handler, function_name): + return getattr(handler, function_name) + + if not hasattr(self, function_name): + raise UserError(_("Invalid method “%s”", function_name)) + # Call the check method without the private prefix to check for others security risks. + return getattr(self, function_name) + + def _get_lines(self, options, all_column_groups_expression_totals=None, warnings=None): + self.ensure_one() + + if options['report_id'] != self.id: + # Should never happen; just there to prevent BIG issues and directly spot them + raise UserError(_("Inconsistent report_id in options dictionary. Options says %(options_report)s; report is %(report)s.", options_report=options['report_id'], report=self.id)) + + # Necessary to ensure consistency of the data if some of them haven't been written in database yet + self.env.flush_all() + + if warnings is not None: + self._generate_common_warnings(options, warnings) + + # Merge static and dynamic lines in a common list + if all_column_groups_expression_totals is None: + self._init_currency_table(options) + all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group( + self.line_ids.expression_ids, + options, + warnings=warnings, + ) + + dynamic_lines = self._get_dynamic_lines(options, all_column_groups_expression_totals, warnings=warnings) + + lines = [] + line_cache = {} # {report_line: report line dict} + hide_if_zero_lines = self.env['account.report.line'] + + # There are two types of lines: + # - static lines: the ones generated from self.line_ids + # - dynamic lines: the ones generated from a call to the functions referred to by self.dynamic_lines_generator + # This loops combines both types of lines together within the lines list + for line in self.line_ids: # _order ensures the sequence of the lines + # Inject all the dynamic lines whose sequence is inferior to the next static line to add + while dynamic_lines and line.sequence > dynamic_lines[0][0]: + lines.append(dynamic_lines.pop(0)[1]) + + parent_generic_id = None + + if line.parent_id: + # Normally, the parent line has necessarily been treated in a previous iteration + try: + parent_generic_id = line_cache[line.parent_id]['id'] + except KeyError as e: + raise UserError(_( + "Line '%(child)s' is configured to appear before its parent '%(parent)s'. This is not allowed.", + child=line.name, parent=e.args[0].name + )) + + line_dict = self._get_static_line_dict(options, line, all_column_groups_expression_totals, parent_id=parent_generic_id) + line_cache[line] = line_dict + + if line.hide_if_zero: + hide_if_zero_lines += line + + lines.append(line_dict) + + for dummy, left_dynamic_line in dynamic_lines: + lines.append(left_dynamic_line) + + # Manage growth comparison + if options.get('column_percent_comparison') == 'growth': + for line in lines: + first_value, second_value = line['columns'][0]['no_format'], line['columns'][1]['no_format'] + + green_on_positive = True + model, line_id = self._get_model_info_from_id(line['id']) + + if model == 'account.report.line' and line_id: + report_line = self.env['account.report.line'].browse(line_id) + compared_expression = report_line.expression_ids.filtered( + lambda expr: expr.label == line['columns'][0]['expression_label'] + ) + green_on_positive = compared_expression.green_on_positive + + line['column_percent_comparison_data'] = self._compute_column_percent_comparison_data( + options, first_value, second_value, green_on_positive=green_on_positive + ) + # Manage budget comparison + elif options.get('column_percent_comparison') == 'budget': + for line in lines: + self._set_budget_column_comparisons(options, line) + + # Manage hide_if_zero lines: + # - If they have column values: hide them if all those values are 0 (or empty) + # - If they don't: hide them if all their children's column values are 0 (or empty) + # Also, hide all the children of a hidden line. + hidden_lines_dict_ids = set() + for line in hide_if_zero_lines: + children_to_check = line + current = line + while current: + children_to_check |= current + current = current.children_ids + + all_children_zero = True + hide_candidates = set() + for child in children_to_check: + child_line_dict_id = line_cache[child]['id'] + + if child_line_dict_id in hidden_lines_dict_ids: + continue + elif all(col.get('is_zero', True) for col in line_cache[child]['columns']): + hide_candidates.add(child_line_dict_id) + else: + all_children_zero = False + break + + if all_children_zero: + hidden_lines_dict_ids |= hide_candidates + + lines[:] = filter(lambda x: x['id'] not in hidden_lines_dict_ids and x.get('parent_id') not in hidden_lines_dict_ids, lines) + + # Create the hierarchy of lines if necessary + if options.get('hierarchy'): + lines = self._create_hierarchy(lines, options) + + # Handle totals below sections for static lines + lines = self._add_totals_below_sections(lines, options) + + # Unfold lines (static or dynamic) if necessary and add totals below section to dynamic lines + lines = self._fully_unfold_lines_if_needed(lines, options) + + self._inject_account_names_for_consolidation(lines) + + if self.custom_handler_model_id: + lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines) + + if warnings is not None: + custom_handler_name = self.custom_handler_model_name or self.root_report_id.custom_handler_model_name + if custom_handler_name: + self.env[custom_handler_name]._customize_warnings(self, options, all_column_groups_expression_totals, warnings) + + # Format values in columns of lines that will be displayed + self._format_column_values(options, lines) + + if options.get('export_mode') == 'print' and options.get('hide_0_lines'): + lines = self._filter_out_0_lines(lines) + + return lines + + # Deprecated, removed in master. + @api.model + def format_column_values(self, options, lines): + self._format_column_values(options, lines, force_format=True) + + return lines + + def format_column_values_from_client(self, options, lines): + """ Format column values for display. Called via dispatch_report_action when rounding unit changes on client side.""" + self._format_column_values(options, lines, force_format=True) + + return lines + + def _format_column_values(self, options, line_dict_list, force_format=False): + for line_dict in line_dict_list: + for column_dict in line_dict['columns']: + if 'name' in column_dict and not force_format: + # Columns which have already received a name are assumed to be already formatted; nothing needs to be done for them. + # This gives additional flexibility to custom reports, if needed. + continue + + if not column_dict: + continue + elif column_dict.get('is_zero') and column_dict.get('blank_if_zero'): + rslt = '' + elif options.get('export_mode') == 'file': + rslt = column_dict.get('no_format', '') + else: + rslt = self.format_value( + options, + column_dict.get('no_format'), + column_dict.get('figure_type'), + format_params=column_dict.get('format_params'), + ) + + column_dict['name'] = rslt + + # Handle the total in case of an horizontal group when there is no comparison and only one level of horizontal group + if options.get('show_horizontal_group_total'): + # In case the line has no formula + if all(column['no_format'] is None for column in line_dict['columns']): + continue + # In case total below section, some line don't have the value displayed + if self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections') and line_dict['unfolded']: + continue + + figure_type_is_valid = all(column['figure_type'] in {'float', 'integer', 'monetary'} for column in line_dict['columns']) + total_value = sum(column["no_format"] for column in line_dict['columns']) if figure_type_is_valid else None + line_dict['horizontal_group_total_data'] = { + 'name': self.format_value( + options, + total_value, + line_dict['columns'][0]['figure_type'], + format_params=line_dict['columns'][0]['format_params'], + ), + 'no_format': total_value, + } + + def _generate_common_warnings(self, options, warnings): + # Display a warning if we're displaying only the data of the current company, but it's also part of a tax unit + if options.get('available_tax_units') and options['tax_unit'] == 'company_only': + warnings['odex30_account_reports.common_warning_tax_unit'] = {} + + report_company_ids = self.get_report_company_ids(options) + # The _accessible_branches function will return the accessible branches from the ones that are already selected, + # and the report_company_ids function will return the current company and its branches (that are selected) with the same VAT + # or tax unit. Therefore, we will display the warning only when the selected companies do not have the same VAT + # and in the context of branches. + if self.filter_multi_company == 'tax_units' and any(accessible_branch.id not in report_company_ids for accessible_branch in self.env.company._accessible_branches()): + warnings['odex30_account_reports.tax_report_warning_tax_id_selected_companies'] = {'alert_type': 'warning'} + + # Check whether there are unposted entries for the selected period and partner or not (if the report allows it) + if options.get('date') and options.get('all_entries') is not None: + domain = osv.expression.AND([ + self.env['account.move']._check_company_domain(report_company_ids), + [('state', '=', 'draft')], + [('date', '<=', options['date']['date_to'])], + ]) + if options.get('partner_ids'): + domain = osv.expression.AND([ + domain, + osv.expression.OR([ + [('partner_id', 'in', options['partner_ids'])], + [('partner_shipping_id', 'in', options['partner_ids'])], + [('commercial_partner_id', 'in', options['partner_ids'])], + ]) + ]) + if self.env['account.move'].search_count(domain, limit=1): + warnings['odex30_account_reports.common_warning_draft_in_period'] = {} + + def _fully_unfold_lines_if_needed(self, lines, options): + def line_need_expansion(line_dict): + return line_dict.get('unfolded') and line_dict.get('expand_function') + + custom_unfold_all_batch_data = None + + # If it's possible to batch unfold and we're unfolding all lines, compute the batch, so that individual expansions are more efficient + if options['unfold_all'] and self.custom_handler_model_id: + lines_to_expand_by_function = {} + for line_dict in lines: + if line_need_expansion(line_dict): + lines_to_expand_by_function.setdefault(line_dict['expand_function'], []).append(line_dict) + + custom_unfold_all_batch_data = self.env[self.custom_handler_model_name]._custom_unfold_all_batch_data_generator(self, options, lines_to_expand_by_function) + + i = 0 + while i < len(lines): + # We iterate in such a way that if the lines added by an expansion need expansion, they will get it as well + line_dict = lines[i] + if line_need_expansion(line_dict): + groupby = line_dict.get('groupby') + progress = line_dict.get('progress') + to_insert = self._expand_unfoldable_line( + line_dict['expand_function'], line_dict['id'], groupby, options, progress, 0, line_dict.get('horizontal_split_side'), + unfold_all_batch_data=custom_unfold_all_batch_data, + ) + lines = lines[:i+1] + to_insert + lines[i+1:] + i += 1 + + return lines + + def _generate_total_below_section_line(self, section_line_dict): + return { + **section_line_dict, + 'id': self._get_generic_line_id(None, None, parent_line_id=section_line_dict['id'], markup='total'), + 'level': section_line_dict['level'] if section_line_dict['level'] != 0 else 1, # Total line should not be level 0 + 'name': _("Total %s", section_line_dict['name']), + 'parent_id': section_line_dict['id'], + 'unfoldable': False, + 'unfolded': False, + 'caret_options': None, + 'action_id': None, + 'page_break': False, # If the section's line possesses a page break, we don't want the total to have it. + } + + def _get_static_line_dict(self, options, line, all_column_groups_expression_totals, parent_id=None): + line_id = self._get_generic_line_id('account.report.line', line.id, parent_line_id=parent_id) + columns = self._build_static_line_columns(line, options, all_column_groups_expression_totals) + groupby = line._get_groupby(options) + has_children = (groupby and any(col['has_sublines'] for col in columns)) or bool(line.children_ids) + + rslt = { + 'id': line_id, + 'name': line.name, + 'groupby': groupby, + 'unfoldable': line.foldable and has_children, + 'unfolded': (not line.foldable and (groupby or has_children)) or line_id in options['unfolded_lines'] or has_children and options['unfold_all'], + 'columns': columns, + 'level': line.hierarchy_level, + 'page_break': line.print_on_new_page, + 'action_id': line.action_id.id, + 'expand_function': groupby and '_report_expand_unfoldable_line_with_groupby' or None, + } + + if line.horizontal_split_side: + rslt['horizontal_split_side'] = line.horizontal_split_side + + if parent_id: + rslt['parent_id'] = parent_id + + if options['export_mode'] == 'file': + rslt['code'] = line.code + + if options['show_debug_column']: + first_group_key = list(options['column_groups'].keys())[0] + column_group_totals = all_column_groups_expression_totals[first_group_key] + # Only consider the first column group, as show_debug_column is only true if there is but one. + + engine_selection_labels = dict(self.env['account.report.expression']._fields['engine']._description_selection(self.env)) + expressions_detail = defaultdict(lambda: []) + col_expression_to_figure_type = { + column.get('expression_label'): column.get('figure_type') for column in options['columns'] + } + for expression in line.expression_ids.filtered(lambda x: not x.label.startswith('_default')): + engine_label = engine_selection_labels[expression.engine] + figure_type = expression.figure_type or col_expression_to_figure_type.get(expression.label) or 'none' + expressions_detail[engine_label].append(( + expression.label, + {'formula': expression.formula, 'subformula': expression.subformula, 'value': self.format_value(options, column_group_totals[expression]['value'], figure_type)} + )) + + # Sort results so that they can be rendered nicely in the UI + for details in expressions_detail.values(): + details.sort(key=lambda x: x[0]) + sorted_expressions_detail = sorted(expressions_detail.items(), key=lambda x: x[0]) + + if sorted_expressions_detail: + try: + rslt['debug_popup_data'] = json.dumps({'expressions_detail': sorted_expressions_detail}) + except TypeError: + raise UserError(_( + 'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s', + expression=expression.label, + line=expression.report_line_id.name, + subformula=expression.subformula, + )) + return rslt + + @api.model + def _build_static_line_columns(self, line, options, all_column_groups_expression_totals, groupby_model=None): + line_expressions_map = {expr.label: expr for expr in line.expression_ids} + columns = [] + for column_data in options['columns']: + col_group_key = column_data['column_group_key'] + current_group_expression_totals = all_column_groups_expression_totals[col_group_key] + target_line_res_dict = {expr.label: current_group_expression_totals[expr] for expr in line.expression_ids if not expr.label.startswith('_default')} + + column_expr_label = column_data['expression_label'] + column_res_dict = target_line_res_dict.get(column_expr_label, {}) + column_value = column_res_dict.get('value') + column_has_sublines = column_res_dict.get('has_sublines', False) + column_expression = line_expressions_map.get(column_expr_label, self.env['account.report.expression']) + figure_type = column_expression.figure_type or column_data['figure_type'] + + # Handle info popup + info_popup_data = {} + + # Check carryover + carryover_expr_label = '_carryover_%s' % column_expr_label + carryover_value = target_line_res_dict.get(carryover_expr_label, {}).get('value', 0) + if self.env.company.currency_id.compare_amounts(0, carryover_value) != 0: + info_popup_data['carryover'] = self._format_value(options, carryover_value, 'monetary') + + carryover_expression = line_expressions_map[carryover_expr_label] + if carryover_expression.carryover_target: + info_popup_data['carryover_target'] = carryover_expression._get_carryover_target_expression(options).report_line_name + # If it's not set, it means the carryover needs to target the same expression + + applied_carryover_value = target_line_res_dict.get('_applied_carryover_%s' % column_expr_label, {}).get('value', 0) + if self.env.company.currency_id.compare_amounts(0, applied_carryover_value) != 0: + info_popup_data['applied_carryover'] = self._format_value(options, applied_carryover_value, 'monetary') + info_popup_data['allow_carryover_audit'] = self.env.user.has_group('base.group_no_one') + info_popup_data['expression_id'] = line_expressions_map['_applied_carryover_%s' % column_expr_label]['id'] + info_popup_data['column_group_key'] = col_group_key + + # Handle manual edition popup + edit_popup_data = {} + formatter_params = {} + if column_expression.engine == 'external' and column_expression.subformula \ + and len(options['companies']) == 1 \ + and (not options['available_vat_fiscal_positions'] or options['fiscal_position'] != 'all'): + + # Compute rounding for manual values + rounding = None + if figure_type == 'integer': + rounding = 0 + else: + rounding_opt_match = re.search(r"\Wrounding\W*=\W*(?P\d+)", column_expression.subformula) + if rounding_opt_match: + rounding = int(rounding_opt_match.group('rounding')) + elif figure_type == 'monetary': + rounding = self.env.company.currency_id.decimal_places + + if 'editable' in column_expression.subformula: + edit_popup_data = { + 'column_group_key': col_group_key, + 'target_expression_id': column_expression.id, + 'rounding': rounding, + 'figure_type': figure_type, + 'column_value': column_value, + } + + formatter_params['digits'] = rounding + + # Handle editable financial budgets + editable_budget = groupby_model == 'account.account' and options['column_groups'][col_group_key]['forced_options'].get('compute_budget') + if editable_budget and self.env.user.has_group('account.group_account_manager'): + edit_popup_data = { + 'column_group_key': col_group_key, + 'target_expression_id': column_expression.id, + 'rounding': self.env.company.currency_id.decimal_places, + 'figure_type': 'monetary', + 'column_value': column_value, + } + + # Build result + if column_value is not None: #In case column value is zero, we still want to go through the condition + foreign_currency_id = target_line_res_dict.get(f'_currency_{column_expr_label}', {}).get('value') + if foreign_currency_id: + formatter_params['currency'] = self.env['res.currency'].browse(foreign_currency_id) + + column_data = self._build_column_dict( + column_value, + column_data, + options=options, + column_expression=column_expression if column_expression else None, + has_sublines=column_has_sublines, + report_line_id=line.id, + **formatter_params, + ) + + if info_popup_data: + column_data['info_popup_data'] = json.dumps(info_popup_data) + + if edit_popup_data: + column_data['edit_popup_data'] = json.dumps(edit_popup_data) + + columns.append(column_data) + + return columns + + def _build_column_dict( + self, col_value, col_data, + options=None, currency=False, digits=1, + column_expression=None, has_sublines=False, + report_line_id=None, + ): + # Empty column + if col_value is None and col_data is None: + return {} + + col_data = col_data or {} + column_expression = column_expression or self.env['account.report.expression'] + options = options or {} + + blank_if_zero = column_expression.blank_if_zero or col_data.get('blank_if_zero', False) + figure_type = column_expression.figure_type or col_data.get('figure_type', 'string') + + format_params = {} + if figure_type == 'monetary' and currency: + format_params['currency_id'] = currency.id + elif figure_type in ('float', 'percentage'): + format_params['digits'] = digits + + col_group_key = col_data.get('column_group_key') + + return { + 'auditable': col_value is not None + and column_expression.auditable + and not options['column_groups'][col_group_key]['forced_options'].get('compute_budget'), + 'blank_if_zero': blank_if_zero, + 'column_group_key': col_group_key, + 'currency': currency, + 'currency_symbol': (currency or self.env.company.currency_id).symbol if options.get('multi_currency') else None, + 'digits': digits, + 'expression_label': col_data.get('expression_label'), + 'figure_type': figure_type, + 'green_on_positive': column_expression.green_on_positive, + 'has_sublines': has_sublines, + 'is_zero': col_value is None or ( + isinstance(col_value, (int, float)) + and figure_type in NUMBER_FIGURE_TYPES + and self._is_value_zero(col_value, figure_type, format_params) + ), + 'no_format': col_value, + 'format_params': format_params, + 'report_line_id': report_line_id, + 'sortable': col_data.get('sortable', False), + 'comparison_mode': col_data.get('comparison_mode'), + } + + def _inject_account_names_for_consolidation(self, lines): + """ When grouping by account_code, in order to make the consolidation clearer, we add the account name in the context + of the current company next to the account_code. + """ + account_codes = [] + for line in lines: + markup = self._get_markup(line['id']) + if isinstance(markup, dict) and markup.get('groupby') == 'account_code': + account_codes.append(line['name']) + if not account_codes: + return + + account_code_to_account_name_dict = {account.code: account.name for account in self.env['account.account'].search([ + *self.env['account.account']._check_company_domain(self.env.company), + ('code', 'in', account_codes), + ])} + for line in lines: + markup = self._get_markup(line['id']) + if isinstance(markup, dict) and markup.get('groupby') == 'account_code': + account_code = line['name'] + account_name = account_code_to_account_name_dict.get(account_code) + if account_code and account_name: + line['name'] = f'{account_code} {account_name}' + + def _get_dynamic_lines(self, options, all_column_groups_expression_totals, warnings=None): + if self.custom_handler_model_id: + rslt = self.env[self.custom_handler_model_name]._dynamic_lines_generator(self, options, all_column_groups_expression_totals, warnings=warnings) + self._apply_integer_rounding_to_dynamic_lines(options, (line for _sequence, line in rslt)) + return rslt + return [] + + def _apply_integer_rounding_to_dynamic_lines(self, options, dynamic_lines): + if options.get('integer_rounding_enabled'): + for line in dynamic_lines: + for column_dict in line.get('columns', []): + if 'name' not in column_dict and column_dict.get('figure_type') == 'monetary' and column_dict.get('no_format'): + # If 'name' is already in it, no need to round the amount ; it is forced by the custom report already + column_dict['no_format'] = float_round( + column_dict['no_format'], + precision_digits=0, + rounding_method=options['integer_rounding'], + ) + + def _compute_expression_totals_for_each_column_group(self, expressions, options, + groupby_to_expand=None, forced_all_column_groups_expression_totals=None, col_groups_restrict=None, offset=0, limit=None, include_default_vals=False, warnings=None): + """ + Main computation function for static lines. + + :param expressions: The account.report.expression objects to evaluate. + + :param options: The options dict for this report, obtained from.get_options({}). + + :param groupby_to_expand: The full groupby string for the grouping we want to evaluate. If None, the aggregated value will be computed. + For example, when evaluating a group by partner_id, which further will be divided in sub-groups by account_id, + then id, the full groupby string will be: 'partner_id, account_id, id'. + + :param forced_all_column_groups_expression_totals: The expression totals already computed for this report, to which we will add the + new totals we compute for expressions (or update the existing ones if some + expressions are already in forced_all_column_groups_expression_totals). This is + a dict in the same format as returned by this function. + This parameter is for example used when adding manual values, where only + the expressions possibly depending on the new manual value + need to be updated, while we want to keep all the other values as-is. + + :param col_groups_restrict: List of column group keys of the groups to compute. Other column groups will be ignored, and will + not be added to the result of this function (they can still be provided beforehand through + forced_all_column_groups_expression_totals). If not provided, all colum groups will be computed. + + :param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle + the load more feature. + + :param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle + the load more feature. + + :return: dict(column_group_key, expressions_totals), where: + - column group key is string identifying each column group in a unique way ; as in options['column_groups'] + - expressions_totals is a dict in the format returned by _compute_expression_totals_for_single_column_group + """ + + def add_expressions_to_groups(expressions_to_add, grouped_formulas, force_date_scope=None): + """ Groups the expressions that should be computed together. + """ + for expression in expressions_to_add: + engine = expression.engine + + if engine not in grouped_formulas: + grouped_formulas[engine] = {} + + date_scope = force_date_scope or self._standardize_date_scope_for_date_range(expression.date_scope) + groupby_data = expression.report_line_id._parse_groupby(options, groupby_to_expand=groupby_to_expand) + + next_groupby = groupby_data['next_groupby'] if engine not in NO_NEXT_GROUPBY_ENGINES else None + grouping_key = (date_scope, groupby_data['current_groupby'], next_groupby) + + if grouping_key not in grouped_formulas[engine]: + grouped_formulas[engine][grouping_key] = {} + + formula = expression.formula + + if expression.engine == 'aggregation' and expression.formula == 'sum_children': + formula = ' + '.join( + f'_expression:{child_expr.id}' + for child_expr in expression.report_line_id.children_ids.expression_ids.filtered(lambda e: e.label == expression.label) + ) + + if formula not in grouped_formulas[engine][grouping_key]: + grouped_formulas[engine][grouping_key][formula] = expression + else: + grouped_formulas[engine][grouping_key][formula] |= expression + + if groupby_to_expand and any(not expression.report_line_id._get_groupby(options) for expression in expressions): + raise UserError(_("Trying to expand groupby results on lines without a groupby value.")) + + # Group formulas for batching (when possible) + grouped_formulas = {} + if expressions and not include_default_vals: + expressions = expressions.filtered(lambda x: not x.label.startswith('_default')) + for expression in expressions: + add_expressions_to_groups(expression, grouped_formulas) + + if expression.engine == 'aggregation' and expression.subformula == 'cross_report': + # Always expand aggregation expressions, in case their subexpressions are not in expressions parameter + # (this can happen in cross report, or when auditing an individual aggregation expression) + expanded_cross = expression._expand_aggregations() + forced_date_scope = self._standardize_date_scope_for_date_range(expression.date_scope) + add_expressions_to_groups(expanded_cross, grouped_formulas, force_date_scope=forced_date_scope) + + # Treat each formula batch for each column group + all_column_groups_expression_totals = {} + for group_key, group_options in self._split_options_per_column_group(options).items(): + if forced_all_column_groups_expression_totals: + forced_column_group_totals = forced_all_column_groups_expression_totals.get(group_key, None) + else: + forced_column_group_totals = None + + if not col_groups_restrict or group_key in col_groups_restrict: + current_group_expression_totals = self._compute_expression_totals_for_single_column_group( + group_options, + grouped_formulas, + forced_column_group_expression_totals=forced_column_group_totals, + offset=offset, + limit=limit, + warnings=warnings, + ) + else: + current_group_expression_totals = forced_column_group_totals + + all_column_groups_expression_totals[group_key] = current_group_expression_totals + + return all_column_groups_expression_totals + + def _standardize_date_scope_for_date_range(self, date_scope): + """ Depending on the fact the report accepts date ranges or not, different date scopes might mean the same thing. + This function is used so that, in those cases, only one of these date_scopes' values is used, to avoid useless creation + of multiple computation batches and improve the overall performance as much as possible. + """ + if not self.filter_date_range and date_scope == 'strict_range': + return 'from_beginning' + else: + return date_scope + + def _split_options_per_column_group(self, options): + """ Get a specific option dict per column group, each enforcing the comparison and horizontal grouping associated + with the column group. Each of these options dict will contain a new key 'owner_column_group', with the column group key of the + group it was generated for. + + :param options: The report options upon which the returned options be be based. + + :return: A dict(column_group_key, options_dict), where column_group_key is the string identifying each column group (the keys + of options['column_groups'], and options_dict the generated options for this group. + """ + options_per_group = {} + for group_key in options['column_groups']: + group_options = self._get_column_group_options(options, group_key) + options_per_group[group_key] = group_options + + return options_per_group + + def _get_column_group_options(self, options, group_key): + column_group = options['column_groups'][group_key] + return { + **options, + **column_group['forced_options'], + 'forced_domain': options.get('forced_domain', []) + column_group['forced_domain'] + column_group['forced_options'].get('forced_domain', []), + 'owner_column_group': group_key, + } + + def _compute_expression_totals_for_single_column_group(self, column_group_options, grouped_formulas, forced_column_group_expression_totals=None, offset=0, limit=None, warnings=None): + """ Evaluates expressions for a single column group. + + :param column_group_options: The options dict obtained from _split_options_per_column_group() for the column group to evaluate. + + :param grouped_formulas: A dict(engine, formula_dict), where: + - engine is a string identifying a report engine, in the same format as in account.report.expression's engine + field's technical labels. + - formula_dict is a dict in the same format as _compute_formula_batch's formulas_dict parameter, + containing only aggregation formulas. + + :param forced_column_group_expression_totals: The expression totals previously computed, in the same format as this function's result. + If provided, the result of this function will be an updated version of this parameter, + recomputing the expressions in grouped_fomulas. + + :param offset: The SQL offset to use when computing the result of these expressions. Used if self.load_more_limit is set, to handle + the load more feature. + + :param limit: The SQL limit to apply when computing these expressions' result. Used if self.load_more_limit is set, to handle + the load more feature. + + :return: A dict(expression, {'value': value, 'has_sublines': has_sublines}), where: + - expression is one of the account.report.expressions that got evaluated + + - value is the result of that evaluation. Two cases are possible: + - if we're evaluating a groupby: value will then be a in the form [(groupby_key, group_val)], where + - groupby_key is the key used in the SQL GROUP BY clause to generate this result + - group_val: The result computed by the engine for this group. Typically a float. + + - else: value will directly be the result computed for this expression + + - has_sublines: [optional key, will default to False if absent] + Whether or not this result corresponds to 1 or more subelements in the database (typically move lines). + This is used to know whether an unfoldable line has results to unfold in the UI. + """ + def inject_formula_results(formula_results, column_group_expression_totals, cross_report_expression_totals=None): + for (_key, expressions), result in formula_results.items(): + for expression in expressions: + subformula_error_format = _( + 'Invalid subformula in expression "%(expression)s" of line "%(line)s": %(subformula)s', + expression=expression.label, + line=expression.report_line_id.name, + subformula=expression.subformula, + ) + if expression.engine not in ('aggregation', 'external') and expression.subformula: + # aggregation subformulas behave differently (cross_report is markup ; if_below, if_above and force_between need evaluation) + # They are directly handled in aggregation engine + result_value_key = expression.subformula + else: + result_value_key = 'result' + + # The expression might be signed, so we can't just access the dict key, and directly evaluate it instead. + + if isinstance(result, list): + # Happens when expanding a groupby line, to compute its children. + # We then want to keep a list(grouping key, total) as the final result of each total + expression_value = [] + expression_has_sublines = False + for key, result_dict in result: + try: + expression_value.append((key, safe_eval(result_value_key, result_dict))) + except (ValueError, SyntaxError): + raise UserError(subformula_error_format) + expression_has_sublines = expression_has_sublines or result_dict.get('has_sublines') + else: + # For non-groupby lines, we directly set the total value for the line. + try: + expression_value = safe_eval(result_value_key, result) + except (ValueError, SyntaxError): + raise UserError(subformula_error_format) + expression_has_sublines = result.get('has_sublines') + + if column_group_options.get('integer_rounding_enabled'): + in_monetary_column = any( + col['expression_label'] == expression.label + for col in column_group_options['columns'] + if col['figure_type'] == 'monetary' + ) + + if (in_monetary_column and not expression.figure_type) or expression.figure_type == 'monetary': + method = column_group_options['integer_rounding'] + if isinstance(expression_value, list): + expression_value = [(key, float_round(value, precision_digits=0, rounding_method=method)) for key, value in expression_value] + else: + expression_value = float_round(expression_value, precision_digits=0, rounding_method=method) + + expression_result = { + 'value': expression_value, + 'has_sublines': expression_has_sublines, + } + + if expression.report_line_id.report_id == self: + if expression in column_group_expression_totals: + # This can happen because of a cross report aggregation referencing an expression of its own report, + # but forcing a different date_scope onto it. This case is not supported for now ; splitting the aggregation can be + # used as a workaround. + raise UserError(_( + "Expression labelled '%(label)s' of line '%(line)s' is being overwritten when computing the current report. " + "Make sure the cross-report aggregations of this report only reference terms belonging to other reports.", + label=expression.label, line=expression.report_line_id.name + )) + column_group_expression_totals[expression] = expression_result + elif cross_report_expression_totals is not None: + # Entering this else means this expression needs to be evaluated because of a cross_report aggregation + cross_report_expression_totals[expression] = expression_result + + # Batch each engine that can be + column_group_expression_totals = dict(forced_column_group_expression_totals) if forced_column_group_expression_totals else {} + cross_report_expr_totals_by_scope = {} + batchable_engines = [ + selection_val[0] + for selection_val in self.env['account.report.expression']._fields['engine'].selection + if selection_val[0] != 'aggregation' + ] + for engine in batchable_engines: + for (date_scope, current_groupby, next_groupby), formulas_dict in grouped_formulas.get(engine, {}).items(): + formula_results = self._compute_formula_batch(column_group_options, engine, date_scope, formulas_dict, current_groupby, next_groupby, + offset=offset, limit=limit, warnings=warnings) + inject_formula_results( + formula_results, + column_group_expression_totals, + cross_report_expression_totals=cross_report_expr_totals_by_scope.setdefault(date_scope, {}) + ) + + # Now that everything else has been computed, resolve aggregation expressions + # (they can't be treated as the other engines, as if we batch them per date_scope, we'll not be able + # to compute expressions depending on other expressions with a different date scope). + aggregation_formulas_dict = {} + for (date_scope, _current_groupby, _next_groupby), formulas_dict in grouped_formulas.get('aggregation', {}).items(): + for formula, expressions in formulas_dict.items(): + for expression in expressions: + # group_by are ignored by this engine, so we merge every grouped entry into a common dict + forced_date_scope = date_scope if expression.subformula == 'cross_report' or expression.report_line_id.report_id != self else None + aggreation_formula_dict_key = (formula, forced_date_scope) + aggregation_formulas_dict.setdefault(aggreation_formula_dict_key, self.env['account.report.expression']) + aggregation_formulas_dict[aggreation_formula_dict_key] |= expression + + if aggregation_formulas_dict: + aggregation_formula_results = self._compute_totals_no_batch_aggregation(column_group_options, aggregation_formulas_dict, column_group_expression_totals, cross_report_expr_totals_by_scope) + inject_formula_results(aggregation_formula_results, column_group_expression_totals) + + return column_group_expression_totals + + def _compute_totals_no_batch_aggregation(self, column_group_options, formulas_dict, other_current_report_expr_totals, other_cross_report_expr_totals_by_scope): + """ Computes expression totals for 'aggregation' engine, after all other engines have been evaluated. + + :param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group. + + :param formulas_dict: A dict {(formula, forced_date_scope): expressions}, containing only aggregation formulas. + forced_date_scope will only be set in case of cross_report expressions. Else, it will be None + + :param other_current_report_expr_totals: The expressions_totals obtained after computing all non-aggregation engines, for the expressions + belonging directly to self (so, not the ones referenced by a cross_report aggreation). + This is a dict in the same format as _compute_expression_totals_for_single_column_group's result + (the only difference being it does not contain any aggregation expression yet). + + :param other_cross_report_expr_totals: A dict(forced_date_scope, expression_totals), where expression_totals is in the same form as + _compute_expression_totals_for_single_column_group's result. This parameter contains the results + of the non-aggregation expressions used by cross_report expressions ; they all belong to different + reports than self. The forced_date_scope corresponds to the original date_scope set on the + cross_report expression referencing them. The same expressions can be referenced multiple times + under different date scopes. + + :return : A dict((formula, expressions), result), where result is in the form {'result': numeric_value} + """ + def _resolve_subformula_on_dict(result, line_codes_expression_map, subformula): + split_subformula = subformula.split('.') + if len(split_subformula) > 1: + line_code, expression_label = split_subformula + return result[line_codes_expression_map[line_code][expression_label]] + + if subformula.startswith('_expression:'): + expression_id = int(subformula.split(':')[1]) + return result[expression_id] + + # Wrong subformula; the KeyError is caught in the function below + raise KeyError() + + def _check_is_float(to_test): + try: + float(to_test) + return True + except ValueError: + return False + + def add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, cross_report=False): + """ + Process an expression and its result, updating various dictionaries with relevant information. + Parameters: + - expression (object): The expression object to process. + - expression_res (dict): The result of the expression. + - figure_types_cache (dict): {report : {label: figure_type}}. + - current_report_eval_dict (dict): {expression_id: value}. + - current_report_codes_map (dict): {line_code: {expression_label: expression_id}}. + - other_reports_eval_dict (dict): {forced_date_scope: {expression_id: value}}. + - other_reports_codes_map (dict): {forced_date_scope: {line_code: {expression_label: expression_id}}}. + - cross_report: A boolean to know if we are processsing cross_report expression. + """ + + expr_report = expression.report_line_id.report_id + report_default_figure_types = figure_types_cache.setdefault(expr_report, {}) + expression_label = report_default_figure_types.get(expression.label, '_not_in_cache') + if expression_label == '_not_in_cache': + report_default_figure_types[expression.label] = expr_report.column_ids.filtered( + lambda x: x.expression_label == expression.label).figure_type + + default_figure_type = figure_types_cache[expr_report][expression.label] + figure_type = expression.figure_type or default_figure_type + value = expression_res['value'] + if figure_type == 'monetary' and value: + value = self.env.company.currency_id.round(value) + + if cross_report: + other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = value + else: + current_report_eval_dict[expression.id] = value + + current_report_eval_dict = {} # {expression_id: value} + other_reports_eval_dict = {} # {forced_date_scope: {expression_id: value}} + current_report_codes_map = {} # {line_code: {expression_label: expression_id}} + other_reports_codes_map = {} # {forced_date_scope: {line_code: {expression_label: expression_id}}} + + figure_types_cache = {} # {report : {label: figure_type}} + for expression, expression_res in other_current_report_expr_totals.items(): + add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map) + if expression.report_line_id.code: + current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id + + for forced_date_scope, scope_expr_totals in other_cross_report_expr_totals_by_scope.items(): + for expression, expression_res in scope_expr_totals.items(): + add_expression_to_map(expression, expression_res, figure_types_cache, current_report_eval_dict, current_report_codes_map, other_reports_eval_dict, other_reports_codes_map, True) + if expression.report_line_id.code: + other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id + + # Complete current_report_eval_dict with the formulas of uncomputed aggregation lines + aggregations_terms_to_evaluate = set() # Those terms are part of the formulas to evaluate; we know they will get a value eventually + for (formula, forced_date_scope), expressions in formulas_dict.items(): + for expression in expressions: + aggregations_terms_to_evaluate.add(f"_expression:{expression.id}") # In case it needs to be called by sum_children + + if expression.report_line_id.code: + if expression.report_line_id.report_id == self: + current_report_codes_map.setdefault(expression.report_line_id.code, {})[expression.label] = expression.id + else: + other_reports_codes_map.setdefault(forced_date_scope, {}).setdefault(expression.report_line_id.code, {})[expression.label] = expression.id + + aggregations_terms_to_evaluate.add(f"{expression.report_line_id.code}.{expression.label}") + + if not expression.subformula: + # Expressions with bounds cannot be replaced by their formula in formulas calling them (otherwize, bounds would be ignored). + # Same goes for cross_report, otherwise the forced_date_scope will be ignored, leading to an impossibility to get evaluate the expression. + if expression.report_line_id.report_id == self: + eval_dict = current_report_eval_dict + else: + eval_dict = other_reports_eval_dict.setdefault(forced_date_scope, {}) + + eval_dict[expression.id] = formula + + rslt = {} + to_treat = [(formula, formula, forced_date_scope) for (formula, forced_date_scope) in formulas_dict.keys()] # Formed like [(expanded formula, original unexpanded formula)] + term_separator_regex = r'(?\w+)\(" + r"(?P\w+)[.](?P\w+),[ ]*" + r"(?P.*)\)$", + expression.subformula + ) + if not other_expr_criterium_match: + raise UserError(_("Wrong format for if_other_expr_above/if_other_expr_below formula: %s", expression.subformula)) + + criterium_code = other_expr_criterium_match['line_code'] + criterium_label = other_expr_criterium_match['expr_label'] + criterium_expression_id = full_codes_map.get(criterium_code, {}).get(criterium_label) + criterium_val = full_eval_dict.get(criterium_expression_id) + + if not criterium_expression_id: + raise UserError(_("This subformula references an unknown expression: %s", expression.subformula)) + + if not isinstance(criterium_val, (float, int)): + # The criterium expression has not be evaluated yet. Postpone the evaluation of this formula, and skip this expression + # for now. We still try to evaluate other expressions using this formula if any; this means those expressions will + # be processed a second time later, giving the same result. This is a rare corner case, and not so costly anyway. + to_treat.append((formula, unexpanded_formula, forced_date_scope)) + continue + + bound_subformula = other_expr_criterium_match['criterium'].replace('other_expr_', '') # e.g. 'if_other_expr_above' => 'if_above' + bound_params = other_expr_criterium_match['bound_params'] + bound_value = self._aggregation_apply_bounds(column_group_options, f"{bound_subformula}({bound_params})", criterium_val) + expression_result = formula_result * int(bool(bound_value)) + + else: + expression_result = self._aggregation_apply_bounds(column_group_options, expression.subformula, formula_result) + + if column_group_options.get('integer_rounding_enabled'): + expression_result = float_round(expression_result, precision_digits=0, rounding_method=column_group_options['integer_rounding']) + + # Store result + standardized_expression_scope = self._standardize_date_scope_for_date_range(expression.date_scope) + if (forced_date_scope == standardized_expression_scope or not forced_date_scope) and expression.report_line_id.report_id == self: + # This condition ensures we don't return necessary subcomputations in the final result + rslt[(unexpanded_formula, expression)] = {'result': expression_result} + + # Handle recursive aggregations (explicit or through the sum_children shortcut). + # We need to make the result of our computation available to other aggregations, as they are still waiting in to_treat to be evaluated. + if expression.report_line_id.report_id == self: + current_report_eval_dict[expression.id] = expression_result + else: + other_reports_eval_dict.setdefault(forced_date_scope, {})[expression.id] = expression_result + + return rslt + + def _aggregation_apply_bounds(self, column_group_options, subformula, unbound_value): + """ Applies the bounds of the provided aggregation expression to an unbounded value that got computed for it and returns the result. + Bounds can be defined as subformulas of aggregation expressions, with the following possible values: + + - if_above(CUR(bound_value)): + => Result will be 0 if it's <= the provided bound value; else it'll be unbound_value + + - if_below(CUR(bound_value)): + => Result will be 0 if it's >= the provided bound value; else it'll be unbound_value + + - if_between(CUR(bound_value1), CUR(bound_value2)): + => Result will be unbound_value if it's strictly between the provided bounds. Else, it will + be brought back to the closest bound. + + - round(decimal_places): + => Result will be round(unbound_value, decimal_places) + + (where CUR is a currency code, and bound_value* are float amounts in CUR currency) + """ + if not subformula: + return unbound_value + + # So an expression can't have bounds and be cross_reports, for simplicity. + # To do that, just split the expression in two parts. + if subformula and subformula.startswith('round'): + precision_string = re.match(r"round\((?P\d+)\)", subformula)['precision'] + return round(unbound_value, int(precision_string)) + + if subformula not in {'cross_report', 'ignore_zero_division'}: + company_currency = self.env.company.currency_id + date_to = column_group_options['date']['date_to'] + + match = re.match( + r"(?P\w*)" + r"\((?P[A-Z]{3})\((?P[-]?\d+(\.\d+)?)\)" + r"(,(?P[A-Z]{3})\((?P[-]?\d+(\.\d+)?)\))?\)$", + subformula.replace(' ', '') + ) + group_values = match.groupdict() + + # Convert the provided bounds into company currency + currency_code_1 = group_values.get('currency_1') + currency_code_2 = group_values.get('currency_2') + currency_codes = [ + currency_code + for currency_code in [currency_code_1, currency_code_2] + if currency_code and currency_code != company_currency.name + ] + + if currency_codes: + currencies = self.env['res.currency'].with_context(active_test=False).search([('name', 'in', currency_codes)]) + else: + currencies = self.env['res.currency'] + + amount_1 = float(group_values['amount_1'] or 0) + amount_2 = float(group_values['amount_2'] or 0) + for currency in currencies: + if currency != company_currency: + if currency.name == currency_code_1: + amount_1 = currency._convert(amount_1, company_currency, self.env.company, date_to) + if amount_2 and currency.name == currency_code_2: + amount_2 = currency._convert(amount_2, company_currency, self.env.company, date_to) + + # Evaluate result + criterium = group_values['criterium'] + if criterium == 'if_below': + if company_currency.compare_amounts(unbound_value, amount_1) >= 0: + return 0 + elif criterium == 'if_above': + if company_currency.compare_amounts(unbound_value, amount_1) <= 0: + return 0 + elif criterium == 'if_between': + if company_currency.compare_amounts(unbound_value, amount_1) < 0 or company_currency.compare_amounts(unbound_value, amount_2) > 0: + return 0 + else: + raise UserError(_("Unknown bound criterium: %s", criterium)) + + return unbound_value + + def _compute_formula_batch(self, column_group_options, formula_engine, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + """ Evaluates a batch of formulas. + + :param column_group_options: The options for the column group being evaluated, as obtained from _split_options_per_column_group. + + :param formula_engine: A string identifying a report engine. Must be one of account.report.expression's engine field's technical labels. + + :param date_scope: The date_scope under which to evaluate the fomulas. Must be one of account.report.expression's date_scope field's + technical labels. + + :param formulas_dict: A dict in the dict(formula, expressions), where: + - formula: a formula to be evaluated with the engine referred to by parent dict key + - expressions: a recordset of all the expressions to evaluate using formula (possibly with distinct subformulas) + + :param current_groupby: The groupby to evaluate, or None if there isn't any. In case of multi-level groupby, only contains the element + that needs to be computed (so, if unfolding a line doing 'partner_id,account_id,id'; current_groupby will only be + 'partner_id'). Subsequent groupby will be in next_groupby. + + :param next_groupby: Full groupby string of the groups that will have to be evaluated next for these expressions, or None if there isn't any. + For example, in the case depicted in the example of current_groupby, next_groupby will be 'account_id,id'. + + :param offset: The SQL offset to use when computing the result of these expressions. + + :param limit: The SQL limit to apply when computing these expressions' result. + + :return: The result might have two different formats depending on the situation: + - if we're computing a groupby: {(formula, expressions): [(grouping_key, {'result': value, 'has_sublines': boolean}), ...], ...} + - if we're not: {(formula, expressions): {'result': value, 'has_sublines': boolean}, ...} + 'result' key is the default; different engines might use one or multiple other keys instead, depending of the subformulas they allow + (e.g. 'sum', 'sum_if_pos', ...) + """ + engine_function_name = f'_compute_formula_batch_with_engine_{formula_engine}' + return getattr(self, engine_function_name)( + column_group_options, date_scope, formulas_dict, current_groupby, next_groupby, + offset=offset, limit=limit, warnings=warnings, + ) + + def _compute_formula_batch_with_engine_tax_tags(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + """ Report engine. + + The formulas made for this report simply consist of a tag label. When an expression using this engine is created, it also creates two + account.account.tag objects, namely -tag and +tag, where tag is the chosen formula. The balance of the expressions using this engine is + computed by gathering all the move lines using their tags, and applying the sign of their tag to their balance, together with a -1 factor + if the tax_tag_invert field of the move line is True. + + This engine does not support any subformula. + """ + self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + all_expressions = self.env['account.report.expression'] + for expressions in formulas_dict.values(): + all_expressions |= expressions + tags = all_expressions._get_matching_tags() + + query = self._get_report_query(options, date_scope) + groupby_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None + tail_query = self._get_engine_query_tail(offset, limit) + lang = get_lang(self.env, self.env.user.lang).code + acc_tag_name = self.with_context(lang='en_US').env['account.account.tag']._field_to_sql('acc_tag', 'name') + sql = SQL( + """ + SELECT + SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1) AS formula, + SUM(%(balance_select)s + * CASE WHEN acc_tag.tax_negate THEN -1 ELSE 1 END + * CASE WHEN account_move_line.tax_tag_invert THEN -1 ELSE 1 END + ) AS balance, + COUNT(account_move_line.id) AS aml_count + %(select_groupby_sql)s + + FROM %(table_references)s + + JOIN account_account_tag_account_move_line_rel aml_tag + ON aml_tag.account_move_line_id = account_move_line.id + JOIN account_account_tag acc_tag + ON aml_tag.account_account_tag_id = acc_tag.id + AND acc_tag.id IN %(tag_ids)s + %(currency_table_join)s + + WHERE %(search_condition)s + + GROUP BY %(groupby_clause)s + + ORDER BY %(groupby_clause)s + + %(tail_query)s + """, + acc_tag_name=acc_tag_name, + select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(), + table_references=query.from_clause, + tag_ids=tuple(tags.ids), + balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=self._currency_table_aml_join(options), + search_condition=query.where_clause, + groupby_clause=SQL( + "SUBSTRING(%(acc_tag_name)s, 2, LENGTH(%(acc_tag_name)s) - 1)%(groupby_sql)s", + acc_tag_name=acc_tag_name, + groupby_sql=SQL(', %s', groupby_sql) if groupby_sql else SQL(), + ), + tail_query=tail_query, + ) + + self._cr.execute(sql) + + rslt = {formula_expr: [] if current_groupby else {'result': 0, 'has_sublines': False} for formula_expr in formulas_dict.items()} + for query_res in self._cr.dictfetchall(): + + formula = query_res['formula'] + rslt_dict = {'result': query_res['balance'], 'has_sublines': query_res['aml_count'] > 0} + if current_groupby: + rslt[(formula, formulas_dict[formula])].append((query_res['grouping_key'], rslt_dict)) + else: + rslt[(formula, formulas_dict[formula])] = rslt_dict + + return rslt + + def _compute_formula_batch_with_engine_domain(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + """ Report engine. + + Formulas made for this engine consist of a domain on account.move.line. Only those move lines will be used to compute the result. + + This engine supports a few subformulas, each returning a slighlty different result: + - sum: the result will be sum of the matched move lines' balances + + - sum_if_pos: the result will be the same as sum only if it's positive; else, it will be 0 + + - sum_if_neg: the result will be the same as sum only if it's negative; else, it will be 0 + + - count_rows: the result will be the number of sublines this expression has. If the parent report line has no groupby, + then it will be the number of matching amls. If there is a groupby, it will be the number of distinct grouping + keys at the first level of this groupby (so, if groupby is 'partner_id, account_id', the number of partners). + """ + def _format_result_depending_on_groupby(formula_rslt): + if not current_groupby: + if formula_rslt: + # There should be only one element in the list; we only return its totals (a dict) ; so that a list is only returned in case + # of a groupby being unfolded. + return formula_rslt[0][1] + else: + # No result at all + return { + 'sum': 0, + 'sum_if_pos': 0, + 'sum_if_neg': 0, + 'count_rows': 0, + 'has_sublines': False, + } + return formula_rslt + + self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + rslt = {} + + for formula, expressions in formulas_dict.items(): + try: + line_domain = literal_eval(formula) + except (ValueError, SyntaxError): + raise UserError(_( + 'Invalid domain formula in expression "%(expression)s" of line "%(line)s": %(formula)s', + expression=expressions.label, + line=expressions.report_line_id.name, + formula=formula, + )) + query = self._get_report_query(options, date_scope, domain=line_domain) + + groupby_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None + select_count_field = self.env['account.move.line']._field_to_sql('account_move_line', next_groupby.split(',')[0] if next_groupby else 'id', query) + + tail_query = self._get_engine_query_tail(offset, limit) + query = SQL( + """ + SELECT + COALESCE(SUM(%(balance_select)s), 0.0) AS sum, + COUNT(DISTINCT %(select_count_field)s) AS count_rows + %(select_groupby_sql)s + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + %(group_by_groupby_sql)s + %(order_by_sql)s + %(tail_query)s + """, + select_count_field=select_count_field, + select_groupby_sql=SQL(', %s AS grouping_key', groupby_sql) if groupby_sql else SQL(), + table_references=query.from_clause, + balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=self._currency_table_aml_join(options), + search_condition=query.where_clause, + group_by_groupby_sql=SQL('GROUP BY %s', groupby_sql) if groupby_sql else SQL(), + order_by_sql=SQL(' ORDER BY %s', groupby_sql) if groupby_sql else SQL(), + tail_query=tail_query, + ) + + # Fetch the results. + formula_rslt = [] + self._cr.execute(query) + all_query_res = self._cr.dictfetchall() + + total_sum = 0 + for query_res in all_query_res: + res_sum = query_res['sum'] + total_sum += res_sum + totals = { + 'sum': res_sum, + 'sum_if_pos': 0, + 'sum_if_neg': 0, + 'count_rows': query_res['count_rows'], + 'has_sublines': query_res['count_rows'] > 0, + } + formula_rslt.append((query_res.get('grouping_key', None), totals)) + + # Handle sum_if_pos, -sum_if_pos, sum_if_neg and -sum_if_neg + expressions_by_sign_policy = defaultdict(lambda: self.env['account.report.expression']) + for expression in expressions: + subformula_without_sign = expression.subformula.replace('-', '').strip() + if subformula_without_sign in ('sum_if_pos', 'sum_if_neg'): + expressions_by_sign_policy[subformula_without_sign] += expression + else: + expressions_by_sign_policy['no_sign_check'] += expression + + # Then we have to check the total of the line and only give results if its sign matches the desired policy. + # This is important for groupby managements, for which we can't just check the sign query_res by query_res + if expressions_by_sign_policy['sum_if_pos'] or expressions_by_sign_policy['sum_if_neg']: + sign_policy_with_value = 'sum_if_pos' if self.env.company.currency_id.compare_amounts(total_sum, 0.0) >= 0 else 'sum_if_neg' + # >= instead of > is intended; usability decision: 0 is considered positive + + formula_rslt_with_sign = [(grouping_key, {**totals, sign_policy_with_value: totals['sum']}) for grouping_key, totals in formula_rslt] + + for sign_policy in ('sum_if_pos', 'sum_if_neg'): + policy_expressions = expressions_by_sign_policy[sign_policy] + + if policy_expressions: + if sign_policy == sign_policy_with_value: + rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby(formula_rslt_with_sign) + else: + rslt[(formula, policy_expressions)] = _format_result_depending_on_groupby([]) + + if expressions_by_sign_policy['no_sign_check']: + rslt[(formula, expressions_by_sign_policy['no_sign_check'])] = _format_result_depending_on_groupby(formula_rslt) + + return rslt + + def _compute_formula_batch_with_engine_account_codes(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + r""" Report engine. + + Formulas made for this engine target account prefixes. Each of the prefix used in the formula will be evaluated as the sum of the move + lines made on the accounts matching it. Those prefixes can be used together with arithmetic operations to perform them on the obtained + results. + Example: '123 - 456' will substract the balance of all account starting with 456 from the one of all accounts starting with 123. + + It is also possible to exclude some subprefixes, with \ operator. + Example: '123\(1234)' will match prefixes all accounts starting with '123', except the ones starting with '1234' + + To only match the balance of an account is it's positive (debit) or negative (credit), the letter D or C can be put just next to the prefix: + Example '123D': will give the total balance of accounts starting with '123' if it's positive, else it will be evaluated as 0. + + Multiple subprefixes can be excluded if needed. + Example: '123\(1234,1236) + + All these syntaxes can be mixed together. + Example: '123D\(1235) + 56 - 416C' + + Note: if C or D character needs to be part of the prefix, it is possible to differentiate them of debit and credit match characters + by using an empty prefix exclusion. + Example 1: '123D\' will take the total balance of accounts starting with '123D' + Example 2: '123D\C' will return the balance of accounts starting with '123D' if it's negative, 0 otherwise. + """ + self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + # Gather the account code prefixes to compute the total from + prefix_details_by_formula = {} # in the form {formula: [(1, prefix1), (-1, prefix2)]} + prefixes_to_compute = set() + for formula in formulas_dict: + prefix_details_by_formula[formula] = [] + for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')): + if token: + token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token) + + if not token_match: + raise UserError(_("Invalid token '%(token)s' in account_codes formula '%(formula)s'", token=token, formula=formula)) + + parsed_token = token_match.groupdict() + + if not parsed_token: + raise UserError(_("Could not parse account_code formula from token '%s'", token)) + + multiplicator = -1 if parsed_token['sign'] == '-' else 1 + excluded_prefixes_match = token_match['excluded_prefixes'] + excluded_prefixes = excluded_prefixes_match.split(',') if excluded_prefixes_match else [] + prefix = token_match['prefix'] + + # We group using both prefix and excluded_prefixes as keys, for the case where two expressions would + # include the same prefix, but exlcude different prefixes (example 104\(1041) and 104\(1042)) + prefix_key = (prefix, *excluded_prefixes) + prefix_details_by_formula[formula].append((multiplicator, prefix_key, token_match['balance_character'])) + prefixes_to_compute.add((prefix, tuple(excluded_prefixes))) + + # Create the subquery for the WITH linking our prefixes with account.account entries + all_prefixes_queries: list[SQL] = [] + prefilter = self.env['account.account']._check_company_domain(self.get_report_company_ids(options)) + for prefix, excluded_prefixes in prefixes_to_compute: + account_domain = [ + *prefilter, + ] + + tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix) + + if tag_match: + if tag_match['ref']: + tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref']) + else: + tag_id = int(tag_match['id']) + + account_domain.append(('tag_ids', 'in', [tag_id])) + else: + account_domain.append(('code', '=like', f'{prefix}%')) + + excluded_prefixes_domains = [] + + for excluded_prefix in excluded_prefixes: + excluded_prefixes_domains.append([('code', '=like', f'{excluded_prefix}%')]) + + if excluded_prefixes_domains: + account_domain.append('!') + account_domain += osv.expression.OR(excluded_prefixes_domains) + + prefix_query = self.env['account.account']._where_calc(account_domain) + all_prefixes_queries.append(prefix_query.select( + SQL("%s AS prefix", [prefix, *excluded_prefixes]), + SQL("account_account.id AS account_id"), + )) + + # Build a map to associate each account with the prefixes it matches + accounts_prefix_map = defaultdict(list) + for prefix, account_id in self.env.execute_query(SQL(' UNION ALL ').join(all_prefixes_queries)): + accounts_prefix_map[account_id].append(tuple(prefix)) + + # Run main query + query = self._get_report_query(options, date_scope) + + current_groupby_aml_sql = self.env['account.move.line']._field_to_sql('account_move_line', current_groupby, query) if current_groupby else None + tail_query = self._get_engine_query_tail(offset, limit) + if current_groupby_aml_sql and tail_query: + tail_query_additional_groupby_where_sql = SQL( + """ + AND %(current_groupby_aml_sql)s IN ( + SELECT DISTINCT %(current_groupby_aml_sql)s + FROM account_move_line + WHERE %(search_condition)s + ORDER BY %(current_groupby_aml_sql)s + %(tail_query)s + ) + """, + current_groupby_aml_sql=current_groupby_aml_sql, + search_condition=query.where_clause, + tail_query=tail_query, + ) + else: + tail_query_additional_groupby_where_sql = SQL() + + extra_groupby_sql = SQL(", %s", current_groupby_aml_sql) if current_groupby_aml_sql else SQL() + extra_select_sql = SQL(", %s AS grouping_key", current_groupby_aml_sql) if current_groupby_aml_sql else SQL() + + query = SQL( + """ + SELECT + account_move_line.account_id AS account_id, + SUM(%(balance_select)s) AS sum, + COUNT(account_move_line.id) AS aml_count + %(extra_select_sql)s + FROM %(table_references)s + %(currency_table_join)s + WHERE %(search_condition)s + %(tail_query_additional_groupby_where_sql)s + GROUP BY account_move_line.account_id%(extra_groupby_sql)s + %(order_by_sql)s + %(tail_query)s + """, + extra_select_sql=extra_select_sql, + table_references=query.from_clause, + balance_select=self._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=self._currency_table_aml_join(options), + search_condition=query.where_clause, + extra_groupby_sql=extra_groupby_sql, + tail_query_additional_groupby_where_sql=tail_query_additional_groupby_where_sql, + order_by_sql=SQL('ORDER BY %s', current_groupby_aml_sql) if current_groupby_aml_sql else SQL(), + tail_query=tail_query if not tail_query_additional_groupby_where_sql else SQL(), + ) + self._cr.execute(query) + + # Parse result + rslt = {} + + res_by_prefix_account_id = {} + for query_res in self._cr.dictfetchall(): + # Done this way so that we can run similar code for groupby and non-groupby + grouping_key = query_res['grouping_key'] if current_groupby else None + account_id = query_res['account_id'] + for prefix_key in accounts_prefix_map[account_id]: + res_by_prefix_account_id.setdefault(prefix_key, {})\ + .setdefault(account_id, [])\ + .append((grouping_key, {'result': query_res['sum'], 'has_sublines': query_res['aml_count'] > 0})) + + for formula, prefix_details in prefix_details_by_formula.items(): + rslt_key = (formula, formulas_dict[formula]) + rslt_destination = rslt.setdefault(rslt_key, [] if current_groupby else {'result': 0, 'has_sublines': False}) + rslt_groups_by_grouping_keys = {} + for multiplicator, prefix_key, balance_character in prefix_details: + res_by_account_id = res_by_prefix_account_id.get(prefix_key, {}) + + for account_results in res_by_account_id.values(): + account_total_value = sum(group_val['result'] for (group_key, group_val) in account_results) + comparator = self.env.company.currency_id.compare_amounts(account_total_value, 0.0) + + # Manage balance_character. + if not balance_character or (balance_character == 'D' and comparator >= 0) or (balance_character == 'C' and comparator < 0): + + for group_key, group_val in account_results: + rslt_group = { + **group_val, + 'result': multiplicator * group_val['result'], + } + if not current_groupby: + rslt_destination['result'] += rslt_group['result'] + rslt_destination['has_sublines'] = rslt_destination['has_sublines'] or rslt_group['has_sublines'] + elif group_key in rslt_groups_by_grouping_keys: + # Will happen if the same grouping key is used on move lines with different accounts. + # This comes from the GROUPBY in the SQL query, which uses both grouping key and account. + # When this happens, we want to aggregate the results of each grouping key, to avoid duplicates in the end result. + already_treated_rslt_group = rslt_groups_by_grouping_keys[group_key] + already_treated_rslt_group['has_sublines'] = already_treated_rslt_group['has_sublines'] or rslt_group['has_sublines'] + already_treated_rslt_group['result'] += rslt_group['result'] + else: + rslt_groups_by_grouping_keys[group_key] = rslt_group + rslt_destination.append((group_key, rslt_group)) + + return rslt + + def _compute_formula_batch_with_engine_external(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + """ Report engine. + + This engine computes its result from the account.report.external.value objects that are linked to the expression. + + Two different formulas are possible: + - sum: if the result must be the sum of all the external values in the period. + - most_recent: it the result must be the value of the latest external value in the period, which can be a number or a text + + No subformula is allowed for this engine. + """ + self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + if current_groupby or next_groupby or offset or limit: + raise UserError(_("'external' engine does not support groupby, limit nor offset.")) + + # Date clause + date_from, date_to = self._get_date_bounds_info(options, date_scope) + external_value_domain = [('date', '<=', date_to)] + if date_from: + external_value_domain.append(('date', '>=', date_from)) + + # Company clause + external_value_domain.append(('company_id', 'in', self.get_report_company_ids(options))) + + # Fiscal Position clause + fpos_option = options['fiscal_position'] + if fpos_option == 'domestic': + external_value_domain.append(('foreign_vat_fiscal_position_id', '=', False)) + elif fpos_option != 'all': + # Then it's a fiscal position id + external_value_domain.append(('foreign_vat_fiscal_position_id', '=', int(fpos_option))) + + # Do the computation + where_clause = self.env['account.report.external.value']._where_calc(external_value_domain).where_clause + + # We have to execute two separate queries, one for text values and one for numeric values + num_queries = [] + string_queries = [] + monetary_queries = [] + for formula, expressions in formulas_dict.items(): + query_end = SQL() + if formula == 'most_recent': + query_end = SQL( + """ + GROUP BY date + ORDER BY date DESC + LIMIT 1 + """, + ) + string_query = """ + SELECT %(expression_id)s, text_value + FROM account_report_external_value + WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s + """ + monetary_query = """ + SELECT + %(expression_id)s, + COALESCE(SUM(COALESCE(%(balance_select)s, 0)), 0) + FROM account_report_external_value + %(currency_table_join)s + WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s + %(query_end)s + """ + num_query = """ + SELECT %(expression_id)s, SUM(COALESCE(value, 0)) + FROM account_report_external_value + WHERE %(where_clause)s AND target_report_expression_id = %(expression_id)s + %(query_end)s + """ + + for expression in expressions: + if expression.figure_type == "string": + string_queries.append(SQL( + string_query, + expression_id=expression.id, + where_clause=where_clause, + )) + elif expression.figure_type == "monetary": + monetary_queries.append(SQL( + monetary_query, + expression_id=expression.id, + balance_select=self._currency_table_apply_rate(SQL("CAST(value AS numeric)")), + currency_table_join=SQL( + """ + JOIN %(currency_table)s + ON account_currency_table.company_id = account_report_external_value.company_id + AND account_currency_table.rate_type = 'current' + """, + currency_table=self._get_currency_table(options), + ), + where_clause=where_clause, + query_end=query_end, + )) + else: + num_queries.append(SQL( + num_query, + expression_id=expression.id, + where_clause=where_clause, + query_end=query_end, + )) + + # Convert to dict to have expression ids as keys + query_results_dict = {} + for query_list in (num_queries, string_queries, monetary_queries): + if query_list: + query_results = self.env.execute_query(SQL(' UNION ALL ').join(SQL("(%s)", query) for query in query_list)) + query_results_dict.update(dict(query_results)) + + # Build result dict + rslt = {} + for formula, expressions in formulas_dict.items(): + for expression in expressions: + expression_value = query_results_dict.get(expression.id) + # If expression_value is None, we have no previous value for this expression (set default at 0.0) + expression_value = expression_value or ('' if expression.figure_type == 'string' else 0.0) + rslt[(formula, expression)] = {'result': expression_value, 'has_sublines': False} + + return rslt + + def _compute_formula_batch_with_engine_custom(self, options, date_scope, formulas_dict, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + self._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else [])) + + rslt = {} + for formula, expressions in formulas_dict.items(): + custom_engine_function = self._get_custom_report_function(formula, 'custom_engine') + rslt[(formula, expressions)] = custom_engine_function( + expressions, options, date_scope, current_groupby, next_groupby, offset=offset, limit=limit, warnings=warnings) + return rslt + + def _get_engine_query_tail(self, offset, limit) -> SQL: + """ Helper to generate the OFFSET, LIMIT and ORDER conditions of formula engines' queries. + """ + query_tail = SQL() + + if offset: + query_tail = SQL("%s OFFSET %s", query_tail, offset) + + if limit: + query_tail = SQL("%s LIMIT %s", query_tail, limit) + + return query_tail + + def _generate_carryover_external_values(self, options): + """ Generates the account.report.external.value objects corresponding to this report's carryover under the provided options. + + In case of multicompany setup, we need to split the carryover per company, for ease of audit, and so that the carryover isn't broken when + a company leaves a tax unit. + + We first generate the carryover for the wholy-aggregated report, so that we can see what final result we want. + Indeed due to force_between, if_above and if_below conditions, each carryover might be different from the sum of the individidual companies' + carryover values. To handle this case, we generate each company's carryover values separately, then do a carryover adjustment on the + main company (main for tax units, first one selected else) in order to bring their total to the result we computed for the whole unit. + """ + self.ensure_one() + + if len(options['column_groups']) > 1: + # The options must be forged in order to generate carryover values. Entering this conditions means this hasn't been done in the right way. + raise UserError(_("Carryover can only be generated for a single column group.")) + + # Get the expressions to evaluate from the report + carryover_expressions = self.line_ids.expression_ids.filtered(lambda x: x.label.startswith('_carryover_')) + expressions_to_evaluate = carryover_expressions._expand_aggregations() + + # Expression totals for all selected companies + expression_totals_per_col_group = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, options) + expression_totals = expression_totals_per_col_group[list(options['column_groups'].keys())[0]] + carryover_values = {expression: expression_totals[expression]['value'] for expression in carryover_expressions} + + if len(options['companies']) == 1: + company = self.env['res.company'].browse(self.get_report_company_ids(options)) + self._create_carryover_for_company(options, company, {expr: result for expr, result in carryover_values.items()}) + else: + multi_company_carryover_values_sum = defaultdict(lambda: 0) + + column_group_key = next(col_group_key for col_group_key in options['column_groups']) + for company_opt in options['companies']: + company = self.env['res.company'].browse(company_opt['id']) + company_options = {**options, 'companies': [{'id': company.id, 'name': company.name}]} + company_expressions_totals = self._compute_expression_totals_for_each_column_group(expressions_to_evaluate, company_options) + company_carryover_values = {expression: company_expressions_totals[column_group_key][expression]['value'] for expression in carryover_expressions} + self._create_carryover_for_company(options, company, company_carryover_values) + + for carryover_expr, carryover_val in company_carryover_values.items(): + multi_company_carryover_values_sum[carryover_expr] += carryover_val + + # Adjust multicompany amounts on main company + main_company = self._get_sender_company_for_export(options) + for expr in carryover_expressions: + difference = carryover_values[expr] - multi_company_carryover_values_sum[expr] + self._create_carryover_for_company(options, main_company, {expr: difference}, label=_("Carryover adjustment for tax unit")) + + @api.model + def _generate_default_external_values(self, date_from, date_to, is_tax_report=False): + """ Generates the account.report.external.value objects for the given dates. + If is_tax_report, the values are only created for tax reports, else for all other reports. + """ + options_dict = {} + default_expr_by_report = defaultdict(list) + tax_report = self.env.ref('account.generic_tax_report') + company = self.env.company + previous_options = { + 'date': { + 'date_from': date_from, + 'date_to': date_to, + } + } + + # Get all the default expressions from all reports + default_expressions = self.env['account.report.expression'].search([('label', '=like', '_default_%')]) + # Options depend on the report, also we need to filter out tax report/other reports depending on is_tax_report + # Hence we need to group the default expressions by report + for expr in default_expressions: + report = expr.report_line_id.report_id + if is_tax_report == (tax_report in (report + report.root_report_id + report.section_main_report_ids.root_report_id)): + if report not in options_dict: + options = report.with_context(allowed_company_ids=[company.id]).get_options(previous_options) + options_dict[report] = options + + if report._is_available_for(options_dict[report]): + default_expr_by_report[report].append(expr) + + external_values_create_vals = [] + for report, report_default_expressions in default_expr_by_report.items(): + options = options_dict[report] + fpos_options = {options['fiscal_position']} + + for available_fp in options['available_vat_fiscal_positions']: + fpos_options.add(available_fp['id']) + + # remove 'all' from fiscal positions if we have several of them - all will then include the sum of other fps + # but if there aren't any other fps, we need to keep 'all' + if len(fpos_options) > 1 and 'all' in fpos_options: + fpos_options.remove('all') + + # The default values should be created for every fiscal position available + for fiscal_pos in fpos_options: + fiscal_pos_id = int(fiscal_pos) if fiscal_pos not in {'domestic', 'all'} else None + fp_options = {**options, 'fiscal_position': fiscal_pos} + + expressions_to_compute = {} + for default_expression in report_default_expressions: + # The default expression needs to have the same label as the target external expression, e.g. '_default_balance' + target_label = default_expression.label[len('_default_'):] + target_external_expression = default_expression.report_line_id.expression_ids.filtered(lambda x: x.label == target_label) + # If the value has been created before/modified manually, we shouldn't create anything + # and we won't recompute expression totals for them + external_value = self.env['account.report.external.value'].search([ + ('company_id', '=', company.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ('foreign_vat_fiscal_position_id', '=', fiscal_pos_id), + ('target_report_expression_id', '=', target_external_expression.id), + ]) + + if not external_value: + expressions_to_compute[default_expression] = target_external_expression.id + + # Evaluate the expressions for the report to fetch the value of the default expression + # These have to be computed for each fiscal position + expression_totals_per_col_group = report.with_company(company)\ + ._compute_expression_totals_for_each_column_group(expressions_to_compute, fp_options, include_default_vals=True) + expression_totals = expression_totals_per_col_group[list(fp_options['column_groups'].keys())[0]] + + for expression, target_expression in expressions_to_compute.items(): + external_values_create_vals.append({ + 'name': _("Manual value"), + 'value': expression_totals[expression]['value'], + 'date': date_to, + 'target_report_expression_id': target_expression, + 'foreign_vat_fiscal_position_id': fiscal_pos_id, + 'company_id': company.id, + }) + + self.env['account.report.external.value'].create(external_values_create_vals) + + @api.model + def _get_sender_company_for_export(self, options): + """ Return the sender company when generating an export file from this report. + :return: self.env.company if not using a tax unit, else the main company of that unit + """ + if options.get('tax_unit', 'company_only') != 'company_only': + tax_unit = self.env['account.tax.unit'].browse(options['tax_unit']) + return tax_unit.main_company_id + + report_companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + options_main_company = report_companies[0] + + if options.get('tax_unit') is not None and options_main_company._get_branches_with_same_vat() == report_companies: + # The line with the smallest number of parents in the VAT sub-hierarchy is assumed to be the root + return report_companies.sorted(lambda x: len(x.parent_ids))[0] + elif options_main_company._all_branches_selected(): + return options_main_company.root_id + + return options_main_company + + def _create_carryover_for_company(self, options, company, carryover_per_expression, label=None): + date_from = options['date']['date_from'] + date_to = options['date']['date_to'] + fiscal_position_opt = options['fiscal_position'] + + if carryover_per_expression and fiscal_position_opt == 'all': + # Not supported, as it wouldn't make sense, and would make the code way more complicated (because of if_below/if_above/force_between, + # just in the same way as it is explained below for multi company) + raise UserError(_("Cannot generate carryover values for all fiscal positions at once!")) + + external_values_create_vals = [] + for expression, carryover_value in carryover_per_expression.items(): + if not company.currency_id.is_zero(carryover_value): + target_expression = expression._get_carryover_target_expression(options) + external_values_create_vals.append({ + 'name': label or _("Carryover from %(date_from)s to %(date_to)s", date_from=format_date(self.env, date_from), date_to=format_date(self.env, date_to)), + 'value': carryover_value, + 'date': date_to, + 'target_report_expression_id': target_expression.id, + 'foreign_vat_fiscal_position_id': fiscal_position_opt if isinstance(fiscal_position_opt, int) else None, + 'carryover_origin_expression_label': expression.label, + 'carryover_origin_report_line_id': expression.report_line_id.id, + 'company_id': company.id, + }) + + self.env['account.report.external.value'].create(external_values_create_vals) + + def get_default_report_filename(self, options, extension): + """The default to be used for the file when downloading pdf,xlsx,...""" + self.ensure_one() + + sections_source_id = options['sections_source_id'] + if sections_source_id != self.id: + sections_source = self.env['account.report'].browse(sections_source_id) + else: + sections_source = self + + return f"{sections_source.name.lower().replace(' ', '_')}.{extension}" + + def execute_action(self, options, params=None): + action_id = int(params.get('actionId')) + action = self.env['ir.actions.actions'].sudo().browse([action_id]) + action_type = action.type + action = self.env[action.type].sudo().browse([action_id]) + action_read = clean_action(action.read()[0], env=action.env) + + if action_type == 'ir.actions.client': + # Check if we are opening another report. If so, generate options for it from the current options. + if action.tag == 'account_report': + target_report = self.env['account.report'].browse(ast.literal_eval(action_read['context'])['report_id']) + new_options = target_report.get_options(previous_options=options) + action_read.update({'params': {'options': new_options, 'ignore_session': True}}) + + if params.get('id'): + # Add the id of the calling object in the action's context + if isinstance(params['id'], int): + # id of the report line might directly be the id of the model we want. + model_id = params['id'] + else: + # It can also be a generic account.report id, as defined by _get_generic_line_id + model_id = self._get_model_info_from_id(params['id'])[1] + + context = action_read.get('context') and literal_eval(action_read['context']) or {} + context.setdefault('active_id', model_id) + action_read['context'] = context + + return action_read + + def action_audit_cell(self, options, params): + report_line = self.env['account.report.line'].browse(params['report_line_id']) + expression_label = params['expression_label'] + expression = report_line.expression_ids.filtered(lambda x: x.label == expression_label) + column_group_options = self._get_column_group_options(options, params['column_group_key']) + + # Audit of external values + if expression.engine == 'external': + date_from, date_to = self._get_date_bounds_info(column_group_options, expression.date_scope) + external_values_domain = [('target_report_expression_id', '=', expression.id), ('date', '<=', date_to)] + if date_from: + external_values_domain.append(('date', '>=', date_from)) + + if expression.formula == 'most_recent': + query = self.env['account.report.external.value']._where_calc(external_values_domain) + rows = self.env.execute_query(SQL(""" + SELECT ARRAY_AGG(id) + FROM %s + WHERE %s + GROUP BY date + ORDER BY date DESC + LIMIT 1 + """, query.from_clause, query.where_clause or SQL("TRUE"))) + if rows: + external_values_domain = [('id', 'in', rows[0][0])] + + return { + 'name': _("Manual values"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.report.external.value', + 'view_mode': 'list', + 'views': [(False, 'list')], + 'domain': external_values_domain, + } + + # Audit of move lines + # If we're auditing a groupby line, we need to make sure to restrict the result of what we audit to the right group values + column = next((col for col in report_line.report_id.column_ids if col.expression_label == expression_label), self.env['account.report.column']) + if column.custom_audit_action_id: + action_dict = column.custom_audit_action_id._get_action_dict() + else: + action_dict = { + 'name': _("Journal Items"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move.line', + 'view_mode': 'list', + 'views': [(False, 'list')], + } + + action = clean_action(action_dict, env=self.env) + action['domain'] = self._get_audit_line_domain(column_group_options, expression, params) + return action + + def action_view_all_variants(self, options, params): + return { + 'name': _('All Report Variants'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.report', + 'view_mode': 'list', + 'views': [(False, 'list'), (False, 'form')], + 'context': { + 'active_test': False, + }, + 'domain': [('id', 'in', self._get_variants(options['variants_source_id']).filtered( + lambda x: x._is_available_for(options) + ).mapped('id'))], + } + + def _get_audit_line_domain(self, column_group_options, expression, params): + groupby_domain = self._get_audit_line_groupby_domain(params['calling_line_dict_id']) + # Aggregate all domains per date scope, then create the final domain. + audit_or_domains_per_date_scope = {} + for expression_to_audit in expression._expand_aggregations(): + expression_domain = self._get_expression_audit_aml_domain(expression_to_audit, column_group_options) + + if expression_domain is None: + continue + + date_scope = expression.date_scope if expression.subformula == 'cross_report' else expression_to_audit.date_scope + audit_or_domains = audit_or_domains_per_date_scope.setdefault(date_scope, []) + audit_or_domains.append(osv.expression.AND([ + expression_domain, + groupby_domain, + ])) + + if audit_or_domains_per_date_scope: + domain = osv.expression.OR([ + osv.expression.AND([ + osv.expression.OR(audit_or_domains), + self._get_options_domain(column_group_options, date_scope), + groupby_domain, + ]) + for date_scope, audit_or_domains in audit_or_domains_per_date_scope.items() + ]) + else: + # Happens when no expression was provided (empty recordset), or if none of the expressions had a standard engine + domain = osv.expression.AND([ + self._get_options_domain(column_group_options, 'strict_range'), + groupby_domain, + ]) + + # Analytic Filter + if column_group_options.get("analytic_accounts"): + domain = osv.expression.AND([ + domain, + [("analytic_distribution", "in", column_group_options["analytic_accounts"])], + ]) + + return domain + + def _get_audit_line_groupby_domain(self, calling_line_dict_id): + parsed_line_dict_id = self._parse_line_id(calling_line_dict_id) + groupby_domain = [] + for markup, dummy, grouping_key in parsed_line_dict_id: + if isinstance(markup, dict) and 'groupby' in markup: + groupby_field_name = markup['groupby'] + custom_handler_model = self._get_custom_handler_model() + if custom_handler_model and (custom_groupby_data := self.env[custom_handler_model]._get_custom_groupby_map().get(groupby_field_name)): + groupby_domain += custom_groupby_data['domain_builder'](grouping_key) + else: + groupby_domain.append((groupby_field_name, '=', grouping_key)) + + return groupby_domain + + def _get_expression_audit_aml_domain(self, expression_to_audit, options): + """ Returns the domain used to audit a single provided expression. + + 'account_codes' engine's D and C formulas can't be handled by a domain: we make the choice to display + everything for them (so, audit shows all the lines that are considered by the formula). To avoid confusion from the user + when auditing such lines, a default group by account can be used in the list view. + """ + if expression_to_audit.engine == 'account_codes': + formula = expression_to_audit.formula.replace(' ', '') + + account_codes_domains = [] + for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(formula.replace(' ', '')): + if token: + match_dict = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token).groupdict() + tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(match_dict['prefix']) + account_codes_domain = [] + + if tag_match: + if tag_match['ref']: + tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_match['ref']) + else: + tag_id = int(tag_match['id']) + + account_codes_domain.append(('account_id.tag_ids', 'in', [tag_id])) + else: + account_codes_domain.append(('account_id.code', '=like', f"{match_dict['prefix']}%")) + + excluded_prefix_str = match_dict['excluded_prefixes'] + if excluded_prefix_str: + for excluded_prefix in excluded_prefix_str.split(','): + # "'not like', prefix%" doesn't work + account_codes_domain += ['!', ('account_id.code', '=like', f"{excluded_prefix}%")] + + account_codes_domains.append(account_codes_domain) + + return osv.expression.OR(account_codes_domains) + + if expression_to_audit.engine == 'tax_tags': + tags = self.env['account.account.tag']._get_tax_tags(expression_to_audit.formula, expression_to_audit.report_line_id.report_id.country_id.id) + return [('tax_tag_ids', 'in', tags.ids)] + + if expression_to_audit.engine == 'domain': + return ast.literal_eval(expression_to_audit.formula) + + return None + + def open_journal_items(self, options, params): + ''' Open the journal items view with the proper filters and groups ''' + record_model, record_id = self._get_model_info_from_id(params.get('line_id')) + view_id = self.env.ref(params['view_ref']).id if params.get('view_ref') else None + + ctx = { + 'search_default_group_by_account': 1, + 'search_default_posted': 0 if options.get('all_entries') else 1, + 'date_from': options.get('date').get('date_from'), + 'date_to': options.get('date').get('date_to'), + 'search_default_journal_id': params.get('journal_id', False), + 'expand': 1, + } + + if options['date'].get('date_from'): + ctx['search_default_date_between'] = 1 + else: + ctx['search_default_date_before'] = 1 + + if options.get('selected_journal_groups'): + ctx.update({ + 'search_default_journal_group_id': [options['selected_journal_groups']['id']], + }) + + journal_type = params.get('journal_type') + if journal_type or options.get('selected_journal_groups') and options['selected_journal_groups']['journal_types']: + type_to_view_param = { + 'bank': { + 'filter': 'search_default_bank', + 'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id + }, + 'cash': { + 'filter': 'search_default_cash', + 'view_id': self.env.ref('account.view_move_line_tree_grouped_bank_cash').id + }, + 'general': { + 'filter': 'search_default_misc_filter', + 'view_id': self.env.ref('account.view_move_line_tree_grouped_misc').id + }, + 'sale': { + 'filter': 'search_default_sales', + 'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id + }, + 'purchase': { + 'filter': 'search_default_purchases', + 'view_id': self.env.ref('account.view_move_line_tree_grouped_sales_purchases').id + }, + 'credit': { + 'filter': 'search_default_credit', + 'view_id': self.env.ref('account.view_move_line_tree').id + }, + } + if options.get('selected_journal_groups'): + ctx_to_update = {} + for journal_type in options['selected_journal_groups']['journal_types']: + ctx_to_update[type_to_view_param[journal_type]['filter']] = 1 + ctx.update(ctx_to_update) + else: + ctx.update({ + type_to_view_param[journal_type]['filter']: 1, + }) + view_id = type_to_view_param[journal_type]['view_id'] + + action_domain = [('display_type', 'not in', ('line_section', 'line_note'))] + + if record_id is None: + # Default filters don't support the 'no set' value. For this case, we use a domain on the action instead + model_fields_map = { + 'account.account': 'account_id', + 'res.partner': 'partner_id', + 'account.journal': 'journal_id', + } + model_field = model_fields_map.get(record_model) + if model_field: + action_domain += [(model_field, '=', False)] + else: + model_default_filters = { + 'account.account': 'search_default_account_id', + 'res.partner': 'search_default_partner_id', + 'account.journal': 'search_default_journal_id', + 'product.product': 'search_default_product_id', + 'product.category': 'search_default_product_category_id', + } + model_filter = model_default_filters.get(record_model) + if model_filter: + ctx.update({ + 'active_id': record_id, + model_filter: [record_id], + }) + + if options: + for account_type in options.get('account_type', []): + ctx.update({ + f"search_default_{account_type['id']}": account_type['selected'] and 1 or 0, + }) + + if options.get('journals') and 'search_default_journal_id' not in ctx: + selected_journals = [journal['id'] for journal in options['journals'] if journal.get('selected')] + if len(selected_journals) == 1: + ctx['search_default_journal_id'] = selected_journals + + if options.get('analytic_accounts'): + analytic_ids = [int(r) for r in options['analytic_accounts']] + ctx.update({ + 'search_default_analytic_accounts': 1, + 'analytic_ids': analytic_ids, + }) + + return { + 'name': self._get_action_name(params, record_model, record_id), + 'view_mode': 'list,pivot,graph,kanban', + 'res_model': 'account.move.line', + 'views': [(view_id, 'list')], + 'type': 'ir.actions.act_window', + 'domain': action_domain, + 'context': ctx, + } + + def open_unposted_moves(self, options, params=None): + ''' Open the list of draft journal entries that might impact the reporting''' + action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line") + action = clean_action(action, env=self.env) + action['domain'] = [('state', '=', 'draft'), ('date', '<=', options['date']['date_to'])] + #overwrite the context to avoid default filtering on 'misc' journals + action['context'] = {} + return action + + def _get_generated_deferral_entries_domain(self, options): + """Get the search domain for the generated deferral entries of the current period. + + :param options: the report's `options` dict containing `date_from`, `date_to` and `deferred_report_type` + :return: a search domain that can be used to get the deferral entries + """ + if options.get('deferred_report_type') == 'expense': + account_types = ('expense', 'expense_depreciation', 'expense_direct_cost') + else: + account_types = ('income', 'income_other') + date_to = fields.Date.from_string(options['date']['date_to']) + date_to_next_reversal = fields.Date.to_string(date_to + datetime.timedelta(days=1)) + return [ + ('company_id', '=', self.env.company.id), + # We exclude the reversal entries of the previous period that fall on the first day of this period + ('date', '>', options['date']['date_from']), + # We include the reversal entries of the current period that fall on the first day of the next period + ('date', '<=', date_to_next_reversal), + ('deferred_original_move_ids', '!=', False), + ('line_ids.account_id.account_type', 'in', account_types), + ('state', '!=', 'cancel'), + ] + + def open_deferral_entries(self, options, params): + domain = self._get_generated_deferral_entries_domain(options) + deferral_line_ids = self.env['account.move'].search(domain).line_ids.ids + return { + 'type': 'ir.actions.act_window', + 'name': _('Deferred Entries'), + 'res_model': 'account.move.line', + 'domain': [('id', 'in', deferral_line_ids)], + 'views': [(False, 'list'), (False, 'form')], + 'context': { + 'search_default_group_by_move': True, + 'expand': True, + } + } + + def action_modify_manual_value(self, line_id, options, column_group_key, new_value_str, target_expression_id, rounding, json_friendly_column_group_totals): + """ Edit a manual value from the report, updating or creating the corresponding account.report.external.value object. + + :param options: The option dict the report is evaluated with. + + :param column_group_key: The string identifying the column group into which the change as manual value needs to be done. + + :param new_value_str: The new value to be set, as a string. + + :param rounding: The number of decimal digits to round with. + + :param json_friendly_column_group_totals: The expression totals by column group already computed for this report, in the format returned + by _get_json_friendly_column_group_totals. These will be used to reevaluate the report, recomputing + only the expressions depending on the newly-modified manual value, and keeping all the results + from the previous computations for the other ones. + """ + self.ensure_one() + + target_column_group_options = self._get_column_group_options(options, column_group_key) + self._init_currency_table(target_column_group_options) + + if target_column_group_options.get('compute_budget'): + expressions_to_recompute = self.env['account.report.expression'].browse(target_expression_id) \ + + self.line_ids.expression_ids.filtered(lambda x: x.engine == 'aggregation') + self._action_modify_manual_budget_value(line_id, target_column_group_options, new_value_str, target_expression_id, rounding) + else: + expressions_to_recompute = self.line_ids.expression_ids.filtered(lambda x: x.engine in ('external', 'aggregation')) + self._action_modify_manual_external_value(target_column_group_options, new_value_str, target_expression_id, rounding) + + # We recompute values for each column group, not only the one we modified a value in; this is important in case some date_scope is used to + # retrieve the manual value from a previous period. + + all_column_groups_expression_totals = self._convert_json_friendly_column_group_totals( + json_friendly_column_group_totals, + expressions_to_exclude=expressions_to_recompute, + ) + + recomputed_expression_totals = self._compute_expression_totals_for_each_column_group( + expressions_to_recompute, options, forced_all_column_groups_expression_totals=all_column_groups_expression_totals) + + return { + 'lines': self._get_lines(options, all_column_groups_expression_totals=recomputed_expression_totals), + 'column_groups_totals': self._get_json_friendly_column_group_totals(recomputed_expression_totals), + } + + def _convert_json_friendly_column_group_totals(self, json_friendly_column_group_totals, expressions_to_exclude=None, col_groups_to_exclude=None): + """ json_friendly_column_group_totals contains ids instead of expressions (because it comes from js) ; this function is used + to convert them back to records. + """ + all_column_groups_expression_totals = {} + for column_group_key, expression_totals in json_friendly_column_group_totals.items(): + if col_groups_to_exclude and column_group_key in col_groups_to_exclude: + continue + + all_column_groups_expression_totals[column_group_key] = {} + for expr_id, expr_totals in expression_totals.items(): + expression = self.env['account.report.expression'].browse(int(expr_id)) # Should already be in cache, so acceptable + if not expressions_to_exclude or expression not in expressions_to_exclude: + all_column_groups_expression_totals[column_group_key][expression] = expr_totals + + return all_column_groups_expression_totals + + def _action_modify_manual_external_value(self, target_column_group_options, new_value_str, target_expression_id, rounding): + """ Edit a manual value from the report, updating or creating the corresponding account.report.external.value object. + + :param target_column_group_options: The options dict of the column group where the modification happened. + + :param column_group_key: The string identifying the column group into which the change as manual value needs to be done. + + :param new_value_str: The new value to be set, as a string. + + :param rounding: The number of decimal digits to round with. + + """ + if len(target_column_group_options['companies']) > 1: + raise UserError(_("Editing a manual report line is not allowed when multiple companies are selected.")) + + if target_column_group_options['fiscal_position'] == 'all' and target_column_group_options['available_vat_fiscal_positions']: + raise UserError(_("Editing a manual report line is not allowed in multivat setup when displaying data from all fiscal positions.")) + + # Create the manual value + target_expression = self.env['account.report.expression'].browse(target_expression_id) + date_from, date_to = self._get_date_bounds_info(target_column_group_options, target_expression.date_scope) + fiscal_position_id = target_column_group_options['fiscal_position'] if isinstance(target_column_group_options['fiscal_position'], int) else False + + external_values_domain = [ + ('target_report_expression_id', '=', target_expression.id), + ('company_id', '=', self.env.company.id), + ('foreign_vat_fiscal_position_id', '=', fiscal_position_id), + ] + + if target_expression.formula == 'most_recent': + value_to_adjust = 0 + existing_value_to_modify = self.env['account.report.external.value'].search([ + *external_values_domain, + ('date', '=', date_to), + ]) + + # There should be at most 1 + if len(existing_value_to_modify) > 1: + raise UserError(_("Inconsistent data: more than one external value at the same date for a 'most_recent' external line.")) + else: + existing_external_values = self.env['account.report.external.value'].search([ + *external_values_domain, + ('date', '>=', date_from), + ('date', '<=', date_to), + ], order='date ASC') + existing_value_to_modify = existing_external_values[-1] if existing_external_values and str(existing_external_values[-1].date) == date_to else None + value_to_adjust = sum(existing_external_values.filtered(lambda x: x != existing_value_to_modify).mapped('value')) + + if not new_value_str and target_expression.figure_type != 'string': + new_value_str = '0' + + try: + float(new_value_str) + is_number = True + except ValueError: + is_number = False + + if target_expression.figure_type == 'string': + value_to_set = new_value_str + else: + if not is_number: + raise UserError(_("%s is not a numeric value", new_value_str)) + if target_expression.figure_type == 'boolean': + rounding = 0 + value_to_set = float_round(float(new_value_str) - value_to_adjust, precision_digits=rounding) + + field_name = 'value' if target_expression.figure_type != 'string' else 'text_value' + + if existing_value_to_modify: + existing_value_to_modify[field_name] = value_to_set + existing_value_to_modify.flush_recordset() + else: + self.env['account.report.external.value'].create({ + 'name': _("Manual value"), + field_name: value_to_set, + 'date': date_to, + 'target_report_expression_id': target_expression.id, + 'company_id': self.env.company.id, + 'foreign_vat_fiscal_position_id': fiscal_position_id, + }) + + def _action_modify_manual_budget_value(self, line_id, target_column_group_options, new_value_str, target_expression_id, rounding): + target_expression = self.env['account.report.expression'].browse(target_expression_id) + + if not new_value_str and target_expression.figure_type != 'string': + new_value_str = '0' + + try: + value_to_set = float_round(float(new_value_str), precision_digits=rounding) + except ValueError: + raise UserError(_("%s is not a numeric value", new_value_str)) + + model, account_id = self._get_model_info_from_id(line_id) + if model != 'account.account': + raise UserError(_("Budget items can only be edited from account lines.")) + + # Depending on the expression's formula, the balance of the account could be multiplied by -1 + # within the report. We need to apply the same multiplier on the budget item we create. + if target_expression.engine == 'domain' and target_expression.subformula.startswith('-'): + value_to_set *= -1 + elif target_expression.engine == 'account_codes': + account = self.env['account.account'].browse(account_id) + + # Search for the sign to apply to this account + for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(target_expression.formula.replace(' ', '')): + if not token: + continue + + token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token) + multiplicator = -1 if token_match['sign'] == '-' else 1 + prefix = token_match['prefix'] + + tag_match = ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix) + if tag_match: + if tag_match['ref']: + tag = self.env.ref(tag_match['ref']) + else: + tag = self.env['account.account.tag'].browse(tag_match['id']) + + account_matches = tag in account.tag_ids + else: + account_matches = account.code.startswith(prefix) + + if account_matches: + value_to_set *= multiplicator + break + + self.env['account.report.budget'].browse(target_column_group_options['compute_budget'])._create_or_update_budget_items( + value_to_set, + account_id, + rounding, + target_column_group_options['date']['date_from'], + target_column_group_options['date']['date_to'], + ) + + def action_display_inactive_sections(self, options): + self.ensure_one() + + return { + 'type': 'ir.actions.act_window', + 'name': _("Enable Sections"), + 'view_mode': 'list,form', + 'res_model': 'account.report', + 'domain': [('section_main_report_ids', 'in', options['sections_source_id']), ('active', '=', False)], + 'views': [(False, 'list'), (False, 'form')], + 'context': { + 'list_view_ref': 'odex30_account_reports.account_report_add_sections_tree', + 'active_test': False, + }, + } + + @api.model + def sort_lines(self, lines, options, result_as_index=False): + ''' Sort report lines based on the 'order_column' key inside the options. + The value of options['order_column'] is an integer, positive or negative, indicating on which column + to sort and also if it must be an ascending sort (positive value) or a descending sort (negative value). + Note that for this reason, its indexing is made starting at 1, not 0. + If this key is missing or falsy, lines is returned directly. + + This method has some limitations: + - The selected_column must have 'sortable' in its classes. + - All lines are sorted except: + - lines having the 'total' class + - static lines (lines with model 'account.report.line') + - This only works when each line has an unique id. + - All lines inside the selected_column must have a 'no_format' value. + + Example: + + parent_line_1 balance=11 + child_line_1 balance=1 + child_line_2 balance=3 + child_line_3 balance=2 + child_line_4 balance=7 + child_line_5 balance=4 + child_line_6 (total line) + parent_line_2 balance=10 + child_line_7 balance=5 + child_line_8 balance=6 + child_line_9 (total line) + + + The resulting lines will be: + + parent_line_2 balance=10 + child_line_7 balance=5 + child_line_8 balance=6 + child_line_9 (total line) + parent_line_1 balance=11 + child_line_1 balance=1 + child_line_3 balance=2 + child_line_2 balance=3 + child_line_5 balance=4 + child_line_4 balance=7 + child_line_6 (total line) + + :param lines: The report lines. + :param options: The report options. + :return: Lines sorted by the selected column. + ''' + def needs_to_be_at_bottom(line_elem): + return self._get_markup(line_elem.get('id')) in ('total', 'load_more') + + def compare_values(a_line, b_line): + if column_index is False: + return 0 + type_seq = { + type(None): 0, + bool: 1, + float: 2, + int: 2, + str: 3, + datetime.date: 4, + datetime.datetime: 5, + } + + a_line_dict = lines[a_line] if result_as_index else a_line + b_line_dict = lines[b_line] if result_as_index else b_line + a_total = needs_to_be_at_bottom(a_line_dict) + b_total = needs_to_be_at_bottom(b_line_dict) + a_model = self._get_model_info_from_id(a_line_dict['id'])[0] + b_model = self._get_model_info_from_id(b_line_dict['id'])[0] + + # static lines are not sorted + if a_model == b_model == 'account.report.line': + return 0 + + if a_total: + if b_total: # a_total & b_total + return 0 + else: # a_total & !b_total + return -1 if descending else 1 + if b_total: # => !a_total & b_total + return 1 if descending else -1 + + a_val = a_line_dict['columns'][column_index].get('no_format') + b_val = b_line_dict['columns'][column_index].get('no_format') + type_a, type_b = type_seq[type(a_val)], type_seq[type(b_val)] + + if type_a == type_b: + return 0 if a_val == b_val else 1 if a_val > b_val else -1 + else: + return type_a - type_b + + def merge_tree(tree_elem, ls): + nonlocal descending # The direction of the sort is needed to compare total lines + ls.append(tree_elem) + + elem = tree[lines[tree_elem]['id']] if result_as_index else tree[tree_elem['id']] + + for tree_subelem in sorted(elem, key=comp_key, reverse=descending): + merge_tree(tree_subelem, ls) + + descending = options['order_column']['direction'] == 'DESC' # To keep total lines at the end, used in compare_values & merge_tree scopes + + column_index = False + for index, col in enumerate(options['columns']): + if options['order_column']['expression_label'] == col['expression_label']: + column_index = index # To know from which column to sort, used in merge_tree scope + break + + comp_key = cmp_to_key(compare_values) + sorted_list = [] + tree = defaultdict(list) + non_total_parents = set() + + for index, line in enumerate(lines): + line_parent = line.get('parent_id') or None + + if result_as_index: + tree[line_parent].append(index) + else: + tree[line_parent].append(line) + + line_markup = self._get_markup(line['id']) + + if line_markup != 'total': + non_total_parents.add(line_parent) + + if None not in tree and len(non_total_parents) == 1: + # Happens when unfolding a groupby line, to sort its children. + sorting_root = next(iter(non_total_parents)) + else: + sorting_root = None + + for line in sorted(tree[sorting_root], key=comp_key, reverse=descending): + merge_tree(line, sorted_list) + + return sorted_list + + def _get_annotations_domain_date_from(self, options): + if options['date']['filter'] in {'today', 'custom'} and options['date']['mode'] == 'single': + options_company_ids = [company['id'] for company in options['companies']] + root_companies_ids = self.env['res.company'].browse(options_company_ids).root_id.ids + fiscal_year = self.env['account.fiscal.year'].search_fetch([ + ('company_id', 'in', root_companies_ids), + ('date_from', '<=', options['date']['date_to']), + ('date_to', '>=', options['date']['date_to']), + ], limit=1, field_names=['date_from']) + if fiscal_year: + return datetime.datetime.combine(fiscal_year.date_from, datetime.time.min) + + period_date_from, _ = date_utils.get_fiscal_year( + datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d'), + day=self.env.company.fiscalyear_last_day, + month=int(self.env.company.fiscalyear_last_month) + ) + return period_date_from + + date_from = datetime.datetime.strptime(options['date']['date_from'], '%Y-%m-%d') + if options['date']['period_type'] == "fiscalyear": + period_date_from, _ = date_utils.get_fiscal_year(date_from) + elif options['date']['period_type'] in ["year", "quarter", "month", "week", "day", "hour"]: + period_date_from = date_utils.start_of(date_from, options['date']['period_type']) + else: + period_date_from = date_from + return period_date_from + + def _adjust_date_for_joined_comparison(self, options, period_date_from): + comparison_filter = options.get('comparison', {}).get('filter') + if comparison_filter == 'previous_period': + comparison_date_from = datetime.datetime.strptime(options['comparison'].get('periods', [{}])[-1].get('date_from'), '%Y-%m-%d') + return min(period_date_from, comparison_date_from) + return period_date_from + + def _adjust_domain_for_unjoined_comparison(self, options, dates_domain): + comparison_filter = options.get('comparison', {}).get('filter') + if comparison_filter and comparison_filter not in {'no_comparison', 'previous_period'}: + unlinked_comparison_periods_domains_list = [ + ['&', ('date', '>=', period['date_from']), ('date', '<=', period['date_to'])] + for period in options['comparison']['periods'] + ] + dates_domain = osv.expression.OR([dates_domain, *unlinked_comparison_periods_domains_list]) + + return dates_domain + + def _build_annotations_domain(self, options): + domain = [('report_id', '=', options['report_id'])] + if options.get('date'): + period_date_from = self._get_annotations_domain_date_from(options) + period_date_from = self._adjust_date_for_joined_comparison(options, period_date_from) + dates_domain = osv.expression.AND([ + [('date', '>=', period_date_from)], + [('date', '<=', options['date']['date_to'])], + ]) + dates_domain = self._adjust_domain_for_unjoined_comparison(options, dates_domain) + + domain = osv.expression.AND([ + domain, + osv.expression.OR([ + [('date', '=', False)], + dates_domain, + ]), + ]) + + fiscal_position_option = options.get('fiscal_position') + if isinstance(fiscal_position_option, int): + domain = osv.expression.AND([domain, [('fiscal_position_id', '=', fiscal_position_option)]]) + elif fiscal_position_option == 'domestic': + domain = osv.expression.AND([domain, [('fiscal_position_id', '=', False)]]) + return domain + + def get_annotations(self, options): + """ + This method handles which annotations have to be displayed on the report. + This decision is based on the different dates and mode of display of those dates in the report. + + param options: dict of options used to generate the report + return: dict of lists containing for each annotated line_id of the report the list of annotations linked to it + """ + self.ensure_one() + annotations_by_line = defaultdict(list) + annotations = self.env['account.report.annotation'].search_read(self._build_annotations_domain(options)) + for annotation in annotations: + line_id_without_tax_grouping = self.env['account.report.annotation']._remove_tax_grouping_from_line_id(annotation['line_id']) + annotation['create_date'] = annotation['create_date'].date() + annotations_by_line[line_id_without_tax_grouping].append(annotation) + return annotations_by_line + + def get_report_information(self, options): + """ + return a dictionary of information that will be consumed by the AccountReport component. + """ + self.ensure_one() + self.env.flush_all() + + warnings = {} + self._init_currency_table(options) + all_column_groups_expression_totals = self._compute_expression_totals_for_each_column_group(self.line_ids.expression_ids, options, warnings=warnings) + + # Convert all_column_groups_expression_totals to a json-friendly form (its keys are records) + json_friendly_column_group_totals = self._get_json_friendly_column_group_totals(all_column_groups_expression_totals) + + if self.custom_handler_model_name: + custom_display_config = self.env[self.custom_handler_model_name]._get_custom_display_config() + elif self.root_report_id and self.root_report_id.custom_handler_model_name: + custom_display_config = self.env[self.root_report_id.custom_handler_model_name]._get_custom_display_config() + else: + custom_display_config = {} + + return { + 'caret_options': self._get_caret_options(), + 'column_headers_render_data': self._get_column_headers_render_data(options), + 'column_groups_totals': json_friendly_column_group_totals, + 'context': self.env.context, + 'custom_display': custom_display_config, + 'filters': { + 'show_all': self.filter_unfold_all, + 'show_analytic': options.get('display_analytic', False), + 'show_analytic_groupby': options.get('display_analytic_groupby', False), + 'show_analytic_plan_groupby': options.get('display_analytic_plan_groupby', False), + 'show_draft': self.filter_show_draft, + 'show_hierarchy': options.get('display_hierarchy_filter', False), + 'show_period_comparison': self.filter_period_comparison, + 'show_totals': self.env.company.totals_below_sections and not options.get('ignore_totals_below_sections'), + 'show_unreconciled': self.filter_unreconciled, + 'show_hide_0_lines': self.filter_hide_0_lines, + }, + 'annotations': self.get_annotations(options), + 'groups': { + 'analytic_accounting': self.env.user.has_group('analytic.group_analytic_accounting'), + 'account_readonly': self.env.user.has_group('account.group_account_readonly'), + 'account_user': self.env.user.has_group('account.group_account_user'), + }, + 'lines': self._get_lines(options, all_column_groups_expression_totals=all_column_groups_expression_totals, warnings=warnings), + 'warnings': warnings, + 'report': { + 'company_name': self.env.company.name, + 'company_country_code': self.env.company.country_code, + 'company_currency_symbol': self.env.company.currency_id.symbol, + 'name': self.name, + 'root_report_id': self.root_report_id, + } + } + + @api.readonly + def get_report_information_readonly(self, options): + """ Readonly version of get_report_information, to be called from RPC when options['readonly_query'] is True, + to better spread the load on servers when possible. + """ + return self.get_report_information(options) + + def _get_json_friendly_column_group_totals(self, all_column_groups_expression_totals): + # Convert all_column_groups_expression_totals to a json-friendly form (its keys are records) + json_friendly_column_group_totals = {} + for column_group_key, expressions_totals in all_column_groups_expression_totals.items(): + json_friendly_column_group_totals[column_group_key] = {expression.id: totals for expression, totals in expressions_totals.items()} + return json_friendly_column_group_totals + + def _is_available_for(self, options): + """ Called on report variants to know whether they are available for the provided options or not, computed for their root report, + computing their availability_condition field. + + Note that only the options initialized by the init_options with a more prioritary sequence than _init_options_variants are guaranteed to + be in the provided options' dict (since this function is called by _init_options_variants, while resolving a call to get_options()). + """ + self.ensure_one() + + companies = self.env['res.company'].browse(self.get_report_company_ids(options)) + + if self.availability_condition == 'country': + countries = companies.account_fiscal_country_id + if self.filter_fiscal_position: + foreign_vat_fpos = self.env['account.fiscal.position'].search([ + ('foreign_vat', '!=', False), + ('company_id', 'in', companies.ids), + ]) + countries += foreign_vat_fpos.country_id + + return not self.country_id or self.country_id in countries + + elif self.availability_condition == 'coa': + # When restricting to 'coa', the report is only available is all the companies have the same CoA as the report + return self.chart_template in set(companies.mapped('chart_template')) + + return True + + def _get_column_headers_render_data(self, options): + column_headers_render_data = {} + + # We only want to consider the columns that are visible in the current report and don't rely on self.column_ids + # since custom reports could alter them (e.g. for multi-currency purposes) + columns = [col for col in options['columns'] if col['column_group_key'] == next(k for k in options['column_groups'])] + + # Compute the colspan of each header level, aka the number of single columns it contains at the base of the hierarchy + level_colspan_list = column_headers_render_data['level_colspan'] = [] + for i in range(len(options['column_headers'])): + colspan = max(len(columns), 1) + for column_header in options['column_headers'][i + 1:]: + # Separate non-budget and budget headers + budget_count = sum( + any(key in header.get('forced_options', {}) for key in ('compute_budget', 'budget_percentage')) + for header in column_header + ) + non_budget_count = len(column_header) - budget_count + + # budget headers (amount and percentage) can only contain a single column each, regardless of the amount of columns in the report. + # This implies that we first need to multiply for the 'regular' columns and then add the budget columns. + colspan *= non_budget_count + colspan += budget_count + + level_colspan_list.append(colspan) + + # Compute the number of times each header level will have to be repeated, and its colspan to properly handle horizontal groups/comparisons + column_headers_render_data['level_repetitions'] = [] + for i in range(len(options['column_headers'])): + colspan = 1 + for column_header in options['column_headers'][:i]: + colspan *= len(column_header) + column_headers_render_data['level_repetitions'].append(colspan) + + # Custom reports have the possibility to define custom subheaders that will be displayed between the generic header and the column names. + column_headers_render_data['custom_subheaders'] = options.get('custom_columns_subheaders', []) * len(options['column_groups']) + + return column_headers_render_data + + def _get_action_name(self, params, record_model=None, record_id=None): + if not (record_model or record_id): + record_model, record_id = self._get_model_info_from_id(params.get('line_id')) + return params.get('name') or self.env[record_model].browse(record_id).display_name or '' + + def _format_lines_for_display(self, lines, options): + """ + This method should be overridden in a report in order to apply specific formatting when printing + the report lines. + + Used for example by the carryover functionnality in the generic tax report. + :param lines: A list with the lines for this report. + :param options: The options for this report. + :return: The formatted list of lines + """ + return lines + + def get_expanded_lines(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side): + self.env.flush_all() + self._init_currency_table(options) + + lines = self._expand_unfoldable_line(expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side) + lines = self._fully_unfold_lines_if_needed(lines, options) + + self._inject_account_names_for_consolidation(lines) + + if self.custom_handler_model_id: + lines = self.env[self.custom_handler_model_name]._custom_line_postprocessor(self, options, lines) + + self._format_column_values(options, lines) + return lines + + @api.readonly + def get_expanded_lines_readonly(self, options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side): + """ Readonly version of get_expanded_lines_readonly, to be called from RPC when options['readonly_query'] is True, + to better spread the load on servers when possible. + """ + return self.get_expanded_lines(options, line_dict_id, groupby, expand_function_name, progress, offset, horizontal_split_side) + + def _expand_unfoldable_line(self, expand_function_name, line_dict_id, groupby, options, progress, offset, horizontal_split_side, unfold_all_batch_data=None): + if not expand_function_name: + raise UserError(_("Trying to expand a line without an expansion function.")) + + if not progress: + progress = {column_group_key: 0 for column_group_key in options['column_groups']} + + expand_function = self._get_custom_report_function(expand_function_name, 'expand_unfoldable_line') + expansion_result = expand_function(line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=unfold_all_batch_data) + + rslt = expansion_result['lines'] + + if horizontal_split_side: + for line in rslt: + line['horizontal_split_side'] = horizontal_split_side + + # Apply integer rounding to the result if needed. + # The groupby expansion function is the only one guaranteed to call the expressions computation, + # so the values computed for it will already have been rounded if integer rounding is enabled. No need to round them again. + if expand_function_name != '_report_expand_unfoldable_line_with_groupby': + self._apply_integer_rounding_to_dynamic_lines(options, rslt) + + if expansion_result.get('has_more'): + # We only add load_more line for groupby + next_offset = offset + expansion_result['offset_increment'] + rslt.append(self._get_load_more_line(next_offset, line_dict_id, expand_function_name, groupby, expansion_result.get('progress', 0), options)) + + # In some specific cases, we may want to add lines that are always at the end. So they need to be added after the load more line. + if expansion_result.get('after_load_more_lines'): + rslt.extend(expansion_result['after_load_more_lines']) + + return self._add_totals_below_sections(rslt, options) + + def _add_totals_below_sections(self, lines, options): + """ Returns a new list, corresponding to lines with the required total lines added as sublines of the sections it contains. + """ + if not self.env.company.totals_below_sections or options.get('ignore_totals_below_sections'): + return lines + + # Gather the lines needing the totals + lines_needing_total_below = set() + for line_dict in lines: + line_markup = self._get_markup(line_dict['id']) + + if line_markup != 'total': + # If we are on the first level of an expandable line, we arelady generate its total + if line_dict.get('unfoldable') or (line_dict.get('unfolded') and line_dict.get('expand_function')): + lines_needing_total_below.add(line_dict['id']) + + # All lines that are parent of other lines need to receive a total + line_parent_id = line_dict.get('parent_id') + if line_parent_id: + lines_needing_total_below.add(line_parent_id) + + # Inject the totals + if lines_needing_total_below: + lines_with_totals_below = [] + totals_below_stack = [] + for line_dict in lines: + while totals_below_stack and not line_dict['id'].startswith(totals_below_stack[-1]['parent_id'] + LINE_ID_HIERARCHY_DELIMITER): + lines_with_totals_below.append(totals_below_stack.pop()) + + lines_with_totals_below.append(line_dict) + + if line_dict['id'] in lines_needing_total_below and any(col.get('no_format') is not None for col in line_dict['columns']): + totals_below_stack.append(self._generate_total_below_section_line(line_dict)) + + while totals_below_stack: + lines_with_totals_below.append(totals_below_stack.pop()) + + return lines_with_totals_below + + return lines + + @api.model + def _get_load_more_line(self, offset, parent_line_id, expand_function_name, groupby, progress, options): + """ Returns a 'Load more' line allowing to reach the subsequent elements of an unfolded line with an expand function if the maximum + limit of sublines is reached (we load them by batch, using the load_more_limit field's value). + + :param offset: The offset to be passed to the expand function to generate the next results, when clicking on this 'load more' line. + + :param parent_line_id: The generic id of the line this load more line is created for. + + :param expand_function_name: The name of the expand function this load_more is created for (so, the one of its parent). + + :param progress: A json-formatted dict(column_group_key, value) containing the progress value for each column group, as it was + returned by the expand function. This is for example used by reports such as the general ledger, whose lines display a c + cumulative sum of their balance and the one of all the previous lines under the same parent. In this case, progress + will be the total sum of all the previous lines before the load_more line, that the subsequent lines will need to use as + base for their own cumulative sum. + + :param options: The options dict corresponding to this report's state. + """ + return { + 'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='load_more'), + 'name': _("Load more..."), + 'parent_id': parent_line_id, + 'expand_function': expand_function_name, + 'columns': [{} for col in options['columns']], + 'unfoldable': False, + 'unfolded': False, + 'offset': offset, + 'groupby': groupby, # We keep the groupby value from the parent, so that it can be propagated through js + 'progress': progress, + } + + def _report_expand_unfoldable_line_with_groupby(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + # The line we're expanding might be an inner groupby; we first need to find the report line generating it + report_line_id = None + for dummy, model, model_id in reversed(self._parse_line_id(line_dict_id)): + if model == 'account.report.line': + report_line_id = model_id + break + + if report_line_id is None: + raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id)) + + line = self.env['account.report.line'].browse(report_line_id) + + if ',' not in groupby and options['export_mode'] is None: + # if ',' not in groupby, then its a terminal groupby (like 'id' in 'partner_id, id'), so we can use the 'load more' feature if necessary + # When printing, we want to ignore the limit. + limit_to_load = self.load_more_limit or None + else: + # Else, we disable it + limit_to_load = None + offset = 0 + + rslt_lines = line._expand_groupby(line_dict_id, groupby, options, offset=offset, limit=limit_to_load, load_one_more=bool(limit_to_load), unfold_all_batch_data=unfold_all_batch_data) + lines_to_load = rslt_lines[:self.load_more_limit] if limit_to_load else rslt_lines + + if not limit_to_load and options['export_mode'] is None: + lines_to_load = self._regroup_lines_by_name_prefix(options, rslt_lines, '_report_expand_unfoldable_line_groupby_prefix_group', line.hierarchy_level, + groupby=groupby, parent_line_dict_id=line_dict_id) + + return { + 'lines': lines_to_load, + 'offset_increment': len(lines_to_load), + 'has_more': len(lines_to_load) < len(rslt_lines) if limit_to_load else False, + } + + def _regroup_lines_by_name_prefix(self, options, lines_to_group, expand_function_name, parent_level, matched_prefix='', groupby=None, parent_line_dict_id=None): + """ Postprocesses a list of report line dictionaries in order to regroup them by name prefix and reduce the overall number of lines + if their number is above a provided threshold (set in the report configuration). + + The lines regrouped under a common prefix will be removed from the returned list of lines; only the prefix line will stay, folded. + Its expand function must ensure the right sublines are reloaded when unfolding it. + + :param options: Option dict for this report. + :lines_to_group: The lines list to regroup by prefix if necessary. They must all have the same parent line (which might be no line at all). + :expand_function_name: Name of the expand function to be called on created prefix group lines, when unfolding them + :parent_level: Level of the parent line, which generated the lines in lines_to_group. It will be used to compute the level of the prefix group lines. + :matched_prefix': A string containing the parent prefix that's already matched. For example, when computing prefix 'ABC', matched_prefix will be 'AB'. + :groupby: groupby value of the parent line, which generated the lines in lines_to_group. + :parent_line_dict_id: id of the parent line, which generated the lines in lines_to_group. + + :return: lines_to_group, grouped by prefix if it was necessary. + """ + threshold = options['prefix_groups_threshold'] + + # When grouping by prefix, we ignore the totals + lines_to_group_without_totals = list(filter(lambda x: self._get_markup(x['id']) != 'total', lines_to_group)) + + if options['export_mode'] == 'print' or threshold <= 0 or len(lines_to_group_without_totals) < threshold: + # No grouping needs to be done + return lines_to_group + + char_index = len(matched_prefix) + prefix_groups = defaultdict(list) + rslt = [] + for line in lines_to_group_without_totals: + line_name = line['name'].strip() + + if len(line_name) - 1 < char_index: + rslt.append(line) + else: + prefix_groups[line_name[char_index].lower()].append(line) + + float_figure_types = {'monetary', 'integer', 'float'} + unfold_all = options['export_mode'] == 'print' or options.get('unfold_all') + for prefix_key, prefix_sublines in sorted(prefix_groups.items(), key=lambda x: x[0]): + # Compute the total of this prefix line, summming all of its content + prefix_expression_totals_by_group = {} + for column_index, column_data in enumerate(options['columns']): + if column_data['figure_type'] in float_figure_types: + # Then we want to sum this column's value in our children + for prefix_subline in prefix_sublines: + prefix_expr_label_result = prefix_expression_totals_by_group.setdefault(column_data['column_group_key'], {}) + prefix_expr_label_result.setdefault(column_data['expression_label'], 0) + prefix_expr_label_result[column_data['expression_label']] += (prefix_subline['columns'][column_index]['no_format'] or 0) + + column_values = [] + for column in options['columns']: + col_value = prefix_expression_totals_by_group.get(column['column_group_key'], {}).get(column['expression_label']) + + column_values.append(self._build_column_dict(col_value, column, options=options)) + + line_id = self._get_generic_line_id(None, None, parent_line_id=parent_line_dict_id, markup={'groupby_prefix_group': prefix_key}) + + sublines_nber = len(prefix_sublines) + prefix_to_display = prefix_key.upper() + + if re.match(r'\s', prefix_to_display[-1]): + # In case the last character of the prefix to_display is blank, replace it by "[ ]", to make the space more visible to the user. + prefix_to_display = f'{prefix_to_display[:-1]}[ ]' + + if sublines_nber == 1: + prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(1 line)") + else: + prefix_group_line_name = f"{matched_prefix}{prefix_to_display} " + _("(%s lines)", sublines_nber) + + prefix_group_line = { + 'id': line_id, + 'name': prefix_group_line_name, + 'unfoldable': True, + 'unfolded': unfold_all or line_id in options['unfolded_lines'], + 'columns': column_values, + 'groupby': groupby, + 'level': parent_level + 1, + 'parent_id': parent_line_dict_id, + 'expand_function': expand_function_name, + 'hide_line_buttons': True, + } + rslt.append(prefix_group_line) + + return rslt + + def _report_expand_unfoldable_line_groupby_prefix_group(self, line_dict_id, groupby, options, progress, offset, unfold_all_batch_data=None): + """ Expand function used by prefix_group lines generated for groupby lines. + """ + report_line_id = None + parent_groupby_count = 0 + for markup, model, model_id in reversed(self._parse_line_id(line_dict_id)): + if model == 'account.report.line': + report_line_id = model_id + break + elif isinstance(markup, dict) and 'groupby' in markup or 'groupby_prefix_group' in markup: + parent_groupby_count += 1 + + if report_line_id is None: + raise UserError(_("Trying to expand a group for a line which was not generated by a report line: %s", line_dict_id)) + + report_line = self.env['account.report.line'].browse(report_line_id) + + + matched_prefix = self._get_prefix_groups_matched_prefix_from_line_id(line_dict_id) + first_groupby = groupby.split(',')[0] + expand_options = { + **options, + 'forced_domain': options.get('forced_domain', []) + [(f"{f'{first_groupby}.' if first_groupby != 'id' else ''}name", '=ilike', f'{matched_prefix}%')] + } + expanded_groupby_lines = report_line._expand_groupby(line_dict_id, groupby, expand_options) + parent_level = report_line.hierarchy_level + parent_groupby_count * 2 + + lines = self._regroup_lines_by_name_prefix( + options, + expanded_groupby_lines, + '_report_expand_unfoldable_line_groupby_prefix_group', + parent_level, + groupby=groupby, + matched_prefix=matched_prefix, + parent_line_dict_id=line_dict_id, + ) + + return { + 'lines': lines, + 'offset_increment': len(lines), + 'has_more': False, + } + + @api.model + def _get_prefix_groups_matched_prefix_from_line_id(self, line_dict_id): + matched_prefix = '' + for markup, dummy1, dummy2 in self._parse_line_id(line_dict_id): + if markup and isinstance(markup, dict) and 'groupby_prefix_group' in markup: + prefix_piece = markup['groupby_prefix_group'] + matched_prefix += prefix_piece.upper() + else: + # Might happen if a groupby is grouped by prefix, then a subgroupby is grouped by another subprefix. + # In this case, we want to reset the prefix group to only consider the one used in the subgroupby. + matched_prefix = '' + + return matched_prefix + + @api.model + def format_value(self, options, value, figure_type, format_params=None): + if format_params is None: + format_params = {} + + if 'currency' in format_params: + format_params['currency'] = self.env['res.currency'].browse(format_params['currency'].id) + + return self._format_value(options=options, value=value, figure_type=figure_type, format_params=format_params) + + def _format_value(self, options, value, figure_type, format_params=None): + """ Formats a value for display in a report (not especially numerical). figure_type provides the type of formatting we want. + """ + if value is None: + return '' + + if figure_type == 'none': + return value + + if isinstance(value, str) or figure_type == 'string': + return str(value) + + if format_params is None: + format_params = {} + + formatLang_params = { + 'rounding_method': 'HALF-UP', + 'rounding_unit': options.get('rounding_unit'), + } + + if figure_type == 'monetary': + currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id + if options.get('multi_currency'): + formatLang_params['currency_obj'] = currency + else: + formatLang_params['digits'] = currency.decimal_places + + elif figure_type == 'integer': + formatLang_params['digits'] = 0 + + elif figure_type == 'boolean': + return _("Yes") if bool(value) else _("No") + + elif figure_type in ('date', 'datetime'): + return format_date(self.env, value) + + else: + formatLang_params['digits'] = format_params.get('digits', 1) + + if self._is_value_zero(value, figure_type, format_params): + # Make sure -0.0 becomes 0.0 + value = abs(value) + + if self._context.get('no_format'): + return value + + formatted_amount = formatLang(self.env, value, **formatLang_params) + + if figure_type == 'percentage': + return f"{formatted_amount}%" + + return formatted_amount + + @api.model + def _is_value_zero(self, amount, figure_type, format_params): + if amount is None: + return True + + if figure_type == 'monetary': + currency = self.env['res.currency'].browse(format_params['currency_id']) if 'currency_id' in format_params else self.env.company.currency_id + return currency.is_zero(amount) + elif figure_type in NUMBER_FIGURE_TYPES: + return float_is_zero(amount, precision_digits=format_params.get('digits', 0)) + else: + return False + + def format_date(self, options, dt_filter='date'): + date_from = fields.Date.from_string(options[dt_filter]['date_from']) + date_to = fields.Date.from_string(options[dt_filter]['date_to']) + return self._get_dates_period(date_from, date_to, options['date']['mode'])['string'] + + def export_file(self, options, file_generator): + self.ensure_one() + + export_options = {**options, 'export_mode': 'file'} + + return { + 'type': 'ir_actions_account_report_download', + 'data': { + 'options': json.dumps(export_options), + 'file_generator': file_generator, + } + } + + def _get_report_send_recipients(self, options): + custom_handler_model = self._get_custom_handler_model() + if custom_handler_model and hasattr(self.env[custom_handler_model], '_get_report_send_recipients'): + return self.env[custom_handler_model]._get_report_send_recipients(options) + return self.env['res.partner'] + + def export_to_pdf(self, options): + self.ensure_one() + + base_url = self.env['ir.config_parameter'].sudo().get_param('report.url') or self.env['ir.config_parameter'].sudo().get_param('web.base.url') + rcontext = { + 'mode': 'print', + 'base_url': base_url, + 'company': self.env.company, + } + + print_options = self.get_options(previous_options={**options, 'export_mode': 'print'}) + if print_options['sections']: + reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']]) + else: + reports_to_print = self + + reports_options = [] + for report in reports_to_print: + reports_options.append(report.get_options(previous_options={**print_options, 'selected_section_id': report.id})) + + grouped_reports_by_format = groupby( + zip(reports_to_print, reports_options), + key=lambda report: len(report[1]['columns']) > 5 or report[1].get('horizontal_split') + ) + + footer = self.env['ir.actions.report']._render_template("odex30_account_reports.internal_layout", values=rcontext) + footer = self.env['ir.actions.report']._render_template("web.minimal_layout", values=dict(rcontext, subst=True, body=markupsafe.Markup(footer.decode()))) + + action_report = self.env['ir.actions.report'] + files_stream = [] + for is_landscape, reports_with_options in grouped_reports_by_format: + bodies = [] + + for report, report_options in reports_with_options: + bodies.append(report._get_pdf_export_html( + report_options, + report._filter_out_folded_children(report._get_lines(report_options)), + additional_context={'base_url': base_url} + )) + + files_stream.append( + io.BytesIO(action_report._run_wkhtmltopdf( + bodies, + footer=footer.decode(), + landscape=is_landscape or self._context.get('force_landscape_printing'), + specific_paperformat_args={ + 'data-report-margin-top': 10, + 'data-report-header-spacing': 10, + 'data-report-margin-bottom': 15, + } + ) + )) + + if len(files_stream) > 1: + result_stream = action_report._merge_pdfs(files_stream) + result = result_stream.getvalue() + # Close the different stream + result_stream.close() + for file_stream in files_stream: + file_stream.close() + else: + result = files_stream[0].read() + + return { + 'file_name': self.get_default_report_filename(options, 'pdf'), + 'file_content': result, + 'file_type': 'pdf', + } + + def _get_pdf_export_html(self, options, lines, additional_context=None, template=None): + report_info = self.get_report_information(options) + + custom_print_templates = report_info['custom_display'].get('pdf_export', {}) + template = custom_print_templates.get('pdf_export_main', 'odex30_account_reports.pdf_export_main') + + render_values = { + 'report': self, + 'report_title': self.name, + 'options': options, + 'table_start': markupsafe.Markup(''), + 'table_end': markupsafe.Markup(''' +
    +
    +
    + + '''), + 'column_headers_render_data': self._get_column_headers_render_data(options), + 'custom_templates': custom_print_templates, + } + if additional_context: + render_values.update(additional_context) + + if options.get('order_column'): + lines = self.sort_lines(lines, options) + + lines = self._format_lines_for_display(lines, options) + + render_values['lines'] = lines + + # Manage annotations. + render_values['annotations'] = self._build_annotations_list_for_pdf_export(options['date'], lines, report_info['annotations']) + + options['css_custom_class'] = report_info['custom_display'].get('css_custom_class', '') + + # Render. + return self.env['ir.qweb']._render(template, render_values) + + def _build_annotations_list_for_pdf_export(self, date_options, lines, annotations_per_line_id): + annotations_to_render = [] + number = 0 + for line in lines: + if line_annotations := annotations_per_line_id.get(line['id']): + line['annotations'] = [] + for annotation in line_annotations: + report_period_date_from = datetime.datetime.strptime(date_options['date_from'], '%Y-%m-%d').date() + report_period_date_to = datetime.datetime.strptime(date_options['date_to'], '%Y-%m-%d').date() + if not annotation['date'] or report_period_date_from <= annotation['date'] <= report_period_date_to: + number += 1 + line['annotations'].append(str(number)) + annotations_to_render.append({ + 'number': str(number), + 'text': annotation['text'], + 'date': format_date(self.env, annotation['date']) if annotation['date'] else None, + }) + return annotations_to_render + + def _filter_out_folded_children(self, lines): + """ Returns a list containing all the lines of the provided list that need to be displayed when printing, + hence removing the children whose parent is folded (especially useful to remove total lines). + """ + rslt = [] + folded_lines = set() + for line in lines: + if line.get('unfoldable') and not line.get('unfolded'): + folded_lines.add(line['id']) + + if 'parent_id' not in line or line['parent_id'] not in folded_lines: + rslt.append(line) + return rslt + + def export_to_xlsx(self, options, response=None): + def add_worksheet_unique_name(workbook, sheet_name): + existing_names = set(workbook.sheetnames.keys()) + count = 1 + max_length = 31 + new_sheet_name = sheet_name[:max_length] + + while new_sheet_name in existing_names: + suffix = f" ({count})" + truncated_name = sheet_name[:max_length - len(suffix)] + new_sheet_name = f"{truncated_name}{suffix}" + count += 1 + return workbook.add_worksheet(new_sheet_name) + + self.ensure_one() + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, { + 'in_memory': True, + 'strings_to_formulas': False, + }) + + print_options = self.get_options(previous_options={**options, 'export_mode': 'print'}) + if print_options['sections']: + reports_to_print = self.env['account.report'].browse([section['id'] for section in print_options['sections']]) + else: + reports_to_print = self + + reports_options = [] + for report in reports_to_print: + report_options = report.get_options(previous_options={**print_options, 'selected_section_id': report.id}) + reports_options.append(report_options) + report._inject_report_into_xlsx_sheet(report_options, workbook, add_worksheet_unique_name(workbook, report.name)) + + self._add_options_xlsx_sheet(workbook, reports_options) + + workbook.close() + output.seek(0) + generated_file = output.read() + output.close() + + return { + 'file_name': self.get_default_report_filename(options, 'xlsx'), + 'file_content': generated_file, + 'file_type': 'xlsx', + } + + @api.model + def _set_xlsx_cell_sizes(self, sheet, fonts, col, row, value, style, has_colspan): + """ This small helper will resize the cells if needed, to allow to get a better output. """ + def get_string_width(font, string): + return font.getlength(string) / 5 + + # Get the correct font for the row style + font_type = ('Bol' if style.bold else 'Reg') + ('Ita' if style.italic else '') + report_font = fonts[font_type] + + # 8.43 is the default width of a column in Excel. + if parse_version(xlsxwriter.__version__) >= parse_version('3.0.6'): + # cols_sizes was removed in 3.0.6 and colinfo was replaced by col_info + try: + col_width = sheet.col_info[col][0] + except KeyError: + col_width = 8.43 + else: + col_width = sheet.col_sizes.get(col, [8.43])[0] + + row_height = sheet.row_sizes.get(row, [8.43])[0] + + if value is None: + value = '' + else: + try: # noqa: SIM105 + # This is needed, otherwise we could compute width on very long number such as 12.0999999998 + # which wouldn't show well in the end result as the numbers are rounded. + value = float_repr(float(value), self.env.company.currency_id.decimal_places) + except (ValueError, OverflowError): + pass + + # Start by computing the width of the cell if we are not using colspans. + if not has_colspan: + # Ensure to take indents into account when computing the width. + formatted_value = f"{' ' * style.indent}{value}" + width = get_string_width( + report_font, + max(formatted_value.split('\n'), key=lambda line: get_string_width(report_font, line)) + ) + # We set the width if it is bigger than the current one, with a limit at 75 (max to avoid taking excessive space). + if width > col_width: + sheet.set_column(col, col, min(width + 4, 75)) # We need to add a little extra padding to ensure our columns are not clipping the text + + def _get_xlsx_export_fonts(self): + """ Get the bold, italic and regular LATO font information so that we can use them for format purposes. """ + fonts = {} + for font_type in ('Reg', 'Bol', 'RegIta', 'BolIta'): + try: + lato_path = f'web/static/fonts/lato/Lato-{font_type}-webfont.ttf' + fonts[font_type] = ImageFont.truetype(file_path(lato_path), 12) + except (OSError, FileNotFoundError): + # This won't give great result, but it will work. + fonts[font_type] = ImageFont.load_default() + return fonts + + def _inject_report_into_xlsx_sheet(self, options, workbook, sheet): + fonts = self._get_xlsx_export_fonts() + + def write_cell(sheet, x, y, value, style, colspan=1, datetime=False): + self._set_xlsx_cell_sizes(sheet, fonts, x, y, value, style, colspan > 1) + if colspan == 1: + if datetime: + sheet.write_datetime(y, x, value, style) + else: + sheet.write(y, x, value, style) + else: + sheet.merge_range(y, x, y, x + colspan - 1, value, style) + + default_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'num_format': '#,##0.00'} + text_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666'} + date_format_props = {'font_name': 'Lato', 'font_size': 12, 'font_color': '#666666', 'align': 'left', 'num_format': 'yyyy-mm-dd'} + title_format = workbook.add_format({'font_name': 'Lato', 'font_size': 12, 'bold': True, 'bottom': 2}) + annotation_format = workbook.add_format({**text_format_props, 'text_wrap': True}) + workbook_formats = { + 0: { + 'default': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}), + 'text': workbook.add_format({**text_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}), + 'date': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}), + 'total': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 6}), + }, + 1: { + 'default': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}), + 'text': workbook.add_format({**text_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}), + 'date': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}), + 'total': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1}), + 'default_indent': workbook.add_format({**default_format_props, 'bold': True, 'font_size': 13, 'bottom': 1, 'indent': 1}), + 'date_indent': workbook.add_format({**date_format_props, 'bold': True, 'font_size': 13, 'bottom': 1, 'indent': 1}), + }, + 2: { + 'default': workbook.add_format({**default_format_props, 'bold': True}), + 'text': workbook.add_format({**text_format_props, 'bold': True}), + 'date': workbook.add_format({**date_format_props, 'bold': True}), + 'initial': workbook.add_format(default_format_props), + 'total': workbook.add_format({**default_format_props, 'bold': True}), + 'default_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': 2}), + 'date_indent': workbook.add_format({**date_format_props, 'bold': True, 'indent': 2}), + 'initial_indent': workbook.add_format({**default_format_props, 'indent': 2}), + 'total_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': 1}), + }, + 'default': { + 'default': workbook.add_format(default_format_props), + 'text': workbook.add_format(text_format_props), + 'date': workbook.add_format(date_format_props), + 'total': workbook.add_format(default_format_props), + 'default_indent': workbook.add_format({**default_format_props, 'indent': 2}), + 'date_indent': workbook.add_format({**date_format_props, 'indent': 2}), + 'total_indent': workbook.add_format({**default_format_props, 'indent': 2}), + }, + } + + def get_format(content_type='default', level='default'): + if isinstance(level, int) and level not in workbook_formats: + workbook_formats[level] = { + **workbook_formats['default'], + 'default_indent': workbook.add_format({**default_format_props, 'indent': level}), + 'date_indent': workbook.add_format({**date_format_props, 'indent': level}), + 'total_indent': workbook.add_format({**default_format_props, 'bold': True, 'indent': level - 1}), + } + + level_formats = workbook_formats[level] + if '_indent' in content_type and not level_formats.get(content_type): + return level_formats.get('default_indent', level_formats.get(content_type.removesuffix('_indent'), level_formats['default'])) + return level_formats.get(content_type, level_formats['default']) + + print_mode_self = self.with_context(no_format=True) + lines = self._filter_out_folded_children(print_mode_self._get_lines(options)) + annotations = self.get_annotations(options) + + # For reports with lines generated for accounts, the account name and codes are shown in a single column. + # To help user post-process the report if they need, we should in such a case split the account name and code in two columns. + account_lines_split_names = {} + for line in lines: + line_model = self._get_model_info_from_id(line['id'])[0] + if line_model == 'account.account': + # Reuse the _split_code_name to split the name and code in two values. + account_lines_split_names[line['id']] = self.env['account.account']._split_code_name(line['name']) + + # Set the (Account) Name column width to 50. + # If we have account lines and split the name and code in two columns, we will also set the code column. + if len(account_lines_split_names) > 0: + sheet.set_column(0, 0, 13) + sheet.set_column(1, 1, 50) + else: + sheet.set_column(0, 0, 50) + + if not options.get('no_xlsx_currency_code_columns'): + self._add_xlsx_currency_codes_columns(options, lines) + + original_x_offset = 1 if len(account_lines_split_names) > 0 else 0 + + y_offset = 0 + # 1 and not 0 to leave space for the line name. original_x_offset allows making place for the code column if needed. + x_offset = original_x_offset + 1 + + # Add headers. + # For this, iterate in the same way as done in main_table_header template + column_headers_render_data = self._get_column_headers_render_data(options) + for header_level_index, header_level in enumerate(options['column_headers']): + for header_to_render in header_level * column_headers_render_data['level_repetitions'][header_level_index]: + colspan = header_to_render.get('colspan', column_headers_render_data['level_colspan'][header_level_index]) + write_cell(sheet, x_offset, y_offset, header_to_render.get('name', ''), title_format, colspan + (1 if options['show_horizontal_group_total'] and header_level_index == 0 else 0)) + x_offset += colspan + if options.get('column_percent_comparison') == 'growth': + write_cell(sheet, x_offset, y_offset, '%', title_format) + x_offset += 1 + + if options['show_horizontal_group_total'] and header_level_index != 0: + horizontal_group_name = next((group['name'] for group in options['available_horizontal_groups'] if group['id'] == options['selected_horizontal_group_id']), None) + write_cell(sheet, x_offset, y_offset, horizontal_group_name, title_format) + x_offset += 1 + if annotations: + annotations_x_offset = x_offset + write_cell(sheet, annotations_x_offset, y_offset, 'Annotations', title_format) + x_offset += 1 + y_offset += 1 + x_offset = original_x_offset + 1 + + for subheader in column_headers_render_data['custom_subheaders']: + colspan = subheader.get('colspan', 1) + write_cell(sheet, x_offset, y_offset, subheader.get('name', ''), title_format, colspan) + x_offset += colspan + y_offset += 1 + x_offset = original_x_offset + 1 + + if account_lines_split_names: + # If we have a separate account code column, add a title for it + write_cell(sheet, x_offset - 2, y_offset, _("Code"), title_format) + write_cell(sheet, x_offset - 1, y_offset, _("Account Name"), title_format) + sheet.set_column(x_offset, x_offset + len(options['columns']), 10) + + for column in options['columns']: + colspan = column.get('colspan', 1) + write_cell(sheet, x_offset, y_offset, column.get('name', ''), title_format, colspan) + x_offset += colspan + + if options['show_horizontal_group_total']: + write_cell(sheet, x_offset, y_offset, options['columns'][0].get('name', ''), title_format, colspan) + + if options.get('column_percent_comparison') == 'growth': + write_cell(sheet, x_offset, y_offset, '', title_format, colspan) + y_offset += 1 + + if options.get('order_column'): + lines = self.sort_lines(lines, options) + + # Disable bold styling for the max level. + max_level = max(line.get('level', -1) for line in lines) if lines else -1 + if max_level in {0, 1, 2}: + # Total lines are supposed to be a level above, so we don't touch them. + for wb_format in (s for s in workbook_formats[max_level] if 'total' not in s): + workbook_formats[max_level][wb_format].set_bold(False) + + # Add lines. + counter = 1 + for y, line in enumerate(lines): + level = line.get('level') + if level == 0: + y_offset += 1 + elif not level: + level = 'default' + + line_id = self._parse_line_id(line.get('id')) + is_initial_line = line_id[-1][0] == 'initial' if line_id else False + is_total_line = line_id[-1][0] == 'total' if line_id else False + + # Write the first column(s), with a specific style to manage the indentation. + cell_type, cell_value = self._get_cell_type_value(line) + account_code_cell_format = get_format('text', level) + + if cell_type == 'date': + cell_format = get_format('date_indent', level) + elif is_initial_line: + cell_format = get_format('initial_indent', level) + elif is_total_line: + cell_format = get_format('total_indent', level) + else: + cell_format = get_format('default_indent', level) + + x_offset = original_x_offset + 1 + if lines[y]['id'] in account_lines_split_names: + # Write the Account Code and Name columns. + code, name = account_lines_split_names[lines[y]['id']] + # Don't indent the account code and don't format is as a monetary value either. + write_cell(sheet, 0, y + y_offset, code, account_code_cell_format) + write_cell(sheet, 1, y + y_offset, name, cell_format) + else: + write_cell(sheet, original_x_offset, y + y_offset, cell_value, cell_format, datetime=cell_type == 'date') + + if 'parent_id' in line and line['parent_id'] in account_lines_split_names: + write_cell(sheet, 1 + original_x_offset, y + y_offset, account_lines_split_names[line['parent_id']][0], account_code_cell_format) + elif account_lines_split_names: + write_cell(sheet, 1 + original_x_offset, y + y_offset, "", account_code_cell_format) + + # Write all the remaining cells. + columns = line['columns'] + if options.get('column_percent_comparison') and 'column_percent_comparison_data' in line: + columns += [line['column_percent_comparison_data']] + + if options['show_horizontal_group_total']: + columns += [line.get('horizontal_group_total_data', {'name': 0})] + for x, column in enumerate(columns, start=x_offset): + cell_type, cell_value = self._get_cell_type_value(column) + if cell_type == 'date': + cell_format = get_format('date', level) + elif is_initial_line: + cell_format = get_format('initial', level) + elif is_total_line: + cell_format = get_format('total', level) + else: + cell_format = get_format('default', level) + write_cell(sheet, x + line.get('colspan', 1) - 1, y + y_offset, cell_value, cell_format, datetime=cell_type == 'date') + + # Write annotations. + if annotations and (line_annotations := annotations.get(line['id'])): + line_annotation_text = [] + for line_annotation in line_annotations: + line_annotation_text.append(f"{counter} - {line_annotation['text']}") + counter += 1 + write_cell(sheet, annotations_x_offset, y + y_offset, "\n".join(line_annotation_text), annotation_format) + + def _add_xlsx_currency_codes_columns(self, options, lines): + """ Adds a 'Currency Code' column for each column displaying amounts in foreign currencies. This is done because + the raw number is displayed on the xlsx file, making it impossible to know the currency used. + To have it displayed, the line must have an expression label starting with '_currency_' """ + required_currency_code_columns = { + label.removeprefix('_currency_') + for label in self.line_ids.expression_ids.mapped('label') + if label.startswith('_currency_') + } + + new_columns = [] + for col in options['columns']: + new_columns.append(col) + + if col['expression_label'] in required_currency_code_columns: + new_columns.append({ + **col, + 'name': _("Currency Code"), + 'figure_type': 'string', + 'expression_label': f"_xlsx_currency_code_{col['expression_label']}" + }) + + options['columns'] = new_columns + + # Add 'Currency Code' values to each line + for line in lines: + new_column_values = [] + + for index, col_data in enumerate(line['columns']): + new_column_values.append(col_data) + + if col_data.get('expression_label') in required_currency_code_columns: + currency = col_data.get('currency') + currency_code = currency.name if currency else '' + new_column = self._build_column_dict(currency_code, options['columns'][index+1], options) + new_column['name'] = new_column['no_format'] + new_column_values.append(new_column) + + line['columns'] = new_column_values + + def _add_options_xlsx_sheet(self, workbook, options_list): + """Adds a new sheet for xlsx report exports with a summary of all filters and options activated at the moment of the export.""" + filters_sheet = workbook.add_worksheet(_("Filters")) + # Set first and second column widths. + filters_sheet.set_column(0, 0, 20) + filters_sheet.set_column(1, 1, 50) + name_style = workbook.add_format({'font_name': 'Arial', 'bold': True, 'bottom': 2}) + y_offset = 0 + + if len(options_list) == 1: + self.env['account.report'].browse(options_list[0]['report_id'])._inject_report_options_into_xlsx_sheet(options_list[0], filters_sheet, y_offset) + return + + # Find uncommon keys + options_sets = list(map(set, options_list)) + common_keys = set.intersection(*options_sets) + all_keys = set.union(*options_sets) + uncommon_options_keys = all_keys - common_keys + # Try to find the common filter values between all reports to avoid duplication. + common_options_values = {} + for key in common_keys: + first_value = options_list[0][key] + if all(options[key] == first_value for options in options_list[1:]): + common_options_values[key] = first_value + else: + uncommon_options_keys.add(key) + + # Write common options to the sheet. + filters_sheet.write(y_offset, 0, _("All"), name_style) + y_offset += 1 + y_offset = self._inject_report_options_into_xlsx_sheet(common_options_values, filters_sheet, y_offset) + + for report_options in options_list: + report = self.env['account.report'].browse(report_options['report_id']) + + filters_sheet.write(y_offset, 0, report.name, name_style) + y_offset += 1 + new_offset = report._inject_report_options_into_xlsx_sheet(report_options, filters_sheet, y_offset, uncommon_options_keys) + + if y_offset == new_offset: + y_offset -= 1 + # Clear the report name's cell since it didn't add any data to the xlsx. + filters_sheet.write(y_offset, 0, " ") + else: + y_offset = new_offset + + def _inject_report_options_into_xlsx_sheet(self, options, sheet, y_offset, options_to_print=None): + """ + Injects the report options into the filters sheet. + + :param options: Dictionary containing report options. + :param sheet: XLSX sheet to inject options into. + :param y_offset: Offset for the vertical position in the sheet. + :param options_to_print: Optional list of names to print. If not provided, all printable options will be included. + """ + def write_filter_lines(filter_title, filter_lines, y_offset): + sheet.write(y_offset, 0, filter_title) + for line in filter_lines: + sheet.write(y_offset, 1, line) + y_offset += 1 + return y_offset + + def should_print_option(option_key): + """Check if the option should be printed based on options_to_print.""" + return not options_to_print or option_key in options_to_print + + # Company + if should_print_option('companies'): + companies = options['companies'] + title = _("Companies") if len(companies) > 1 else _("Company") + lines = [company['name'] for company in companies] + y_offset = write_filter_lines(title, lines, y_offset) + + # Journals + if should_print_option('journals') and (journals := options.get('journals')): + journal_titles = [journal.get('title') for journal in journals if journal.get('selected')] + if journal_titles: + y_offset = write_filter_lines(_("Journals"), journal_titles, y_offset) + + # Partners + if should_print_option('selected_partner_ids') and (partner_names := options.get('selected_partner_ids')): + y_offset = write_filter_lines(_("Partners"), partner_names, y_offset) + + # Partner categories + if should_print_option('selected_partner_categories') and (partner_categories := options.get('selected_partner_categories')): + y_offset = write_filter_lines(_("Partner Categories"), partner_categories, y_offset) + + # Horizontal groups + if should_print_option('selected_horizontal_group_id') and (group_id := options.get('selected_horizontal_group_id')): + for horizontal_group in options['available_horizontal_groups']: + if horizontal_group['id'] == group_id: + filter_name = horizontal_group['name'] + y_offset = write_filter_lines(_("Horizontal Group"), [filter_name], y_offset) + break + + # Currency + if should_print_option('company_currency') and options.get('company_currency'): + y_offset = write_filter_lines(_("Company Currency"), [options['company_currency']['currency_name']], y_offset) + + # Filters + if should_print_option('aml_ir_filters'): + if options.get('aml_ir_filters') and any(opt['selected'] for opt in options['aml_ir_filters']): + filter_names = [opt['name'] for opt in options['aml_ir_filters'] if opt['selected']] + y_offset = write_filter_lines(_("Filters"), filter_names, y_offset) + + # Extra options + # Array of tuples for the extra options: (name, option_key, condition) + extra_options = [ + (_("With Draft Entries"), 'all_entries', self.filter_show_draft), + (_("Unreconciled Entries"), 'unreconciled', self.filter_unreconciled), + (_("Including Analytic Simulations"), 'include_analytic_without_aml', True) + ] + filter_names = [ + name for name, option_key, condition in extra_options + if (not options_to_print or option_key in options_to_print) and condition and options.get(option_key) + ] + if filter_names: + y_offset = write_filter_lines(_("Options"), filter_names, y_offset) + + return y_offset + + def _get_cell_type_value(self, cell): + if 'date' not in cell.get('class', '') or not cell.get('name'): + # cell is not a date + return ('text', cell.get('name', '')) + if isinstance(cell['name'], (float, datetime.date, datetime.datetime)): + # the date is xlsx compatible + return ('date', cell['name']) + try: + # the date is parsable to a xlsx compatible date + lg = get_lang(self.env, self.env.user.lang) + return ('date', datetime.datetime.strptime(cell['name'], lg.date_format)) + except: + # the date is not parsable thus is returned as text + return ('text', cell['name']) + + def get_vat_for_export(self, options, raise_warning=True): + """ Returns the VAT number to use when exporting this report with the provided + options. If a single fiscal_position option is set, its VAT number will be + used; else the current company's will be, raising an error if its empty. + """ + self.ensure_one() + + if self.filter_multi_company == 'tax_units' and options['tax_unit'] != 'company_only': + tax_unit = self.env['account.tax.unit'].browse(options['tax_unit']) + return tax_unit.vat + + if options['fiscal_position'] in {'all', 'domestic'}: + company = self._get_sender_company_for_export(options) + if not company.vat and raise_warning: + action = self.env.ref('base.action_res_company_form') + raise RedirectWarning(_('No VAT number associated with your company. Please define one.'), action.id, _("Company Settings")) + return company.vat + + fiscal_position = self.env['account.fiscal.position'].browse(options['fiscal_position']) + return fiscal_position.foreign_vat + + @api.model + def get_report_company_ids(self, options): + """ Returns a list containing the ids of the companies to be used to + render this report, following the provided options. + """ + return [comp_data['id'] for comp_data in options['companies']] + + def _get_partner_and_general_ledger_initial_balance_line(self, options, parent_line_id, eval_dict, account_currency=None, level_shift=0): + """ Helper to generate dynamic 'initial balance' lines, used by general ledger and partner ledger. + """ + line_columns = [] + for column in options['columns']: + col_value = eval_dict[column['column_group_key']].get(column['expression_label']) + col_expr_label = column['expression_label'] + + if col_value is None or (col_expr_label == 'amount_currency' and not account_currency): + line_columns.append(self._build_column_dict(None, None)) + else: + line_columns.append(self._build_column_dict( + col_value, + column, + options=options, + currency=account_currency if col_expr_label == 'amount_currency' else None, + )) + + # Display unfold & initial balance even when debit/credit column is hidden and the balance == 0 + if not any(isinstance(column.get('no_format'), (int, float)) and column.get('expression_label') != 'balance' for column in line_columns): + return None + + return { + 'id': self._get_generic_line_id(None, None, parent_line_id=parent_line_id, markup='initial'), + 'name': _("Initial Balance"), + 'level': 3 + level_shift, + 'parent_id': parent_line_id, + 'columns': line_columns, + } + + def _compute_column_percent_comparison_data(self, options, value1, value2, green_on_positive=True): + ''' Helper to get the additional columns due to the growth comparison feature. When only one comparison is + requested, an additional column is there to show the percentage of growth based on the compared period. + :param options: The report options. + :param value1: The value in the current period. + :param value2: The value in the compared period. + :param green_on_positive: A flag customizing the value with a green color depending if the growth is positive. + :return: The new columns to add to line['columns']. + ''' + if value1 is None or value2 is None or float_is_zero(value2, precision_rounding=0.1): + return {'name': _('n/a'), 'mode': 'muted'} + + comparison_type = options['column_percent_comparison'] + if comparison_type == 'growth': + + values_diff = value1 - value2 + growth = round(values_diff / value2 * 100, 1) + + # In case the comparison is made on a negative figure, the color should be the other + # way around. For example: + # 2018 2017 % + # Product Sales 1000.00 -1000.00 -200.0% + # + # The percentage is negative, which is mathematically correct, but my sales increased + # => it should be green, not red! + if float_is_zero(growth, 1): + return {'name': '0.0%', 'mode': 'muted'} + else: + return { + 'name': f"{float_repr(growth, 1)}%", + 'mode': 'red' if ((values_diff > 0) ^ green_on_positive) else 'green', + } + + elif comparison_type == 'budget': + percentage_value = value1 / value2 * 100 + if float_is_zero(percentage_value, 1): + # To avoid negative 0 + return {'name': '0.0%', 'mode': 'green'} + + comparison_value = float_compare(value1, value2, 1) + return { + 'name': f"{float_repr(percentage_value, 1)}%", + 'mode': 'green' if (comparison_value >= 0 and green_on_positive) or (comparison_value == -1 and not green_on_positive) else 'red', + } + + def _set_budget_column_comparisons(self, options, line): + """ + Set the percentage values in the budget columns + """ + for col_index, col in enumerate(line['columns']): + col_group_data = options['column_groups'][col['column_group_key']] + if 'budget_percentage' in col_group_data.get('forced_options'): + budget_id = col_group_data['forced_options']['budget_percentage'] + date_key = col_group_data.get('forced_options', {}).get('date') + if not date_key: + continue + + budget_base_col = None + budget_amount_col = None + for line_col in line['columns']: + other_col_group_key = line_col['column_group_key'] + other_col_options = options['column_groups'][other_col_group_key] + if other_col_options.get('forced_options', {}).get('date') == date_key: + if other_col_options.get('forced_options', {}).get('budget_base') and line_col['figure_type'] == 'monetary': + budget_base_col = line_col + elif other_col_options.get('forced_options', {}).get('compute_budget') == budget_id: + budget_amount_col = line_col + + value = self._compute_column_percent_comparison_data( + options, + budget_base_col['no_format'], + budget_amount_col['no_format'], + green_on_positive=budget_base_col['green_on_positive'], + ) + comparison_column = self._build_column_dict( + value['name'], + { + **budget_amount_col, + 'figure_type': 'string', + 'comparison_mode': value['mode'], + } + ) + line['columns'][col_index] = comparison_column + + def _check_groupby_fields(self, groupby_fields_name: list[str] | str): + """ Checks that each string in the groupby_fields_name list is a valid groupby value for an accounting report. + So it must be: + - a field from account.move.line which is (1) searchable and (2) for which _field_to_sql is implemented, + this includes stored and related non-stored fields, or + - a custom value allowed by the _get_custom_groupby_map function of the custom handler + """ + self.ensure_one() + if isinstance(groupby_fields_name, str | bool): + groupby_fields_name = groupby_fields_name.split(',') if groupby_fields_name else [] + + for field_name in (fname.strip() for fname in groupby_fields_name): + groupby_field = self.env['account.move.line']._fields.get(field_name) + if groupby_field: + if not groupby_field._description_searchable: + raise UserError(self.env._("Field %s of account.move.line is not searchable and can therefore not be used in a groupby expression.", field_name)) + try: + self.env['account.move.line']._field_to_sql('account_move_line', field_name, Query(self.env, 'account_move_line')) + except ValueError: + raise UserError(self.env._("Field %s of account.move.line cannot be used in a groupby expression.", field_name)) from None + elif (custom_handler_name := self._get_custom_handler_model()): + if field_name not in self.env[custom_handler_name]._get_custom_groupby_map(): + raise UserError(_("Field %s does not exist on account.move.line, and is not supported by this report's custom handler.", field_name)) + else: + raise UserError(_("Field %s does not exist on account.move.line.", field_name)) + + # ============ Accounts Coverage Debugging Tool - START ================ + @api.depends('country_id', 'chart_template', 'root_report_id') + def _compute_is_account_coverage_report_available(self): + for report in self: + report.is_account_coverage_report_available = ( + ( + report.availability_condition == 'country' and self.env.company.account_fiscal_country_id == report.country_id + or + report.availability_condition == 'coa' and self.env.company.chart_template == report.chart_template + or + report.availability_condition == 'always' + ) + and + report.root_report_id in ( + self.env.ref('odex30_account_reports.profit_and_loss', raise_if_not_found=False), + self.env.ref('odex30_account_reports.balance_sheet', raise_if_not_found=False) + ) + ) + + def action_download_xlsx_accounts_coverage_report(self): + """ + Generate an XLSX file that can be used to debug the + report by issuing the following warnings if applicable: + - an account exists in the Chart of Accounts but is not mentioned in any line of the report (red) + - an account is reported in multiple lines of the report (orange) + - an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow) + """ + self.ensure_one() + if not self.is_account_coverage_report_available: + raise UserError(_("The Accounts Coverage Report is not available for this report.")) + + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + worksheet = workbook.add_worksheet(_('Accounts coverage')) + worksheet.set_column(0, 0, 20) + worksheet.set_column(1, 1, 75) + worksheet.set_column(2, 2, 80) + worksheet.freeze_panes(1, 0) + + headers = [_("Account Code / Tag"), _("Error message"), _("Report lines mentioning the account code"), '#FFFFFF'] + lines = [headers] + self._generate_accounts_coverage_report_xlsx_lines() + for i, line in enumerate(lines): + worksheet.write_row(i, 0, line[:-1], workbook.add_format({'bg_color': line[-1]})) + + workbook.close() + attachment_id = self.env['ir.attachment'].create({ + 'name': f"{self.display_name} - {_('Accounts Coverage Report')}", + 'datas': base64.encodebytes(output.getvalue()) + }) + return { + "type": "ir.actions.act_url", + "url": f"/web/content/{attachment_id.id}", + "target": "download", + } + + def _generate_accounts_coverage_report_xlsx_lines(self): + """ + Generate the lines of the XLSX file that can be used to debug the + report by issuing the following warnings if applicable: + - an account exists in the Chart of Accounts but is not mentioned in any line of the report (red) + - an account is reported in multiple lines of the report (orange) + - an account is reported in a line of the report but does not exist in the Chart of Accounts (yellow) + """ + def get_account_domain(prefix): + # Helper function to get the right domain to find the account + # This function verifies if we have to look for a tag or if we have + # to look for an account code. + if tag_matching := ACCOUNT_CODES_ENGINE_TAG_ID_PREFIX_REGEX.match(prefix): + if tag_matching['ref']: + account_tag_id = self.env['ir.model.data']._xmlid_to_res_id(tag_matching['ref']) + else: + account_tag_id = int(tag_matching['id']) + return 'tag_ids', 'in', (account_tag_id,) + else: + return 'code', '=like', f'{prefix}%' + + self.ensure_one() + + all_reported_accounts = self.env["account.account"] # All accounts mentioned in the report (including those reported without using the account code) + accounts_by_expressions = {} # {expression_id: account.account objects} + reported_account_codes = [] # [{'prefix': ..., 'balance': ..., 'exclude': ..., 'line': ...}, ...] + non_existing_codes = defaultdict(lambda: self.env["account.report.line"]) # {non_existing_account_code: {lines_with_that_code,}} + lines_per_non_linked_tag = defaultdict(lambda: self.env['account.report.line']) + lines_using_bad_operator_per_tag = defaultdict(lambda: self.env['account.report.line']) + candidate_duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {candidate_duplicate_account_code: {lines_with_that_code,}} + duplicate_codes = defaultdict(lambda: self.env["account.report.line"]) # {verified duplicate_account_code: {lines_with_that_code,}} + duplicate_codes_same_line = defaultdict(lambda: self.env["account.report.line"]) # {duplicate_account_code: {line_with_that_code_multiple_times,}} + common_account_domain = [ + *self.env['account.account']._check_company_domain(self.env.company), + ('deprecated', '=', False), + ] + + # tag_ids already linked to an account - avoid several search_count to know if the tag is used or not + tag_ids_linked_to_account = set(self.env['account.account'].search([('tag_ids', '!=', False)]).tag_ids.ids) + + expressions = self.line_ids.expression_ids._expand_aggregations() + for i, expr in enumerate(expressions): + reported_accounts = self.env["account.account"] + if expr.engine == "domain": + domain = literal_eval(expr.formula.strip()) + accounts_domain = [] + for j, operand in enumerate(domain): + if isinstance(operand, tuple): + operand = list(operand) + # Skip tuples that will not be used in the new domain to retrieve the reported accounts + if not operand[0].startswith('account_id.'): + if domain[j - 1] in ("&", "|", "!"): # Remove the operator linked to the tuple if it exists + accounts_domain.pop() + continue + operand[0] = operand[0].replace('account_id.', '') + # Check that the code exists in the CoA + if operand[0] == 'code' and not self.env["account.account"].search_count([operand]): + non_existing_codes[operand[2]] |= expr.report_line_id + elif operand[0] == 'tag_ids': + tag_ids = operand[2] + if not isinstance(tag_ids, (list, tuple, set)): + tag_ids = [tag_ids] + + if operand[1] in ('=', 'in'): + tag_ids_to_browse = [tag_id for tag_id in tag_ids if tag_id not in tag_ids_linked_to_account] + for tag in self.env['account.account.tag'].browse(tag_ids_to_browse): + lines_per_non_linked_tag[f'{tag.name} ({tag.id})'] |= expr.report_line_id + else: + for tag in self.env['account.account.tag'].browse(tag_ids): + lines_using_bad_operator_per_tag[f'{tag.name} ({tag.id}) - Operator: {operand[1]}'] |= expr.report_line_id + + accounts_domain.append(operand) + reported_accounts += self.env['account.account'].search(accounts_domain) + elif expr.engine == "account_codes": + account_codes = [] + for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(expr.formula.replace(' ', '')): + if not token: + continue + token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token) + if not token_match: + continue + + parsed_token = token_match.groupdict() + account_codes.append({ + 'prefix': parsed_token['prefix'], + 'balance': parsed_token['balance_character'], + 'exclude': parsed_token['excluded_prefixes'].split(',') if parsed_token['excluded_prefixes'] else [], + 'line': expr.report_line_id, + }) + + for account_code in account_codes: + reported_account_codes.append(account_code) + exclude_domain_accounts = [get_account_domain(exclude_code) for exclude_code in account_code['exclude']] + reported_accounts += self.env["account.account"].search([ + *common_account_domain, + get_account_domain(account_code['prefix']), + *[excl_domain for excl_tuple in exclude_domain_accounts for excl_domain in ("!", excl_tuple)], + ]) + + # Check that the code exists in the CoA or that the tag is linked to an account + prefixes_to_check = [account_code['prefix']] + account_code['exclude'] + for prefix_to_check in prefixes_to_check: + account_domain = get_account_domain(prefix_to_check) + if not self.env["account.account"].search_count([ + *common_account_domain, + account_domain, + ]): + # Identify if we're working with account codes or account tags + if account_domain[0] == 'code': + non_existing_codes[prefix_to_check] |= account_code['line'] + elif account_domain[0] == 'tag_ids': + lines_per_non_linked_tag[prefix_to_check] |= account_code['line'] + + all_reported_accounts |= reported_accounts + accounts_by_expressions[expr.id] = reported_accounts + + # Check if an account is reported multiple times in the same line of the report + if len(reported_accounts) != len(set(reported_accounts)): + seen = set() + for reported_account in reported_accounts: + if reported_account not in seen: + seen.add(reported_account) + else: + duplicate_codes_same_line[reported_account.code] |= expr.report_line_id + + # Check if the account is reported in multiple lines of the report + for expr2 in expressions[:i + 1]: + reported_accounts2 = accounts_by_expressions[expr2.id] + for duplicate_account in (reported_accounts & reported_accounts2): + if len(expr.report_line_id | expr2.report_line_id) > 1 \ + and expr.date_scope == expr2.date_scope \ + and expr.subformula == expr2.subformula: + candidate_duplicate_codes[duplicate_account.code] |= expr.report_line_id | expr2.report_line_id + + # Check that the duplicates are not false positives because of the balance character + for candidate_duplicate_code, candidate_duplicate_lines in candidate_duplicate_codes.items(): + seen_balance_chars = [] + for reported_account_code in reported_account_codes: + if candidate_duplicate_code.startswith(reported_account_code['prefix']) and reported_account_code['balance']: + seen_balance_chars.append(reported_account_code['balance']) + if not seen_balance_chars or seen_balance_chars.count("C") > 1 or seen_balance_chars.count("D") > 1: + duplicate_codes[candidate_duplicate_code] |= candidate_duplicate_lines + + # Check that all codes in CoA are correctly reported + if self.root_report_id == self.env.ref('odex30_account_reports.profit_and_loss'): + accounts_in_coa = self.env["account.account"].search([ + *common_account_domain, + ('account_type', 'in', ("income", "income_other", "expense", "expense_depreciation", "expense_direct_cost")), + ('account_type', '!=', "off_balance"), + ]) + else: # Balance Sheet + accounts_in_coa = self.env["account.account"].search([ + *common_account_domain, + ('account_type', 'not in', ("off_balance", "income", "income_other", "expense", "expense_depreciation", "expense_direct_cost")) + ]) + + # Compute codes that exist in the CoA but are not reported in the report + non_reported_codes = set((accounts_in_coa - all_reported_accounts).mapped('code')) + + # Create the lines that will be displayed in the xlsx + all_reported_codes = sorted(set(all_reported_accounts.mapped("code")) | non_reported_codes | non_existing_codes.keys()) + errors_trie = self._get_accounts_coverage_report_errors_trie(all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes) + errors_trie['children'].update(**self._get_account_tag_coverage_report_errors_trie(lines_per_non_linked_tag, lines_using_bad_operator_per_tag)) # Add tags that are not linked to an account + + errors_trie = self._regroup_accounts_coverage_report_errors_trie(errors_trie) + return self._get_accounts_coverage_report_coverage_lines("", errors_trie) + + def _get_accounts_coverage_report_errors_trie(self, all_reported_codes, non_reported_codes, duplicate_codes, duplicate_codes_same_line, non_existing_codes): + """ + Create the trie that will be used to regroup the same errors on the same subcodes. + This trie will be in the form of: + { + "children": { + "1": { + "children": { + "10": { ... }, + "11": { ... }, + }, + "lines": { + "Line1", + "Line2", + }, + "errors": { + "DUPLICATE" + } + }, + "lines": { + "", + }, + "errors": { + None # Avoid that all codes are merged into the root with the code "" in case all of the errors are the same + }, + } + """ + errors_trie = {"children": {}, "lines": {}, "errors": {None}} + for reported_code in all_reported_codes: + current_trie = errors_trie + lines = self.env["account.report.line"] + errors = set() + if reported_code in non_reported_codes: + errors.add("NON_REPORTED") + elif reported_code in duplicate_codes_same_line: + lines |= duplicate_codes_same_line[reported_code] + errors.add("DUPLICATE_SAME_LINE") + elif reported_code in duplicate_codes: + lines |= duplicate_codes[reported_code] + errors.add("DUPLICATE") + elif reported_code in non_existing_codes: + lines |= non_existing_codes[reported_code] + errors.add("NON_EXISTING") + else: + errors.add("NONE") + + for j in range(1, len(reported_code) + 1): + current_trie = current_trie["children"].setdefault(reported_code[:j], { + "children": {}, + "lines": lines, + "errors": errors + }) + return errors_trie + + @api.model + def _get_account_tag_coverage_report_errors_trie(self, lines_per_non_linked_tag, lines_per_bad_operator_tag): + """ As we don't want to make a hierarchy for tags, we use a specific + function to handle tags. + """ + errors = { + non_linked_tag: { + 'children': {}, + 'lines': line, + 'errors': {'NON_LINKED'}, + } + for non_linked_tag, line in lines_per_non_linked_tag.items() + } + errors.update({ + bad_operator_tag: { + 'children': {}, + 'lines': line, + 'errors': {'BAD_OPERATOR'}, + } + for bad_operator_tag, line in lines_per_bad_operator_tag.items() + }) + return errors + + def _regroup_accounts_coverage_report_errors_trie(self, trie): + """ + Regroup the codes that have the same error under the same common subcode/prefix. + This is done in-place on the given trie. + """ + if trie.get("children"): + children_errors = set() + children_lines = self.env["account.report.line"] + if trie.get("errors"): # Add own error + children_errors |= set(trie.get("errors")) + for child in trie["children"].values(): + regroup = self._regroup_accounts_coverage_report_errors_trie(child) + children_lines |= regroup["lines"] + children_errors |= set(regroup["errors"]) + if len(children_errors) == 1 and children_lines and children_lines == trie["lines"]: + trie["children"] = {} + trie["lines"] = children_lines + trie["errors"] = children_errors + return trie + + def _get_accounts_coverage_report_coverage_lines(self, subcode, trie, coverage_lines=None): + """ + Create the coverage lines from the grouped trie. Each line has + - the account code + - the error message + - the lines on which the account code is used + - the color of the error message for the xlsx + """ + # Dictionnary of the three possible errors, their message and the corresponding color for the xlsx file + ERRORS = { + "NON_REPORTED": { + "msg": _("This account exists in the Chart of Accounts but is not mentioned in any line of the report"), + "color": "#FF0000" + }, + "DUPLICATE": { + "msg": _("This account is reported in multiple lines of the report"), + "color": "#FF8916" + }, + "DUPLICATE_SAME_LINE": { + "msg": _("This account is reported multiple times on the same line of the report"), + "color": "#E6A91D" + }, + "NON_EXISTING": { + "msg": _("This account is reported in a line of the report but does not exist in the Chart of Accounts"), + "color": "#FFBF00" + }, + "NON_LINKED": { + "msg": _("This tag is reported in a line of the report but is not linked to any account of the Chart of Accounts"), + "color": "#FFBF00", + }, + "BAD_OPERATOR": { + "msg": _("The used operator is not supported for this expression."), + "color": "#FFBF00", + } + } + if coverage_lines is None: + coverage_lines = [] + if trie.get("children"): + for child in trie.get("children"): + self._get_accounts_coverage_report_coverage_lines(child, trie["children"][child], coverage_lines) + else: + error = list(trie["errors"])[0] if trie["errors"] else False + if error and error != "NONE": + coverage_lines.append([ + subcode, + ERRORS[error]["msg"], + " + ".join(trie["lines"].sorted().mapped("name")), + ERRORS[error]["color"] + ]) + return coverage_lines + + # ============ Accounts Coverage Debugging Tool - END ================ + + def _generate_file_data_with_error_check(self, options, content_generator, generator_params, errors): + """ Checks for critical errors (i.e. errors that would cause the rendering to fail) in the generator values. + If at least one error is critical, the 'account.report.file.download.error.wizard' wizard is opened + before rendering the file, so they can be fixed. + If there are only non-critical errors, the wizard is opened after the file has been generated, + allowing the user to download it anyway. + + :param dict options: The report options. + :param def content_generator: The function used to generate the exported content. + :param dict generator_params: The parameters passed to the 'content_generator' method (List). + :param list errors: A list of errors in the following format: + [ + { + 'message': The error message to be displayed in the wizard (String), + 'action_text': The text of the action button (String), + 'action': Contains the action values (Dictionary), + 'level': One of 'info', 'warning', 'danger'. (String). + Only the 'danger' level represents a blocking error. + }, + {...}, + ] + :returns: The data that will be used by the file generator. + :rtype: dict + """ + if errors is None: + errors = [] + self.ensure_one() + if any(error_value.get('level') == 'danger' for error_value in errors.values()): + raise AccountReportFileDownloadException(errors) + + content = content_generator(**generator_params) + + file_data = { + 'file_name': self.get_default_report_filename(options, generator_params['file_type']), + 'file_content': re.sub(r'\n\s*\n', '\n', content).encode(), + 'file_type': generator_params['file_type'], + } + + if errors: + raise AccountReportFileDownloadException(errors, file_data) + + return file_data + + def action_create_composite_report(self): + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.report', + 'views': [[False, 'form']], + 'context': { + 'default_section_report_ids': self.ids, + } + } + + def show_error_branch_allowed(self, *args, **kwargs): + raise UserError(_("Please select the main company and its branches in the company selector to proceed.")) + + +class AccountReportLine(models.Model): + _inherit = 'account.report.line' + + display_custom_groupby_warning = fields.Boolean(compute='_compute_display_custom_groupby_warning') + + @api.depends('groupby', 'user_groupby') + def _compute_display_custom_groupby_warning(self): + for line in self: + line.display_custom_groupby_warning = line.get_external_id()[line.id] and line.user_groupby != line.groupby + + @api.constrains('groupby', 'user_groupby') + def _validate_groupby(self): + super()._validate_groupby() + for report_line in self: + report_line.report_id._check_groupby_fields(report_line.user_groupby) + report_line.report_id._check_groupby_fields(report_line.groupby) + + def _expand_groupby(self, line_dict_id, groupby, options, offset=0, limit=None, load_one_more=False, unfold_all_batch_data=None): + """ Expand function used to get the sublines of a groupby. + groupby param is a string consisting of one or more coma-separated field names. Only the first one + will be used for the expansion; if there are subsequent ones, the generated lines will themselves used them as + their groupby value, and point to this expand_function, hence generating a hierarchy of groupby). + """ + self.ensure_one() + + group_indent = 0 + line_id_list = self.report_id._parse_line_id(line_dict_id) + + # Parse groupby + groupby_data = self._parse_groupby(options, groupby_to_expand=groupby) + groupby_model = groupby_data['current_groupby_model'] + next_groupby = groupby_data['next_groupby'] + current_groupby = groupby_data['current_groupby'] + custom_groupby_map = groupby_data['custom_groupby_map'] + + # If this line is a sub-groupby of groupby line (for example, when grouping by partner, id; the id line is a subgroup of partner), + # we need to add the domain of the parent groupby criteria to the options + prefix_groups_count = 0 + sub_groupby_domain = [] + full_sub_groupby_key_elements = [] + for markup, model, value in line_id_list: + if isinstance(markup, dict) and 'groupby' in markup: + field_name = markup['groupby'] + if field_name in custom_groupby_map: + sub_groupby_domain += custom_groupby_map[field_name]['domain_builder'](value) + else: + sub_groupby_domain.append((field_name, '=', value)) + full_sub_groupby_key_elements.append(f"{field_name}:{value}") + elif isinstance(markup, dict) and 'groupby_prefix_group' in markup: + prefix_groups_count += 1 + + if model == 'account.group': + group_indent += 1 + + if sub_groupby_domain: + forced_domain = options.get('forced_domain', []) + sub_groupby_domain + options = {**options, 'forced_domain': forced_domain} + + # If the report transmitted custom_unfold_all_batch_data dictionary, use it + full_sub_groupby_key = f"[{self.id}]{','.join(full_sub_groupby_key_elements)}=>{current_groupby}" + + cached_result = (unfold_all_batch_data or {}).get(full_sub_groupby_key) + + if cached_result is not None: + all_column_groups_expression_totals = cached_result + else: + all_column_groups_expression_totals = self.report_id._compute_expression_totals_for_each_column_group( + self.expression_ids, + options, + groupby_to_expand=groupby, + offset=offset, + limit=limit + 1 if limit and load_one_more else limit, + ) + + # Put similar grouping keys from different totals/periods together, so that we don't display multiple + # lines for the same grouping key + + figure_types_defaulting_to_0 = {'monetary', 'percentage', 'integer', 'float'} + + default_value_per_expr_label = { + col_opt['expression_label']: 0 if col_opt['figure_type'] in figure_types_defaulting_to_0 else None + for col_opt in options['columns'] + } + + # Gather default value for each expression, in case it has no value for a given grouping key + default_value_per_expression = {} + for expression in self.expression_ids: + if expression.figure_type: + default_value = 0 if expression.figure_type in figure_types_defaulting_to_0 else None + else: + default_value = default_value_per_expr_label.get(expression.label) + + default_value_per_expression[expression] = {'value': default_value} + + # Build each group's result + aggregated_group_totals = defaultdict(lambda: defaultdict(default_value_per_expression.copy)) + for column_group_key, expression_totals in all_column_groups_expression_totals.items(): + for expression in self.expression_ids: + for grouping_key, result in expression_totals[expression]['value']: + aggregated_group_totals[grouping_key][column_group_key][expression] = {'value': result} + + # Generate groupby lines + group_lines_by_keys = {} + for grouping_key, group_totals in aggregated_group_totals.items(): + # For this, we emulate a dict formatted like the result of _compute_expression_totals_for_each_column_group, so that we can call + # _build_static_line_columns like on non-grouped lines + line_id = self.report_id._get_generic_line_id(groupby_model, grouping_key, parent_line_id=line_dict_id, markup={'groupby': current_groupby}) + group_line_dict = { + # 'name' key will be set later, so that we can browse all the records of this expansion at once (in case we're dealing with records) + 'id': line_id, + 'unfoldable': bool(next_groupby), + 'unfolded': (next_groupby and options['unfold_all']) or line_id in options['unfolded_lines'], + 'groupby': next_groupby, + 'columns': self.report_id._build_static_line_columns(self, options, group_totals, groupby_model=groupby_model), + 'level': self.hierarchy_level + 2 * (prefix_groups_count + len(sub_groupby_domain) + 1) + (group_indent - 1), + 'parent_id': line_dict_id, + 'expand_function': '_report_expand_unfoldable_line_with_groupby' if next_groupby else None, + 'caret_options': groupby_model if not next_groupby else None, + } + + if self.report_id.custom_handler_model_id: + self.env[self.report_id.custom_handler_model_name]._custom_groupby_line_completer(self.report_id, options, group_line_dict) + + # Growth comparison column. + if options.get('column_percent_comparison') == 'growth': + compared_expression = self.expression_ids.filtered(lambda expr: expr.label == group_line_dict['columns'][0]['expression_label']) + group_line_dict['column_percent_comparison_data'] = self.report_id._compute_column_percent_comparison_data( + options, group_line_dict['columns'][0]['no_format'], group_line_dict['columns'][1]['no_format'], green_on_positive=compared_expression.green_on_positive) + # Manage budget comparison + elif options.get('column_percent_comparison') == 'budget': + self.report_id._set_budget_column_comparisons(options, group_line_dict) + + group_lines_by_keys[grouping_key] = group_line_dict + + # Sort grouping keys in the right order and generate line names + keys_and_names_in_sequence = {} # Order of this dict will matter + + if groupby_model: + browsed_groupby_keys = self.env[groupby_model].browse(list(key for key in group_lines_by_keys if key is not None)) + + out_of_sorting_record = None + records_to_sort = browsed_groupby_keys + if browsed_groupby_keys and load_one_more and len(browsed_groupby_keys) >= limit: + out_of_sorting_record = browsed_groupby_keys[-1] + records_to_sort = records_to_sort[:-1] + + for record in records_to_sort.with_context(active_test=False).sorted(): + keys_and_names_in_sequence[record.id] = record.display_name + + if None in group_lines_by_keys: + keys_and_names_in_sequence[None] = _("Unknown") + + if out_of_sorting_record: + keys_and_names_in_sequence[out_of_sorting_record.id] = out_of_sorting_record.display_name + + else: + for non_relational_key in sorted(group_lines_by_keys.keys(), key=lambda k: (k is None, k)): + if custom_groupby_name_builder := custom_groupby_map.get(current_groupby, {}).get('label_builder'): + keys_and_names_in_sequence[non_relational_key] = custom_groupby_name_builder(non_relational_key) + else: + if non_relational_key is None: + keys_and_names_in_sequence[non_relational_key] = _("Undefined") + else: + groupby_field = self.env['account.move.line']._fields[groupby_data['current_groupby']] + if groupby_field.type == 'selection': + selection_options = dict(groupby_field._description_selection(self.env)) + keys_and_names_in_sequence[non_relational_key] = selection_options.get(non_relational_key) or _("Undefined") + else: + keys_and_names_in_sequence[non_relational_key] = str(non_relational_key) + + # Build result: add a name to the groupby lines and handle totals below section for multi-level groupby + group_lines = [] + for grouping_key, line_name in keys_and_names_in_sequence.items(): + group_line_dict = group_lines_by_keys[grouping_key] + group_line_dict['name'] = line_name + group_lines.append(group_line_dict) + + if options.get('hierarchy'): + group_lines = self.report_id._create_hierarchy(group_lines, options) + + return group_lines + + def _get_groupby_line_name(self, groupby_field_name, groupby_model, grouping_key): + # TODO master: remove this method as it is dead code + if groupby_model is None: + return grouping_key + + if grouping_key is None: + return _("Unknown") + + return self.env[groupby_model].browse(grouping_key).display_name + + def _parse_groupby(self, options, groupby_to_expand=None): + """ Retrieves the information needed to handle the groupby feature on the current line. + + :param groupby_to_expand: A coma-separated string containing, in order, all the fields that are used in the groupby we're expanding. + None if we're not expanding anything. + + :return: A dictionary with 4 keys: + 'current_groupby': The name of the value to be used to retrieve the results of the current groupby we're + expanding, or None if nothing is being expanded. That value can be either a field of account.move.line, or + a custom groupby value defined in this report's custom handler's _get_custom_groupby_map function. + + 'next_groupby': The subsequent groupings to be applied after current_groupby, as a string of coma-separated values (again, + either field names from account.move.line or a custom groupby defined on the handler). + If no subsequent grouping exists, next_groupby will be None. + + 'current_groupby_model': The model name corresponding to current_groupby, or None if current_groupby is None. + + 'custom_groupby_map'; The groupby map, used to handle custom groupby values, as returned by the _get_custom_groupby_map function + of the custom handler (by default, it will be an empty dict) + + EXAMPLE: + When computing a line with groupby=partner_id,account_id,id , without expanding it: + - groupby_to_expand will be None + - current_groupby will be None + - next_groupby will be 'partner_id,account_id,id' + - current_groupby_model will be None + + When expanding the first group level of the line: + - groupby_to_expand will be: partner_id,account_id,id + - current_groupby will be 'partner_id' + - next_groupby will be 'account_id,id' + - current_groupby_model will be 'res.partner' + + When expanding further: + - groupby_to_expand will be: account_id,id ; corresponding to the next_groupby computed when expanding partner_id + - current_groupby will be 'account_id' + - next_groupby will be 'id' + - current_groupby_model will be 'account.account' + """ + self.ensure_one() + + if groupby_to_expand: + groupby_to_expand = groupby_to_expand.replace(' ', '') + split_groupby = groupby_to_expand.split(',') + current_groupby = split_groupby[0] + next_groupby = ','.join(split_groupby[1:]) if len(split_groupby) > 1 else None + else: + current_groupby = None + groupby = self._get_groupby(options) + next_groupby = groupby.replace(' ', '') if groupby else None + + custom_handler_name = self.report_id._get_custom_handler_model() + custom_groupby_map = self.env[custom_handler_name]._get_custom_groupby_map() if custom_handler_name else {} + if current_groupby in custom_groupby_map: + groupby_model = custom_groupby_map[current_groupby]['model'] + elif current_groupby == 'id': + groupby_model = 'account.move.line' + elif current_groupby: + groupby_model = self.env['account.move.line']._fields[current_groupby].comodel_name + else: + groupby_model = None + + return { + 'current_groupby': current_groupby, + 'next_groupby': next_groupby, + 'current_groupby_model': groupby_model, + 'custom_groupby_map': custom_groupby_map, + } + + def _get_groupby(self, options): + self.ensure_one() + if options['export_mode'] == 'file': + return self.groupby + return self.user_groupby + + def action_reset_custom_groupby(self): + self.ensure_one() + self.user_groupby = self.groupby + + +class AccountReportExpression(models.Model): + _inherit = 'account.report.expression' + + def action_view_carryover_lines(self, options, column_group_key=None): + if column_group_key: + options = self.report_line_id.report_id._get_column_group_options(options, column_group_key) + + date_from, date_to = self.report_line_id.report_id._get_date_bounds_info(options, self.date_scope) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Carryover lines for: %s', self.report_line_name), + 'res_model': 'account.report.external.value', + 'views': [(False, 'list')], + 'domain': [ + ('target_report_expression_id', '=', self.id), + ('date', '>=', date_from), + ('date', '<=', date_to), + ], + } + + +class AccountReportExternalValue(models.Model): + _inherit = 'account.report.external.value' + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + self._check_lock_date_violation(set(self._build_vals_to_check_for_lock_date(records))) + return records + + def write(self, vals): + # We need to build vals_to_check before the super() call because of the 'target_report_expression_id' field : + # if the user tries to modify this specific field, it'll potentially change the linked report id, and so he can + # bypass the lock dates from the original report (if it was a tax report for example) + vals_to_check = set(self._build_vals_to_check_for_lock_date(self)) + res = super().write(vals) + # Then we add the modified records + for lock_date_to_check in self._build_vals_to_check_for_lock_date(self): + vals_to_check.add(lock_date_to_check) + self._check_lock_date_violation(vals_to_check) + return res + + @api.model + def _build_vals_to_check_for_lock_date(self, records): + """ + Generator method to build tuples out of records. The tuples will contain 3 values: + - is tax, bool: is the external value linked to a tax report + - date to check, date: the date we want to check the lock dates for + - company, res.company: the company we want to check the lock dates for + """ + generic_tax_report = self.env.ref('account.generic_tax_report') + for external_value in records: + report = external_value.target_report_expression_id.report_line_id.report_id + yield ( + not self.env.context.get('ignore_tax_lock_date') and generic_tax_report in (report + report.root_report_id + report.section_main_report_ids.root_report_id), # is tax + external_value.date, # date to check + external_value.company_id, # company + ) + + def _check_lock_date_violation(self, vals_to_check): + """ + This method raises an error if the companies have lock dates after the date we want to create/write the values + :param vals_to_check: a set of tuples like: `{(is_tax, date, company_id)}` + """ + for is_tax, date, company_id in vals_to_check: + violated_lock_dates = company_id._get_lock_date_violations( + date, + sale=False, + purchase=False, + tax=is_tax, + ) + if violated_lock_dates: + lock_date_names = [company_id._fields[lock_date[1]].get_description(self.env)['string'] for lock_date in violated_lock_dates] + lock_dates = "\n- " + "\n- ".join(lock_date_names) + raise ValidationError(_("You cannot update this value as it's locked by: %s", lock_dates)) + + +class AccountReportHorizontalGroup(models.Model): + _name = "account.report.horizontal.group" + _description = "Horizontal group for reports" + + name = fields.Char(string="Name", required=True, translate=True) + rule_ids = fields.One2many(string="Rules", comodel_name='account.report.horizontal.group.rule', inverse_name='horizontal_group_id', required=True) + report_ids = fields.Many2many(string="Reports", comodel_name='account.report') + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "A horizontal group with the same name already exists."), + ] + + def _get_header_levels_data(self): + return [ + (rule.field_name, rule._get_matching_records()) + for rule in self.rule_ids + ] + +class AccountReportHorizontalGroupRule(models.Model): + _name = "account.report.horizontal.group.rule" + _description = "Horizontal group rule for reports" + + def _field_name_selection_values(self): + return [ + (aml_field['name'], aml_field['string']) + for aml_field in self.env['account.move.line'].fields_get().values() + if aml_field['type'] in ('many2one', 'many2many') + ] + + horizontal_group_id = fields.Many2one(string="Horizontal Group", comodel_name='account.report.horizontal.group', required=True) + domain = fields.Char(string="Domain", required=True, default='[]') + field_name = fields.Selection(string="Field", selection='_field_name_selection_values', required=True) + res_model_name = fields.Char(string="Model", compute='_compute_res_model_name') + + @api.depends('field_name') + def _compute_res_model_name(self): + for record in self: + if record.field_name: + record.res_model_name = self.env['account.move.line']._fields[record.field_name].comodel_name + else: + record.res_model_name = None + + def _get_matching_records(self): + self.ensure_one() + model_name = self.env['account.move.line']._fields[self.field_name].comodel_name + domain = ast.literal_eval(self.domain) + return self.env[model_name].search(domain) + + +class AccountReportCustomHandler(models.AbstractModel): + _name = 'account.report.custom.handler' + _description = 'Account Report Custom Handler' + + # This abstract model allows case-by-case localized changes of behaviors of reports. + # This is used for custom reports, for cases that cannot be supported by the standard engines. + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + """ Generates lines dynamically for reports that require a custom processing which cannot be handled + by regular report engines. + :return: A list of tuples [(sequence, line_dict), ...], where: + - sequence is the sequence to apply when rendering the line (can be mixed with static lines), + - line_dict is a dict containing all the line values. + """ + return [] + + def _caret_options_initializer(self): + """ Returns the caret options dict to be used when rendering this report, + in the same format as the one used in _caret_options_initializer_default (defined on 'account.report'). + If the result is empty, the engine will use the default caret options. + """ + return self.env['account.report']._caret_options_initializer_default() + + def _custom_options_initializer(self, report, options, previous_options): + """ To be overridden to add report-specific _init_options... code to the report. """ + if report.root_report_id and report.root_report_id.custom_handler_model_id != report.custom_handler_model_id: + report.root_report_id._init_options_custom(options, previous_options) + + def _custom_line_postprocessor(self, report, options, lines): + """ Postprocesses the result of the report's _get_lines() before returning it. """ + return lines + + def _custom_groupby_line_completer(self, report, options, line_dict): + """ Postprocesses the dict generated by the group_by_line, to customize its content. """ + + def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function): + """ When using the 'unfold all' option, some reports might end up recomputing the same query for + each line to unfold, leading to very inefficient computation. This function allows batching this computation, + and returns a dictionary where all results are cached, for use in expansion functions. + """ + return None + + def _get_custom_display_config(self): + """ To be overridden in order to change the templates used by Javascript to render this report (keeping the same + OWL components), and/or replace some of the default OWL components by custom-made ones. + + This function returns a dict (possibly empty, if there is no custom display config): + + { + 'css_custom_class: 'class', + 'components': { + + }, + 'pdf_export': { + + }, + 'templates': { + + }, + }, + """ + return {} + + def _get_custom_groupby_map(self): + """ Allows the use of custom values in the groupby field of account.report.line, to use them in custom engines. Those custom + values can be anything, and need to be properly handled by the custom engine using them. This allows adding support for grouping on + something else than just the fields of account.move.line, which is the default. + + :return: A dict, in the form {groupby_name: {'model': model, 'domain_builder': domain_builder}}, where: + - groupby_name is the custom value to use in groupby instead of one of aml's field names + - model: is a model name (a string), representing the model the value returned for this custom groupby targets. + The model will be used to compute the display_name to show for each generated groupby line, in the UI. + This value can be passed to None ; in such case, the raw value returned by the engine will be shown. + - domain_builder is a function to be called when expanding a groupby line generated by this custom groupby, to compute the + domain to apply in order to restrict the computation to the content of this groupby line. + This function must accept a single parameter, corresponding to the groupby value to compute the domain for. + - label_builder is a function to be called to compute a label for the groupby value, that will be shown as the line name + in the UI. This ways, translatable labels and multi-values keys serialized to json can be fully supported. + """ + return {} + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + """ To be overridden to add report-specific warnings in the warnings dictionary. + When a root report defines something in this function, its variants without any custom handler will also call the root report's + _customize_warnings function. This can hence be used to share warnings between all variants. + + Should only be used when necessary, _dynamic_lines_generator is preferred. + """ + + def _enable_export_buttons_for_common_vat_groups_in_branches(self, options): + """ DEPRECATED: to be removed in master. Buttons are now set to 'branch_allowed' when needed in get_options() """ + pass + + +class AccountReportFileDownloadException(Exception): + def __init__(self, errors, content=None): + super().__init__() + self.errors = errors + self.content = content diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py new file mode 100644 index 0000000..0f55676 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_sales_report.py @@ -0,0 +1,420 @@ +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.tools import SQL + + +class ECSalesReportCustomHandler(models.AbstractModel): + _name = 'account.ec.sales.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'EC Sales Report Custom Handler' + + def _get_custom_display_config(self): + return { + 'components': { + 'AccountReportFilters': 'odex30_account_reports.SalesReportFilters', + }, + } + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + """ + Generate the dynamic lines for the report in a vertical style (one line per tax per partner). + """ + lines = [] + totals_by_column_group = { + column_group_key: { + 'balance': 0.0, + 'goods': 0.0, + 'triangular': 0.0, + 'services': 0.0, + 'vat_number': '', + 'country_code': '', + 'sales_type_code': '', + } + for column_group_key in options['column_groups'] + } + + operation_categories = options['sales_report_taxes'].get('operation_category', {}) + ec_tax_filter_selection = {v.get('id'): v.get('selected') for v in options.get('ec_tax_filter_selection', [])} + for partner, results in self._query_partners(report, options, warnings): + for tax_ec_category in ('goods', 'triangular', 'services'): + if not ec_tax_filter_selection[tax_ec_category]: + # Skip the line if the tax is not selected + continue + partner_values = defaultdict(dict) + country_specific_code = operation_categories.get(tax_ec_category) + has_found_a_line = False + for col_grp_key in options['column_groups']: + partner_sum = results.get(col_grp_key, {}) + partner_values[col_grp_key]['vat_number'] = partner_sum.get('vat_number', 'UNKNOWN') + partner_values[col_grp_key]['country_code'] = partner_sum.get('country_code', 'UNKNOWN') + partner_values[col_grp_key]['sales_type_code'] = [] + partner_values[col_grp_key]['balance'] = partner_sum.get(tax_ec_category, 0.0) + totals_by_column_group[col_grp_key]['balance'] += partner_sum.get(tax_ec_category, 0.0) + for i, operation_id in enumerate(partner_sum.get('tax_element_id', [])): + if operation_id in options['sales_report_taxes'][tax_ec_category]: + has_found_a_line = True + partner_values[col_grp_key]['sales_type_code'] += [ + country_specific_code or + (partner_sum.get('sales_type_code') and partner_sum.get('sales_type_code')[i]) + or None] + partner_values[col_grp_key]['sales_type_code'] = ', '.join(set(partner_values[col_grp_key]['sales_type_code'])) + if has_found_a_line: + lines.append((0, self._get_report_line_partner(report, options, partner, partner_values, markup=tax_ec_category))) + + # Report total line. + if lines: + lines.append((0, self._get_report_line_total(report, options, totals_by_column_group))) + + return lines + + def _caret_options_initializer(self): + """ + Add custom caret option for the report to link to the partner and allow cleaner overrides. + """ + return { + 'ec_sales': [ + {'name': _("View Partner"), 'action': 'caret_option_open_record_form'} + ], + } + + def _custom_options_initializer(self, report, options, previous_options): + """ + Add the invoice lines search domain that is specific to the country. + Typically, the taxes tag_ids relative to the country for the triangular, sale of goods or services + :param dict options: Report options + :param dict previous_options: Previous report options + """ + super()._custom_options_initializer(report, options, previous_options=previous_options) + self._init_core_custom_options(report, options, previous_options) + options.update({ + 'sales_report_taxes': { + 'goods': tuple(self.env['account.tax'].search([ + *self.env['account.tax']._check_company_domain(self.env.company), + ('amount', '=', 0.0), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'sale'), + ]).ids), + 'services': tuple(), + 'triangular': tuple(), + 'use_taxes_instead_of_tags': True, + # We can't use tags as we don't have a country tax report correctly set, 'use_taxes_instead_of_tags' + # should never be used outside this case + } + }) + country_ids = self.env['res.country'].search([ + ('code', 'in', tuple(self._get_ec_country_codes(options))) + ]).ids + other_country_ids = tuple(set(country_ids) - {self.env.company.account_fiscal_country_id.id}) + options.setdefault('forced_domain', []).extend([ + '|', + ('move_id.partner_shipping_id.country_id', 'in', other_country_ids), + '&', + ('move_id.partner_shipping_id', '=', False), + ('partner_id.country_id', 'in', other_country_ids), + ]) + + report._init_options_journals(options, previous_options=previous_options) + + options['enable_export_buttons_for_common_vat_in_branches'] = True + + def _init_core_custom_options(self, report, options, previous_options): + """ + Add the invoice lines search domain that is common to all countries. + :param dict options: Report options + :param dict previous_options: Previous report options + """ + default_tax_filter = [ + {'id': 'goods', 'name': _('Goods'), 'selected': True}, + {'id': 'triangular', 'name': _('Triangular'), 'selected': True}, + {'id': 'services', 'name': _('Services'), 'selected': True}, + ] + + ec_tax_filter_selection = previous_options.get('ec_tax_filter_selection', default_tax_filter) + # In case we have a EC sale list report with more ec_tax_filter_selection the previous options will have extra + # item we just keep the default ones, and we let variant extend the function to add the ones they need + if ec_tax_filter_selection != default_tax_filter: + filtered_ec_tax_filter_selection = [item for item in ec_tax_filter_selection if item['id'] in {item['id'] for item in default_tax_filter}] + options['ec_tax_filter_selection'] = filtered_ec_tax_filter_selection + else: + options['ec_tax_filter_selection'] = ec_tax_filter_selection + + def _get_report_line_partner(self, report, options, partner, partner_values, markup=''): + """ + Convert the partner values to a report line. + :param dict options: Report options + :param recordset partner: the corresponding res.partner record + :param dict partner_values: Dictionary of values for the report line + :return dict: Return a dict with the values for the report line. + """ + column_values = [] + for column in options['columns']: + value = partner_values[column['column_group_key']].get(column['expression_label']) + column_values.append(report._build_column_dict(value, column, options=options)) + + return { + 'id': report._get_generic_line_id('res.partner', partner.id, markup=markup), + 'name': partner is not None and (partner.name or '')[:128] or _('Unknown Partner'), + 'columns': column_values, + 'level': 2, + 'trust': partner.trust if partner else None, + 'caret_options': 'ec_sales', + } + + def _get_report_line_total(self, report, options, totals_by_column_group): + """ + Convert the total values to a report line. + :param dict options: Report options + :param dict totals_by_column_group: Dictionary of values for the total line + :return dict: Return a dict with the values for the report line. + """ + column_values = [] + for column in options['columns']: + col_value = totals_by_column_group[column['column_group_key']].get(column['expression_label']) + col_value = col_value if column['figure_type'] == 'monetary' else '' + + column_values.append(report._build_column_dict(col_value, column, options=options)) + + return { + 'id': report._get_generic_line_id(None, None, markup='total'), + 'name': _('Total'), + 'class': 'total', + 'level': 1, + 'columns': column_values, + } + + def _query_partners(self, report, options, warnings=None): + ''' Execute the queries, perform all the computation, then + returns a lists of tuple (partner, fetched_values) sorted by the table's model _order: + - partner is a res.parter record. + - fetched_values is a dictionary containing: + - sums by operation type: {'goods': float, + 'triangular': float, + 'services': float, + + - tax identifiers: 'tax_element_id': list[int], > the tag_id in almost every case + 'sales_type_code': list[str], + + - partner identifier elements: 'vat_number': str, + 'full_vat_number': str, + 'country_code': str} + + :param options: The report options. + :return: (accounts_values, taxes_results) + ''' + groupby_partners = {} + vat_set = set() + + def assign_sum(row): + """ + Assign corresponding values from the SQL querry row to the groupby_partners dictionary. + If the line balance isn't 0, find the tax tag_id and check in which column/report line the SQL line balance + should be displayed. + + The tricky part is to allow for the report to be displayed in vertical or horizontal format. + In vertical, you have up to 3 lines per partner (one for each operation type). + In horizontal, you have one line with 3 columns per partner (one for each operation type). + + Add then the more straightforward data (vat number, country code, ...) + :param dict row: + """ + if not company_currency.is_zero(row['balance']): + vat = row['vat_number'] or '' + vat_country_code = vat[:2] if vat[:2].isalpha() else None + duplicated_vat = vat and vat in vat_set and row['groupby'] not in groupby_partners + if vat: + vat_set.add(vat) + + groupby_partners.setdefault(row['groupby'], defaultdict(lambda: defaultdict(float))) + groupby_partners_keyed = groupby_partners[row['groupby']][row['column_group_key']] + for key in options['sales_report_taxes']: + # options['sales_report_taxes'][key] could be either a list, set, tuple or boolean, in case of boolean + # the in operator would traceback + if not isinstance(options['sales_report_taxes'][key], bool) and row['tax_element_id'] in options['sales_report_taxes'][key]: + groupby_partners_keyed[key] += row['balance'] + + groupby_partners_keyed.setdefault('tax_element_id', []).append(row['tax_element_id']) + groupby_partners_keyed.setdefault('sales_type_code', []).append(row['sales_type_code']) + + groupby_partners_keyed.setdefault('vat_number', vat if not vat_country_code else vat[2:]) + groupby_partners_keyed.setdefault('full_vat_number', vat) + groupby_partners_keyed.setdefault('country_code', vat_country_code or row.get('country_code')) + + if warnings is not None: + if row['country_code'] not in self._get_ec_country_codes(options): + warnings['odex30_account_reports.sales_report_warning_non_ec_country'] = {'alert_type': 'warning'} + elif not row.get('vat_number'): + warnings['odex30_account_reports.sales_report_warning_missing_vat'] = {'alert_type': 'warning'} + if row.get('same_country') and row['country_code']: + warnings['odex30_account_reports.sales_report_warning_same_country'] = {'alert_type': 'warning'} + if duplicated_vat: + if warnings.get('odex30_account_reports.sales_report_warning_duplicated_vat'): + warnings['odex30_account_reports.sales_report_warning_duplicated_vat']['duplicated_partners_vat'].append(vat) + else: + warnings['odex30_account_reports.sales_report_warning_duplicated_vat'] = {'alert_type': 'warning', 'duplicated_partners_vat': [vat]} + + company_currency = self.env.company.currency_id + + # Execute the queries and dispatch the results. + query = self._get_query_sums(report, options) + self._cr.execute(query) + + dictfetchall = self._cr.dictfetchall() + for res in dictfetchall: + assign_sum(res) + + if groupby_partners: + partners = self.env['res.partner'].with_context(active_test=False).browse(groupby_partners.keys()) + else: + partners = self.env['res.partner'] + + return [(partner, groupby_partners[partner.id]) for partner in partners.sorted()] + + def _get_query_sums(self, report, options) -> SQL: + ''' Construct a query retrieving all the aggregated sums to build the report. It includes: + - sums for all partners. + - sums for the initial balances. + :param options: The report options. + :return: query as SQL object + ''' + queries = [] + # Create the currency table. + allowed_ids = self._get_tag_ids_filtered(options) + + # In the case of the generic report, we don't have a country defined. So no reliable tax report whose + # tag_ids can be used. So we have a fallback to tax_ids. + + if options.get('sales_report_taxes', {}).get('use_taxes_instead_of_tags'): + tax_elem_table = SQL('account_tax') + tax_elem_table_id = SQL('account_tax_id') + aml_rel_table = SQL('account_move_line_account_tax_rel') + tax_elem_table_name = self.env['account.tax']._field_to_sql('account_tax', 'name') + else: + tax_elem_table = SQL('account_account_tag') + tax_elem_table_id = SQL('account_account_tag_id') + aml_rel_table = SQL('account_account_tag_account_move_line_rel') + tax_elem_table_name = self.env['account.account.tag']._field_to_sql('account_account_tag', 'name') + + for column_group_key, column_group_options in report._split_options_per_column_group(options).items(): + query = report._get_report_query(column_group_options, 'strict_range') + if allowed_ids: + query.add_where(SQL('%s.id IN %s', tax_elem_table, tuple(allowed_ids))) + queries.append(SQL( + """ + SELECT + %(column_group_key)s AS column_group_key, + account_move_line.partner_id AS groupby, + res_partner.vat AS vat_number, + res_country.code AS country_code, + -SUM(%(balance_select)s) AS balance, + %(tax_elem_table_name)s AS sales_type_code, + %(tax_elem_table)s.id AS tax_element_id, + (comp_partner.country_id = res_partner.country_id) AS same_country + FROM %(table_references)s + %(currency_table_join)s + JOIN %(aml_rel_table)s ON %(aml_rel_table)s.account_move_line_id = account_move_line.id + JOIN %(tax_elem_table)s ON %(aml_rel_table)s.%(tax_elem_table_id)s = %(tax_elem_table)s.id + JOIN res_partner ON account_move_line.partner_id = res_partner.id + JOIN res_country ON res_partner.country_id = res_country.id + JOIN res_company ON res_company.id = account_move_line.company_id + JOIN res_partner comp_partner ON comp_partner.id = res_company.partner_id + WHERE %(search_condition)s + GROUP BY %(tax_elem_table)s.id, %(tax_elem_table)s.name, account_move_line.partner_id, + res_partner.vat, res_country.code, comp_partner.country_id, res_partner.country_id + """, + column_group_key=column_group_key, + tax_elem_table_name=tax_elem_table_name, + tax_elem_table=tax_elem_table, + table_references=query.from_clause, + balance_select=report._currency_table_apply_rate(SQL("account_move_line.balance")), + currency_table_join=report._currency_table_aml_join(column_group_options), + aml_rel_table=aml_rel_table, + tax_elem_table_id=tax_elem_table_id, + search_condition=query.where_clause, + )) + return SQL(' UNION ALL ').join(queries) + + @api.model + def _get_tag_ids_filtered(self, options): + """ + Helper function to get all the tag_ids concerned by the report for the given options. + :param dict options: Report options + :return tuple: tag_ids untyped after filtering + """ + allowed_taxes = set() + for operation_type in options.get('ec_tax_filter_selection', []): + if operation_type.get('selected'): + allowed_taxes.update(options['sales_report_taxes'][operation_type.get('id')]) + return allowed_taxes + + @api.model + def _get_ec_country_codes(self, options): + """ + Return the list of country codes for the EC countries. + :param dict options: Report options + :return set: List of country codes for a given date (UK case) + """ + rslt = {'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', + 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'XI'} + + # GB left the EU on January 1st 2021. But before this date, it's still to be considered as a EC country + if fields.Date.from_string(options['date']['date_from']) < fields.Date.from_string('2021-01-01'): + rslt.add('GB') + # Monaco is treated as part of France for VAT purposes (but should not be displayed within FR context) + if self.env.company.account_fiscal_country_id.code != 'FR': + rslt.add('MC') + + return rslt + + def get_warning_act_window(self, options, params): + act_window = {'type': 'ir.actions.act_window', 'context': {}} + if params['type'] == 'no_vat': + aml_domains = [ + ('partner_id.vat', '=', None), + ('partner_id.country_id.code', 'in', tuple(self._get_ec_country_codes(options))), + ] + act_window.update({ + 'name': _("Entries with partners with no VAT"), + 'context': {'search_default_group_by_partner': 1, 'expand': 1} + }) + elif params['type'] == 'non_ec_country': + aml_domains = [('partner_id.country_id.code', 'not in', tuple(self._get_ec_country_codes(options)))] + act_window['name'] = _("EC tax on non EC countries") + elif params['type'] == 'duplicated_vat': + return self._get_duplicated_vat_partners(tuple(params['duplicated_partners_vat'])) + else: + aml_domains = [('partner_id.country_id.code', '=', options.get('same_country_warning'))] + act_window['name'] = _("EC tax on same country") + use_taxes_instead_of_tags = options.get('sales_report_taxes', {}).get('use_taxes_instead_of_tags') + tax_or_tag_field = 'tax_ids.id' if use_taxes_instead_of_tags else 'tax_tag_ids.id' + amls = self.env['account.move.line'].search([ + *aml_domains, + *self.env['account.report']._get_options_date_domain(options, 'strict_range'), + (tax_or_tag_field, 'in', tuple(self._get_tag_ids_filtered(options))) + ]) + + if params['model'] == 'move': + act_window.update({ + 'views': [[self.env.ref('account.view_move_tree').id, 'list'], (False, 'form')], + 'res_model': 'account.move', + 'domain': [('id', 'in', amls.move_id.ids)], + }) + else: + act_window.update({ + 'views': [(False, 'list'), (False, 'form')], + 'res_model': 'res.partner', + 'domain': [('id', 'in', amls.move_id.partner_id.ids)], + }) + + return act_window + + def _get_duplicated_vat_partners(self, duplicated_partners_vat): + view_ref = self.env.ref('odex30_account_reports.duplicated_vat_partner_tree_view', raise_if_not_found=False) + return { + 'type': 'ir.actions.act_window', + 'name': _('Partners with duplicated VAT numbers'), + 'context': {'group_by': 'vat', 'expand': 1, 'duplicated_partners_vat': duplicated_partners_vat}, + 'views': [(view_ref and view_ref.id or False, 'list'), (False, 'form')], + 'res_model': 'res.partner', + 'domain': [('vat', 'in', duplicated_partners_vat)], + } diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_tax.py b/dev_odex30_accounting/odex30_account_reports/models/account_tax.py new file mode 100644 index 0000000..b116dc5 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_tax.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, fields, Command, _ +from odoo.exceptions import ValidationError + + +class AccountTaxUnit(models.Model): + _name = "account.tax.unit" + _description = "Tax Unit" + + name = fields.Char(string="Name", required=True) + country_id = fields.Many2one(string="Country", comodel_name='res.country', required=True, help="The country in which this tax unit is used to group your companies' tax reports declaration.") + vat = fields.Char(string="Tax ID", required=True, help="The identifier to be used when submitting a report for this unit.") + company_ids = fields.Many2many(string="Companies", comodel_name='res.company', required=True, help="Members of this unit") + main_company_id = fields.Many2one(string="Main Company", comodel_name='res.company', required=True, help="Main company of this unit; the one actually reporting and paying the taxes.") + fpos_synced = fields.Boolean(string="Fiscal Positions Synchronised", compute='_compute_fiscal_position_completion', help="Technical field indicating whether Fiscal Positions exist for all companies in the unit") + + def create(self, vals_list): + res = super().create(vals_list) + + horizontal_groups = self.env['account.report.horizontal.group'].create([ + { + 'name': tax_unit.name, + 'rule_ids': [ + Command.create({ + 'field_name': 'company_id', + 'domain': f"[('account_tax_unit_ids', 'in', {tax_unit.id})]", + }), + ], + } + for tax_unit in res + ]) + + generic_tax_report = self.env.ref('account.generic_tax_report') + generic_tax_report.horizontal_group_ids |= horizontal_groups + + generic_tax_report_account_tax = self.env.ref('account.generic_tax_report_account_tax') + generic_tax_report_account_tax.horizontal_group_ids |= horizontal_groups + + generic_tax_report_tax_account = self.env.ref('account.generic_tax_report_tax_account') + generic_tax_report_tax_account.horizontal_group_ids |= horizontal_groups + + generic_ec_sales_report = self.env.ref('odex30_account_reports.generic_ec_sales_report') + generic_ec_sales_report.horizontal_group_ids |= horizontal_groups + + for tax_unit in res: + generic_tax_report.variant_report_ids.filtered(lambda variant: variant.country_id == tax_unit.country_id).write( + { + 'horizontal_group_ids': [Command.link(group.id) for group in horizontal_groups], + } + ) + + return res + + @api.depends('company_ids') + def _compute_fiscal_position_completion(self): + for unit in self: + synced = True + for company in unit.company_ids: + origin_company = company._origin if isinstance(company.id, models.NewId) else company + fp = unit._get_tax_unit_fiscal_positions(companies=origin_company) + all_partners_with_fp = self.env['res.company'].search([]).with_company(origin_company).partner_id\ + .filtered(lambda p: p.property_account_position_id == fp) if fp else self.env['res.partner'] + synced = all_partners_with_fp == (unit.company_ids - origin_company).partner_id + if not synced: + break + unit.fpos_synced = synced + + def _get_tax_unit_fiscal_positions(self, companies, create_or_refresh=False): + """ + Retrieves or creates fiscal positions for all companies specified. + Each Fiscal Position contains all the taxes of the company mapped to no tax + + @param {recordset} companies: companies for which to find/create fiscal positions + @param {boolean} create_or_refresh: a boolean indicating whether the fiscal positions should be created if not found + @return {recordset} all the fiscal positions found/created for the companies requested. + """ + fiscal_positions = self.env['account.fiscal.position'].with_context(allowed_company_ids=self.env.user.company_ids.ids) + for unit in self: + for company in companies: + fp_identifier = 'account.tax_unit_%s_fp_%s' % (unit.id, company.id) + existing_fp = self.env.ref(fp_identifier, raise_if_not_found=False) + if create_or_refresh: + taxes_to_map = self.env['account.tax'].with_context( + allowed_company_ids=self.env.user.company_ids.ids, + ).search(self.env['account.tax']._check_company_domain(company)) + data = { + 'xml_id': fp_identifier, + 'values': { + 'name': unit.name, + 'company_id': company.id, + 'tax_ids': [Command.clear()] + [Command.create({'tax_src_id': tax.id}) for tax in taxes_to_map] + } + } + existing_fp = fiscal_positions._load_records([data]) + if existing_fp: + fiscal_positions += existing_fp + return fiscal_positions + + def action_sync_unit_fiscal_positions(self): + self._get_tax_unit_fiscal_positions(companies=self.env['res.company'].search([])).unlink() + for unit in self: + for company in unit.company_ids: + fp = unit._get_tax_unit_fiscal_positions(companies=company, create_or_refresh=True) + (unit.company_ids - company).with_company(company).partner_id.property_account_position_id = fp + + def unlink(self): + # EXTENDS base + self._get_tax_unit_fiscal_positions(companies=self.env['res.company'].search([])).unlink() + return super().unlink() + + @api.constrains('country_id', 'company_ids') + def _validate_companies_country(self): + for record in self: + currencies = set() + for company in record.company_ids: + currencies.add(company.currency_id) + + if any(unit != record and unit.country_id == record.country_id for unit in company.account_tax_unit_ids): + raise ValidationError(_("Company %(company)s already belongs to a tax unit in %(country)s. A company can at most be part of one tax unit per country.", company=company.name, country=record.country_id.name)) + + if len(currencies) > 1: + raise ValidationError(_("A tax unit can only be created between companies sharing the same main currency.")) + + @api.constrains('company_ids', 'main_company_id') + def _validate_main_company(self): + for record in self: + if record.main_company_id not in record.company_ids: + raise ValidationError(_("The main company of a tax unit has to be part of it.")) + + @api.constrains('company_ids') + def _validate_companies(self): + for record in self: + if len(record.company_ids) < 2: + raise ValidationError(_("A tax unit must contain a minimum of two companies. You might want to delete the unit.")) + + @api.constrains('country_id', 'vat') + def _validate_vat(self): + for record in self: + if not record.vat: + continue + + checked_country_code = self.env['res.partner']._run_vat_test(record.vat, record.country_id) + + if checked_country_code and checked_country_code != record.country_id.code.lower(): + raise ValidationError(_("The country detected for this VAT number does not match the one set on this Tax Unit.")) + + if not checked_country_code: + tu_label = _("tax unit [%s]", record.name) + error_message = self.env['res.partner']._build_vat_error_message(record.country_id.code.lower(), record.vat, tu_label) + raise ValidationError(error_message) + + @api.onchange('company_ids') + def _onchange_company_ids(self): + if self.main_company_id not in self.company_ids and self.company_ids: + self.main_company_id = self.company_ids[0]._origin + elif not self.company_ids: + self.main_company_id = False diff --git a/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py b/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py new file mode 100644 index 0000000..5f9bb16 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/account_trial_balance_report.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, _, fields +from odoo.tools import float_compare +from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT + + +class TrialBalanceCustomHandler(models.AbstractModel): + _name = 'account.trial.balance.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Trial Balance Custom Handler' + + def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None): + def _update_column(line, column_key, new_value): + line['columns'][column_key]['no_format'] = new_value + line['columns'][column_key]['is_zero'] = self.env.company.currency_id.is_zero(new_value) + + def _update_balance_columns(line, debit_column_key, credit_column_key, balance_column_key=None): + debit_value = line['columns'][debit_column_key]['no_format'] if debit_column_key is not None else False + credit_value = line['columns'][credit_column_key]['no_format'] if credit_column_key is not None else False + + if debit_value and credit_value: + new_debit_value = 0.0 + new_credit_value = 0.0 + + if self.env.company.currency_id.compare_amounts(debit_value, credit_value) == 1: + new_debit_value = debit_value - credit_value + else: + new_credit_value = (debit_value - credit_value) * -1 + + _update_column(line, debit_column_key, new_debit_value) + _update_column(line, credit_column_key, new_credit_value) + + if balance_column_key is not None: + _update_column(line, balance_column_key, debit_value - credit_value) + + def is_end_balance_column(column): + return options['column_groups'][column['column_group_key']].get('forced_options').get('is_end_balance') + + lines = [line[1] for line in self.env['account.general.ledger.report.handler']._dynamic_lines_generator(report, options, all_column_groups_expression_totals, warnings=warnings)] + + # We need to find the index of debit and credit columns for initial and end balance in case of extra custom columns + init_balance_debit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'debit'), None) + init_balance_credit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'credit'), None) + + end_balance_debit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'debit' and is_end_balance_column(column)), None) + end_balance_credit_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'credit' and is_end_balance_column(column)), None) + end_balance_balance_index = next((index for index, column in enumerate(options['columns']) if column.get('expression_label') == 'balance' and is_end_balance_column(column)), None) + + currency = self.env.company.currency_id + for line in lines[:-1]: + # Initial balance + _update_balance_columns(line, init_balance_debit_index, init_balance_credit_index) + _update_balance_columns(line, end_balance_debit_index, end_balance_credit_index, end_balance_balance_index) + + line.pop('expand_function', None) + line.pop('groupby', None) + line.update({ + 'unfoldable': False, + 'unfolded': False, + }) + + res_model = report._get_model_info_from_id(line['id'])[0] + if res_model == 'account.account': + line['caret_options'] = 'trial_balance' + + # Total line + if lines: + total_line = lines[-1] + + for index in (init_balance_debit_index, init_balance_credit_index, end_balance_debit_index, end_balance_credit_index): + if index is not None: + total_line['columns'][index]['no_format'] = sum(currency.round(line['columns'][index]['no_format']) for line in lines[:-1] if report._get_model_info_from_id(line['id'])[0] == 'account.account') + total_line['columns'][index]['blank_if_zero'] = False + + return [(0, line) for line in lines] + + def _caret_options_initializer(self): + return { + 'trial_balance': [ + {'name': _("General Ledger"), 'action': 'caret_option_open_general_ledger'}, + {'name': _("Journal Items"), 'action': 'open_journal_items'}, + ], + } + + def _get_column_group_creation_data(self, report, options, previous_options=None): + """ + Return tuple of tuples containing a reference to the column_group creation function and on which side ('left' | 'right') of the report the column_group goes + """ + return ( + (self._create_column_group_initial_balance, 'left'), + (self._create_column_group_end_balance, 'right'), + ) + + @api.model + def _create_and_append_column_group(self, report, options, header_name, forced_options, side_to_append, group_vals, exclude_initial_balance=False, append_col_groups=True): + header_element = [{'name': header_name, 'forced_options': forced_options}] + column_headers = [header_element, *options['column_headers'][1:]] + column_group_vals = report._generate_columns_group_vals_recursively(column_headers, group_vals) + + if exclude_initial_balance: + # This column group must not include initial balance; we use a special option key for that in general ledger + for column_group in column_group_vals: + column_group['forced_options']['general_ledger_strict_range'] = True + + columns, column_groups = report._build_columns_from_column_group_vals(forced_options, column_group_vals) + + side_to_append['column_headers'] += header_element + if append_col_groups: + side_to_append['column_groups'] |= column_groups + side_to_append['columns'] += columns + + def _custom_options_initializer(self, report, options, previous_options): + """ Modifies the provided options to add a column group for initial balance and end balance, as well as the appropriate columns. + """ + default_group_vals = {'horizontal_groupby_element': {}, 'forced_options': {}} + left_side = {'column_headers': [], 'column_groups': {}, 'columns': []} + right_side = {'column_headers': [], 'column_groups': {}, 'columns': []} + + # Columns between initial and end balance must not include initial balance; we use a special option key for that in general ledger + for column_group in options['column_groups'].values(): + column_group['forced_options']['general_ledger_strict_range'] = True + + if options.get('comparison') and not options['comparison'].get('periods'): + options['comparison']['period_order'] = 'ascending' + + # Create column groups + for function, side in self._get_column_group_creation_data(report, options, previous_options): + function(report, options, previous_options, default_group_vals, left_side if side == 'left' else right_side) + + # Update options + options['column_headers'][0] = left_side['column_headers'] + options['column_headers'][0] + right_side['column_headers'] + options['column_groups'].update(left_side['column_groups']) + options['column_groups'].update(right_side['column_groups']) + options['columns'] = left_side['columns'] + options['columns'] + right_side['columns'] + options['ignore_totals_below_sections'] = True # So that GL does not compute them + + # All the periods displayed between initial and end balance need to use the same rates, so we manually change the period key. + # account.report will then compute the currency table periods accordingly + middle_periods_period_key = '_trial_balance_middle_periods' + for col_group in options['column_groups'].values(): + col_group_date = col_group['forced_options'].get('date') + if col_group_date: + col_group_date['currency_table_period_key'] = middle_periods_period_key + + report._init_options_order_column(options, previous_options) + + def _custom_line_postprocessor(self, report, options, lines): + # If the hierarchy is enabled, ensure to add the o_account_coa_column_contrast class to the hierarchy lines + if options.get('hierarchy'): + for line in lines: + model, dummy = report._get_model_info_from_id(line['id']) + if model == 'account.group': + line_classes = line.get('class', '') + line['class'] = line_classes + ' o_account_coa_column_contrast_hierarchy' + + return lines + + def _create_column_group_initial_balance(self, report, options, previous_options, default_group_vals, side_to_append): + initial_balance_options = self.env['account.general.ledger.report.handler']._get_options_initial_balance(options) + initial_forced_options = { + 'date': initial_balance_options['date'], + 'include_current_year_in_unaff_earnings': initial_balance_options['include_current_year_in_unaff_earnings'], + 'no_impact_on_currency_table': True, + } + + self._create_and_append_column_group( + report, + options, + _("Initial Balance"), + initial_forced_options, + side_to_append, + default_group_vals, + ) + + def _create_column_group_end_balance(self, report, options, previous_options, default_group_vals, side_to_append): + end_date_to = options['date']['date_to'] + end_date_from = options['date']['date_from'] + end_forced_options = { + 'date': report._get_dates_period( + fields.Date.from_string(end_date_from), + fields.Date.from_string(end_date_to), + 'range', + ), + 'is_end_balance': True, + } + + self._create_and_append_column_group( + report, + options, + _("End Balance"), + end_forced_options, + side_to_append, + default_group_vals, + ) diff --git a/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py b/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py new file mode 100644 index 0000000..189098a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/balance_sheet.py @@ -0,0 +1,11 @@ +from odoo import models + + +class BalanceSheetCustomHandler(models.AbstractModel): + _name = 'account.balance.sheet.report.handler' + _inherit = 'account.report.custom.handler' + _description = "Balance Sheet Custom Handler" + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + if options['currency_table']['type'] == 'cta': + warnings['odex30_account_reports.common_possibly_unbalanced_because_cta'] = {} diff --git a/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py b/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py new file mode 100644 index 0000000..e2ad93d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/bank_reconciliation_report.py @@ -0,0 +1,620 @@ +from datetime import date +import logging +from odoo import models, fields, _ +from odoo.exceptions import UserError +from odoo.tools import SQL +from odoo.osv import expression + +_logger = logging.getLogger(__name__) + + +class BankReconciliationReportCustomHandler(models.AbstractModel): + _name = 'account.bank.reconciliation.report.handler' + _inherit = 'account.report.custom.handler' + _description = 'Bank Reconciliation Report Custom Handler' + + ###################### + # Options + ###################### + def _custom_options_initializer(self, report, options, previous_options): + super()._custom_options_initializer(report, options, previous_options=previous_options) + + # Options is needed otherwise some elements added in the post processor go on the total line + options['ignore_totals_below_sections'] = True + options['no_xlsx_currency_code_columns'] = True + if 'active_id' in self._context and self._context.get('active_model') == 'account.journal': + options['bank_reconciliation_report_journal_id'] = self._context['active_id'] + elif 'bank_reconciliation_report_journal_id' in previous_options: + options['bank_reconciliation_report_journal_id'] = previous_options['bank_reconciliation_report_journal_id'] + else: + # This should never happen except in some test cases + options['bank_reconciliation_report_journal_id'] = self.env['account.journal'].search([('type', '=', 'bank')], limit=1).id + + # Remove multi-currency columns if needed + is_multi_currency = self.env.user.has_group('base.group_multi_currency') and self.env.user.has_group('base.group_no_one') + if not is_multi_currency: + options['columns'] = [ + column for column in options['columns'] + if column['expression_label'] not in ('amount_currency', 'currency') + ] + + ###################### + # Getter + ###################### + def _get_bank_journal_and_currencies(self, options): + journal = self.env['account.journal'].browse(options.get('bank_reconciliation_report_journal_id')) + company_currency = journal.company_id.currency_id + journal_currency = journal.currency_id or company_currency + return journal, journal_currency, company_currency + + ###################### + # Return function + ###################### + def _build_custom_engine_result(self, date=None, label=None, amount_currency=None, amount_currency_currency_id=None, currency=None, amount=0, amount_currency_id=None, has_sublines=False): + return { + 'date': date, + 'label': label, + 'amount_currency': amount_currency, + 'amount_currency_currency_id': amount_currency_currency_id, + 'currency': currency, + 'amount': amount, + 'amount_currency_id': amount_currency_id, + 'has_sublines': has_sublines, + } + + ###################### + # Engine + ###################### + def _report_custom_engine_forced_currency_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + _journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + return self._build_custom_engine_result(amount_currency_id=journal_currency.id) + + def _report_custom_engine_unreconciled_last_statement_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_common(options, 'receipts', current_groupby, True) + + def _report_custom_engine_unreconciled_last_statement_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_common(options, 'payments', current_groupby, True) + + def _report_custom_engine_unreconciled_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_common(options, 'receipts', current_groupby, False) + + def _report_custom_engine_unreconciled_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_common(options, 'payments', current_groupby, False) + + def _report_custom_engine_outstanding_receipts(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_outstanding_common(options, 'receipts', current_groupby) + + def _report_custom_engine_outstanding_payments(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_outstanding_common(options, 'payments', current_groupby) + + def _report_custom_engine_misc_operations(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields([current_groupby] if current_groupby else []) + + journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + exchange_journal = journal.company_id.currency_exchange_journal_id + + bank_miscellaneous_domain = self._get_bank_miscellaneous_move_lines_domain(options, journal) + bank_miscellaneous_domain = expression.AND([ + bank_miscellaneous_domain, + [('journal_id', '!=', exchange_journal.id)] + ]) + + base_query = report._get_report_query(options, 'strict_range', domain=bank_miscellaneous_domain or []) + + groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, base_query) if current_groupby else None + query_sql = SQL( + """ + SELECT + %(select_from_groupby)s, + COALESCE(SUM(COALESCE(NULLIF(account_move_line.amount_currency, 0), account_move_line.balance)), 0) + FROM %(table_references)s + WHERE %(search_condition)s + %(groupby_sql)s + """, + select_from_groupby=groupby_field_sql, + table_references=base_query.from_clause, + search_condition=base_query.where_clause, + groupby_sql=SQL("GROUP BY %s", groupby_field_sql) if groupby_field_sql else SQL(), + ) + + self._cr.execute(query_sql) + query_res_lines = self._cr.fetchall() + + if not current_groupby: + return self._build_custom_engine_result(amount=query_res_lines[-1][1], amount_currency_id=journal_currency.id) + else: + return [ + (grouping_key, self._build_custom_engine_result(amount=amount, amount_currency_id=journal_currency.id)) + for grouping_key, amount in query_res_lines + ] + + def _report_custom_engine_last_statement_balance_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + if current_groupby: + raise UserError(_("Custom engine _report_custom_engine_last_statement_balance_amount does not support groupby")) + + journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + last_statement = self._get_last_bank_statement(journal, options) + + return self._build_custom_engine_result(amount=last_statement.balance_end_real, amount_currency_id=journal_currency.id) + + def _report_custom_engine_transaction_without_statement_amount(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + return self._bank_reconciliation_report_custom_engine_common(options, 'all', current_groupby, False, unreconciled=False) + + def _bank_reconciliation_report_custom_engine_common(self, options, internal_type, current_groupby, from_last_statement, unreconciled=True): + """ + Retrieve entries for bank reconciliation based on specified parameters. + Parameters: + - options (dict): A dictionary containing options of the report. + - internal_type (str): The internal type used for classification (e.g., receipt, payment). For the receipt + we will query the entries with a positive amounts and for the payment + the negative amounts. + If the internal type is another thing that receipt or payment it will get all the + entries position or negative + - current_groupby (str): The current grouping criteria. + - last_statement (bool, optional): If True, query entries from the last bank statement. + Otherwise, query entries that are not part of the last bank + statement. + - unreconciled (bool, optional): If True, query the unreconciled entries only + + """ + journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + if not journal: + return self._build_custom_engine_result() + + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields([current_groupby] if current_groupby else []) + + def build_result_dict(query_res_lines): + # The query should find exactly one account move line per bank statement line + if current_groupby == 'id': + res = query_res_lines[0] + foreign_currency = self.env['res.currency'].browse(res['foreign_currency_id']) + rate = 1 # journal_currency / foreign_currency + if foreign_currency: + rate = (res['amount'] / res['amount_currency']) if res['amount_currency'] else 0 + + return self._build_custom_engine_result( + date=res['date'] if res['date'] else None, + label=res['payment_ref'] or res['ref'] or '/', + amount_currency=-res['amount_residual'] if res['foreign_currency_id'] else None, + amount_currency_currency_id=foreign_currency.id if res['foreign_currency_id'] else None, + currency=foreign_currency.display_name if res['foreign_currency_id'] else None, + amount=-res['amount_residual'] * rate if res['amount_residual'] else None, + amount_currency_id=journal_currency.id, + ) + else: + amount = 0 + for res in query_res_lines: + rate = 1 # journal_currency / foreign_currency + if res['foreign_currency_id']: + rate = (res['amount'] / res['amount_currency']) if res['amount_currency'] else 0 + amount += -res.get('amount_residual', 0) * rate if unreconciled else res.get('amount', 0) + + return self._build_custom_engine_result( + amount=amount, + amount_currency_id=journal_currency.id, + has_sublines=bool(len(query_res_lines)), + ) + + query = report._get_report_query(options, 'strict_range', domain=[ + ('journal_id', '=', journal.id), + ('account_id', '=', journal.default_account_id.id), # There should be only 1 line per move with that account + ]) + + if from_last_statement: + last_statement_id = self._get_last_bank_statement(journal, options).id + if last_statement_id: + last_statement_id_condition = SQL("st_line.statement_id = %s", last_statement_id) + else: + # If there is no last statement, the last statement section must be empty and the other must have all + # transaction + return self._compute_result([], current_groupby, build_result_dict) + else: + last_statement_id_condition = SQL("st_line.statement_id IS NULL") + + if internal_type == 'receipts': + st_line_amount_condition = SQL("AND st_line.amount > 0") + elif internal_type == 'payments': + st_line_amount_condition = SQL("AND st_line.amount < 0") + else: + # For the Transaction without statement, the internal type is 'all' + st_line_amount_condition = SQL("") + + groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) if current_groupby else SQL('NULL') + # Build query + query = SQL( + """ + SELECT %(select_from_groupby)s, + st_line.id, + move.name, + move.ref, + move.date, + st_line.payment_ref, + st_line.amount, + st_line.amount_residual, + st_line.amount_currency, + st_line.foreign_currency_id + FROM %(table_references)s + JOIN account_bank_statement_line st_line ON st_line.move_id = account_move_line.move_id + JOIN account_move move ON move.id = st_line.move_id + WHERE %(search_condition)s + %(is_unreconciled)s + %(st_line_amount_condition)s + AND %(last_statement_id_condition)s + GROUP BY %(group_by)s, + st_line.id, + move.id + """, + select_from_groupby=SQL("%s AS grouping_key", groupby_field_sql), + table_references=query.from_clause, + search_condition=query.where_clause, + is_receipt=SQL("st_line.amount > 0") if internal_type == "receipts" else SQL("st_line.amount < 0"), + is_unreconciled=SQL("AND NOT st_line.is_reconciled") if unreconciled else SQL(""), + st_line_amount_condition=st_line_amount_condition, + last_statement_id_condition=last_statement_id_condition, + group_by=groupby_field_sql if current_groupby else SQL('st_line.id'), # Same key in the groupby because we can't put a null key in a group by + ) + + self._cr.execute(query) + query_res_lines = self._cr.dictfetchall() + + return self._compute_result(query_res_lines, current_groupby, build_result_dict) + + def _bank_reconciliation_report_custom_engine_outstanding_common(self, options, internal_type, current_groupby): + """ + This engine retrieves the data of all recorded payments/receipts that have not been matched with a bank + statement yet + """ + journal, journal_currency, company_currency = self._get_bank_journal_and_currencies(options) + if not journal: + return self._build_custom_engine_result() + + report = self.env['account.report'].browse(options['report_id']) + report._check_groupby_fields([current_groupby] if current_groupby else []) + + def build_result_dict(query_res_lines): + if current_groupby == 'id': + res = query_res_lines[0] + convert = not (journal_currency and res['currency_id'] == journal_currency.id) + amount_currency = res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency'] + balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance'] + foreign_currency = self.env['res.currency'].browse(res['currency_id']) + + return self._build_custom_engine_result( + date=res['date'] if res['date'] else None, + label=res['ref'] if res['ref'] else None, + amount_currency=amount_currency if convert else None, + amount_currency_currency_id=foreign_currency.id if convert else None, + currency=foreign_currency.display_name if convert else None, + amount=company_currency._convert(balance, journal_currency, journal.company_id, options['date']['date_to']) if convert else amount_currency, + amount_currency_id=journal_currency.id, + ) + else: + amount = 0 + for res in query_res_lines: + convert = not (journal_currency and res['currency_id'] == journal_currency.id) + if convert: + balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance'] + amount += company_currency._convert(balance, journal_currency, journal.company_id, options['date']['date_to']) + else: + amount += res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency'] + + return self._build_custom_engine_result( + amount=amount, + amount_currency_id=journal_currency.id, + has_sublines=bool(len(query_res_lines)), + ) + + accounts = journal._get_journal_inbound_outstanding_payment_accounts() + journal._get_journal_outbound_outstanding_payment_accounts() + + query = report._get_report_query(options, 'from_beginning', domain=[ + ('journal_id', '=', journal.id), + ('account_id', 'in', accounts.ids), + ('full_reconcile_id', '=', False), + ('amount_residual_currency', '!=', 0.0) + ]) + + # Build query + groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query) if current_groupby else SQL('NULL') + query = SQL( + """ + SELECT %(select_from_groupby)s, + account_move_line.account_id, + account_move_line.payment_id, + account_move_line.move_id, + account_move_line.currency_id, + account_move_line.move_name AS name, + account_move_line.ref, + account_move_line.date, + account.reconcile AS is_account_reconcile, + SUM(account_move_line.amount_residual) AS amount_residual, + SUM(account_move_line.balance) AS balance, + SUM(account_move_line.amount_residual_currency) AS amount_residual_currency, + SUM(account_move_line.amount_currency) AS amount_currency + FROM %(table_references)s + JOIN account_account account ON account.id = account_move_line.account_id + WHERE %(search_condition)s + AND %(is_receipt)s + GROUP BY %(group_by)s, + account_move_line.account_id, + account_move_line.payment_id, + account_move_line.move_id, + account_move_line.currency_id, + account_move_line.move_name, + account_move_line.ref, + account_move_line.date, + account.reconcile + """, + select_from_groupby=SQL("%s AS grouping_key", groupby_field_sql), + table_references=query.from_clause, + search_condition=query.where_clause, + is_receipt=SQL("account_move_line.balance > 0") if internal_type == "receipts" else SQL("account_move_line.balance < 0"), + group_by=groupby_field_sql if current_groupby else SQL('account_move_line.account_id'), # Same key in the groupby because we can't put a null key in a group by + ) + self._cr.execute(query) + query_res_lines = self._cr.dictfetchall() + + return self._compute_result(query_res_lines, current_groupby, build_result_dict) + + def _compute_result(self, query_res_lines, current_groupby, build_result_dict): + if not current_groupby: + return build_result_dict(query_res_lines) + else: + rslt = [] + + all_res_per_grouping_key = {} + for query_res in query_res_lines: + grouping_key = query_res['grouping_key'] + all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res) + + for grouping_key, query_res_lines in all_res_per_grouping_key.items(): + rslt.append((grouping_key, build_result_dict(query_res_lines))) + + return rslt + + def _custom_line_postprocessor(self, report, options, lines): + lines = super()._custom_line_postprocessor(report, options, lines) + journal, _journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + if not journal: + return lines + + last_statement = self._get_last_bank_statement(journal, options) + + for line in lines: + line_id = report._get_res_id_from_line_id(line['id'], 'account.report.line') + code = self.env['account.report.line'].browse(line_id).code + + if code == "balance_bank": + line['name'] = _("Balance of '%s'", journal.default_account_id.display_name) + + if code == "last_statement_balance": + line['class'] = 'o_bold_tr' + if last_statement: + line['columns'][1].update({ + 'name': last_statement.display_name, + 'auditable': True, + }) + + if code == "transaction_without_statement": + line['class'] = 'o_bold_tr' + + if code == "misc_operations": + line['class'] = 'o_bold_tr' + + # Check if it's a leaf node + model, _model_id = report._get_model_info_from_id(line['id']) + if model == "account.move.line": + line_name = line['name'].split() + line['name'] = line_name[0] # This will give just the name without the ref or label + + return lines + + def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings): + journal, journal_currency, _company_currency = self._get_bank_journal_and_currencies(options) + inconsistent_statement = self._get_inconsistent_statements(options, journal).ids + bank_miscellaneous_domain = self._get_bank_miscellaneous_move_lines_domain(options, journal) + has_bank_miscellaneous_move_lines = bank_miscellaneous_domain and bool(self.env['account.move.line'].search_count(bank_miscellaneous_domain, limit=1)) + last_statement, balance_gl, balance_end, unexplained_difference, general_ledger_not_matching = self._compute_journal_balances(report, options, journal, journal_currency) + + if warnings is not None: + if last_statement and general_ledger_not_matching: + warnings['odex30_account_reports.journal_balance'] = { + 'alert_type': 'warning', + 'general_ledger_amount': balance_gl, + 'last_bank_statement_amount': balance_end, + 'unexplained_difference': unexplained_difference, + } + if inconsistent_statement: + warnings['odex30_account_reports.inconsistent_statement_warning'] = {'alert_type': 'warning', 'args': inconsistent_statement} + if has_bank_miscellaneous_move_lines: + warnings['odex30_account_reports.has_bank_miscellaneous_move_lines'] = {'alert_type': 'warning', 'args': journal.default_account_id.display_name} + + def _compute_journal_balances(self, report, options, journal, journal_currency): + """ + This function compute all necessary information for the warning 'odex30_account_reports.journal_balance' + :param report: The bank reconciliation report. + :param options: The report options. + :param journal: The journal used. + """ + # Get domain and balances + domain = report._get_options_domain(options, 'from_beginning') + balance_gl = journal._get_journal_bank_account_balance(domain=domain)[0] + last_statement, balance_end, difference, general_ledger_not_matching = self._compute_balances(options, journal, balance_gl, journal_currency) + + # Format values + balance_gl = report.format_value(options, balance_gl, format_params={'currency_id': journal_currency.id}, figure_type='monetary') + balance_end = report.format_value(options, balance_end, format_params={'currency_id': journal_currency.id}, figure_type='monetary') + difference = report.format_value(options, difference, format_params={'currency_id': journal_currency.id}, figure_type='monetary') + + return last_statement, balance_gl, balance_end, difference, general_ledger_not_matching + + def _compute_balances(self, options, journal, balance_gl, report_currency): + """ + This function will compute the balance of the last statement and the unexplained difference. + :param options: The report options. + :param journal: The journal used. + :param balance_gl: The balance of the general ledger. + :param report_currency: The currency of the report. + """ + report_date = fields.Date.from_string(options['date']['date_to']) + last_statement = self._get_last_bank_statement(journal, options) + balance_end = 0 + difference = 0 + general_ledger_not_matching = False + + if last_statement: + lines_before_date_to = last_statement.line_ids.filtered(lambda line: line.date <= report_date) + balance_end = last_statement.balance_start + sum(lines_before_date_to.mapped('amount')) + difference = balance_gl - balance_end + general_ledger_not_matching = not report_currency.is_zero(difference) + + return last_statement, balance_end, difference, general_ledger_not_matching + + def _get_last_bank_statement(self, journal, options): + """ + Retrieve the last bank statement created using this journal. + :param journal: The journal used. + :param domain: An additional domain to be applied on the account.bank.statement model. + :return: An account.bank.statement record or an empty recordset. + """ + report_date = fields.Date.from_string(options['date']['date_to']) + last_statement_domain = [('journal_id', '=', journal.id), ('statement_id', '!=', False), ('date', '<=', report_date)] + last_st_line = self.env['account.bank.statement.line'].search(last_statement_domain, order='date desc, id desc', limit=1) + return last_st_line.statement_id + + def _get_inconsistent_statements(self, options, journal): + """ + Retrieve the account.bank.statements records on the range of the options date having different starting + balance regarding its previous statement. + :param options: The report options. + :param journal: The account.journal from which this report has been opened. + :return: An account.bank.statements recordset. + """ + return self.env['account.bank.statement'].search([ + ('journal_id', '=', journal.id), + ('date', '<=', options['date']['date_to']), + ('is_valid', '=', False), + ]) + + def _get_bank_miscellaneous_move_lines_domain(self, options, journal): + """ + Get the domain to be used to retrieve the journal items affecting the bank accounts but not linked to + a statement line. (Limited in a year) + :param options: The report options. + :param journal: The account.journal from which this report has been opened. + :return: A domain to search on the account.move.line model. + + """ + if not journal.default_account_id: + return None + + report = self.env['account.report'].browse(options['report_id']) + domain = [ + ('account_id', '=', journal.default_account_id.id), + ('statement_line_id', '=', False), + *report._get_options_domain(options, 'from_beginning'), + ] + + fiscal_lock_date = journal.company_id._get_user_fiscal_lock_date(journal) + if fiscal_lock_date != date.min: + domain.append(('date', '>', fiscal_lock_date)) + + if journal.company_id.account_opening_move_id: + domain.append(('move_id', '!=', journal.company_id.account_opening_move_id.id)) + + return domain + + ################ + # Audit + ################ + def action_audit_cell(self, options, params): + report_line = self.env['account.report.line'].browse(params['report_line_id']) + if report_line.code == "balance_bank": + return self.action_redirect_to_general_ledger(options) + elif report_line.code == "misc_operations": + return self.open_bank_miscellaneous_move_lines(options) + elif report_line.code == "last_statement_balance": + return self.action_redirect_to_bank_statement_widget(options) + else: + return report_line.report_id.action_audit_cell(options, params) + + ################ + # ACTIONS + ################ + def action_redirect_to_general_ledger(self, options): + """ + Action to redirect to the general ledger + :param options: The report options. + :return: Actions to the report + """ + general_ledger_action = self.env['ir.actions.actions']._for_xml_id('odex30_account_reports.action_account_report_general_ledger') + general_ledger_action['params'] = { + 'options': options, + 'ignore_session': True, + } + + return general_ledger_action + + def action_redirect_to_bank_statement_widget(self, options): + """ + Redirect the user to the requested bank statement, if empty displays all bank transactions of the journal. + :param options: The report options. + :param params: The action params containing at least 'statement_id', can be false. + :return: A dictionary representing an ir.actions.act_window. + """ + journal = self.env['account.journal'].browse(options.get('bank_reconciliation_report_journal_id')) + last_statement = self._get_last_bank_statement(journal, options) + return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( + default_context={'create': False, 'search_default_statement_id': last_statement.id}, + name=last_statement.display_name, + ) + + def open_bank_miscellaneous_move_lines(self, options): + """ + An action opening the account.move.line list view affecting the bank account balance but not linked to + a bank statement line. + :param options: The report options. + :param params: -Not used-. + :return: An action redirecting to the list view of journal items. + """ + journal = self.env['account.journal'].browse(options['bank_reconciliation_report_journal_id']) + + return { + 'name': _('Journal Items'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move.line', + 'view_type': 'list', + 'view_mode': 'list', + 'target': 'current', + 'views': [(self.env.ref('account.view_move_line_tree').id, 'list')], + 'domain': self.env['account.bank.reconciliation.report.handler']._get_bank_miscellaneous_move_lines_domain(options, journal), + } + + def bank_reconciliation_report_open_inconsistent_statements(self, options, params=None): + """ + An action opening the account.bank.statement view (form or list) depending the 'inconsistent_statement_ids' + key set on the options. + :param options: The report options. + :param params: -Not used-. + :return: An action redirecting to a view of statements. + """ + inconsistent_statement_ids = params['args'] + action = { + 'name': _("Inconsistent Statements"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement', + } + if len(inconsistent_statement_ids) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': inconsistent_statement_ids[0], + 'views': [(False, 'form')], + }) + else: + action.update({ + 'view_mode': 'list', + 'domain': [('id', 'in', inconsistent_statement_ids)], + 'views': [(False, 'list')], + }) + return action diff --git a/dev_odex30_accounting/odex30_account_reports/models/budget.py b/dev_odex30_accounting/odex30_account_reports/models/budget.py new file mode 100644 index 0000000..ddf15a8 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/budget.py @@ -0,0 +1,116 @@ +from itertools import zip_longest +from dateutil.relativedelta import relativedelta + +from odoo import api, Command, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools import date_utils, float_is_zero, float_round + + +class AccountReportBudget(models.Model): + _name = 'account.report.budget' + _description = "Accounting Report Budget" + _order = 'sequence, id' + + sequence = fields.Integer(string="Sequence") + name = fields.Char(string="Name", required=True) + item_ids = fields.One2many(string="Items", comodel_name='account.report.budget.item', inverse_name='budget_id') + company_id = fields.Many2one(string="Company", comodel_name='res.company', required=True, default=lambda x: x.env.company) + + @api.constrains('name') + def _contrains_name(self): + for budget in self: + if not budget.name: + raise ValidationError(_("Please enter a valid budget name.")) + + @api.model_create_multi + def create(self, create_values): + for values in create_values: + if name := values.get('name'): + values['name'] = name.strip() + return super().create(create_values) + + def _create_or_update_budget_items(self, value_to_set, account_id, rounding, date_from, date_to): + """ This method will create / update several budget items following the number + of months between date_from(include) and date_to(include). + + :param value_to_set: The value written by the user in the report cell. + :param account_id: The related account id. + :param rounding: The rounding for the decimal precision. + :param date_from: The start date for the budget item creation. + :param date_to: The end date for the budget item creation. + """ + self.ensure_one() + + date_from, date_to = fields.Date.to_date(date_from), fields.Date.to_date(date_to) + if date_from != date_utils.start_of(date_from, 'month'): + date_from = (date_from.replace(day=1) + relativedelta(months=1)) + existing_budget_items = self.env['account.report.budget.item'].search_fetch([ + ('budget_id', '=', self.id), + ('account_id', '=', account_id), + ('date', '<=', date_to), + ('date', '>=', date_from), + ], ['id', 'amount']) + existing_budget_items_by_date = {item.date: item for item in existing_budget_items} + total_amount = sum(existing_budget_items.mapped('amount')) + + value_to_compute = value_to_set - total_amount + if float_is_zero(value_to_compute, precision_digits=rounding): + # In case the computed amount equals 0, we do an early return as + # it's not necessary to create new budget item + return + + start_month_dates = [ + date_utils.start_of(date, 'month') + for date in date_utils.date_range(date_from, date_to) + ] + + # Fill a list with the same amounts for each month + amounts = [float_round(value_to_compute / len(start_month_dates), precision_digits=rounding, rounding_method='DOWN')] * len(start_month_dates) + # Add the remainder in the last amount + amounts[-1] += float_round(value_to_compute - sum(amounts), precision_digits=rounding) + + budget_items_commands = [] + for start_month_date, amount in zip_longest(start_month_dates, amounts): + existing_budget_item = existing_budget_items_by_date.get(start_month_date) + if existing_budget_item: + budget_items_commands.append(Command.update(existing_budget_item.id, { + 'amount': existing_budget_item.amount + amount, + })) + else: + budget_items_commands.append(Command.create({ + 'account_id': account_id, + 'amount': amount, + 'date': start_month_date, + })) + + if budget_items_commands: + self.item_ids = budget_items_commands + # Make sure that the model is flushed before continuing the code and fetching these new items + self.env['account.report.budget.item'].flush_model() + + def copy_data(self, default=None): + vals_list = super().copy_data(default=default) + return [dict(vals, name=self.env._("%s (copy)", budget.name)) for budget, vals in zip(self, vals_list)] + + def copy(self, default=None): + new_budgets = super().copy(default) + for old_budget, new_budget in zip(self, new_budgets): + for item in old_budget.item_ids: + item.copy({ + 'budget_id': new_budget.id, + 'account_id': item.account_id.id, + 'amount': item.amount, + 'date': item.date, + }) + + return new_budgets + + +class AccountReportBudgetItem(models.Model): + _name = 'account.report.budget.item' + _description = "Accounting Report Budget Item" + + budget_id = fields.Many2one(string="Budget", comodel_name='account.report.budget', required=True, ondelete='cascade') + account_id = fields.Many2one(string="Account", comodel_name='account.account', required=True) + amount = fields.Float(string="Amount", default=0) + date = fields.Date(required=True) diff --git a/dev_odex30_accounting/odex30_account_reports/models/chart_template.py b/dev_odex30_accounting/odex30_account_reports/models/chart_template.py new file mode 100644 index 0000000..0a9728b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/chart_template.py @@ -0,0 +1,39 @@ +# coding: utf-8 +from odoo import fields, models, _ +from odoo.exceptions import ValidationError + + +class AccountChartTemplate(models.AbstractModel): + _inherit = 'account.chart.template' + + def _post_load_data(self, template_code, company, template_data): + super()._post_load_data(template_code, company, template_data) + + company = company or self.env.company + default_misc_journal = self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(company), + ('type', '=', 'general') + ], limit=1) + if not default_misc_journal: + raise ValidationError(_("No default miscellaneous journal could be found for the active company")) + + company.update({ + 'totals_below_sections': company.anglo_saxon_accounting, + 'account_tax_periodicity_journal_id': default_misc_journal, + 'account_tax_periodicity_reminder_day': 7, + }) + default_misc_journal.show_on_dashboard = True + + generic_tax_report = self.env.ref('account.generic_tax_report') + tax_report = self.env['account.report'].search([ + ('availability_condition', '=', 'country'), + ('country_id', '=', company.country_id.id), + ('root_report_id', '=', generic_tax_report.id), + ], limit=1) + if not tax_report: + tax_report = generic_tax_report + + _dummy, period_end = company._get_tax_closing_period_boundaries(fields.Date.today(), tax_report) + activity = company._get_tax_closing_reminder_activity(tax_report.id, period_end) + if not activity: + company._generate_tax_closing_reminder_activity(tax_report, period_end) diff --git a/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py b/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py new file mode 100644 index 0000000..dd409a4 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/executive_summary_report.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models +from odoo.exceptions import UserError + +class ExecutiveSummaryReport(models.Model): + _inherit = 'account.report' + + def _report_custom_engine_executive_summary_ndays(self, expressions, options, date_scope, current_groupby, next_groupby, offset=0, limit=None, warnings=None): + if current_groupby or next_groupby: + raise UserError("NDays expressions of executive summary report don't support the 'group by' feature.") + + date_diff = fields.Date.from_string(options['date']['date_to']) - fields.Date.from_string(options['date']['date_from']) + return {'result': date_diff.days} diff --git a/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py b/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py new file mode 100644 index 0000000..cfe18b9 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/ir_actions.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +from odoo import models + +class IrActionsAccountReportDownload(models.AbstractModel): + + _name = 'ir_actions_account_report_download' + _description = 'Technical model for accounting report downloads' + + def _get_readable_fields(self): + + return self.env['ir.actions.actions']._get_readable_fields() | {'data'} diff --git a/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py b/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py new file mode 100644 index 0000000..8ca2a7f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/mail_activity.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models, _ + + +class AccountTaxReportActivity(models.Model): + _inherit = "mail.activity" + + account_tax_closing_params = fields.Json(string="Tax closing additional params") + + def action_open_tax_activity(self): + self.ensure_one() + if self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_pay'): + move = self.env['account.move'].browse(self.res_id) + return move._action_tax_to_pay_wizard() + elif self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_to_be_sent'): + move = self.env['account.move'].browse(self.res_id) + return move._action_tax_to_send() + + if self.activity_type_id == self.env.ref('odex30_account_reports.mail_activity_type_tax_report_error'): + move = self.env['account.move'].browse(self.res_id) + return move._action_tax_report_error() + + journal = self.env['account.journal'].browse(self.res_id) + options = {} + if self.account_tax_closing_params: + options = self.env['account.move']._get_tax_closing_report_options( + journal.company_id, + self.env['account.fiscal.position'].browse(self.account_tax_closing_params['fpos_id']) if self.account_tax_closing_params['fpos_id'] else False, + self.env['account.report'].browse(self.account_tax_closing_params['report_id']), + fields.Date.from_string(self.account_tax_closing_params['tax_closing_end_date']) + ) + action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_gt") + action.update({'params': {'options': options, 'ignore_session': True}}) + return action diff --git a/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py b/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py new file mode 100644 index 0000000..d02c57e --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/mail_activity_type.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class AccountTaxReportActivityType(models.Model): + _inherit = "mail.activity.type" + + category = fields.Selection(selection_add=[('tax_report', 'Tax report')]) + + @api.model + def _get_model_info_by_xmlid(self): + info = super()._get_model_info_by_xmlid() + info['odex30_account_reports.tax_closing_activity_type'] = { + 'res_model': 'account.journal', + 'unlink': False, + } + info['odex30_account_reports.mail_activity_type_tax_report_to_pay'] = { + 'res_model': 'account.move', + 'unlink': False, + } + info['odex30_account_reports.mail_activity_type_tax_report_to_be_sent'] = { + 'res_model': 'account.move', + 'unlink': False, + } + return info diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_company.py b/dev_odex30_accounting/odex30_account_reports/models/res_company.py new file mode 100644 index 0000000..fd3dd36 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/res_company.py @@ -0,0 +1,465 @@ +# -*- coding: utf-8 -*- + +import datetime +from dateutil.relativedelta import relativedelta +import itertools + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import date_utils +from odoo.tools.misc import format_date + + +class ResCompany(models.Model): + _inherit = "res.company" + + totals_below_sections = fields.Boolean( + string='Add totals below sections', + help='When ticked, totals and subtotals appear below the sections of the report.') + account_tax_periodicity = fields.Selection([ + ('year', 'annually'), + ('semester', 'semi-annually'), + ('4_months', 'every 4 months'), + ('trimester', 'quarterly'), + ('2_months', 'every 2 months'), + ('monthly', 'monthly')], string="Delay units", help="Periodicity", default='monthly', required=True) + account_tax_periodicity_reminder_day = fields.Integer(string='Start from', default=7, required=True) + account_tax_periodicity_journal_id = fields.Many2one('account.journal', string='Journal', domain=[('type', '=', 'general')], check_company=True) + account_revaluation_journal_id = fields.Many2one('account.journal', domain=[('type', '=', 'general')], check_company=True) + account_revaluation_expense_provision_account_id = fields.Many2one('account.account', string='Expense Provision Account', check_company=True) + account_revaluation_income_provision_account_id = fields.Many2one('account.account', string='Income Provision Account', check_company=True) + account_tax_unit_ids = fields.Many2many(string="Tax Units", comodel_name='account.tax.unit', help="The tax units this company belongs to.") + account_representative_id = fields.Many2one('res.partner', string='Accounting Firm', + help="Specify an Accounting Firm that will act as a representative when exporting reports.") + account_display_representative_field = fields.Boolean(compute='_compute_account_display_representative_field') + + @api.depends('account_fiscal_country_id.code') + def _compute_account_display_representative_field(self): + country_set = self._get_countries_allowing_tax_representative() + for record in self: + record.account_display_representative_field = record.account_fiscal_country_id.code in country_set + + def _get_countries_allowing_tax_representative(self): + """ Returns a set containing the country codes of the countries for which + it is possible to use a representative to submit the tax report. + This function is a hook that needs to be overridden in localisation modules. + """ + return set() + + def _get_default_misc_journal(self): + """ Returns a default 'miscellanous' journal to use for + account_tax_periodicity_journal_id field. This is useful in case a + CoA was already installed on the company at the time the module + is installed, so that the field is set automatically when added.""" + return self.env['account.journal'].search([ + *self.env['account.journal']._check_company_domain(self), + ('type', '=', 'general'), + ], limit=1) + + def _get_tax_closing_journal(self): + journals = self.env['account.journal'] + for company in self: + journals |= company.account_tax_periodicity_journal_id or company._get_default_misc_journal() + + return journals + + @api.model_create_multi + def create(self, vals_list): + companies = super().create(vals_list) + companies._initiate_account_onboardings() + return companies + + def write(self, values): + tax_closing_update_dependencies = ('account_tax_periodicity', 'account_tax_periodicity_journal_id.id') + to_update = self.env['res.company'] + for company in self: + if company._get_tax_closing_journal(): + need_tax_closing_update = any( + update_dep in values and company.mapped(update_dep)[0] != values[update_dep] + for update_dep in tax_closing_update_dependencies + ) + + if need_tax_closing_update: + to_update += company + + res = super().write(values) + + # Early return + if not to_update: + return res + + to_reset_closing_moves = self.env['account.move'].sudo().search([ + ('company_id', 'in', to_update.ids), + ('tax_closing_report_id', '!=', False), + ('state', '=', 'draft'), + ]) + to_reset_closing_moves.button_cancel() + misc_journals = self.env['account.journal'].sudo().search([ + *self.env['account.journal']._check_company_domain(to_update), + ('type', '=', 'general'), + ]) + to_reset_closing_reminder_activities = self.env['mail.activity'].sudo().search([ + ('res_id', 'in', misc_journals.ids), + ('res_model_id', '=', self.env['ir.model']._get_id('account.journal')), + ('activity_type_id', '=', self.env.ref('odex30_account_reports.tax_closing_activity_type').id), + ('active', '=', True), + ]) + to_reset_closing_reminder_activities.action_cancel() + generic_tax_report = self.env.ref('account.generic_tax_report') + + # Create a new reminder + # The user is unlikely to change the periodicity often and for multiple companies at once + # So it is fair enough to make this that way as we are obliged to get the tax report for each company + # And then loop over all the reports to get their period boudaries and look for activity + for company in to_update: + tax_reports = self.env['account.report'].search([ + ('availability_condition', '=', 'country'), + ('country_id', 'in', company.account_enabled_tax_country_ids.ids), + ('root_report_id', '=', generic_tax_report.id), + ]) + if not tax_reports.filtered(lambda x: x.country_id == company.account_fiscal_country_id): + tax_reports += generic_tax_report + + for tax_report in tax_reports: + period_start, period_end = company._get_tax_closing_period_boundaries(fields.Date.today(), tax_report) + activity = company._get_tax_closing_reminder_activity(tax_report.id, period_end) + if not activity and self.env['account.move'].search_count([ + ('date', '<=', period_end), + ('date', '>=', period_start), + ('tax_closing_report_id', '=', tax_report.id), + ('company_id', '=', company.id), + ('state', '=', 'posted') + ]) == 0: + company._generate_tax_closing_reminder_activity(tax_report, period_end) + + hidden_tax_journals = self._get_tax_closing_journal().sudo().filtered(lambda j: not j.show_on_dashboard) + if hidden_tax_journals: + hidden_tax_journals.show_on_dashboard = True + + return res + + def _get_closing_report_for_tax_closing_move(self, report, fpos): + closing_report = report + + if not closing_report.country_id and closing_report.root_report_id: + # Fallback to root report if we're using a non-localized variant (typically the grouped tax reports) + closing_report = closing_report.root_report_id + + target_country = (fpos and fpos.country_id) or self.env.company.account_fiscal_country_id + country_variants = [variant for variant in (closing_report.root_report_id or closing_report).variant_report_ids if variant.country_id == target_country] + if len(country_variants) > 1: + # More than one national variant available: use the generic tax report + closing_report = self.env.ref('account.generic_tax_report') + elif country_variants and closing_report.country_id != target_country: + # Only one national variant available: select it + closing_report = country_variants[0] + + return closing_report + + def _get_and_update_tax_closing_moves(self, in_period_date, report, fiscal_positions=None, include_domestic=False): + """ Searches for tax closing moves. If some are missing for the provided parameters, + they are created in draft state. Also, existing moves get updated in case of configuration changes + (closing journal or periodicity, for example). Note the content of these moves stays untouched. + + :param in_period_date: A date within the tax closing period we want the closing for. + :param fiscal_positions: The fiscal positions we want to generate the closing for (as a recordset). + :param include_domestic: Whether or not the domestic closing (i.e. the one without any fiscal_position_id) must be included + + :return: The closing moves, as a recordset. + """ + self.ensure_one() + + if not fiscal_positions: + fiscal_positions = [] + + # Compute period dates depending on the date + tax_closing_journal = self._get_tax_closing_journal() + + all_closing_moves = self.env['account.move'] + for fpos in itertools.chain(fiscal_positions, [False] if include_domestic else []): + closing_report = self._get_closing_report_for_tax_closing_move(report, fpos) + + period_start, period_end = self._get_tax_closing_period_boundaries(in_period_date, closing_report) + periodicity = self._get_tax_periodicity(closing_report) + + fpos_id = fpos.id if fpos else False + tax_closing_move = self.env['account.move'].search([ + ('state', '=', 'draft'), + ('company_id', '=', self.id), + ('tax_closing_report_id', '=', closing_report.id), + ('date', '>=', period_start), + ('date', '<=', period_end), + ('fiscal_position_id', '=', fpos.id if fpos else None), + ]) + + # This should never happen, but can be caused by wrong manual operations + if len(tax_closing_move) > 1: + if fpos: + error = _("Multiple draft tax closing entries exist for fiscal position %(position)s after %(period_start)s. There should be at most one. \n %(closing_entries)s", + position=fpos.name, period_start=period_start, closing_entries=tax_closing_move.mapped('display_name')) + + else: + error = _("Multiple draft tax closing entries exist for your domestic region after %(period_start)s. There should be at most one. \n %(closing_entries)s", + period_start=period_start, closing_entries=tax_closing_move.mapped('display_name')) + + raise UserError(error) + + # Compute tax closing description + ref = _("%(report_label)s: %(period)s", report_label=self._get_tax_closing_report_display_name(closing_report), period=self._get_tax_closing_move_description(periodicity, period_start, period_end, fpos, closing_report)) + + # Values for update/creation of closing move + closing_vals = { + 'company_id': self.id,# Important to specify together with the journal, for branches + 'journal_id': tax_closing_journal.id, + 'date': period_end, + 'tax_closing_report_id': closing_report.id, + 'fiscal_position_id': fpos_id, + 'ref': ref, + 'name': '/', # Explicitly set a void name so that we don't set the sequence for the journal and don't consume a sequence number + } + + if tax_closing_move: + tax_closing_move.write(closing_vals) + else: + # Create a new, empty, tax closing move + tax_closing_move = self.env['account.move'].create(closing_vals) + + # Create a reminder activity if it doesn't exist + activity = self._get_tax_closing_reminder_activity(closing_report.id, period_end, fpos_id) + tax_closing_options = tax_closing_move._get_tax_closing_report_options(tax_closing_move.company_id, tax_closing_move.fiscal_position_id, tax_closing_move.tax_closing_report_id, tax_closing_move.date) + if not activity and closing_report._get_sender_company_for_export(tax_closing_options) == tax_closing_move.company_id: + self._generate_tax_closing_reminder_activity(closing_report, period_end, fpos) + + all_closing_moves += tax_closing_move + + return all_closing_moves + + def _get_tax_closing_report_display_name(self, report): + if report.get_external_id().get(report.id) in ('account.generic_tax_report', 'account.generic_tax_report_account_tax', 'account.generic_tax_report_tax_account'): + return _("Tax return") + + return report.display_name + + def _generate_tax_closing_reminder_activity(self, report, date_in_period=None, fiscal_position=None): + """ + Create a reminder on the current tax_closing_journal for a certain report with a fiscal_position or not if None. + The reminder will target the period from which the date sits in + """ + self.ensure_one() + if not date_in_period: + date_in_period = fields.Date.today() + # Search for an existing tax closing move + tax_closing_activity_type = self.env.ref('odex30_account_reports.tax_closing_activity_type') + + # Tax period + period_start, period_end = self._get_tax_closing_period_boundaries(date_in_period, report) + periodicity = self._get_tax_periodicity(report) + activity_deadline = period_end + relativedelta(days=self.account_tax_periodicity_reminder_day) + + # Reminder title + summary = _( + "%(report_label)s: %(period)s", + report_label=self._get_tax_closing_report_display_name(report), + period=self._get_tax_closing_move_description(periodicity, period_start, period_end, fiscal_position, report) + ) + + activity_user = tax_closing_activity_type.default_user_id if tax_closing_activity_type else self.env['res.users'] + if activity_user and not (self in activity_user.company_ids and activity_user.has_group('account.group_account_manager')): + activity_user = self.env['res.users'] + + if not activity_user: + activity_user = self.env['res.users'].search( + [('company_ids', 'in', self.ids), ('groups_id', 'in', self.env.ref('account.group_account_manager').ids)], + limit=1, order="id ASC", + ) + + self.env['mail.activity'].with_context(mail_activity_quick_update=True).create({ + 'res_id': self._get_tax_closing_journal().id, + 'res_model_id': self.env['ir.model']._get_id('account.journal'), + 'activity_type_id': tax_closing_activity_type.id, + 'date_deadline': activity_deadline, + 'automated': True, + 'summary': summary, + 'user_id': activity_user.id or self.env.user.id, + 'account_tax_closing_params': { + 'report_id': report.id, + 'tax_closing_end_date': fields.Date.to_string(period_end), + 'fpos_id': fiscal_position.id if fiscal_position else False, + }, + }) + + def _get_tax_closing_reminder_activity(self, report_id, period_end, fpos_id=False): + self.ensure_one() + tax_closing_activity_type = self.env.ref('odex30_account_reports.tax_closing_activity_type') + return self._get_tax_closing_journal().activity_ids.filtered( + lambda act: act.account_tax_closing_params and (act.activity_type_id == tax_closing_activity_type and act.account_tax_closing_params['report_id'] == report_id + and fields.Date.from_string(act.account_tax_closing_params['tax_closing_end_date']) == period_end + and act.account_tax_closing_params['fpos_id'] == fpos_id) + ) + + def _get_tax_closing_move_description(self, periodicity, period_start, period_end, fiscal_position, report): + """ Returns a string description of the provided period dates, with the + given tax periodicity. + """ + self.ensure_one() + + foreign_vat_fpos_count = self.env['account.fiscal.position'].search_count([ + ('company_id', '=', self.id), + ('foreign_vat', '!=', False) + ]) + if foreign_vat_fpos_count: + if fiscal_position: + country_code = fiscal_position.country_id.code + state_codes = fiscal_position.mapped('state_ids.code') if fiscal_position.state_ids else [] + else: + # On domestic country + country_code = self.account_fiscal_country_id.code + + # Only consider the state in case there are foreign VAT fpos on states in this country + vat_fpos_with_state_count = self.env['account.fiscal.position'].search_count([ + ('company_id', '=', self.id), + ('foreign_vat', '!=', False), + ('country_id', '=', self.account_fiscal_country_id.id), + ('state_ids', '!=', False), + ]) + state_codes = [self.state_id.code] if self.state_id and vat_fpos_with_state_count else [] + + if state_codes: + region_string = " (%s - %s)" % (country_code, ', '.join(state_codes)) + else: + region_string = " (%s)" % country_code + else: + # Don't add region information in case there is no foreign VAT fpos + region_string = '' + + # Shift back to normal dates if we are using a start date so periods aren't broken + start_day, start_month = self._get_tax_closing_start_date_attributes(report) + if start_day != 1 or start_month != 1: + return f"{format_date(self.env, period_start)} - {format_date(self.env, period_end)}{region_string}" + + if periodicity == 'year': + return f"{period_start.year}{region_string}" + elif periodicity == 'trimester': + return f"{format_date(self.env, period_start, date_format='qqq yyyy')}{region_string}" + elif periodicity == 'monthly': + return f"{format_date(self.env, period_start, date_format='LLLL yyyy')}{region_string}" + else: + return f"{format_date(self.env, period_start)} - {format_date(self.env, period_end)}{region_string}" + + def _get_tax_closing_period_boundaries(self, date, report): + """ Returns the boundaries of the tax period containing the provided date + for this company, as a tuple (start, end). + + This function needs to stay consitent with the one inside Javascript in the filters for the tax report + """ + self.ensure_one() + period_months = self._get_tax_periodicity_months_delay(report) + start_day, start_month = self._get_tax_closing_start_date_attributes(report) + aligned_date = date + relativedelta(days=-(start_day - 1)) # we offset the date back from start_day amount of day - 1 so we can compute months periods aligned to the start and end of months + year = aligned_date.year + month_offset = aligned_date.month - start_month + period_number = (month_offset // period_months) + 1 + + # If the date is before the start date and start month of this year, this mean we are in the previous period + # So the initial_date should be one year before and the period_number should be computed in reverse because month_offset is negative + if date < datetime.date(date.year, start_month, start_day): + year -= 1 + period_number = ((12 + month_offset) // period_months) + 1 + + month_delta = period_number * period_months + + # We need to work with offsets because it handle automatically the end of months (28, 29, 30, 31) + end_date = datetime.date(year, start_month, 1) + relativedelta(months=month_delta, days=start_day - 2) # -1 because the first days is aldready counted and -1 because the first day of the next period must not be in this range + start_date = datetime.date(year, start_month, 1) + relativedelta(months=month_delta - period_months, day=start_day) + + return start_date, end_date + + def _get_available_tax_unit(self, report): + """ + Must ensures that report has a country_id to search for a tax unit + + :return: A recordset of available tax units for this report country_id and this company + """ + self.ensure_one() + return self.env['account.tax.unit'].search([ + ('company_ids', 'in', self.id), + ('country_id', '=', report.country_id.id), + ], limit=1) + + def _get_tax_periodicity(self, report): + main_company = self + if report.filter_multi_company == 'tax_units' and report.country_id and (tax_unit := self._get_available_tax_unit(report)): + main_company = tax_unit.main_company_id + + return main_company.account_tax_periodicity + + def _get_tax_closing_start_date_attributes(self, report): + if not report.tax_closing_start_date: + start_year = fields.Date.start_of(fields.Date.today(), 'year') + return start_year.day, start_year.month + + main_company = self + if report.filter_multi_company == 'tax_units' and report.country_id and (tax_unit := self._get_available_tax_unit(report)): + main_company = tax_unit.main_company_id + + start_date = report.with_company(main_company).tax_closing_start_date + + return start_date.day, start_date.month + + def _get_tax_periodicity_months_delay(self, report): + """ Returns the number of months separating two tax returns with the provided periodicity + """ + self.ensure_one() + periodicities = { + 'year': 12, + 'semester': 6, + '4_months': 4, + 'trimester': 3, + '2_months': 2, + 'monthly': 1, + } + return periodicities[self._get_tax_periodicity(report)] + + def _get_branches_with_same_vat(self, accessible_only=False): + """ Returns all companies among self and its branch hierachy (considering children and parents) that share the same VAT number + as self. An empty VAT number is considered as being the same as the one of the closest parent with a VAT number. + + self is always returned as the first element of the resulting recordset (so that this can safely be used to restore the active company). + + Example: + - main company ; vat = 123 + - branch 1 + - branch 1_1 + - branch 2 ; vat = 456 + - branch 2_1 ; vat = 789 + - branch 2_2 + + In this example, the following VAT numbers will be considered for each company: + - main company: 123 + - branch 1: 123 + - branch 1_1: 123 + - branch 2: 456 + - branch 2_1: 789 + - branch 2_2: 456 + + :param accessible_only: whether the returned companies should exclude companies that are not in self.env.companies + """ + self.ensure_one() + + current = self.sudo() + same_vat_branch_ids = [current.id] # Current is always available + current_strict_parents = current.parent_ids - current + if accessible_only: + candidate_branches = current.root_id._accessible_branches() + else: + candidate_branches = self.env['res.company'].sudo().search([('id', 'child_of', current.root_id.ids)]) + + current_vat_check_set = {current.vat} if current.vat else set() + for branch in candidate_branches - current: + parents_vat_set = set(filter(None, (branch.parent_ids - current_strict_parents).mapped('vat'))) + if parents_vat_set == current_vat_check_set: + # If all the branches between the active company and branch (both included) share the same VAT number as the active company, + # we want to add the branch to the selection. + same_vat_branch_ids.append(branch.id) + + return self.browse(same_vat_branch_ids) diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py b/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py new file mode 100644 index 0000000..f589d33 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/res_config_settings.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +from calendar import monthrange + +from odoo import api, fields, models, _ +from dateutil.relativedelta import relativedelta +from odoo.tools.misc import format_date +from odoo.tools import date_utils + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + totals_below_sections = fields.Boolean(related='company_id.totals_below_sections', string='Add totals below sections', readonly=False, + help='When ticked, totals and subtotals appear below the sections of the report.') + account_tax_periodicity = fields.Selection(related='company_id.account_tax_periodicity', string='Periodicity', readonly=False, required=True) + account_tax_periodicity_reminder_day = fields.Integer(related='company_id.account_tax_periodicity_reminder_day', string='Reminder', readonly=False, required=True) + account_tax_periodicity_journal_id = fields.Many2one(related='company_id.account_tax_periodicity_journal_id', string='Journal', readonly=False) + + account_reports_show_per_company_setting = fields.Boolean(compute="_compute_account_reports_show_per_company_setting") + + def open_tax_group_list(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Tax groups', + 'res_model': 'account.tax.group', + 'view_mode': 'list', + 'context': { + 'default_country_id': self.account_fiscal_country_id.id, + 'search_default_country_id': self.account_fiscal_country_id.id, + }, + } + + @api.depends('account_tax_periodicity', 'company_id', 'fiscalyear_last_day', 'fiscalyear_last_month') + def _compute_account_reports_show_per_company_setting(self): + custom_start_country_codes = self._get_country_codes_with_another_tax_closing_start_date() + countries = self.env['account.fiscal.position'].search([ + ('company_id', '=', self.env.company.id), + ('foreign_vat', '!=', False), + ]).mapped('country_id') + self.env.company.account_fiscal_country_id + countries_to_always_show = bool(set(countries.mapped('code')) & custom_start_country_codes) + for config_settings in self: + if countries_to_always_show: + config_settings.account_reports_show_per_company_setting = True + else: + max_last_day = monthrange(fields.Date.today().year, int(config_settings.fiscalyear_last_month))[1] + if config_settings.account_tax_periodicity == 'monthly': + config_settings.account_reports_show_per_company_setting = max_last_day != config_settings.fiscalyear_last_day + else: + config_settings.account_reports_show_per_company_setting = config_settings.fiscalyear_last_month != '12' or config_settings.fiscalyear_last_day != max_last_day + + def open_company_dependent_report_settings(self): + self.ensure_one() + generic_tax_report = self.env.ref('account.generic_tax_report') + available_reports = generic_tax_report._get_variants(generic_tax_report.id) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Configure your start dates'), + 'res_model': 'account.report', + 'domain': [('id', 'in', available_reports.ids)], + 'views': [(self.env.ref('odex30_account_reports.account_report_tree_configure_start_dates').id, 'list')] + } + + def _get_country_codes_with_another_tax_closing_start_date(self): + """ + To be overridden by specific countries that wants this + + Used to know which countries can have specific start dates settings on reports + + :returns set(str): A set of country codes from which the start date settings should be shown + """ + return set() diff --git a/dev_odex30_accounting/odex30_account_reports/models/res_partner.py b/dev_odex30_accounting/odex30_account_reports/models/res_partner.py new file mode 100644 index 0000000..f767469 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/models/res_partner.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ + + +class ResPartner(models.Model): + _name = 'res.partner' + _inherit = 'res.partner' + + account_represented_company_ids = fields.One2many('res.company', 'account_representative_id') + + def _get_followup_responsible(self): + return self.env.user + + def open_partner_ledger(self): + # Deprecated, will be removed in master + action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_partner_ledger") + action['params'] = { + 'options': {'partner_ids': self.ids, 'unfold_all': len(self.ids) == 1}, + 'ignore_session': True, + } + return action + + def open_customer_statement(self): + if not self.env.ref('odex30_account_reports.customer_statement_report', raise_if_not_found=False): + return self.open_partner_ledger() + action = self.env["ir.actions.actions"]._for_xml_id("odex30_account_reports.action_account_report_customer_statement") + action['params'] = { + 'options': { + 'partner_ids': (self | self.commercial_partner_id).ids, + 'unfold_all': len(self.ids) == 1, + }, + 'ignore_session': True, + } + return action + + def open_partner(self): + return { + 'type': 'ir.actions.act_window', + 'res_model': 'res.partner', + 'res_id': self.id, + 'views': [[False, 'form']], + 'view_mode': 'form', + 'target': 'current', + } + + @api.depends_context('show_more_partner_info') + def _compute_display_name(self): + if not self.env.context.get('show_more_partner_info'): + return super()._compute_display_name() + for partner in self: + res = "" + if partner.vat: + res += f" {partner.vat}," + if partner.country_id: + res += f" {partner.country_id.code}," + partner.display_name = f"{partner.name} - " + res + + def _get_partner_account_report_attachment(self, report, options=None): + self.ensure_one() + if self.lang: + # Print the followup in the customer's language + report = report.with_context(lang=self.lang) + + if not options: + options = report.get_options({ + 'forced_companies': self.env.company.search([('id', 'child_of', self.env.context.get('allowed_company_ids', self.env.company.id))]).ids, + 'partner_ids': self.ids, + 'unfold_all': True, + 'unreconciled': True, + # The following two options are Deprecated, will be removed in master + 'hide_account': True, + 'hide_debit_credit': True, + 'all_entries': False, + }) + attachment_file = report.export_to_pdf(options) + return self.env['ir.attachment'].create([ + { + 'name': f"{self.name} - {attachment_file['file_name']}", + 'res_model': self._name, + 'res_id': self.id, + 'type': 'binary', + 'raw': attachment_file['file_content'], + 'mimetype': 'application/pdf', + }, + ]) + + def set_commercial_partner_main(self): + self.ensure_one() + + main_partner = self + duplicated_partners = self.env['res.partner'].search([ + ('vat', '=', main_partner.vat), + ('id', '!=', main_partner.id) + ]) + # Update commercial partner of all duplicates + duplicated_partners.write({ + 'is_company': False, + 'parent_id': main_partner.id, + 'type': 'invoice', + }) + duplicated_partners_vat = self._context.get('duplicated_partners_vat', []) + remaining_vats = [pvat for pvat in duplicated_partners_vat if pvat != main_partner.vat] + return self.env['account.ec.sales.report.handler']._get_duplicated_vat_partners(remaining_vats) diff --git a/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv b/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv new file mode 100644 index 0000000..beaceee --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/security/ir.model.access.csv @@ -0,0 +1,19 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_account_report_annotation_readonly,account.account_report_annotation_readonly,model_account_report_annotation,account.group_account_readonly,1,0,0,0 +access_account_report_annotation,account.account_report_annotation,model_account_report_annotation,account.group_account_user,1,1,1,1 +access_account_report_annotation_invoice,account.account_report_annotation,model_account_report_annotation,account.group_account_invoice,1,0,0,0 +access_account_reports_export_wizard,access.account_reports.export.wizard,model_account_reports_export_wizard,account.group_account_user,1,1,1,0 +access_account_reports_export_wizard_format,access.account_reports.export.wizard.format,model_account_reports_export_wizard_format,account.group_account_user,1,1,1,0 +access_account_report_file_download_error_wizard,account.report.file.download.error.wizard,model_account_report_file_download_error_wizard,account.group_account_user,1,1,1,0 +access_account_multicurrency_revaluation_wizard,access.account.multicurrency.revaluation.wizard,model_account_multicurrency_revaluation_wizard,account.group_account_user,1,1,1,0 +access_account_tax_unit_readonly,access_account_tax_unit_readonly,model_account_tax_unit,account.group_account_readonly,1,0,0,0 +access_account_tax_unit_manager,access_account_tax_unit_manager,model_account_tax_unit,account.group_account_manager,1,1,1,1 +access_account_report_horizontal_group_readonly,account.report.horizontal.group.readonly,model_account_report_horizontal_group,account.group_account_readonly,1,0,0,0 +access_account_report_horizontal_group_ac_user,account.report.horizontal.group.ac.user,model_account_report_horizontal_group,account.group_account_manager,1,1,1,1 +access_account_report_horizontal_group_rule_readonly,account.report.horizontal.group.rule.readonly,model_account_report_horizontal_group_rule,account.group_account_readonly,1,0,0,0 +access_account_report_horizontal_group_rule_ac_user,account.report.horizontal.group.rule.ac.user,model_account_report_horizontal_group_rule,account.group_account_manager,1,1,1,1 +access_account_report_budget_readonly,account.report.budget.readonly,model_account_report_budget,account.group_account_readonly,1,0,0,0 +access_account_report_budget_ac_user,account.report.budget.ac.user,model_account_report_budget,account.group_account_manager,1,1,1,1 +access_account_report_budget_item_readonly,account.report.budget.item.readonly,model_account_report_budget_item,account.group_account_readonly,1,0,0,0 +access_account_report_budget_item_ac_user,account.report.budget.item.ac.user,model_account_report_budget_item,account.group_account_manager,1,1,1,1 +access_account_report_send,access.account.report.send,model_account_report_send,account.group_account_invoice,1,1,1,1 diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js new file mode 100644 index 0000000..79b5307 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.js @@ -0,0 +1,125 @@ +/** @odoo-module */ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { ControlPanel } from "@web/search/control_panel/control_panel"; + +import { Component, onWillStart, useRef, useState, useSubEnv } from "@odoo/owl"; + +import { AccountReportController } from "@odex30_account_reports/components/account_report/controller"; +import { AccountReportButtonsBar } from "@odex30_account_reports/components/account_report/buttons_bar/buttons_bar"; +import { AccountReportCogMenu } from "@odex30_account_reports/components/account_report/cog_menu/cog_menu"; +import { AccountReportEllipsis } from "@odex30_account_reports/components/account_report/ellipsis/ellipsis"; +import { AccountReportFilters } from "@odex30_account_reports/components/account_report/filters/filters"; +import { AccountReportHeader } from "@odex30_account_reports/components/account_report/header/header"; +import { AccountReportLine } from "@odex30_account_reports/components/account_report/line/line"; +import { AccountReportLineCell } from "@odex30_account_reports/components/account_report/line_cell/line_cell"; +import { AccountReportLineName } from "@odex30_account_reports/components/account_report/line_name/line_name"; +import { AccountReportSearchBar } from "@odex30_account_reports/components/account_report/search_bar/search_bar"; +import { standardActionServiceProps } from "@web/webclient/actions/action_service"; +import { useSetupAction } from "@web/search/action_hook"; + + +export class AccountReport extends Component { + static template = "odex30_account_reports.AccountReport"; + static props = { ...standardActionServiceProps }; + static components = { + ControlPanel, + AccountReportButtonsBar, + AccountReportCogMenu, + AccountReportSearchBar, + }; + + static customizableComponents = [ + AccountReportEllipsis, + AccountReportFilters, + AccountReportHeader, + AccountReportLine, + AccountReportLineCell, + AccountReportLineName, + ]; + static defaultComponentsMap = []; + + setup() { + this.rootRef = useRef("root"); + useSetupAction({ + rootRef: this.rootRef, + getLocalState: () => { + return { + keep_journal_groups_options: true, // used when using the breadcrumb + }; + } + }) + if (this.props?.state?.keep_journal_groups_options !== undefined) { + this.props.action.keep_journal_groups_options = true; + } + + // Can not use 'control-panel-bottom-right' slot without this, as viewSwitcherEntries doesn't exist here. + this.env.config.viewSwitcherEntries = []; + + this.orm = useService("orm"); + this.actionService = useService("action"); + this.controller = useState(new AccountReportController(this.props.action)); + this.initialQuery = this.props.action.context.default_filter_accounts || ''; + + for (const customizableComponent of AccountReport.customizableComponents) + AccountReport.defaultComponentsMap[customizableComponent.name] = customizableComponent; + + onWillStart(async () => { + await this.controller.load(this.env); + }); + + useSubEnv({ + controller: this.controller, + component: this.getComponent.bind(this), + template: this.getTemplate.bind(this), + }); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Custom overrides + // ----------------------------------------------------------------------------------------------------------------- + static registerCustomComponent(customComponent) { + registry.category("account_reports_custom_components").add(customComponent.template, customComponent); + } + + get cssCustomClass() { + return this.controller.data.custom_display.css_custom_class || ""; + } + + getComponent(name) { + const customComponents = this.controller.data.custom_display.components; + + if (customComponents && customComponents[name]) + return registry.category("account_reports_custom_components").get(customComponents[name]); + + return AccountReport.defaultComponentsMap[name]; + } + + getTemplate(name) { + const customTemplates = this.controller.data.custom_display.templates; + + if (customTemplates && customTemplates[name]) + return customTemplates[name]; + + return `odex30_account_reports.${ name }Customizable`; + } + + // ----------------------------------------------------------------------------------------------------------------- + // Table + // ----------------------------------------------------------------------------------------------------------------- + get tableClasses() { + let classes = ""; + + if (this.controller.options.columns.length > 1) { + classes += " striped"; + } + + if (this.controller.options['horizontal_split']) + classes += " w-50 mx-2"; + + return classes; + } +} + +registry.category("actions").add("account_report", AccountReport); diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss new file mode 100644 index 0000000..1c6cfe1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.scss @@ -0,0 +1,465 @@ +.account_report { + .fit-content { width: fit-content } + + //------------------------------------------------------------------------------------------------------------------ + // Control panel + //------------------------------------------------------------------------------------------------------------------ + .o_control_panel_main_buttons { + .dropdown-item { + padding: 0; + .btn-link { + width: 100%; + text-align: left; + padding: 3px 20px; + border-radius: 0; + } + } + } + + .o_control_panel_breadcrumbs { + flex-basis: min-content; + } + + //------------------------------------------------------------------------------------------------------------------ + // Sections + //------------------------------------------------------------------------------------------------------------------ + .section_selector { + display: flex; + gap: 4px; + margin: 16px 16px 8px 16px; + justify-content: center; + } + + //------------------------------------------------------------------------------------------------------------------ + // Alert + //------------------------------------------------------------------------------------------------------------------ + .warnings { margin-bottom: 1rem } + .alert { + margin-bottom: 0; + border-radius: 0; + + a:hover { cursor:pointer } + } + + //------------------------------------------------------------------------------------------------------------------ + // No content + //------------------------------------------------------------------------------------------------------------------ + .o_view_nocontent { z-index: -1 } + + //------------------------------------------------------------------------------------------------------------------ + // Table + //------------------------------------------------------------------------------------------------------------------ + .table { + background-color: $o-view-background-color; + border-collapse: separate; //!\\ Allows to add padding to the table + border-spacing: 0; //!\\ Removes default spacing between cells due to 'border-collapse: separate' + font-size: 0.8rem; + margin: 0 auto 24px; + padding: 24px; + width: auto; + min-width: 800px; + border: 1px solid $o-gray-300; + border-radius: 0.25rem; + + > :not(caption) > * > * { padding: 0.25rem 0.75rem } //!\\ Override of bootstrap, keep selector + + > thead { + > tr { + th:first-child { + color: lightgrey; + } + th:not(:first-child) { + text-align: center; + vertical-align: middle; + } + } + > tr:not(:last-child) > th:not(:first-child) { border: 1px solid $o-gray-300 } + } + + > tbody { + > tr { + &.unfolded { font-weight: bold } + > td { + a { cursor: pointer } + .clickable { color: $o-enterprise-action-color } + &.muted { color: var(--AccountReport-muted-data-color, $o-gray-300) } + &:empty::after{ content: "\00a0" } //!\\ Prevents the collapse of empty table rows + &:empty { line-height: 1 } + .btn_annotation { color: $o-enterprise-action-color } + } + + &:not(.empty) > td { border-bottom: 1px solid var(--AccountReport-fine-line-separator-color, $o-gray-200) } + &.total { font-weight: bold } + &.o_bold_tr { font-weight: bold } + + &.unfolded { + > td { border-bottom: 1px solid $o-gray-300 } + .btn_action { opacity: 1 } + .btn_more { opacity: 1 } + } + + &:hover { + &.empty > * { --table-accent-bg: transparent } + .auditable { + color: $o-enterprise-action-color !important; + + > a:hover { cursor: pointer } + } + .muted { color: $o-gray-800 } + .btn_action, .btn_more { + opacity: 1; + color: $o-enterprise-action-color; + } + .btn_edit { color: $o-enterprise-action-color } + .btn_dropdown { color: $o-enterprise-action-color } + .btn_foldable { color: $o-enterprise-action-color } + .btn_ellipsis { color: $o-enterprise-action-color } + .btn_annotation_go { color: $o-enterprise-action-color } + .btn_debug { color: $o-enterprise-action-color } + } + } + } + } + + table.striped { + //!\\ Changes the background of every even column starting with the 3rd one + > thead > tr:not(:first-child) > th:nth-child(2n+3) { background: $o-gray-100 } + > tbody { + > tr:not(.line_level_0):not(.empty) > td:nth-child(2n+3) { background: $o-gray-100 } + > tr.line_level_0 > td:nth-child(2n+3) { background: $o-gray-300 } + } + } + + thead.sticky { + background-color: $o-view-background-color; + position: sticky; + top: 0; + z-index: 999; + } + + //------------------------------------------------------------------------------------------------------------------ + // Line + //------------------------------------------------------------------------------------------------------------------ + .line_name { + vertical-align: middle; + > .wrapper { + display: flex; + align-items: center; + + > .content { + display: flex; + align-items: center; + sup { top: auto } + } + } + + .name { white-space: nowrap } + &.unfoldable:hover { cursor: pointer } + } + + .line_cell { + vertical-align: middle; + > .wrapper { + display: flex; + align-items: center; + + > .content { + display: flex; + align-items: center; + } + } + + &.date > .wrapper { justify-content: center } + &.numeric > .wrapper { justify-content: flex-end } + .name { white-space: nowrap } + } + + .editable-cell { + input { + color: $o-enterprise-action-color; + border: none; + max-width: 100px; + float: right; + + &:hover { + cursor: pointer; + } + } + + &:hover { + cursor: pointer; + } + + &:focus-within { + border-bottom-color: $o-enterprise-action-color !important; + + input { + color: $o-black; + } + } + } + + .line_level_0 { + color: $o-gray-700; + font-weight: bold; + + > td { + border-bottom: 0 !important; + background-color: $o-gray-300; + } + .muted { color: $o-gray-400 !important } + .btn_debug { color: $o-gray-400 } + } + + @for $i from 2 through 16 { + .line_level_#{$i} { + $indentation: (($i + 1) * 8px) - 20px; // 20px are for the btn_foldable width + + > td { + color: $o-gray-700; + + &.line_name.unfoldable .wrapper { column-gap: calc(#{ $indentation }) } + &.line_name:not(.unfoldable) .wrapper { padding-left: $indentation } + } + } + } + + //------------------------------------------------------------------------------------------------------------------ + // Link + //------------------------------------------------------------------------------------------------------------------ + .link { color: $o-enterprise-action-color } + + //------------------------------------------------------------------------------------------------------------------ + // buttons + //------------------------------------------------------------------------------------------------------------------ + .btn_debug, .btn_dropdown, .btn_foldable, .btn_foldable_empty, .btn_sortable, .btn_ellipsis, + .btn_more, .btn_annotation, .btn_annotation_go, .btn_annotation_delete, .btn_action, .btn_edit { + border: none; + color: $o-gray-300; + font-size: inherit; + font-weight: normal; + padding: 0; + text-align: center; + width: 20px; + white-space: nowrap; + + &:hover { + color: $o-enterprise-action-color !important; + cursor: pointer; + } + } + + .btn_sortable > .fa-long-arrow-up, .btn_sortable > .fa-long-arrow-down { color: $o-enterprise-action-color } + .btn_foldable { color: $o-gray-500 } + .btn_foldable_empty:hover { cursor: default } + .btn_ellipsis > i { vertical-align: bottom } + .btn_more { opacity: 1 } + .btn_annotation { margin-left: 6px } + .btn_annotation_go { color: $o-gray-600 } + .btn_annotation_delete { + margin-left: 4px; + vertical-align: baseline; + } + .btn_action { + opacity: 0; + background-color: $o-view-background-color; + color: $o-gray-600; + width: auto; + padding: 0 0.25rem; + margin: 0 0.25rem; + border: 1px solid $o-gray-300; + border-radius: 0.25rem; + } + + //------------------------------------------------------------------------------------------------------------------ + // Dropdown + //------------------------------------------------------------------------------------------------------------------ + .dropdown { display: inline } + + //------------------------------------------------------------------------------------------------------------------ + // Annotation + //------------------------------------------------------------------------------------------------------------------ + .annotations { + border-top: 1px solid $o-gray-300; + font-size: 0.8rem; + padding: 24px 0; + + > li { + line-height: 24px; + margin-left: 24px; + &:hover > button { color: $o-enterprise-action-color } + } + } +} + +//---------------------------------------------------------------------------------------------------------------------- +// Dialogs +//---------------------------------------------------------------------------------------------------------------------- +.account_report_annotation_dialog { + textarea { + border: 1px solid $o-gray-300; + border-radius: 0.25rem; + height: 120px; + padding: .5rem; + } +} + +//---------------------------------------------------------------------------------------------------------------------- +// Popovers +//---------------------------------------------------------------------------------------------------------------------- +.account_report_popover_edit { + padding: .5rem 1rem; + box-sizing: content-box; + + .edit_popover_boolean label { padding: 0 12px 0 4px } + + .edit_popover_string { + width: 260px; + padding: 8px; + border-color: $o-gray-200; + } + + .btn { + color: $o-white; + background-color: $o-enterprise-action-color; + } +} + +.account_report_popover_ellipsis { + > p { + float: left; + margin: 1rem; + width: 360px; + } +} + +.account_report_btn_clone { + margin: 1rem 1rem 0 0; + border: none; + color: $o-gray-300; + font-size: inherit; + font-weight: normal; + padding: 0; + text-align: center; + width: 20px; + + &:hover { + color: $o-enterprise-action-color !important; + cursor: pointer; + } +} + +.account_report_popover_debug { + width: 350px; + overflow-x: auto; + + > .line_debug { + display: flex; + flex-direction: row; + padding: .25rem 1rem; + + &:first-child { padding-top: 1rem } + &:last-child { padding-bottom: 1rem } + + > span:first-child { + color: $o-gray-600; + max-width: 25%; //!\\ Not the same as 'width' because of 'display: flex' + min-width: 25%; //!\\ Not the same as 'width' because of 'display: flex' + white-space: nowrap; + margin-right: 10px; + } + > span:last-child { + color: $o-gray-800; + max-width: 75%; //!\\ Not the same as 'width' because of 'display: flex' + min-width: 75%; //!\\ Not the same as 'width' because of 'display: flex' + } + } + + > .totals_separator { margin: .25rem 1rem } + > .engine_separator { margin: 1rem } +} + +.carryover_popover { + margin: 12px; + width: 300px; +} + +.o_web_client:has(.annotation_popover) { + + .popover:has(.annotation_tooltip) { visibility: hidden; } + + .popover:has(.annotation_popover) { + max-height: 45%; + max-width: 60%; + white-space: pre-wrap; + overflow-y: auto; + + .annotation_popover { + overflow: scroll; + + .annotation_popover_line th{ + background-color: $o-white; + position: sticky; + top: 0; + z-index: 10; + } + + } + + .annotation_popover_line:nth-child(2n+2) { background: $o-gray-200; } + + .annotation_popover_line { + .o_datetime_input { + border: none; + } + } + + tr, th, td:not(:has(.btn_annotation_update)):not(:has(.btn_annotation_delete)) { + padding: .5rem 1rem .5rem .5rem; + vertical-align: top; + } + + .annotation_popover_editable_cell { + background-color: transparent; + border: 0; + box-shadow: none; + color: $o-gray-700; + resize: none; + width: 85px; + outline: none; + } + } +} + +label:focus-within input { border: 0; } + +.popover:has(.annotation_tooltip) { + + > .tooltip-inner { + padding: 0; + color: $o-white; + background-color: $o-white; + + > .annotation_tooltip { + color: $o-gray-700; + background-color: $o-white; + white-space: pre-wrap; + + > .annotation_tooltip_line:nth-child(2n+2) { background: $o-gray-200; } + + tr, th, td { + padding: .25rem .5rem .25rem .25rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; + } + } + + + .popover-arrow { + &.top-0::after { border-right-color: $o-white; } + &.bottom-0::after { border-left-color: $o-white; } + &.start-0::after { border-bottom-color: $o-white; } + &.end-0::after { border-top-color: $o-white; } + } + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml new file mode 100644 index 0000000..cb3a036 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/account_report.xml @@ -0,0 +1,107 @@ + + + + + + + +
    + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + +
    +
    +

    No data to display !

    +

    There is no data to display for the given filters.

    +
    +
    +
    +
    +
    + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js new file mode 100644 index 0000000..a3c1e7b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.js @@ -0,0 +1,27 @@ +/** @odoo-module */ + +import { Component, useState } from "@odoo/owl"; + +export class AccountReportButtonsBar extends Component { + static template = "odex30_account_reports.AccountReportButtonsBar"; + static props = {}; + + setup() { + this.controller = useState(this.env.controller); + } + + //------------------------------------------------------------------------------------------------------------------ + // Buttons + //------------------------------------------------------------------------------------------------------------------ + get barButtons() { + const buttons = []; + + for (const button of this.controller.buttons) { + if (button.always_show) { + buttons.push(button); + } + } + + return buttons; + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml new file mode 100644 index 0000000..47a5501 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/buttons_bar/buttons_bar.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js new file mode 100644 index 0000000..45d262d --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import {Component, useState} from "@odoo/owl"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; + + +export class AccountReportCogMenu extends Component { + static template = "odex30_account_reports.AccountReportCogMenu"; + static components = {Dropdown, DropdownItem}; + static props = {}; + + setup() { + this.controller = useState(this.env.controller); + } + + //------------------------------------------------------------------------------------------------------------------ + // Buttons + //------------------------------------------------------------------------------------------------------------------ + get cogButtons() { + const buttons = []; + + for (const button of this.controller.buttons) { + if (!button.always_show) { + buttons.push(button); + } + } + + return buttons; + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml new file mode 100644 index 0000000..3efa3b1 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/cog_menu/cog_menu.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js new file mode 100644 index 0000000..1d9623a --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/controller.js @@ -0,0 +1,820 @@ +/* global owl:readonly */ + +import { browser } from "@web/core/browser/browser"; +import { session } from "@web/session"; +import { useService } from "@web/core/utils/hooks"; + +import { removeTaxGroupingFromLineId } from "@odex30_account_reports/js/util"; + +export class AccountReportController { + constructor(action) { + this.action = action; + this.actionService = useService("action"); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + } + + async load(env) { + this.env = env; + this.reportOptionsMap = {}; + this.reportInformationMap = {}; + this.lastOpenedSectionByReport = {}; + this.loadingCallNumberByCacheKey = new Proxy( + {}, + { + get(target, name) { + return name in target ? target[name] : 0; + }, + set(target, name, newValue) { + target[name] = newValue; + return true; + }, + } + ); + this.actionReportId = this.action.context.report_id; + const isOpeningReport = !this.action?.keep_journal_groups_options // true when opening the report, except when coming from the breadcrumb + const mainReportOptions = await this.loadReportOptions(this.actionReportId, false, this.action.params?.ignore_session, isOpeningReport); + const cacheKey = this.getCacheKey(mainReportOptions['sections_source_id'], mainReportOptions['report_id']); + + // We need the options to be set and saved in order for the loading to work properly + this.options = mainReportOptions; + this.reportOptionsMap[cacheKey] = mainReportOptions; + this.incrementCallNumber(cacheKey); + this.options["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey]; + this.saveSessionOptions(mainReportOptions); + + const activeSectionPromise = this.displayReport(mainReportOptions['report_id']); + this.preLoadClosedSections(); + await activeSectionPromise; + } + + getCacheKey(sectionsSourceId, reportId) { + return `${sectionsSourceId}_${reportId}` + } + + incrementCallNumber(cacheKey = null) { + if (!cacheKey) { + cacheKey = this.getCacheKey(this.options['sections_source_id'], this.options['report_id']); + } + this.loadingCallNumberByCacheKey[cacheKey] += 1; + } + + async displayReport(reportId) { + const cacheKey = await this.loadReport(reportId); + const options = await this.reportOptionsMap[cacheKey]; + const informationMap = await this.reportInformationMap[cacheKey]; + if ( + options !== undefined + && this.loadingCallNumberByCacheKey[cacheKey] === options["loading_call_number"] + && (this.lastOpenedSectionByReport === {} || this.lastOpenedSectionByReport[options['selected_variant_id']] === options['selected_section_id']) + ) { + // the options gotten from the python correspond to the ones that called this displayReport + this.options = options; + + // informationMap might be undefined if the promise has been deleted by another call. + // Don't need to set data, the call that deleted it is coming to re-put data + if (informationMap !== undefined) { + this.data = informationMap; + // If there is a specific order for lines in the options, we want to use it by default + if (this.areLinesOrdered()) { + await this.sortLines(); + } + this.setLineVisibility(this.lines); + this.refreshVisibleAnnotations(); + this.saveSessionOptions(this.options); + } + + } + } + + async reload(optionPath, newOptions) { + const rootOptionKey = optionPath ? optionPath.split(".")[0] : ""; + + /* + When reloading the UI after setting an option filter, invalidate the cached options and data of all sections supporting this filter. + This way, those sections will be reloaded (either synchronously when the user tries to access them or asynchronously via the preloading + feature), and will then use the new filter value. This ensures the filters are always applied consistently to all sections. + */ + for (const [cacheKey, cachedOptionsPromise] of Object.entries(this.reportOptionsMap)) { + let cachedOptions = await cachedOptionsPromise; + + if (rootOptionKey === "" || cachedOptions.hasOwnProperty(rootOptionKey)) { + delete this.reportOptionsMap[cacheKey]; + delete this.reportInformationMap[cacheKey]; + } + } + + this.saveSessionOptions(newOptions); // The new options will be loaded from the session. Saving them now ensures the new filter is taken into account. + await this.displayReport(newOptions['report_id']); + } + + async preLoadClosedSections() { + let sectionLoaded = false; + for (const section of this.options['sections']) { + // Preload the first non-loaded section we find amongst this report's sections. + const cacheKey = this.getCacheKey(this.options['sections_source_id'], section.id); + if (section.id != this.options['report_id'] && !this.reportInformationMap[cacheKey]) { + await this.loadReport(section.id, true); + + sectionLoaded = true; + // Stop iterating and schedule next call. We don't go on in the loop in case the cache is reset and we need to restart preloading. + break; + } + } + + let nextCallDelay = (sectionLoaded) ? 100 : 1000; + + const self = this; + setTimeout(() => self.preLoadClosedSections(), nextCallDelay); + } + + async loadReport(reportId, preloading=false) { + const options = await this.loadReportOptions(reportId, preloading, false); // This also sets the promise in the cache + const reportToDisplayId = options['report_id']; // Might be different from reportId, in case the report to open uses sections + + const cacheKey = this.getCacheKey(options['sections_source_id'], reportToDisplayId) + if (!this.reportInformationMap[cacheKey]) { + this.reportInformationMap[cacheKey] = this.orm.call( + "account.report", + options.readonly_query ? "get_report_information_readonly" : "get_report_information", + [ + reportToDisplayId, + options, + ], + { + context: this.action.context, + }, + ); + } + + await this.reportInformationMap[cacheKey]; + + if (!preloading) { + if (options['sections'].length) + this.lastOpenedSectionByReport[options['sections_source_id']] = options['selected_section_id']; + } + + return cacheKey; + } + + async loadReportOptions(reportId, preloading=false, ignore_session=false, isOpeningReport=false) { + const loadOptions = (ignore_session || !this.hasSessionOptions()) ? (this.action.params?.options || {}) : this.sessionOptions(); + const cacheKey = this.getCacheKey(loadOptions['sections_source_id'] || reportId, reportId); + + if (!(cacheKey in this.loadingCallNumberByCacheKey)) { + this.incrementCallNumber(cacheKey); + } + loadOptions["loading_call_number"] = this.loadingCallNumberByCacheKey[cacheKey]; + + loadOptions["is_opening_report"] = isOpeningReport; + + if (!this.reportOptionsMap[cacheKey]) { + // The options for this section are not loaded nor loading. Let's load them ! + + if (preloading) + loadOptions['selected_section_id'] = reportId; + else { + /* Reopen the last opened section by default (cannot be done through regular caching, because composite reports' options are not + cached (since they always reroute). */ + if (this.lastOpenedSectionByReport[reportId]) + loadOptions['selected_section_id'] = this.lastOpenedSectionByReport[reportId]; + } + + this.reportOptionsMap[cacheKey] = this.orm.call( + "account.report", + "get_options", + [ + reportId, + loadOptions, + ], + { + context: this.action.context, + }, + ); + + // Wait for the result, and check the report hasn't been rerouted to a section or variant; fix the cache if it has + let reportOptions = await this.reportOptionsMap[cacheKey]; + + // In case of a reroute, also set the cached options into the reroute target's key + const loadedOptionsCacheKey = this.getCacheKey(reportOptions['sections_source_id'], reportOptions['report_id']); + if (loadedOptionsCacheKey !== cacheKey) { + /* We delete the rerouting report from the cache, to avoid redoing this reroute when reloading the cached options, as it would mean + route reports can never be opened directly if they open some variant by default.*/ + delete this.reportOptionsMap[cacheKey]; + this.reportOptionsMap[loadedOptionsCacheKey] = reportOptions; + + this.loadingCallNumberByCacheKey[loadedOptionsCacheKey] = 1; + delete this.loadingCallNumberByCacheKey[cacheKey]; + return reportOptions; + } + } + + return this.reportOptionsMap[cacheKey]; + } + + //------------------------------------------------------------------------------------------------------------------ + // Generic data getters + //------------------------------------------------------------------------------------------------------------------ + get buttons() { + return this.options.buttons; + } + + get caretOptions() { + return this.data.caret_options; + } + + get columnHeadersRenderData() { + return this.data.column_headers_render_data; + } + + get columnGroupsTotals() { + return this.data.column_groups_totals; + } + + get context() { + return this.data.context; + } + + get filters() { + return this.data.filters; + } + + get annotations() { + return this.data.annotations; + } + + get groups() { + return this.data.groups; + } + + get lines() { + return this.data.lines; + } + + get warnings() { + return this.data.warnings; + } + + get linesOrder() { + return this.data.lines_order; + } + + get report() { + return this.data.report; + } + + get visibleAnnotations() { + return this.data.visible_annotations; + } + + //------------------------------------------------------------------------------------------------------------------ + // Generic data setters + //------------------------------------------------------------------------------------------------------------------ + set annotations(value) { + this.data.annotations = value; + } + + set columnGroupsTotals(value) { + this.data.column_groups_totals = value; + } + + set lines(value) { + this.data.lines = value; + this.setLineVisibility(this.lines); + } + + set linesOrder(value) { + this.data.lines_order = value; + } + + set visibleAnnotations(value) { + this.data.visible_annotations = value; + } + + //------------------------------------------------------------------------------------------------------------------ + // Helpers + //------------------------------------------------------------------------------------------------------------------ + get needsColumnPercentComparison() { + return this.options.column_percent_comparison === "growth"; + } + + get hasCustomSubheaders() { + return this.columnHeadersRenderData.custom_subheaders.length > 0; + } + + get hasDebugColumn() { + return Boolean(this.options.show_debug_column); + } + + get hasStringDate() { + return "date" in this.options && "string" in this.options.date; + } + + get hasVisibleAnnotations() { + return Boolean(this.visibleAnnotations.length); + } + + //------------------------------------------------------------------------------------------------------------------ + // Options + //------------------------------------------------------------------------------------------------------------------ + async _updateOption(operationType, optionPath, optionValue=null, reloadUI=false) { + const optionKeys = optionPath.split("."); + + let currentOptionKey = null; + let option = this.options; + + while (optionKeys.length > 1) { + currentOptionKey = optionKeys.shift(); + option = option[currentOptionKey]; + + if (option === undefined) + throw new Error(`Invalid option key in _updateOption(): ${ currentOptionKey } (${ optionPath })`); + } + + switch (operationType) { + case "update": + option[optionKeys[0]] = optionValue; + break; + case "delete": + delete option[optionKeys[0]]; + break; + case "toggle": + option[optionKeys[0]] = !option[optionKeys[0]]; + break; + default: + throw new Error(`Invalid operation type in _updateOption(): ${ operationType }`); + } + + if (reloadUI) { + this.incrementCallNumber(); + await this.reload(optionPath, this.options); + } + } + + async updateOption(optionPath, optionValue, reloadUI=false) { + await this._updateOption('update', optionPath, optionValue, reloadUI); + } + + async deleteOption(optionPath, reloadUI=false) { + await this._updateOption('delete', optionPath, null, reloadUI); + } + + async toggleOption(optionPath, reloadUI=false) { + await this._updateOption('toggle', optionPath, null, reloadUI); + } + + async switchToSection(reportId) { + this.saveSessionOptions({...this.options, 'selected_section_id': reportId}); + this.displayReport(reportId); + } + + //------------------------------------------------------------------------------------------------------------------ + // Session options + //------------------------------------------------------------------------------------------------------------------ + sessionOptionsID() { + /* Options are stored by action report (so, the report that was targetted by the original action triggering this flow). + This allows a more intelligent reloading of the previous options during user navigation (especially concerning sections and variants; + you expect your report to open by default the same section as last time you opened it in this http session). + */ + return `account.report:${ this.actionReportId }:${ session.user_companies.current_company }`; + } + + hasSessionOptions() { + return Boolean(browser.sessionStorage.getItem(this.sessionOptionsID())) + } + + saveSessionOptions(options) { + browser.sessionStorage.setItem(this.sessionOptionsID(), JSON.stringify(options)); + } + + sessionOptions() { + return JSON.parse(browser.sessionStorage.getItem(this.sessionOptionsID())); + } + + //------------------------------------------------------------------------------------------------------------------ + // Lines + //------------------------------------------------------------------------------------------------------------------ + lineHasDebugData(lineIndex) { + return 'debug_popup_data' in this.lines[lineIndex]; + } + + lineHasGrowthComparisonData(lineIndex) { + return Boolean(this.lines[lineIndex].column_percent_comparison_data); + } + + isLineAncestorOf(ancestorLineId, lineId) { + return lineId.startsWith(`${ancestorLineId}|`); + } + + isLineChildOf(childLineId, lineId) { + return childLineId.startsWith(`${lineId}|`); + } + + isLineRelatedTo(relatedLineId, lineId) { + return this.isLineAncestorOf(relatedLineId, lineId) || this.isLineChildOf(relatedLineId, lineId); + } + + isNextLineChild(index, lineId) { + return index < this.lines.length && this.lines[index].id.startsWith(`${lineId}|`); + } + + isNextLineDirectChild(index, lineId) { + return index < this.lines.length && this.lines[index].parent_id === lineId; + } + + isTotalLine(lineIndex) { + return this.lines[lineIndex].id.includes("|total~~"); + } + + isLoadMoreLine(lineIndex) { + return this.lines[lineIndex].id.includes("|load_more~~"); + } + + isLoadedLine(lineIndex) { + const lineID = this.lines[lineIndex].id; + const nextLineIndex = lineIndex + 1; + + return this.isNextLineChild(nextLineIndex, lineID) && !this.isTotalLine(nextLineIndex) && !this.isLoadMoreLine(nextLineIndex); + } + + async replaceLineWith(replaceIndex, newLines) { + await this.insertLines(replaceIndex, 1, newLines); + } + + async insertLinesAfter(insertIndex, newLines) { + await this.insertLines(insertIndex + 1, 0, newLines); + } + + async insertLines(lineIndex, deleteCount, newLines) { + this.lines.splice(lineIndex, deleteCount, ...newLines); + } + + //------------------------------------------------------------------------------------------------------------------ + // Unfolded/Folded lines + //------------------------------------------------------------------------------------------------------------------ + async unfoldLoadedLine(lineIndex) { + const lineId = this.lines[lineIndex].id; + let nextLineIndex = lineIndex + 1; + + while (this.isNextLineChild(nextLineIndex, lineId)) { + if (this.isNextLineDirectChild(nextLineIndex, lineId)) { + const nextLine = this.lines[nextLineIndex]; + nextLine.visible = true; + if (!nextLine.unfoldable && this.isNextLineChild(nextLineIndex + 1, nextLine.id)) { + await this.unfoldLine(nextLineIndex); + } + } + nextLineIndex += 1; + } + return nextLineIndex; + } + + async unfoldNewLine(lineIndex) { + const options = await this.options; + const newLines = await this.orm.call( + "account.report", + options.readonly_query ? "get_expanded_lines_readonly" : "get_expanded_lines", + [ + this.options['report_id'], + this.options, + this.lines[lineIndex].id, + this.lines[lineIndex].groupby, + this.lines[lineIndex].expand_function, + this.lines[lineIndex].progress, + 0, + this.lines[lineIndex].horizontal_split_side, + ], + ); + + if (this.areLinesOrdered()) { + this.updateLinesOrderIndexes(lineIndex, newLines, false) + } + this.insertLinesAfter(lineIndex, newLines); + + const totalIndex = lineIndex + newLines.length + 1; + + if (this.filters.show_totals && this.lines[totalIndex] && this.isTotalLine(totalIndex)) + this.lines[totalIndex].visible = true; + + // Update options + this.options.unfolded_lines.push( + ...newLines.filter(line => line.unfolded).map(({ id }) => id) + ); + + this.saveSessionOptions(this.options); + + return totalIndex + } + + /** + * When unfolding a line of a sorted report, we need to update the linesOrder array by adding the new lines, + * which will require subsequent updates on the array. + * + * - lineOrderValue represents the line index before sorting the report. + * @param {Integer} lineIndex: Index of the current line + * @param {Array} newLines: Array of lines to be added + * @param {Boolean} replaceLine: Useful for the splice of the linesOrder array in case we want to replace some line + * example: With the load more, we want to replace the line with others + **/ + updateLinesOrderIndexes(lineIndex, newLines, replaceLine) { + let unfoldedLineIndex; + // The offset is useful because in case we use 'replaceLineWith' we want to replace the line at index + // unfoldedLineIndex with the new lines. + const offset = replaceLine ? 0 : 1; + for (const [lineOrderIndex, lineOrderValue] of Object.entries(this.linesOrder)) { + // Since we will have to add new lines into the linesOrder array, we have to update the index of the lines + // having a bigger index than the one we will unfold. + // deleteCount of 1 means that a line need to be replaced so the index need to be increase by 1 less than usual + if (lineOrderValue > lineIndex) { + this.linesOrder[lineOrderIndex] += newLines.length - replaceLine; + } + // The unfolded line is found, providing a reference for adding children in the 'linesOrder' array. + if (lineOrderValue === lineIndex) { + unfoldedLineIndex = parseInt(lineOrderIndex) + } + } + + const arrayOfNewIndex = Array.from({ length: newLines.length }, (dummy, index) => this.linesOrder[unfoldedLineIndex] + index + offset); + this.linesOrder.splice(unfoldedLineIndex + offset, replaceLine, ...arrayOfNewIndex); + } + + async unfoldLine(lineIndex) { + const targetLine = this.lines[lineIndex]; + let lastLineIndex = lineIndex + 1; + + if (this.isLoadedLine(lineIndex)) + lastLineIndex = await this.unfoldLoadedLine(lineIndex); + else if (targetLine.expand_function) { + lastLineIndex = await this.unfoldNewLine(lineIndex); + } + + this.setLineVisibility(this.lines.slice(lineIndex + 1, lastLineIndex)); + targetLine.unfolded = true; + this.refreshVisibleAnnotations(); + + // Update options + if (!this.options.unfolded_lines.includes(targetLine.id)) + this.options.unfolded_lines.push(targetLine.id); + + this.saveSessionOptions(this.options); + } + + foldLine(lineIndex) { + const targetLine = this.lines[lineIndex]; + + let foldedLinesIDs = new Set([targetLine.id]); + let nextLineIndex = lineIndex + 1; + + while (this.isNextLineChild(nextLineIndex, targetLine.id)) { + this.lines[nextLineIndex].unfolded = false; + this.lines[nextLineIndex].visible = false; + + foldedLinesIDs.add(this.lines[nextLineIndex].id); + + nextLineIndex += 1; + } + + targetLine.unfolded = false; + + this.refreshVisibleAnnotations(); + + // Update options + this.options.unfolded_lines = this.options.unfolded_lines.filter( + unfoldedLineID => !foldedLinesIDs.has(unfoldedLineID) + ); + + this.saveSessionOptions(this.options); + } + + //------------------------------------------------------------------------------------------------------------------ + // Ordered lines + //------------------------------------------------------------------------------------------------------------------ + linesCurrentOrderByColumn(columnIndex) { + if (this.areLinesOrderedByColumn(columnIndex)) + return this.options.order_column.direction; + + return "default"; + } + + areLinesOrdered() { + return this.linesOrder != null && this.options.order_column != null; + } + + areLinesOrderedByColumn(columnIndex) { + return this.areLinesOrdered() && this.options.order_column.expression_label === this.options.columns[columnIndex].expression_label; + } + + async sortLinesByColumnAsc(columnIndex) { + this.options.order_column = { + expression_label: this.options.columns[columnIndex].expression_label, + direction: "ASC", + }; + + await this.sortLines(); + this.saveSessionOptions(this.options); + } + + async sortLinesByColumnDesc(columnIndex) { + this.options.order_column = { + expression_label: this.options.columns[columnIndex].expression_label, + direction: "DESC", + }; + + await this.sortLines(); + this.saveSessionOptions(this.options); + } + + sortLinesByDefault() { + delete this.options.order_column; + delete this.data.lines_order; + + this.saveSessionOptions(this.options); + } + + async sortLines() { + this.linesOrder = await this.orm.call( + "account.report", + "sort_lines", + [ + this.lines, + this.options, + true, + ], + { + context: this.action.context, + }, + ); + } + + //------------------------------------------------------------------------------------------------------------------ + // Annotations + //------------------------------------------------------------------------------------------------------------------ + async refreshAnnotations() { + this.annotations = await this.orm.call("account.report", "get_annotations", [ + this.action.context.report_id, + this.options, + ]); + + this.refreshVisibleAnnotations(); + } + + //------------------------------------------------------------------------------------------------------------------ + // Visibility + //------------------------------------------------------------------------------------------------------------------ + + refreshVisibleAnnotations() { + const visibleAnnotations = new Proxy( + {}, + { + get(target, name) { + return name in target ? target[name] : []; + }, + set(target, name, newValue) { + target[name] = newValue; + return true; + }, + } + ); + + this.lines.forEach((line) => { + line["visible_annotations"] = []; + const lineWithoutTaxGrouping = removeTaxGroupingFromLineId(line.id); + if (line.visible && this.annotations[lineWithoutTaxGrouping]) { + for (const index in this.annotations[lineWithoutTaxGrouping]) { + const annotation = this.annotations[lineWithoutTaxGrouping][index]; + visibleAnnotations[lineWithoutTaxGrouping] = [ + ...visibleAnnotations[lineWithoutTaxGrouping], + { ...annotation }, + ]; + line["visible_annotations"].push({ + ...annotation, + }); + } + } + + if ( + line.visible_annotations && + (!this.annotations[lineWithoutTaxGrouping] || !line.visible) + ) { + delete line.visible_annotations; + } + }); + + this.visibleAnnotations = visibleAnnotations; + } + + /** + Defines which lines should be visible in the provided list of lines (depending on what is folded). + **/ + setLineVisibility(linesToAssign) { + let needHidingChildren = new Set(); + + linesToAssign.forEach((line) => { + line.visible = !needHidingChildren.has(line.parent_id); + + if (!line.visible || (line.unfoldable &! line.unfolded)) + needHidingChildren.add(line.id); + }); + + // If the hide 0 lines is activated we will go through the lines to set the visibility. + if (this.options.hide_0_lines) { + this.hideZeroLines(linesToAssign); + } + } + + /** + * Defines whether the line should be visible depending on its value and the ones of its children. + * For parent lines, it's visible if there is at least one child with a value different from zero + * or if a child is visible, indicating it's a parent line. + * For leaf nodes, it's visible if the value is different from zero. + * + * By traversing the 'lines' array in reverse, we can set the visibility of the lines easily by keeping + * a dict of visible lines for each parent. + * + * @param {Object} lines - The lines for which we want to determine visibility. + */ + hideZeroLines(lines) { + const hasVisibleChildren = new Set(); + const reversed_lines = [...lines].reverse() + + const number_figure_types = ['integer', 'float', 'monetary', 'percentage']; + reversed_lines.forEach((line) => { + const isZero = line.columns.every(column => !number_figure_types.includes(column.figure_type) || column.is_zero); + + // If the line has no visible children and all the columns are equals to zero then the line needs to be hidden + if (!hasVisibleChildren.has(line.id) && isZero) { + line.visible = false; + } + + // If the line has a parent_id and is not hidden then we fill the set 'hasVisibleChildren'. Each parent + // will have an array of his visible children + if (line.parent_id && line.visible) { + // This line allows the initialization of that list. + hasVisibleChildren.add(line.parent_id); + } + }) + } + + //------------------------------------------------------------------------------------------------------------------ + // Server calls + //------------------------------------------------------------------------------------------------------------------ + buttonAction(ev, button) { + // Might be overidden to add specific functionality to button + // For instance adding context to a call ... + this.reportAction(ev, button.error_action || button.action, button.action_param, true); + } + + async reportAction(ev, action, actionParam = null, callOnSectionsSource = false, actionContext=null) { + // 'ev' might be 'undefined' if event is not triggered from a button/anchor + ev?.preventDefault(); + ev?.stopPropagation(); + + let actionOptions = this.options; + if (callOnSectionsSource) { + // When calling the sections source, we want to keep track of all unfolded lines of all sections + const allUnfoldedLines = this.options.sections.length ? [] : [...this.options['unfolded_lines']] + + for (const sectionData of this.options['sections']) { + const cacheKey = this.getCacheKey(this.options['sections_source_id'], sectionData['id']); + const sectionOptions = await this.reportOptionsMap[cacheKey]; + if (sectionOptions) + allUnfoldedLines.push(...sectionOptions['unfolded_lines']); + } + + actionOptions = {...this.options, unfolded_lines: allUnfoldedLines}; + } + + const dispatchReportAction = await this.orm.call( + "account.report", + "dispatch_report_action", + [ + this.options['report_id'], + actionOptions, + action, + actionParam, + callOnSectionsSource, + ], + { + context: Object.assign({}, this.context, actionContext) + } + ); + if (dispatchReportAction?.help) { + dispatchReportAction.help = owl.markup(dispatchReportAction.help) + } + + return dispatchReportAction ? this.actionService.doAction(dispatchReportAction) : null; + } + + // ----------------------------------------------------------------------------------------------------------------- + // Budget + // ----------------------------------------------------------------------------------------------------------------- + + async openBudget(budget) { + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "account.report.budget", + res_id: budget.id, + views: [[false, "form"]], + }); + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js new file mode 100644 index 0000000..c448c09 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.js @@ -0,0 +1,64 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { localization } from "@web/core/l10n/localization"; +import { useService } from "@web/core/utils/hooks"; +import { Component, useState } from "@odoo/owl"; + +import { AccountReportEllipsisPopover } from "@odex30_account_reports/components/account_report/ellipsis/popover/ellipsis_popover"; + +export class AccountReportEllipsis extends Component { + static template = "odex30_account_reports.AccountReportEllipsis"; + static props = { + name: { type: String, optional: true }, + no_format: { optional: true }, + type: { type: String, optional: true }, + maxCharacters: Number, + }; + + setup() { + this.popover = useService("popover"); + this.notification = useService("notification"); + this.controller = useState(this.env.controller); + } + + //------------------------------------------------------------------------------------------------------------------ + // Ellipsis + //------------------------------------------------------------------------------------------------------------------ + get triggersEllipsis() { + if (this.props.name) + return this.props.name.length > this.props.maxCharacters; + + return false; + } + + copyEllipsisText() { + navigator.clipboard.writeText(this.props.name); + this.notification.add(_t("Text copied"), { type: 'success' }); + this.popoverCloseFn(); + this.popoverCloseFn = null; + } + + showEllipsisPopover(ev) { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.popoverCloseFn) { + this.popoverCloseFn(); + this.popoverCloseFn = null; + } + + this.popoverCloseFn = this.popover.add( + ev.currentTarget, + AccountReportEllipsisPopover, + { + name: this.props.name, + copyEllipsisText: this.copyEllipsisText.bind(this), + }, + { + closeOnClickAway: true, + position: localization.direction === "rtl" ? "left" : "right", + }, + ); + } +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml new file mode 100644 index 0000000..ffc0798 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/ellipsis.xml @@ -0,0 +1,28 @@ + + + + + + + + +
    + +
    + + + + +
    + +
    + +
    +
    +
    +
    diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.js b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.js new file mode 100644 index 0000000..2f73313 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.js @@ -0,0 +1,12 @@ +/** @odoo-module */ + +import { Component } from "@odoo/owl"; + +export class AccountReportEllipsisPopover extends Component { + static template = "odex30_account_reports.AccountReportEllipsisPopover"; + static props = { + close: Function, + name: String, + copyEllipsisText: Function, + }; +} diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.xml new file mode 100644 index 0000000..a8f6f7f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/ellipsis/popover/ellipsis_popover.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml new file mode 100644 index 0000000..df0496b --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_account_type.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml new file mode 100644 index 0000000..edc34ad --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_aml_ir_filters.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml new file mode 100644 index 0000000..ef4a05f --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml new file mode 100644 index 0000000..f26fb87 --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_analytic_groupby.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml new file mode 100644 index 0000000..a5da3fa --- /dev/null +++ b/dev_odex30_accounting/odex30_account_reports/static/src/components/account_report/filters/filter_budgets.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + +