diff --git a/addons/account/__manifest__.py b/addons/account/__manifest__.py index 47f91fd2cd4..9b98ed5ff11 100644 --- a/addons/account/__manifest__.py +++ b/addons/account/__manifest__.py @@ -67,6 +67,7 @@ Core mechanisms for the accounting modules. To display the menuitems, install th 'views/report_tax.xml', 'views/res_users_views.xml', 'data/service_cron_reverse.xml', + 'views/account_fiscal_year_view.xml', ], 'demo': [ 'demo/account_demo.xml', diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index 4eb991c1183..95d9fcbf996 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -10,6 +10,7 @@ from . import chart_template from . import account_analytic_line from . import account_journal_dashboard from . import product +from . import account_fiscal_year from . import company from . import res_config_settings from . import account_cash_rounding diff --git a/addons/account/models/account_fiscal_year.py b/addons/account/models/account_fiscal_year.py new file mode 100644 index 00000000000..ce031fb3f59 --- /dev/null +++ b/addons/account/models/account_fiscal_year.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +from odoo.exceptions import ValidationError +from odoo import api, fields, models, _ +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + + +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.user.company_id) + + @api.constrains('date_from', 'date_to', 'company_id') + def _check_dates(self): + ''' + Check interleaving between fiscal years. + There are 3 cases to consider: + + s1 s2 e1 e2 + ( [----)----] + + s2 s1 e2 e1 + [----(----] ) + + s1 s2 e2 e1 + ( [----] ) + ''' + for fy in self: + # Starting date must be prior to the ending date + date_from = datetime.strptime(fy.date_from, DEFAULT_SERVER_DATE_FORMAT) + date_to = datetime.strptime(fy.date_to, DEFAULT_SERVER_DATE_FORMAT) + if date_to < date_from: + raise ValidationError(_('The ending date must not be prior to the starting date.')) + + + 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/addons/account/models/company.py b/addons/account/models/company.py index aa1526d7ce8..638b0b4bcd7 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -7,6 +7,7 @@ from odoo import fields, models, api, _ from odoo.exceptions import ValidationError, UserError from odoo.exceptions import UserError from odoo.tools.float_utils import float_round, float_is_zero +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, date_utils class ResCompany(models.Model): @@ -74,27 +75,59 @@ Best Regards,''')) return last_day > last_day_of_month and last_day_of_month or last_day @api.multi - def compute_fiscalyear_dates(self, date): - """ Computes the start and end dates of the fiscalyear where the given 'date' belongs to - @param date: a datetime object - @returns: a dictionary with date_from and date_to - """ - self = self[0] - last_month = self.fiscalyear_last_month - last_day = self.fiscalyear_last_day - if (date.month < last_month or (date.month == last_month and date.day <= last_day)): - date = date.replace(month=last_month, day=last_day) - else: - if last_month == 2 and last_day == 29 and (date.year + 1) % 4 != 0: - date = date.replace(month=last_month, day=28, year=date.year + 1) - else: - date = date.replace(month=last_month, day=last_day, year=date.year + 1) - date_to = date - date_from = date + timedelta(days=1) - if date_from.month == 2 and date_from.day == 29: - date_from = date_from.replace(day=28, year=date_from.year - 1) - else: - date_from = date_from.replace(year=date_from.year - 1) + def compute_fiscalyear_dates(self, current_date): + '''Computes 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': datetime.strptime(fiscalyear.date_from, DEFAULT_SERVER_DATE_FORMAT).date(), + 'date_to': datetime.strptime(fiscalyear.date_to, DEFAULT_SERVER_DATE_FORMAT).date(), + 'record': fiscalyear, + } + + date_from, date_to = date_utils.get_fiscal_year( + current_date, day=self.fiscalyear_last_day, month=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 = datetime.strptime(fiscalyear_from.date_to, DEFAULT_SERVER_DATE_FORMAT).date() + 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 = datetime.strptime(fiscalyear_to.date_from, DEFAULT_SERVER_DATE_FORMAT).date() - timedelta(days=1) + return {'date_from': date_from, 'date_to': date_to} def get_new_account_code(self, current_code, old_prefix, new_prefix): diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index 4f3116abd55..601bda4541e 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -31,6 +31,7 @@ class ResConfigSettings(models.TransientModel): group_analytic_tags = fields.Boolean(string='Analytic Tags', implied_group='analytic.group_analytic_tags') group_warning_account = fields.Boolean(string="Warnings in Invoices", implied_group='account.group_warning_account') group_cash_rounding = fields.Boolean(string="Cash Rounding", implied_group='account.group_cash_rounding') + group_fiscal_year = fields.Boolean(string='Fiscal Years', implied_group='account.group_fiscal_year') module_account_asset = fields.Boolean(string='Assets Management') module_account_deferred_revenue = fields.Boolean(string="Revenue Recognition") module_account_budget = fields.Boolean(string='Budget Management') diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 6e6d9ce21ce..b73419fd7fb 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -34,6 +34,11 @@ Allow the cash rounding management + + Allow to define fiscal years of more or less than a year + + + diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 8171601d4d9..932c565be0f 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -84,3 +84,5 @@ access_account_cashbox_line,account.bank.statement.cashbox.line,model_account_ca access_account_tax_group_internal_user,account.tax.group internal user,model_account_tax_group,base.group_user,1,0,0,0 access_account_tax_group,account.tax.group,model_account_tax_group,account.group_account_invoice,1,0,0,0 access_account_tax_group_manager,account.tax.group,model_account_tax_group,account.group_account_manager,1,1,1,1 +access_account_fiscal_year_user,account.fiscal.year.user,model_account_fiscal_year,account.group_account_user,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 diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index d50e277028c..750cddd82fa 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -16,3 +16,4 @@ from . import test_search from . import test_setup_bar from . import test_tax from . import test_templates_consistency +from . import test_account_fiscal_year diff --git a/addons/account/tests/test_account_fiscal_year.py b/addons/account/tests/test_account_fiscal_year.py new file mode 100644 index 00000000000..e1ce44c38fe --- /dev/null +++ b/addons/account/tests/test_account_fiscal_year.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + +import odoo.tests + +from datetime import datetime + + +@odoo.tests.tagged('post_install', '-at_install') +class TestFiscalPosition(AccountingTestCase): + + 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 = datetime.strptime(date, DEFAULT_SERVER_DATE_FORMAT) + res = company.compute_fiscalyear_dates(current_date) + self.assertEqual(res['date_from'].strftime(DEFAULT_SERVER_DATE_FORMAT), expected_date_from) + self.assertEqual(res['date_to'].strftime(DEFAULT_SERVER_DATE_FORMAT), expected_date_to) + + def test_default_fiscal_year(self): + '''Basic case with a fiscal year xxxx-01-01 - xxxx-12-31.''' + company = self.env.ref('base.main_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.ref('base.main_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.ref('base.main_company') + company.fiscalyear_last_day = 28 + company.fiscalyear_last_month = 2 + + self.check_compute_fiscal_year( + company, + '2016-03-01', + '2016-02-29', + '2017-02-28', + ) + + def test_custom_fiscal_year(self): + '''Case with custom fiscal years.''' + company = self.env.ref('base.main_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/addons/account/views/account_fiscal_year_view.xml b/addons/account/views/account_fiscal_year_view.xml new file mode 100644 index 00000000000..4714e5a0f8b --- /dev/null +++ b/addons/account/views/account_fiscal_year_view.xml @@ -0,0 +1,56 @@ + + + + + Fiscal Years + account.fiscal.year + form + tree,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.tree + account.fiscal.year + + + + + + + + + +
+
diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index c92d484504e..0776e2de628 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -183,6 +183,23 @@ + +

Fiscal Periods

+
+
+
+
+ +
+
+
+
+
+

Customer Payments

@@ -266,21 +283,6 @@
-

Fiscal Periods

-
-
-
-
- -
-
-
-
-

Bank & Cash

diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index 1719cb5b80d..6e89bdd9d43 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -17,3 +17,4 @@ from .mail import * from .func import * from .debugger import * from .xml_utils import * +from .date_utils import * diff --git a/odoo/tools/date_utils.py b/odoo/tools/date_utils.py new file mode 100644 index 00000000000..76b19c2cf55 --- /dev/null +++ b/odoo/tools/date_utils.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +import math +import calendar + +from dateutil.relativedelta import relativedelta + + +def get_month(date): + ''' Compute the month dates range on which the 'date' parameter belongs to. + + :param date: A datetime.datetime or datetime.date object. + :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter. + ''' + date_from = type(date)(date.year, date.month, 1) + date_to = type(date)(date.year, date.month, calendar.monthrange(date.year, date.month)[1]) + return date_from, date_to + + +def get_quarter_number(date): + ''' Get the number of the quarter on which the 'date' parameter belongs to. + + :param date: A datetime.datetime or datetime.date object. + :return: A [1-4] integer. + ''' + return math.ceil(date.month / 3) + + +def get_quarter(date): + ''' Compute the quarter dates range on which the 'date' parameter belongs to. + + :param date: A datetime.datetime or datetime.date object. + :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter. + ''' + quarter_number = get_quarter_number(date) + month_from = ((quarter_number - 1) * 3) + 1 + date_from = type(date)(date.year, month_from, 1) + date_to = (date_from + relativedelta(months=2)) + date_to = date_to.replace(day=calendar.monthrange(date_to.year, date_to.month)[1]) + return date_from, date_to + + +def get_fiscal_year(date, day=31, month=12): + ''' Compute the fiscal year dates range on which the 'date' parameter belongs to. + A fiscal year is the period used by governments for accounting purposes and vary between countries. + + By default, calling this method with only one parameter gives the calendar year because the ending date of the + fiscal year is set to the YYYY-12-31. + + :param date: A datetime.datetime or datetime.date object. + :param day: The day of month the fiscal year ends. + :param month: The month of year the fiscal year ends. + :return: A tuple (date_from, date_to) having the same object type as the 'date' parameter. + ''' + max_day = calendar.monthrange(date.year, month)[1] + date_to = type(date)(date.year, month, min(day, max_day)) + if date <= date_to: + date_from = date_to - relativedelta(years=1) + date_from += relativedelta(days=1) + else: + date_from = date_to + relativedelta(days=1) + max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1] + date_to = type(date)(date.year + 1, month, min(day, max_day)) + return date_from, date_to