diff --git a/intrastat_base/models/__init__.py b/intrastat_base/models/__init__.py index cea19b9f8..cc10e08e5 100644 --- a/intrastat_base/models/__init__.py +++ b/intrastat_base/models/__init__.py @@ -3,3 +3,4 @@ from . import account_fiscal_position from . import account_fiscal_position_template from . import account_move +from . import res_partner diff --git a/intrastat_base/models/res_partner.py b/intrastat_base/models/res_partner.py new file mode 100644 index 000000000..ed692b8ce --- /dev/null +++ b/intrastat_base/models/res_partner.py @@ -0,0 +1,67 @@ +# Copyright 2022 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.exceptions import UserError + +XI_COUNTY_NAMES = [ + "antrim", + "armagh", + "down", + "fermanagh", + "londonderry", + "tyrone", + "northern ireland", +] + +XI_COUNTIES = [ + "base.state_uk18", # County Antrim + "base.state_uk19", # County Armagh + "base.state_uk20", # County Down + "base.state_uk22", # County Fermanagh + "base.state_uk23", # County Londonderry + "base.state_uk24", # County Tyrone + "base.state_ie_27", # Antrim + "base.state_ie_28", # Armagh + "base.state_ie_29", # Down + "base.state_ie_30", # Fermanagh + "base.state_ie_31", # Londonderry + "base.state_ie_32", # Tyrone +] + + +class ResPartner(models.Model): + _inherit = "res.partner" + + @api.model + def _get_xi_counties(self): + return [self.env.ref(x) for x in XI_COUNTIES] + + @api.model + def _get_xu_counties(self): + uk_counties = self.env.ref("base.uk").state_ids + xu_counties = uk_counties.filtered(lambda r: r not in self._get_xi_counties()) + return xu_counties + + def _get_intrastat_country_code(self, country=None, state=None): + if self: + self.ensure_one() + country = self.country_id + state = self.state_id + else: + state = state or self.env["res.country.state"] + country = country or state.country_id + if not country: + raise UserError( + _("Programming Error when calling '_get_intrastat_country_code()") + ) + cc = country.code + if cc == "GB": + cc = "XU" + if state and cc in ["XU", "IE"]: + if ( + state in self._get_xi_counties() + or state.name.lower().strip() in XI_COUNTY_NAMES + ): + cc = "XI" + return cc diff --git a/intrastat_product/models/account_move.py b/intrastat_product/models/account_move.py index f916aa436..6c7e14386 100644 --- a/intrastat_product/models/account_move.py +++ b/intrastat_product/models/account_move.py @@ -1,5 +1,5 @@ # Copyright 2011-2020 Akretion France (http://www.akretion.com) -# Copyright 2009-2020 Noviat (http://www.noviat.com) +# Copyright 2009-2022 Noviat (http://www.noviat.com) # @author Alexis de Lattre # @author Luc de Meyer @@ -93,6 +93,14 @@ def _get_intrastat_line_vals(self, line): if not hs_code: return vals weight, qty = decl_model._get_weight_and_supplunits(line, hs_code, notedict) + product_country = line.product_id.origin_country_id + product_state = line.product_id.origin_state_id + country = product_country or product_state.country_id + product_origin_country_code = "QU" + if country: + product_origin_country_code = self.env[ + "res.partner" + ]._get_intrastat_country_code(product_country, product_state) vals.update( { "invoice_line_id": line.id, @@ -100,6 +108,7 @@ def _get_intrastat_line_vals(self, line): "transaction_weight": int(weight), "transaction_suppl_unit_qty": qty, "product_origin_country_id": line.product_id.origin_country_id.id, + "product_origin_country_code": product_origin_country_code, } ) return vals @@ -160,11 +169,23 @@ class AccountMoveIntrastatLine(models.Model): transaction_weight = fields.Integer( help="Transaction weight in Kg: Quantity x Product Weight" ) + # product_origin_country_id is replaced by product_origin_country_code + # this field should be dropped once the localisation modules have been + # adapted accordingly product_origin_country_id = fields.Many2one( comodel_name="res.country", string="Country of Origin", help="Country of origin of the product i.e. product " "'made in ____'.", ) + product_origin_country_code = fields.Char( + string="Country of Origin of the Product", + size=2, + required=True, + default="QU", + help="2 digit code of country of origin of the product except for the UK.\n" + "Specify 'XI' for UK Northern Ireland and 'XU' for rest of the UK.\n" + "Specify 'QU' when the country is unknown.\n", + ) @api.onchange("invoice_line_id") def _onchange_move_id(self): @@ -176,3 +197,18 @@ def _onchange_move_id(self): ("id", "not in", moves.mapped("intrastat_line_ids.invoice_line_id").ids), ] return {"domain": {"invoice_line_id": dom}} + + @api.model + def create(self, vals): + self._format_vals(vals) + return super().create(vals) + + def write(self, vals): + self._format_vals(vals) + return super().write(vals) + + def _format_vals(self, vals): + if "product_origin_country_code" in vals: + vals["product_origin_country_code"] = ( + vals["product_origin_country_code"].upper().strip() + ) diff --git a/intrastat_product/models/intrastat_product_declaration.py b/intrastat_product/models/intrastat_product_declaration.py index 91cc15d51..ca066eceb 100644 --- a/intrastat_product/models/intrastat_product_declaration.py +++ b/intrastat_product/models/intrastat_product_declaration.py @@ -1,9 +1,10 @@ # Copyright 2011-2020 Akretion France (http://www.akretion.com) -# Copyright 2009-2020 Noviat (http://www.noviat.com) +# Copyright 2009-2022 Noviat (http://www.noviat.com) # @author Alexis de Lattre # @author Luc de Meyer import logging +import warnings from datetime import date from dateutil.relativedelta import relativedelta @@ -490,8 +491,31 @@ def _get_incoterm(self, inv_line, notedict): return incoterm def _get_product_origin_country(self, inv_line, notedict): + warnings.warn( + "Method '_get_product_origin_country' is deprecated, " + "please use '_get_product_origin_country_code'.", + DeprecationWarning, + ) return inv_line.product_id.origin_country_id + def _get_product_origin_country_code( + self, inv_line, product_origin_country, notedict + ): + cc = "QU" + if product_origin_country.code: + cc = product_origin_country.code + year = self.year or str(inv_line.move_id.date.year) + if year >= "2021": + product_origin_state = getattr( + inv_line.product_id, + "origin_state_id", + self.env["res.country.state"], + ) + cc = self.env["res.partner"]._get_intrastat_country_code( + product_origin_country, product_origin_state + ) + return cc + def _get_vat(self, inv_line, notedict): vat = False inv = inv_line.move_id @@ -667,6 +691,9 @@ def _gather_invoices(self, notedict): partner_country = self._get_partner_country( inv_line, notedict, eu_countries ) + partner_country_code = ( + invoice.commercial_partner_id._get_intrastat_country_code() + ) if inv_intrastat_line: hs_code = inv_intrastat_line.hs_code_id @@ -708,9 +735,13 @@ def _gather_invoices(self, notedict): product_origin_country = ( inv_intrastat_line.product_origin_country_id ) + product_origin_country_code = ( + inv_intrastat_line.product_origin_country_code + ) else: - product_origin_country = self._get_product_origin_country( - inv_line, notedict + product_origin_country = inv_line.product_id.origin_country_id + product_origin_country_code = self._get_product_origin_country_code( + inv_line, product_origin_country, notedict ) region = self._get_region(inv_line, notedict) @@ -721,6 +752,7 @@ def _gather_invoices(self, notedict): "parent_id": self.id, "invoice_line_id": inv_line.id, "src_dest_country_id": partner_country.id, + "src_dest_country_code": partner_country_code, "product_id": inv_line.product_id.id, "hs_code_id": hs_code.id, "weight": weight, @@ -729,6 +761,7 @@ def _gather_invoices(self, notedict): "amount_accessory_cost_company_currency": 0.0, "transaction_id": intrastat_transaction.id, "product_origin_country_id": product_origin_country.id or False, + "product_origin_country_code": product_origin_country_code, "region_id": region and region.id or False, "vat": vat, } @@ -826,14 +859,13 @@ def action_gather(self): @api.model def _group_line_hashcode_fields(self, computation_line): return { - "country": computation_line.src_dest_country_id.id or False, + "country": computation_line.src_dest_country_code, "hs_code_id": computation_line.hs_code_id.id or False, "intrastat_unit": computation_line.intrastat_unit_id.id or False, "transaction": computation_line.transaction_id.id or False, "transport": computation_line.transport_id.id or False, "region": computation_line.region_id.id or False, - "product_origin_country": computation_line.product_origin_country_id.id - or False, + "product_origin_country": computation_line.product_origin_country_code, "vat": computation_line.vat or False, } @@ -846,6 +878,7 @@ def group_line_hashcode(self, computation_line): def _prepare_grouped_fields(self, computation_line, fields_to_sum): vals = { "src_dest_country_id": computation_line.src_dest_country_id.id, + "src_dest_country_code": computation_line.src_dest_country_code, "intrastat_unit_id": computation_line.intrastat_unit_id.id, "hs_code_id": computation_line.hs_code_id.id, "transaction_id": computation_line.transaction_id.id, @@ -853,6 +886,7 @@ def _prepare_grouped_fields(self, computation_line, fields_to_sum): "region_id": computation_line.region_id.id, "parent_id": computation_line.parent_id.id, "product_origin_country_id": computation_line.product_origin_country_id.id, + "product_origin_country_code": computation_line.product_origin_country_code, "amount_company_currency": 0.0, "vat": computation_line.vat, } @@ -1049,6 +1083,15 @@ class IntrastatProductComputationLine(models.Model): string="Country", help="Country of Origin/Destination", ) + src_dest_country_code = fields.Char( + string="Country Code", + compute="_compute_src_dest_country_code", + store=True, + required=True, + readonly=False, + help="2 digit code of country of origin/destination.\n" + "Specify 'XI' for UK Northern Ireland and 'XU' for rest of the UK.", + ) product_id = fields.Many2one( "product.product", related="invoice_line_id.product_id" ) @@ -1086,16 +1129,37 @@ class IntrastatProductComputationLine(models.Model): "intrastat.transaction", string="Intrastat Transaction" ) region_id = fields.Many2one("intrastat.region", string="Intrastat Region") - # extended declaration - incoterm_id = fields.Many2one("account.incoterms", string="Incoterm") - transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode") + # product_origin_country_id is replaced by product_origin_country_code + # this field should be dropped once the localisation modules have been + # adapted accordingly product_origin_country_id = fields.Many2one( "res.country", string="Country of Origin of the Product", help="Country of origin of the product i.e. product 'made in ____'", ) + product_origin_country_code = fields.Char( + string="Country of Origin of the Product", + size=2, + required=True, + default="QU", + help="2 digit code of country of origin of the product except for the UK.\n" + "Specify 'XI' for UK Northern Ireland and 'XU' for rest of the UK.\n" + "Specify 'QU' when the country is unknown.\n", + ) vat = fields.Char(string="VAT Number") + # extended declaration + incoterm_id = fields.Many2one("account.incoterms", string="Incoterm") + transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode") + + @api.onchange("src_dest_country_id") + def _onchange_src_dest_country_id(self): + self.src_dest_country_code = self.src_dest_country_id.code + if self.parent_id.year >= "2021": + self.src_dest_country_code = self.env[ + "res.partner" + ]._get_intrastat_country_code(country=self.src_dest_country_id) + @api.depends("transport_id") def _compute_check_validity(self): """TO DO: logic based upon fields""" @@ -1151,6 +1215,12 @@ class IntrastatProductDeclarationLine(models.Model): string="Country", help="Country of Origin/Destination", ) + src_dest_country_code = fields.Char( + string="Country Code", + required=True, + help="2 digit code of country of origin/destination.\n" + "Specify 'XI' for UK Northern Ireland and 'XU' for rest of the UK.", + ) hs_code_id = fields.Many2one("hs.code", string="Intrastat Code") intrastat_unit_id = fields.Many2one( "intrastat.unit", @@ -1172,15 +1242,35 @@ class IntrastatProductDeclarationLine(models.Model): "intrastat.transaction", string="Intrastat Transaction" ) region_id = fields.Many2one("intrastat.region", string="Intrastat Region") - # extended declaration - incoterm_id = fields.Many2one("account.incoterms", string="Incoterm") - transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode") + # product_origin_country_id is replaced by product_origin_country_code + # this field should be dropped once the localisation modules have been + # adapted accordingly product_origin_country_id = fields.Many2one( "res.country", string="Country of Origin of the Product", help="Country of origin of the product i.e. product 'made in ____'", ) + product_origin_country_code = fields.Char( + string="Country of Origin of the Product", + size=2, + required=True, + default="QU", + help="2 digit code of country of origin of the product except for the UK.\n" + "Specify 'XI' for UK Northern Ireland and 'XU' for rest of the UK.\n" + "Specify 'QU' when the country is unknown.\n", + ) vat = fields.Char(string="VAT Number") + # extended declaration + incoterm_id = fields.Many2one("account.incoterms", string="Incoterm") + transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode") + + @api.onchange("src_dest_country_id") + def _onchange_src_dest_country_id(self): + self.src_dest_country_code = self.src_dest_country_id.code + if self.parent_id.year >= "2021": + self.src_dest_country_code = self.env[ + "res.partner" + ]._get_intrastat_country_code(country=self.src_dest_country_id) @api.constrains("vat") def _check_vat(self): diff --git a/intrastat_product/report/intrastat_product_report_xls.py b/intrastat_product/report/intrastat_product_report_xls.py index 48ccb267e..54968fb6a 100644 --- a/intrastat_product/report/intrastat_product_report_xls.py +++ b/intrastat_product/report/intrastat_product_report_xls.py @@ -1,4 +1,4 @@ -# Copyright 2009-2020 Noviat +# Copyright 2009-2022 Noviat # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging @@ -60,7 +60,7 @@ def _get_template(self, declaration): }, "line": { "type": "string", - "value": self._render("line.src_dest_country_id.name"), + "value": self._render("line.src_dest_country_code"), }, "width": 28, }, diff --git a/intrastat_product/tests/__init__.py b/intrastat_product/tests/__init__.py index 804b5f798..a7ccefcbc 100644 --- a/intrastat_product/tests/__init__.py +++ b/intrastat_product/tests/__init__.py @@ -2,6 +2,7 @@ from . import common_purchase from . import common_sale from . import test_intrastat_product +from . import test_brexit from . import test_company from . import test_purchase_order from . import test_sale_order diff --git a/intrastat_product/tests/common.py b/intrastat_product/tests/common.py index 6efb68b55..ae849885f 100644 --- a/intrastat_product/tests/common.py +++ b/intrastat_product/tests/common.py @@ -39,7 +39,7 @@ def _init_fiscal_position(cls): @classmethod def _init_regions(cls): - # Create Belgium Region + # Create Region cls._create_region() vals = { @@ -75,7 +75,7 @@ def setUpClass(cls): @classmethod def _create_region(cls, vals=None): values = { - "code": "BE_w", + "code": "2", "country_id": cls.env.ref("base.be").id, "company_id": cls.env.company.id, "description": "Belgium Walloon Region", diff --git a/intrastat_product/tests/test_brexit.py b/intrastat_product/tests/test_brexit.py new file mode 100644 index 000000000..5001c5ace --- /dev/null +++ b/intrastat_product/tests/test_brexit.py @@ -0,0 +1,126 @@ +# Copyright 2022 Noviat. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests import Form, SavepointCase + +from .common import IntrastatProductCommon + + +class TestIntrastatBrexit(IntrastatProductCommon, SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.inv_obj = cls.env["account.move"] + cls.hs_code_whiskey = cls.env["hs.code"].create( + { + "description": "Whiskey", + "local_code": "22083000", + } + ) + cls.product_xi = cls.env["product.product"].create( + { + "name": "Bushmills Original", + "weight": 1.4, + "list_price": 30.0, + "standard_price": 15.0, + "origin_country_id": cls.env.ref("base.uk").id, + "origin_state_id": cls.env.ref("base.state_uk18").id, + "hs_code_id": cls.hs_code_whiskey.id, + } + ) + cls.product_xu = cls.env["product.product"].create( + { + "name": "Glenfiddich", + "weight": 1.4, + "list_price": 50.0, + "standard_price": 25.0, + "origin_country_id": cls.env.ref("base.uk").id, + "origin_state_id": cls.env.ref("base.state_uk6").id, + "hs_code_id": cls.hs_code_whiskey.id, + } + ) + cls.partner_xi = cls.env["res.partner"].create( + { + "name": "Bushmills Distillery", + "country_id": cls.env.ref("base.uk").id, + "state_id": cls.env.ref("base.state_uk18").id, + "vat": "XI123456782", + "property_account_position_id": cls.position.id, + } + ) + + def test_brexit_sale(self): + inv_out_xi = self.inv_obj.with_context(default_move_type="out_invoice").create( + { + "partner_id": self.partner_xi.id, + "fiscal_position_id": self.position.id, + } + ) + with Form(inv_out_xi) as inv_form: + with inv_form.invoice_line_ids.new() as ail: + ail.product_id = self.product_c3po.product_variant_ids[0] + inv_out_xi.action_post() + + self._create_declaration( + { + "declaration_type": "dispatches", + "year": str(inv_out_xi.date.year), + "month": str(inv_out_xi.date.month).zfill(2), + } + ) + self.declaration.action_gather() + self.declaration.generate_declaration() + cline = self.declaration.computation_line_ids + dline = self.declaration.declaration_line_ids + self.assertEqual(cline.src_dest_country_code, "XI") + self.assertEqual(dline.src_dest_country_code, "XI") + + def test_brexit_purchase(self): + inv_in_xi = self.inv_obj.with_context(default_move_type="in_invoice").create( + { + "partner_id": self.partner_xi.id, + "fiscal_position_id": self.position.id, + } + ) + with Form(inv_in_xi) as inv_form: + with inv_form.invoice_line_ids.new() as ail: + ail.product_id = self.product_xi + with inv_form.invoice_line_ids.new() as ail: + ail.product_id = self.product_xu + inv_in_xi.invoice_date = inv_in_xi.date + inv_in_xi.action_post() + + self._create_declaration( + { + "declaration_type": "arrivals", + "year": str(inv_in_xi.date.year), + "month": str(inv_in_xi.date.month).zfill(2), + } + ) + self.declaration.action_gather() + self.declaration.generate_declaration() + clines = self.declaration.computation_line_ids + cl_xi = clines.filtered(lambda r: r.product_id == self.product_xi) + cl_xu = clines.filtered(lambda r: r.product_id == self.product_xu) + dlines = self.declaration.declaration_line_ids + dl_xi = dlines.filtered(lambda r: r.computation_line_ids == cl_xi) + dl_xu = dlines.filtered(lambda r: r.computation_line_ids == cl_xu) + self.assertEqual(cl_xi.product_origin_country_code, "XI") + self.assertEqual(cl_xu.product_origin_country_code, "XU") + self.assertEqual(dl_xi.product_origin_country_code, "XI") + self.assertEqual(dl_xu.product_origin_country_code, "XU") + + def test_brexit_invoice_intrastat_details(self): + inv_in_xi = self.inv_obj.with_context(default_move_type="in_invoice").create( + { + "partner_id": self.partner_xi.id, + "fiscal_position_id": self.position.id, + } + ) + with Form(inv_in_xi) as inv_form: + with inv_form.invoice_line_ids.new() as ail: + ail.product_id = self.product_xi + inv_in_xi.invoice_date = inv_in_xi.date + inv_in_xi.compute_intrastat_lines() + ilines = inv_in_xi.intrastat_line_ids + self.assertEqual(ilines.product_origin_country_code, "XI") diff --git a/intrastat_product/views/account_move.xml b/intrastat_product/views/account_move.xml index 6d0ad073b..654faf892 100644 --- a/intrastat_product/views/account_move.xml +++ b/intrastat_product/views/account_move.xml @@ -47,10 +47,7 @@ - + diff --git a/intrastat_product/views/intrastat_product_declaration.xml b/intrastat_product/views/intrastat_product_declaration.xml index ce21b8d5d..aee1eeed9 100644 --- a/intrastat_product/views/intrastat_product_declaration.xml +++ b/intrastat_product/views/intrastat_product_declaration.xml @@ -224,6 +224,7 @@ + + + @@ -273,6 +279,7 @@ + @@ -287,11 +294,17 @@ attrs="{'required': [('reporting_level', '=', 'extended')], 'invisible': [('reporting_level', '!=', 'extended')]}" /> + + @@ -311,6 +324,7 @@ /> + + + @@ -351,7 +370,8 @@ invisible="not context.get('intrastat_product_declaration_line_main_view')" /> - + + @@ -365,11 +385,17 @@ /> + + diff --git a/product_harmonized_system/models/product_template.py b/product_harmonized_system/models/product_template.py index 4670cfc74..902deeaa4 100644 --- a/product_harmonized_system/models/product_template.py +++ b/product_harmonized_system/models/product_template.py @@ -23,10 +23,20 @@ class ProductTemplate(models.Model): "and configure the H.S. code on the product category.", ) origin_country_id = fields.Many2one( - "res.country", + comodel_name="res.country", string="Country of Origin", help="Country of origin of the product i.e. product " "'made in ____'.", ) + origin_state_id = fields.Many2one( + comodel_name="res.country.state", + string="Country State of Origin", + domain="[('country_id', '=?', origin_country_id)]", + help="Country State of origin of the product.\n" + "This field is used for the Intrastat declaration, " + "selecting one of the Northern Ireland counties will set the code 'XI' " + "for products from the United Kingdom whereas code 'XU' " + "will be used for the other UK counties.", + ) class ProductProduct(models.Model): diff --git a/product_harmonized_system/views/product_template.xml b/product_harmonized_system/views/product_template.xml index 59fa83efc..79d2b2713 100644 --- a/product_harmonized_system/views/product_template.xml +++ b/product_harmonized_system/views/product_template.xml @@ -20,6 +20,10 @@ name="origin_country_id" attrs="{'invisible': [('type', '=', 'service')]}" /> +