[IMP] l10n_in_edi: allow negative line for in invoice

Indian edi does not support line with a negative subtotal
but this use case is common when you want to apply a global discount.

This commit modify the json generated. The negative line are
splitted on same HSN code of the positive line affecting the discount of the positive
line.

The total of the invoice and the taxes remain unchanged.

closes odoo/odoo#109389

X-original-commit: 80701911229af6b9a6a29420796e45fcdbbd13ec
Signed-off-by: Quentin De Paoli <qdp@odoo.com>
Signed-off-by: Josse Colpaert <jco@odoo.com>
This commit is contained in:
Jigar Vaghela
2023-01-10 10:16:54 +01:00
parent e602a55503
commit 99e75e4e4e
3 changed files with 246 additions and 27 deletions
+113 -8
View File
@@ -6,9 +6,11 @@ import json
import pytz
import markupsafe
from collections import defaultdict
from odoo import models, fields, api, _
from odoo.tools import html_escape, float_is_zero
from odoo.exceptions import AccessError
from odoo.tools import html_escape, float_is_zero, float_compare
from odoo.exceptions import AccessError, ValidationError
from odoo.addons.iap import jsonrpc
import logging
@@ -69,6 +71,11 @@ class AccountEdiFormat(models.Model):
if not re.match("^.{1,16}$", move.name):
error_message.append(_("Invoice number should not be more than 16 characters"))
for line in move.invoice_line_ids.filtered(lambda line: line.display_type not in ('line_note', 'line_section', 'rounding')):
if line.price_subtotal < 0:
# Line having a negative amount is not allowed.
if not move._l10n_in_edi_is_managing_invoice_negative_lines_allowed():
raise ValidationError(_("Invoice lines having a negative amount are not allowed to generate the IRN. "
"Please create a credit note instead."))
if line.product_id:
hsn_code = self._l10n_in_edi_extract_digits(line.product_id.l10n_in_hsn_code)
if not hsn_code:
@@ -299,7 +306,8 @@ class AccountEdiFormat(models.Model):
"""
sign = line.move_id.is_inbound() and -1 or 1
tax_details_by_code = self._get_l10n_in_tax_details_by_line_code(line_tax_details.get("tax_details", {}))
full_discount_or_zero_quantity = line.discount == 100.00 or float_is_zero(line.quantity, 3)
quantity = line.quantity
full_discount_or_zero_quantity = line.discount == 100.00 or float_is_zero(quantity, 3)
if full_discount_or_zero_quantity:
unit_price_in_inr = line.currency_id._convert(
line.price_unit,
@@ -308,19 +316,26 @@ class AccountEdiFormat(models.Model):
line.date or fields.Date.context_today(self)
)
else:
unit_price_in_inr = ((sign * line.balance) / (1 - (line.discount / 100))) / line.quantity
unit_price_in_inr = ((sign * line.balance) / (1 - (line.discount / 100))) / quantity
if unit_price_in_inr < 0 and quantity < 0:
# If unit price and quantity both is negative then
# We set unit price and quantity as positive because
# government does not accept negative in qty or unit price
unit_price_in_inr = unit_price_in_inr * -1
quantity = quantity * -1
return {
"SlNo": str(index),
"PrdDesc": line.name.replace("\n", ""),
"IsServc": line.product_id.type == "service" and "Y" or "N",
"HsnCd": self._l10n_in_edi_extract_digits(line.product_id.l10n_in_hsn_code),
"Qty": self._l10n_in_round_value(line.quantity or 0.0, 3),
"Qty": self._l10n_in_round_value(quantity or 0.0, 3),
"Unit": line.product_uom_id.l10n_in_code and line.product_uom_id.l10n_in_code.split("-")[0] or "OTH",
# Unit price in company currency and tax excluded so its different then price_unit
"UnitPrice": self._l10n_in_round_value(unit_price_in_inr, 3),
# total amount is before discount
"TotAmt": self._l10n_in_round_value(unit_price_in_inr * line.quantity),
"Discount": self._l10n_in_round_value((unit_price_in_inr * line.quantity) * (line.discount / 100)),
"TotAmt": self._l10n_in_round_value(unit_price_in_inr * quantity),
"Discount": self._l10n_in_round_value((unit_price_in_inr * quantity) * (line.discount / 100)),
"AssAmt": self._l10n_in_round_value((sign * line.balance)),
"GstRt": self._l10n_in_round_value(tax_details_by_code.get("igst_rate", 0.00) or (
tax_details_by_code.get("cgst_rate", 0.00) + tax_details_by_code.get("sgst_rate", 0.00)), 3),
@@ -339,6 +354,94 @@ class AccountEdiFormat(models.Model):
"TotItemVal": self._l10n_in_round_value(((sign * line.balance) + line_tax_details.get("tax_amount", 0.00))),
}
def _l10n_in_edi_generate_invoice_json_managing_negative_lines(self, invoice, json_payload):
"""Set negative lines against positive lines as discount with same HSN code and tax rate
With negative lines
product name | hsn code | unit price | qty | discount | total
=============================================================
product A | 123456 | 1000 | 1 | 100 | 900
product B | 123456 | 1500 | 2 | 0 | 3000
Discount | 123456 | -300 | 1 | 0 | -300
Converted to without negative lines
product name | hsn code | unit price | qty | discount | total
=============================================================
product A | 123456 | 1000 | 1 | 100 | 900
product B | 123456 | 1500 | 2 | 300 | 2700
totally discounted lines are kept as 0, though
"""
def discount_group_key(line_vals):
return "%s-%s"%(line_vals['HsnCd'], line_vals['GstRt'])
def put_discount_on(discount_line_vals, other_line_vals):
discount = discount_line_vals['AssAmt'] * -1
discount_to_allow = other_line_vals['AssAmt']
if float_compare(discount_to_allow, discount, precision_rounding=invoice.currency_id.rounding) < 0:
# Update discount line, needed when discount is more then max line, in short remaining_discount is not zero
discount_line_vals.update({
'AssAmt': self._l10n_in_round_value(discount_line_vals['AssAmt'] + other_line_vals['AssAmt']),
'IgstAmt': self._l10n_in_round_value(discount_line_vals['IgstAmt'] + other_line_vals['IgstAmt']),
'CgstAmt': self._l10n_in_round_value(discount_line_vals['CgstAmt'] + other_line_vals['CgstAmt']),
'SgstAmt': self._l10n_in_round_value(discount_line_vals['SgstAmt'] + other_line_vals['SgstAmt']),
'CesAmt': self._l10n_in_round_value(discount_line_vals['CesAmt'] + other_line_vals['CesAmt']),
'CesNonAdvlAmt': self._l10n_in_round_value(discount_line_vals['CesNonAdvlAmt'] + other_line_vals['CesNonAdvlAmt']),
'StateCesAmt': self._l10n_in_round_value(discount_line_vals['StateCesAmt'] + other_line_vals['StateCesAmt']),
'StateCesNonAdvlAmt': self._l10n_in_round_value(discount_line_vals['StateCesNonAdvlAmt'] + other_line_vals['StateCesNonAdvlAmt']),
'OthChrg': self._l10n_in_round_value(discount_line_vals['OthChrg'] + other_line_vals['OthChrg']),
'TotItemVal': self._l10n_in_round_value(discount_line_vals['TotItemVal'] + other_line_vals['TotItemVal']),
})
other_line_vals.update({
'Discount': self._l10n_in_round_value(other_line_vals['Discount'] + discount_to_allow),
'AssAmt': 0.00,
'IgstAmt': 0.00,
'CgstAmt': 0.00,
'SgstAmt': 0.00,
'CesAmt': 0.00,
'CesNonAdvlAmt': 0.00,
'StateCesAmt': 0.00,
'StateCesNonAdvlAmt': 0.00,
'OthChrg': 0.00,
'TotItemVal': 0.00,
})
return False
other_line_vals.update({
'Discount': self._l10n_in_round_value(other_line_vals['Discount'] + discount),
'AssAmt': self._l10n_in_round_value(other_line_vals['AssAmt'] + discount_line_vals['AssAmt']),
'IgstAmt': self._l10n_in_round_value(other_line_vals['IgstAmt'] + discount_line_vals['IgstAmt']),
'CgstAmt': self._l10n_in_round_value(other_line_vals['CgstAmt'] + discount_line_vals['CgstAmt']),
'SgstAmt': self._l10n_in_round_value(other_line_vals['SgstAmt'] + discount_line_vals['SgstAmt']),
'CesAmt': self._l10n_in_round_value(other_line_vals['CesAmt'] + discount_line_vals['CesAmt']),
'CesNonAdvlAmt': self._l10n_in_round_value(other_line_vals['CesNonAdvlAmt'] + discount_line_vals['CesNonAdvlAmt']),
'StateCesAmt': self._l10n_in_round_value(other_line_vals['StateCesAmt'] + discount_line_vals['StateCesAmt']),
'StateCesNonAdvlAmt': self._l10n_in_round_value(other_line_vals['StateCesNonAdvlAmt'] + discount_line_vals['StateCesNonAdvlAmt']),
'OthChrg': self._l10n_in_round_value(other_line_vals['OthChrg'] + discount_line_vals['OthChrg']),
'TotItemVal': self._l10n_in_round_value(other_line_vals['TotItemVal'] + discount_line_vals['TotItemVal']),
})
return True
discount_lines = []
for discount_line in json_payload['ItemList'].copy(): #to be sure to not skip in the loop:
if discount_line['AssAmt'] < 0:
discount_lines.append(discount_line)
json_payload['ItemList'].remove(discount_line)
if not discount_lines:
return json_payload
lines_grouped_and_sorted = defaultdict(list)
for line in sorted(json_payload['ItemList'], key=lambda i: i['AssAmt'], reverse=True):
lines_grouped_and_sorted[discount_group_key(line)].append(line)
for discount_line in discount_lines:
apply_discount_on_lines = lines_grouped_and_sorted.get(discount_group_key(discount_line), [])
for apply_discount_on in apply_discount_on_lines:
if put_discount_on(discount_line, apply_discount_on):
break
return json_payload
def _l10n_in_edi_generate_invoice_json(self, invoice):
tax_details = self._l10n_in_prepare_edi_tax_details(invoice)
saler_buyer = self._get_l10n_in_edi_saler_buyer_party(invoice)
@@ -418,7 +521,9 @@ class AccountEdiFormat(models.Model):
json_payload["ExpDtls"].update({
"Port": invoice.l10n_in_shipping_port_code_id.code
})
return json_payload
if not invoice._l10n_in_edi_is_managing_invoice_negative_lines_allowed():
return json_payload
return self._l10n_in_edi_generate_invoice_json_managing_negative_lines(invoice, json_payload)
@api.model
def _l10n_in_prepare_edi_tax_details(self, move, in_foreign=False):
+10
View File
@@ -51,3 +51,13 @@ class AccountMove(models.Model):
return json.loads(l10n_in_edi.attachment_id.raw.decode("utf-8"))
else:
return {}
@api.model
def _l10n_in_edi_is_managing_invoice_negative_lines_allowed(self):
""" Negative lines are not allowed by the Indian government making some features unavailable like sale_coupon
or global discounts. This method allows odoo to distribute the negative discount lines to each others lines
with same HSN code making such features available even for Indian people.
:return: True if odoo needs to distribute the negative discount lines, False otherwise.
"""
param_name = 'l10n_in_edi.manage_invoice_negative_lines'
return bool(self.env['ir.config_parameter'].sudo().get_param(param_name))
+123 -19
View File
@@ -10,6 +10,7 @@ class TestEdiJson(AccountTestInvoicingCommon):
@classmethod
def setUpClass(cls, chart_template_ref="l10n_in.indian_chart_template_standard"):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env['ir.config_parameter'].set_param('l10n_in_edi.manage_invoice_negative_lines', True)
cls.maxDiff = None
cls.company_data["company"].write({
"street": "Block no. 401",
@@ -31,6 +32,28 @@ class TestEdiJson(AccountTestInvoicingCommon):
"l10n_in_gst_treatment": "regular",
})
cls.product_a.write({"l10n_in_hsn_code": "01111"})
cls.product_a2 = cls.env['product.product'].create({
'name': 'product_a2',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'lst_price': 1000.0,
'standard_price': 1000.0,
'property_account_income_id': cls.company_data['default_account_revenue'].id,
'property_account_expense_id': cls.company_data['default_account_expense'].id,
'taxes_id': [(6, 0, cls.tax_sale_a.ids)],
'supplier_taxes_id': [(6, 0, cls.tax_purchase_a.ids)],
"l10n_in_hsn_code": "01111",
})
cls.product_a_discount = cls.env['product.product'].create({
'name': 'product_a discount',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
'lst_price': 400.0,
'standard_price': 400.0,
'property_account_income_id': cls.company_data['default_account_revenue'].id,
'property_account_expense_id': cls.company_data['default_account_expense'].id,
'taxes_id': [(6, 0, cls.tax_sale_a.ids)],
'supplier_taxes_id': [(6, 0, cls.tax_purchase_a.ids)],
"l10n_in_hsn_code": "01111",
})
gst_with_cess = cls.env.ref("l10n_in.%s_sgst_sale_12" % (cls.company_data["company"].id)
) + cls.env.ref("l10n_in.%s_cess_5_plus_1591_sale" % (cls.company_data["company"].id))
product_with_cess = cls.env["product.product"].create({
@@ -56,13 +79,42 @@ class TestEdiJson(AccountTestInvoicingCommon):
cls.invoice_zero_qty.write({
"invoice_line_ids": [(1, l_id, {"quantity": 0}) for l_id in cls.invoice_zero_qty.invoice_line_ids.ids]})
cls.invoice_zero_qty.action_post()
cls.invoice_negative_unit_price = cls.init_invoice("out_invoice", post=False, products=cls.product_a + product_with_cess)
cls.invoice_negative_unit_price = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_unit_price.write({
"invoice_line_ids": [
(1, cls.invoice_negative_unit_price.invoice_line_ids[0].id, {"price_unit": -400}),
(1, cls.invoice_negative_unit_price.invoice_line_ids[1].id, {"price_unit": 1000}),
(1, cls.invoice_negative_unit_price.invoice_line_ids[0].id, {"price_unit": 1000}),
(1, cls.invoice_negative_unit_price.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_unit_price.action_post()
cls.invoice_negative_qty = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_qty.write({
"invoice_line_ids": [
(1, cls.invoice_negative_qty.invoice_line_ids[0].id, {"price_unit": 1000}),
(1, cls.invoice_negative_qty.invoice_line_ids[1].id, {"price_unit": 400, 'quantity': -1}),
]})
cls.invoice_negative_qty.action_post()
cls.invoice_negative_unit_price_and_qty = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount + product_with_cess)
cls.invoice_negative_unit_price_and_qty.write({
"invoice_line_ids": [
(1, cls.invoice_negative_unit_price_and_qty.invoice_line_ids[0].id, {"price_unit": -1000, 'quantity': -1}),
(1, cls.invoice_negative_unit_price_and_qty.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_unit_price_and_qty.action_post()
cls.invoice_negative_with_discount = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a_discount)
cls.invoice_negative_with_discount.write({
"invoice_line_ids": [
(1, cls.invoice_negative_with_discount.invoice_line_ids[0].id, {"price_unit": 2000, 'discount': 50}),
(1, cls.invoice_negative_with_discount.invoice_line_ids[1].id, {"price_unit": -400}),
]})
cls.invoice_negative_with_discount.action_post()
cls.invoice_negative_more_than_max_line = cls.init_invoice("out_invoice", post=False, products=cls.product_a + cls.product_a2 + cls.product_a_discount)
cls.invoice_negative_more_than_max_line.write({
"invoice_line_ids": [
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[0].id, {"price_unit": 2000, 'discount': 50}),
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[1].id, {"price_unit": 1000}),
(1, cls.invoice_negative_more_than_max_line.invoice_line_ids[2].id, {"price_unit": -1100}),
]})
cls.invoice_negative_more_than_max_line.action_post()
def test_edi_json(self):
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice)
@@ -142,22 +194,74 @@ class TestEdiJson(AccountTestInvoicingCommon):
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_unit_price)
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00004", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": -400.0, "TotAmt": -400.0, "Discount": 0.0, "AssAmt": -400.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": -10.0, "SgstAmt": -10.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": -420.0
}, {
"SlNo": "2", "PrdDesc": "product_with_cess", "IsServc": "N", "HsnCd": "02222", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 0.0, "AssAmt": 1000.0,
"GstRt": 12.0, "IgstAmt": 0.0, "CgstAmt": 60.0, "SgstAmt": 60.0, "CesRt": 5.0, "CesAmt": 50.0,
"CesNonAdvlAmt": 1.59, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 1171.59
}],
"ItemList": [
{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 400.0, "AssAmt": 600.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 15.0, "SgstAmt": 15.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 630.0
},
{
"SlNo": "3", "PrdDesc": "product_with_cess", "IsServc": "N", "HsnCd": "02222", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 0.0, "AssAmt": 1000.0,
"GstRt": 12.0, "IgstAmt": 0.0, "CgstAmt": 60.0, "SgstAmt": 60.0, "CesRt": 5.0, "CesAmt": 50.0,
"CesNonAdvlAmt": 1.59, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 1171.59
}
],
"ValDtls": {
"AssVal": 600.0, "CgstVal": 50.0, "SgstVal": 50.0, "IgstVal": 0.0, "CesVal": 51.59,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 751.59
"AssVal": 1600.0, "CgstVal": 75.0, "SgstVal": 75.0, "IgstVal": 0.0, "CesVal": 51.59,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 1801.59
},
})
self.assertDictEqual(json_value, expected, "Indian EDI with Negative unit price sent json value is not matched")
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price sent json value is not matched")
expected.update({"DocDtls": {"Typ": "INV", "No": "INV/2019/00005", "Dt": "01/01/2019"}})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_qty)
self.assertDictEqual(json_value, expected, "Indian EDI with negative quantity sent json value is not matched")
expected.update({"DocDtls": {"Typ": "INV", "No": "INV/2019/00006", "Dt": "01/01/2019"}})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_unit_price_and_qty)
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price and quantity sent json value is not matched")
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00007", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 2000.0, "TotAmt": 2000.0, "Discount": 1400.0, "AssAmt": 600.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 15.0, "SgstAmt": 15.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 630.0
}],
"ValDtls": {
"AssVal": 600.0, "CgstVal": 15.0, "SgstVal": 15.0, "IgstVal": 0.0, "CesVal": 0.0,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 630.0
},
})
json_value = self.env["account.edi.format"]._l10n_in_edi_generate_invoice_json(self.invoice_negative_with_discount)
self.assertDictEqual(json_value, expected, "Indian EDI with negative unit price and quantity sent json value is not matched")
expected.update({
"DocDtls": {"Typ": "INV", "No": "INV/2019/00008", "Dt": "01/01/2019"},
"ItemList": [{
"SlNo": "1", "PrdDesc": "product_a", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 2000.0, "TotAmt": 2000.0, "Discount": 2000.0, "AssAmt": 0.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 0.0, "SgstAmt": 0.0, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 0.0
},
{
"SlNo": "2", "PrdDesc": "product_a2", "IsServc": "N", "HsnCd": "01111", "Qty": 1.0,
"Unit": "UNT", "UnitPrice": 1000.0, "TotAmt": 1000.0, "Discount": 100.0, "AssAmt": 900.0,
"GstRt": 5.0, "IgstAmt": 0.0, "CgstAmt": 22.5, "SgstAmt": 22.5, "CesRt": 0.0, "CesAmt": 0.0,
"CesNonAdvlAmt": 0.0, "StateCesRt": 0.0, "StateCesAmt": 0.0, "StateCesNonAdvlAmt": 0.0,
"OthChrg": 0.0, "TotItemVal": 945.0
}],
"ValDtls": {
"AssVal": 900.0, "CgstVal": 22.5, "SgstVal": 22.5, "IgstVal": 0.0, "CesVal": 0.0,
"StCesVal": 0.0, "RndOffAmt": 0.0, "TotInvVal": 945.0
},
})
json_value = self.env['account.edi.format']._l10n_in_edi_generate_invoice_json(self.invoice_negative_more_than_max_line)
self.assertDictEqual(json_value, expected, "Indian EDI with negative value more than max line sent json value is not matched")