diff --git a/addons_extensions/document_parser/__init__.py b/addons_extensions/document_parser/__init__.py
new file mode 100644
index 000000000..0650744f6
--- /dev/null
+++ b/addons_extensions/document_parser/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/addons_extensions/document_parser/__manifest__.py b/addons_extensions/document_parser/__manifest__.py
new file mode 100644
index 000000000..05d96bc4f
--- /dev/null
+++ b/addons_extensions/document_parser/__manifest__.py
@@ -0,0 +1,19 @@
+{
+ "name": "Document Parser",
+ "summary": "Reusable AI-assisted document text and data extraction",
+ "version": "1.0.0",
+ "category": "Tools",
+ "author": "Pranay",
+ "website": "https://www.ftprotech.com",
+ "license": "LGPL-3",
+ "depends": ["base"],
+ "data": [
+ "views/res_config_settings_views.xml",
+ ],
+ "installable": True,
+ "application": False,
+ "auto_install": False,
+ "external_dependencies": {
+ "python": ["requests"],
+ },
+}
diff --git a/addons_extensions/document_parser/models/__init__.py b/addons_extensions/document_parser/models/__init__.py
new file mode 100644
index 000000000..de02c70d2
--- /dev/null
+++ b/addons_extensions/document_parser/models/__init__.py
@@ -0,0 +1,2 @@
+from . import document_parser_service
+from . import res_config_settings
diff --git a/addons_extensions/document_parser/models/document_parser_service.py b/addons_extensions/document_parser/models/document_parser_service.py
new file mode 100644
index 000000000..a672c9706
--- /dev/null
+++ b/addons_extensions/document_parser/models/document_parser_service.py
@@ -0,0 +1,444 @@
+import base64
+import json
+import logging
+import mimetypes
+import re
+from io import BytesIO
+
+import requests
+
+from odoo import _, api, models
+from odoo.exceptions import UserError
+
+try:
+ import pytesseract
+except Exception: # pragma: no cover - optional dependency
+ pytesseract = None
+
+try:
+ from PIL import Image
+except Exception: # pragma: no cover - optional dependency
+ Image = None
+
+try:
+ from pdf2image import convert_from_bytes
+except Exception: # pragma: no cover - optional dependency
+ convert_from_bytes = None
+
+try:
+ from pypdf import PdfReader
+except Exception: # pragma: no cover - optional dependency
+ PdfReader = None
+
+try:
+ from docx import Document
+except Exception: # pragma: no cover - optional dependency
+ Document = None
+
+_logger = logging.getLogger(__name__)
+
+
+class DocumentParserService(models.AbstractModel):
+ _name = "document.parser.service"
+ _description = "Document Parser Service"
+
+ TOGETHER_ENDPOINT = "https://api.together.xyz/v1/chat/completions"
+ OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
+
+ TOGETHER_MODELS = [
+ "Qwen/Qwen2.5-7B-Instruct-Turbo",
+ "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo",
+ ]
+ OPENROUTER_MODELS = [
+ "qwen/qwen-2.5-7b-instruct",
+ "qwen/qwen-2.5-7b-instruct:free",
+ "deepseek/deepseek-chat:free",
+ ]
+
+ @api.model
+ def parse_document(
+ self,
+ file_content,
+ filename=None,
+ required_fields=None,
+ extra_instructions=None,
+ json_schema=None,
+ ):
+ if not file_content:
+ raise UserError(_("No document provided."))
+ if not filename:
+ raise UserError(_("Filename is required."))
+
+ binary = self._decode_file_content(file_content)
+ mimetype = self._detect_mimetype(binary, filename)
+ text_content = self._extract_text(binary, mimetype)
+ fields_spec = self._normalize_required_fields(required_fields or {})
+
+ if not text_content.strip():
+ return {
+ "filename": filename,
+ "mimetype": mimetype,
+ "text": "",
+ "result": {},
+ "provider": False,
+ "errors": [_("No text could be extracted from the document.")],
+ "error": _("No text could be extracted from the document."),
+ }
+
+ schema_text = json_schema or self._build_json_schema_text(fields_spec)
+ ai_result, provider_used, provider_errors = self._send_to_ai(
+ text_content=text_content[:45000],
+ schema_text=schema_text,
+ extra_instructions=extra_instructions,
+ )
+
+ if not ai_result:
+ ai_result = self._extract_with_heuristics(text_content, fields_spec)
+
+ ai_result = ai_result or {}
+ error_message = False
+ if not ai_result and provider_errors:
+ error_message = "; ".join(provider_errors[:3])
+
+ return {
+ "filename": filename,
+ "mimetype": mimetype,
+ "text": text_content,
+ "result": ai_result,
+ "provider": provider_used,
+ "errors": provider_errors,
+ "error": error_message,
+ }
+
+ @api.model
+ def extract_requested_data(self, file_content, filename, required_fields, extra_instructions=None, json_schema=None):
+ return self.parse_document(
+ file_content=file_content,
+ filename=filename,
+ required_fields=required_fields,
+ extra_instructions=extra_instructions,
+ json_schema=json_schema,
+ )["result"]
+
+ def _decode_file_content(self, file_content):
+ if isinstance(file_content, bytes):
+ if file_content.startswith((b"%PDF", b"\xFF\xD8", b"\x89PNG", b"PK")):
+ return file_content
+ try:
+ return base64.b64decode(file_content)
+ except Exception:
+ return file_content
+ if isinstance(file_content, str):
+ try:
+ return base64.b64decode(file_content)
+ except Exception as exc:
+ raise UserError(_("Invalid base64 document.")) from exc
+ raise UserError(_("Unsupported file format."))
+
+ def _detect_mimetype(self, binary, filename):
+ if filename:
+ guessed = mimetypes.guess_type(filename)[0]
+ if guessed:
+ return guessed
+ if binary.startswith(b"%PDF"):
+ return "application/pdf"
+ if binary.startswith(b"\xFF\xD8"):
+ return "image/jpeg"
+ if binary.startswith(b"\x89PNG"):
+ return "image/png"
+ if binary[:2] == b"PK":
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ return "application/octet-stream"
+
+ def _extract_text(self, binary, mimetype):
+ text_content = ""
+ try:
+ if mimetype == "application/pdf":
+ text_content = self._extract_text_from_pdf(binary)
+ elif mimetype in {"image/png", "image/jpeg", "image/jpg"}:
+ text_content = self._extract_text_from_image(binary)
+ elif mimetype == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ text_content = self._extract_text_from_docx(binary)
+ elif mimetype.startswith("text/"):
+ text_content = binary.decode("utf-8", errors="ignore")
+ except Exception as exc:
+ _logger.exception("Document text extraction failed: %s", exc)
+ return (text_content or "").strip()
+
+ def _extract_text_from_pdf(self, binary):
+ extracted_parts = []
+ if PdfReader:
+ try:
+ reader = PdfReader(BytesIO(binary))
+ extracted_parts.extend(page.extract_text() or "" for page in reader.pages)
+ except Exception as exc:
+ _logger.warning("PdfReader extraction failed: %s", exc)
+ text_content = "\n".join(part for part in extracted_parts if part).strip()
+ if text_content:
+ return text_content
+ if convert_from_bytes and pytesseract:
+ try:
+ images = convert_from_bytes(binary, dpi=300)
+ return "\n".join(
+ pytesseract.image_to_string(image)
+ for image in images
+ ).strip()
+ except Exception as exc:
+ _logger.warning("PDF OCR extraction failed: %s", exc)
+ return ""
+
+ def _extract_text_from_image(self, binary):
+ if not pytesseract or not Image:
+ return ""
+ try:
+ image = Image.open(BytesIO(binary))
+ return pytesseract.image_to_string(image).strip()
+ except Exception as exc:
+ _logger.warning("Image OCR extraction failed: %s", exc)
+ return ""
+
+ def _extract_text_from_docx(self, binary):
+ if not Document:
+ return ""
+ try:
+ document = Document(BytesIO(binary))
+ return "\n".join(
+ paragraph.text for paragraph in document.paragraphs if paragraph.text
+ ).strip()
+ except Exception as exc:
+ _logger.warning("DOCX extraction failed: %s", exc)
+ return ""
+
+ def _send_to_ai(self, text_content, schema_text, extra_instructions=None):
+ prompt = self._build_prompt(text_content, schema_text, extra_instructions)
+ errors = []
+
+ together_key = self._get_param("document_parser.together_ai_key") or self._get_param("document_parser.together_api_key")
+ openrouter_key = self._get_param("document_parser.openrouter_ai_key") or self._get_param("document_parser.openrouter_api_key")
+
+ if together_key:
+ result, provider_errors = self._call_provider(
+ provider_name="Together",
+ endpoint=self.TOGETHER_ENDPOINT,
+ headers={
+ "Authorization": f"Bearer {together_key}",
+ "Content-Type": "application/json",
+ },
+ models=self.TOGETHER_MODELS,
+ prompt=prompt,
+ )
+ if result:
+ return result, "together", errors
+ errors.extend(provider_errors)
+ else:
+ errors.append(_("Together AI key is not configured."))
+
+ if openrouter_key:
+ result, provider_errors = self._call_provider(
+ provider_name="OpenRouter",
+ endpoint=self.OPENROUTER_ENDPOINT,
+ headers={
+ "Authorization": f"Bearer {openrouter_key}",
+ "Content-Type": "application/json",
+ "HTTP-Referer": self._get_param("web.base.url") or "odoo.local",
+ "X-Title": "Document Parser",
+ },
+ models=self.OPENROUTER_MODELS,
+ prompt=prompt,
+ )
+ if result:
+ return result, "openrouter", errors
+ errors.extend(provider_errors)
+ else:
+ errors.append(_("OpenRouter key is not configured."))
+
+ return {}, False, errors
+
+ def _build_prompt(self, text_content, schema_text, extra_instructions=None):
+ return f"""
+You are a strict JSON generator.
+
+RULES:
+- Output ONLY valid raw JSON.
+- No explanation.
+- No markdown.
+- No backticks.
+- No extra text.
+- Follow schema strictly.
+- If a field is missing in text, return null.
+- Scan the entire document carefully before answering.
+- Extract ONLY what exists in text.
+- FOR ANY DATES CHANGE FORMAT TO %Y-%m-%d
+
+FIELD RULES:
+- If "skills" exists, extract only explicit technical skills written in the document.
+- Do NOT infer similar skills from role names, responsibilities, or projects.
+- Normalize names like "Expert Python" to "Python".
+- Exclude soft skills and business phrases.
+- Exclude responsibility-style phrases like Cross-Functional Collaboration, Cost Saving, Resource Utilization, Documentation, Reporting, and Team Handling.
+- Prefer concrete tools, methods, technologies, platforms, certifications, engineering/process methods, and domain techniques explicitly written in the resume.
+- If the resume explicitly mentions items like AutoCAD, Root Cause Analysis, Project Management, Manufacturing Processes, Lean, Six Sigma, or Quality Control, include them.
+- Remove duplicates and return each skill only once.
+- If "email" exists, return one valid normalized email.
+- If "name" exists, prefer the full name at the top and exclude titles, companies, and addresses.
+- If "phone" exists, return the most complete phone number found.
+- If "experience" exists, return only clearly supported numeric values.
+
+Schema:
+{schema_text}
+
+Instructions:
+{extra_instructions or "None"}
+
+Document:
+{text_content}
+"""
+
+ def _call_provider(self, provider_name, endpoint, headers, models, prompt):
+ errors = []
+ for model in models:
+ payload = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": 0,
+ "max_tokens": 1500,
+ }
+ try:
+ response = requests.post(endpoint, headers=headers, json=payload, timeout=90)
+ if response.status_code != 200:
+ message = _("%(provider)s model %(model)s failed with %(status)s: %(body)s") % {
+ "provider": provider_name,
+ "model": model,
+ "status": response.status_code,
+ "body": (response.text or "")[:300],
+ }
+ _logger.warning(message)
+ errors.append(message)
+ continue
+
+ body = response.json()
+ content = self._extract_message_content(body)
+ parsed = self._safe_json_load(content)
+ if parsed:
+ return parsed, errors
+
+ message = _("%(provider)s model %(model)s returned invalid JSON.") % {
+ "provider": provider_name,
+ "model": model,
+ }
+ _logger.warning(message)
+ errors.append(message)
+ except Exception as exc:
+ message = _("%(provider)s model %(model)s error: %(error)s") % {
+ "provider": provider_name,
+ "model": model,
+ "error": str(exc),
+ }
+ _logger.warning(message)
+ errors.append(message)
+ return {}, errors
+
+ def _extract_message_content(self, response_body):
+ try:
+ content = response_body["choices"][0]["message"]["content"]
+ except Exception:
+ return ""
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ if isinstance(item, dict):
+ if item.get("type") == "text":
+ parts.append(item.get("text", ""))
+ elif item.get("text"):
+ parts.append(item.get("text"))
+ else:
+ parts.append(str(item))
+ return "\n".join(part for part in parts if part)
+ if isinstance(content, dict):
+ return content.get("text", "")
+ return content or ""
+
+ def _safe_json_load(self, content):
+ if not content:
+ return {}
+ content = content.strip().replace("```json", "").replace("```", "").strip()
+ try:
+ return json.loads(content)
+ except Exception:
+ pass
+ match = re.search(r"\{[\s\S]*\}", content)
+ if match:
+ try:
+ return json.loads(match.group(0))
+ except Exception:
+ pass
+ _logger.warning("JSON parse failed for provider response: %s", content[:500])
+ return {}
+
+ def _extract_with_heuristics(self, text_content, fields):
+ result = {}
+ email_match = re.search(r"([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})", text_content or "", re.I)
+ phone_match = re.search(r"(\+?\d[\d\-\s()]{7,}\d)", text_content or "")
+ linkedin_match = re.search(r"(https?://(?:www\.)?linkedin\.com/[^\s]+)", text_content or "", re.I)
+ name_guess = self._guess_name(text_content or "")
+ skills_guess = self._guess_skills(text_content or "")
+
+ for field_name, field_spec in fields.items():
+ field_type = field_spec.get("type", "string")
+ if field_name in {"email", "email_from"}:
+ result[field_name] = email_match.group(1).lower() if email_match else None
+ elif field_name in {"phone", "mobile", "partner_phone"}:
+ result[field_name] = phone_match.group(1).strip() if phone_match else None
+ elif field_name in {"linkedin_profile", "linkedin"}:
+ result[field_name] = linkedin_match.group(1).strip() if linkedin_match else None
+ elif field_name in {"name", "full_name", "partner_name"}:
+ result[field_name] = name_guess
+ elif field_name == "skills" and field_type == "list":
+ result[field_name] = skills_guess
+ else:
+ result[field_name] = None
+ return result
+
+ def _guess_name(self, text_content):
+ for line in [line.strip() for line in (text_content or "").splitlines() if line.strip()][:12]:
+ cleaned = re.sub(r"[^A-Za-z .'-]", "", line).strip()
+ if len(cleaned.split()) in {2, 3, 4} and not re.search(r"(resume|cv|email|phone|linkedin|skills|experience)", cleaned, re.I):
+ return cleaned
+ return None
+
+ def _guess_skills(self, text_content):
+ section = re.search(r"(skills|technical skills|core competencies)(.*?)(experience|education|projects|certifications|$)", text_content or "", re.I | re.S)
+ if not section:
+ return []
+ parts = re.split(r"[,;\n|•]", section.group(2))
+ cleaned = []
+ for part in parts:
+ value = re.sub(r"\s+", " ", part).strip(" -:\t\r\n")
+ if value and 1 < len(value) < 50 and not re.search(r"^(skills?|experience|education)$", value, re.I):
+ cleaned.append(value)
+ return list(dict.fromkeys(cleaned[:25]))
+
+ def _get_param(self, key):
+ return self.env["ir.config_parameter"].sudo().get_param(key)
+
+ def _normalize_required_fields(self, fields):
+ if isinstance(fields, dict):
+ normalized = {}
+ for field_name, field_value in fields.items():
+ if isinstance(field_value, dict):
+ normalized[field_name] = {
+ "type": field_value.get("type", "string"),
+ "description": field_value.get("description", field_name.replace("_", " ").title()),
+ }
+ else:
+ normalized[field_name] = {
+ "type": "string",
+ "description": str(field_value or field_name.replace("_", " ").title()),
+ }
+ return normalized
+ if isinstance(fields, list):
+ return {field_name: {"type": "string", "description": field_name.replace("_", " ").title()} for field_name in fields}
+ return {}
+
+ def _build_json_schema_text(self, fields):
+ return json.dumps(fields, ensure_ascii=True)
diff --git a/addons_extensions/document_parser/models/res_config_settings.py b/addons_extensions/document_parser/models/res_config_settings.py
new file mode 100644
index 000000000..4e0479fdb
--- /dev/null
+++ b/addons_extensions/document_parser/models/res_config_settings.py
@@ -0,0 +1,67 @@
+import requests
+
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ together_ai_key = fields.Char(
+ string="Together AI Key",
+ config_parameter="document_parser.together_ai_key",
+ )
+ openrouter_ai_key = fields.Char(
+ string="OpenRouter AI Key",
+ config_parameter="document_parser.openrouter_ai_key",
+ )
+
+ def action_test_together_ai_connection(self):
+ self.ensure_one()
+ if not self.together_ai_key:
+ raise UserError(_("Please add the Together AI key first."))
+
+ response = requests.get(
+ "https://api.together.xyz/v1/models",
+ headers={"Authorization": f"Bearer {self.together_ai_key}"},
+ timeout=20,
+ )
+ if response.ok:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": _("Together AI Connection"),
+ "message": _("Connection successful."),
+ "type": "success",
+ "sticky": False,
+ },
+ }
+ raise UserError(_("Together AI connection failed: %s") % (response.text or response.reason))
+
+ def action_test_openrouter_ai_connection(self):
+ self.ensure_one()
+ if not self.openrouter_ai_key:
+ raise UserError(_("Please add the OpenRouter key first."))
+
+ response = requests.get(
+ "https://openrouter.ai/api/v1/models",
+ headers={
+ "Authorization": f"Bearer {self.openrouter_ai_key}",
+ "HTTP-Referer": self.env["ir.config_parameter"].sudo().get_param("web.base.url", ""),
+ "X-Title": "Odoo Document Parser",
+ },
+ timeout=20,
+ )
+ if response.ok:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": _("OpenRouter Connection"),
+ "message": _("Connection successful."),
+ "type": "success",
+ "sticky": False,
+ },
+ }
+ raise UserError(_("OpenRouter connection failed: %s") % (response.text or response.reason))
diff --git a/addons_extensions/document_parser/views/res_config_settings_views.xml b/addons_extensions/document_parser/views/res_config_settings_views.xml
new file mode 100644
index 000000000..4fde40cbe
--- /dev/null
+++ b/addons_extensions/document_parser/views/res_config_settings_views.xml
@@ -0,0 +1,39 @@
+
+
+
+ res.config.settings.view.form.document.parser
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py
index d6227eb75..eecf2c1fd 100644
--- a/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py
+++ b/addons_extensions/employee_it_declaration/models/it_tax_statement_wiz.py
@@ -237,66 +237,66 @@ class ITTaxStatementWizard(models.TransientModel):
dummy_payslip.sudo().action_payslip_cancel()
dummy_payslip.sudo().unlink()
- def _get_salary_components_for_period_line(self, period_line):
- rule_codes = ['BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'ASSIG_SALARY', 'ATTACH_SALARY']
- rule_amounts = self._get_rule_amounts_for_period_line(
- period_line,
- rule_codes
- )
- return {
- 'basic_salary': rule_amounts['BASIC'],
+ def _get_salary_components_for_period_line(self, period_line):
+ rule_codes = ['BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'ASSIG_SALARY', 'ATTACH_SALARY']
+ rule_amounts = self._get_rule_amounts_for_period_line(
+ period_line,
+ rule_codes
+ )
+ return {
+ 'basic_salary': rule_amounts['BASIC'],
'hra_salary': rule_amounts['HRA'],
'lta_salary': rule_amounts['LTA'],
'special_allowance': rule_amounts['SPA'],
'gross_salary': rule_amounts['GROSS'],
'net_salary': rule_amounts['NET'],
'salary_advance': rule_amounts['ASSIG_SALARY'],
- 'advance_recovery': rule_amounts['ATTACH_SALARY'],
- }
-
- def _get_other_payslip_components_for_period_line(self, period_line):
- payslip = self._get_valid_payslip_for_period_line(period_line)
- if not payslip:
- return []
-
- excluded_codes = {
- 'BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'PT', 'PFE', 'PF',
- 'ATTACH_SALARY',
- }
- grouped = {}
- income_category_codes = {'BASIC', 'ALW', 'LEAVE'}
- for line in payslip.line_ids.filtered(
- lambda item: item.total
- and (item.salary_rule_id.code or item.code) not in excluded_codes
- and item.category_id.code in income_category_codes):
- name = line.name or line.salary_rule_id.name or line.code
- code = line.salary_rule_id.code or line.code
- key = (code, name)
- if key not in grouped:
- grouped[key] = {
- 'code': code,
- 'name': name,
- 'actual': 0.0,
- 'projected': 0.0,
- }
- grouped[key]['actual'] += line.total
-
- for input_line in payslip.input_line_ids.filtered(lambda item: item.amount and item.code not in excluded_codes):
- name = input_line.name or input_line.input_type_id.name or input_line.code
- key = (input_line.code, name)
- if key in grouped:
- continue
- grouped[key] = {
- 'code': input_line.code,
- 'name': name,
- 'actual': input_line.amount,
- 'projected': 0.0,
- }
-
- return list(grouped.values())
-
- def fetch_salary_components(self):
- """fetch salary components from payroll data"""
+ 'advance_recovery': rule_amounts['ATTACH_SALARY'],
+ }
+
+ def _get_other_payslip_components_for_period_line(self, period_line):
+ payslip = self._get_valid_payslip_for_period_line(period_line)
+ if not payslip:
+ return []
+
+ excluded_codes = {
+ 'BASIC', 'HRA', 'LTA', 'SPA', 'GROSS', 'NET', 'PT', 'PFE', 'PF',
+ 'ATTACH_SALARY',
+ }
+ grouped = {}
+ income_category_codes = {'BASIC', 'ALW', 'LEAVE'}
+ for line in payslip.line_ids.filtered(
+ lambda item: item.total
+ and (item.salary_rule_id.code or item.code) not in excluded_codes
+ and item.category_id.code in income_category_codes):
+ name = line.name or line.salary_rule_id.name or line.code
+ code = line.salary_rule_id.code or line.code
+ key = (code, name)
+ if key not in grouped:
+ grouped[key] = {
+ 'code': code,
+ 'name': name,
+ 'actual': 0.0,
+ 'projected': 0.0,
+ }
+ grouped[key]['actual'] += line.total
+
+ for input_line in payslip.input_line_ids.filtered(lambda item: item.amount and item.code not in excluded_codes):
+ name = input_line.name or input_line.input_type_id.name or input_line.code
+ key = (input_line.code, name)
+ if key in grouped:
+ continue
+ grouped[key] = {
+ 'code': input_line.code,
+ 'name': name,
+ 'actual': input_line.amount,
+ 'projected': 0.0,
+ }
+
+ return list(grouped.values())
+
+ def fetch_salary_components(self):
+ """fetch salary components from payroll data"""
for rec in self:
data = {
'basic_salary' : {'actual':[],'projected':[]},
@@ -304,11 +304,11 @@ class ITTaxStatementWizard(models.TransientModel):
'lta_salary': {'actual': [], 'projected': []},
'special_allowance' : {'actual':[],'projected':[]},
'gross_salary' : {'actual':[],'projected':[]},
- 'net_salary' : {'actual':[],'projected':[]},
- 'salary_advance' : {'actual':[],'projected':[]},
- 'advance_recovery' : {'actual':[],'projected':[]},
- 'other_components': {},
- }
+ 'net_salary' : {'actual':[],'projected':[]},
+ 'salary_advance' : {'actual':[],'projected':[]},
+ 'advance_recovery' : {'actual':[],'projected':[]},
+ 'other_components': {},
+ }
if not rec.employee_id or not rec.contract_id or not rec.period_id or not rec.period_line:
return data
period_lines = rec.period_id.period_line_ids
@@ -321,31 +321,31 @@ class ITTaxStatementWizard(models.TransientModel):
data['lta_salary']['actual'].append(components['lta_salary'])
data['special_allowance']['actual'].append(components['special_allowance'])
data['gross_salary']['actual'].append(components['gross_salary'])
- data['net_salary']['actual'].append(components['net_salary'])
- data['salary_advance']['actual'].append(components['salary_advance'])
- data['advance_recovery']['actual'].append(components['advance_recovery'])
- bucket = 'actual'
- else:
- data['basic_salary']['projected'].append(components['basic_salary'])
- data['hra_salary']['projected'].append(components['hra_salary'])
+ data['net_salary']['actual'].append(components['net_salary'])
+ data['salary_advance']['actual'].append(components['salary_advance'])
+ data['advance_recovery']['actual'].append(components['advance_recovery'])
+ bucket = 'actual'
+ else:
+ data['basic_salary']['projected'].append(components['basic_salary'])
+ data['hra_salary']['projected'].append(components['hra_salary'])
data['lta_salary']['projected'].append(components['lta_salary'])
data['special_allowance']['projected'].append(components['special_allowance'])
data['gross_salary']['projected'].append(components['gross_salary'])
- data['net_salary']['projected'].append(components['net_salary'])
- data['salary_advance']['projected'].append(components['salary_advance'])
- data['advance_recovery']['projected'].append(components['advance_recovery'])
- bucket = 'projected'
- for other_component in rec._get_other_payslip_components_for_period_line(line):
- key = (other_component['code'], other_component['name'])
- if key not in data['other_components']:
- data['other_components'][key] = {
- 'code': other_component['code'],
- 'name': other_component['name'],
- 'actual': 0.0,
- 'projected': 0.0,
- }
- data['other_components'][key][bucket] += other_component['actual']
- return data
+ data['net_salary']['projected'].append(components['net_salary'])
+ data['salary_advance']['projected'].append(components['salary_advance'])
+ data['advance_recovery']['projected'].append(components['advance_recovery'])
+ bucket = 'projected'
+ for other_component in rec._get_other_payslip_components_for_period_line(line):
+ key = (other_component['code'], other_component['name'])
+ if key not in data['other_components']:
+ data['other_components'][key] = {
+ 'code': other_component['code'],
+ 'name': other_component['name'],
+ 'actual': 0.0,
+ 'projected': 0.0,
+ }
+ data['other_components'][key][bucket] += other_component['actual']
+ return data
def _get_salary_rule_amount(self, payslip, rule_code):
"""Get amount for a specific salary rule from payslip"""
@@ -598,38 +598,38 @@ class ITTaxStatementWizard(models.TransientModel):
old_standard_deduction if self.tax_regime == 'old' else new_standard_deduction
)
- salary_components_data = self.fetch_salary_components()
- other_components_actual = sum(
- component['actual'] for component in salary_components_data['other_components'].values()
- )
- other_components_projected = sum(
- component['projected'] for component in salary_components_data['other_components'].values()
- )
- visible_gross_actual = (
- sum(salary_components_data['basic_salary']['actual']) +
- sum(salary_components_data['hra_salary']['actual']) +
- sum(salary_components_data['lta_salary']['actual']) +
- sum(salary_components_data['special_allowance']['actual']) +
- other_components_actual
- )
- visible_gross_projected = (
- sum(salary_components_data['basic_salary']['projected']) +
- sum(salary_components_data['hra_salary']['projected']) +
- sum(salary_components_data['lta_salary']['projected']) +
- sum(salary_components_data['special_allowance']['projected']) +
- other_components_projected
- )
- gross_salary_actual = max(sum(salary_components_data['gross_salary']['actual']), visible_gross_actual)
- gross_salary_projected = max(
- sum(salary_components_data['gross_salary']['projected']),
- visible_gross_projected
- )
- annual_gross_salary = (
- gross_salary_actual +
- gross_salary_projected
- )
- annual_net_salary = (
- sum(salary_components_data['net_salary']['actual']) +
+ salary_components_data = self.fetch_salary_components()
+ other_components_actual = sum(
+ component['actual'] for component in salary_components_data['other_components'].values()
+ )
+ other_components_projected = sum(
+ component['projected'] for component in salary_components_data['other_components'].values()
+ )
+ visible_gross_actual = (
+ sum(salary_components_data['basic_salary']['actual']) +
+ sum(salary_components_data['hra_salary']['actual']) +
+ sum(salary_components_data['lta_salary']['actual']) +
+ sum(salary_components_data['special_allowance']['actual']) +
+ other_components_actual
+ )
+ visible_gross_projected = (
+ sum(salary_components_data['basic_salary']['projected']) +
+ sum(salary_components_data['hra_salary']['projected']) +
+ sum(salary_components_data['lta_salary']['projected']) +
+ sum(salary_components_data['special_allowance']['projected']) +
+ other_components_projected
+ )
+ gross_salary_actual = max(sum(salary_components_data['gross_salary']['actual']), visible_gross_actual)
+ gross_salary_projected = max(
+ sum(salary_components_data['gross_salary']['projected']),
+ visible_gross_projected
+ )
+ annual_gross_salary = (
+ gross_salary_actual +
+ gross_salary_projected
+ )
+ annual_net_salary = (
+ sum(salary_components_data['net_salary']['actual']) +
sum(salary_components_data['net_salary']['projected'])
)
if not annual_net_salary or self.is_general_tax_statement:
@@ -653,8 +653,8 @@ class ITTaxStatementWizard(models.TransientModel):
# self.professional_tax +
# self.nps_employer_contribution
)
- taxable_old = max(0.0, annual_gross_salary + self.other_income + hp_income - old_deductions)
- taxable_new = max(0.0, annual_gross_salary + self.other_income + hp_income - new_deductions)
+ taxable_old = max(0.0, annual_gross_salary + self.other_income + hp_income - old_deductions - (-(self.professional_tax)))
+ taxable_new = max(0.0, annual_gross_salary + self.other_income + hp_income - new_deductions - (-(self.professional_tax)))
tax_result_old = self._compute_tax_old_regime(taxable_old, old_slab) if old_slab else False
tax_result_new = self._compute_tax_new_regime(taxable_new, new_slab) if new_slab else False
comparison_available = bool(tax_result_old and tax_result_new)
@@ -670,10 +670,10 @@ class ITTaxStatementWizard(models.TransientModel):
'new_slab': new_slab,
'selected_standard_deduction': selected_standard_deduction,
'salary_components_data': salary_components_data,
- 'annual_gross_salary': annual_gross_salary,
- 'gross_salary_actual': gross_salary_actual,
- 'gross_salary_projected': gross_salary_projected,
- 'annual_net_salary': annual_net_salary,
+ 'annual_gross_salary': annual_gross_salary,
+ 'gross_salary_actual': gross_salary_actual,
+ 'gross_salary_projected': gross_salary_projected,
+ 'annual_net_salary': annual_net_salary,
'hp_income': hp_income,
'old_deductions': old_deductions,
'new_deductions': new_deductions,
@@ -743,13 +743,14 @@ class ITTaxStatementWizard(models.TransientModel):
fy_start = date(today.year - 1, 4, 1)
fy_end = date(today.year, 3, 31)
- values = self._get_tax_base_values(include_comparison=include_comparison)
- salary_components_data = values['salary_components_data']
- annual_gross_salary = values['annual_gross_salary']
- gross_salary_actual = values['gross_salary_actual']
- gross_salary_projected = values['gross_salary_projected']
- annual_net_salary = values['annual_net_salary']
+ values = self._get_tax_base_values(include_comparison=include_comparison)
+ salary_components_data = values['salary_components_data']
+ annual_gross_salary = values['annual_gross_salary']
+ gross_salary_actual = values['gross_salary_actual']
+ gross_salary_projected = values['gross_salary_projected']
+ annual_net_salary = values['annual_net_salary']
selected_standard_deduction = values['selected_standard_deduction']
+ total_sec_16_deduction = values['selected_standard_deduction']+(-(self.professional_tax))
old_deductions = values['old_deductions']
new_deductions = values['new_deductions']
hp_income = values['hp_income']
@@ -786,25 +787,25 @@ class ITTaxStatementWizard(models.TransientModel):
line_start = self.period_line.from_date
current_month_index = ((line_start.year - fy_start.year) * 12 +
(line_start.month - fy_start.month) + 1)
- tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10)
- birthday = self.employee_id.birthday
- if birthday:
- diff = relativedelta(date.today(), birthday)
- years_months = f"{diff.years} years {diff.months} months"
- else:
- years_months = "N/A"
- month_age = str(self.period_line.name)+ " / " + str(years_months)
- other_salary_components = []
- for component in salary_components_data['other_components'].values():
- total = component['actual'] + component['projected']
- if total:
- other_salary_components.append({
- 'name': component['name'],
- 'actual': component['actual'],
- 'projected': component['projected'],
- 'total': total,
- })
- data = {
+ tax_result['roundoff_taxable_income'] = float(round(tax_result["taxable_income"] / 10) * 10)
+ birthday = self.employee_id.birthday
+ if birthday:
+ diff = relativedelta(date.today(), birthday)
+ years_months = f"{diff.years} years {diff.months} months"
+ else:
+ years_months = "N/A"
+ month_age = str(self.period_line.name)+ " / " + str(years_months)
+ other_salary_components = []
+ for component in salary_components_data['other_components'].values():
+ total = component['actual'] + component['projected']
+ if total:
+ other_salary_components.append({
+ 'name': component['name'],
+ 'actual': component['actual'],
+ 'projected': component['projected'],
+ 'total': total,
+ })
+ data = {
'financial_year': f"{fy_start.year}-{fy_end.year}",
'assessment_year': fy_end.year + 1,
'report_time': today.strftime('%d-%m-%Y %H:%M'),
@@ -834,17 +835,18 @@ class ITTaxStatementWizard(models.TransientModel):
'special_allowance':{'actual': sum(salary_components_data['special_allowance']['actual']), 'projected': sum(salary_components_data['special_allowance']['projected']), 'total':sum(salary_components_data['special_allowance']['actual']) + sum(salary_components_data['special_allowance']['projected'])},
'perquisites': {'actual': 0 * current_month_index, 'projected': 0 * (total_months - current_month_index), 'total': 0 * total_months},
'reimbursement': {'actual': 0 * current_month_index, 'projected': 0 * (total_months - current_month_index), 'total': 0 * total_months},
- 'gross_salary': {'actual': gross_salary_actual, 'projected': gross_salary_projected, 'total': annual_gross_salary},
- 'salary_advance': {'actual': sum(salary_components_data['salary_advance']['actual']), 'projected': sum(salary_components_data['salary_advance']['projected']), 'total': sum(salary_components_data['salary_advance']['actual']) + sum(salary_components_data['salary_advance']['projected'])},
- 'advance_recovery': {'actual': sum(salary_components_data['advance_recovery']['actual']), 'projected': sum(salary_components_data['advance_recovery']['projected']), 'total': sum(salary_components_data['advance_recovery']['actual']) + sum(salary_components_data['advance_recovery']['projected'])},
- 'other_components': other_salary_components,
- 'net_salary': {'actual': sum(salary_components_data['net_salary']['actual']), 'projected': sum(salary_components_data['net_salary']['projected']),
- 'total': annual_net_salary}
- },
+ 'gross_salary': {'actual': gross_salary_actual, 'projected': gross_salary_projected, 'total': annual_gross_salary},
+ 'salary_advance': {'actual': sum(salary_components_data['salary_advance']['actual']), 'projected': sum(salary_components_data['salary_advance']['projected']), 'total': sum(salary_components_data['salary_advance']['actual']) + sum(salary_components_data['salary_advance']['projected'])},
+ 'advance_recovery': {'actual': sum(salary_components_data['advance_recovery']['actual']), 'projected': sum(salary_components_data['advance_recovery']['projected']), 'total': sum(salary_components_data['advance_recovery']['actual']) + sum(salary_components_data['advance_recovery']['projected'])},
+ 'other_components': other_salary_components,
+ 'net_salary': {'actual': sum(salary_components_data['net_salary']['actual']), 'projected': sum(salary_components_data['net_salary']['projected']),
+ 'total': annual_net_salary}
+ },
'deductions': {
'professional_tax': self.professional_tax,
'standard_deduction': selected_standard_deduction,
+ 'total_sec_16_deduction': total_sec_16_deduction,
'nps_employer': self.nps_employer_contribution,
'hra_exemption': self.hra_exemption,
'interest_home_loan': self.interest_home_loan_self + self.interest_home_loan_letout,
@@ -861,7 +863,7 @@ class ITTaxStatementWizard(models.TransientModel):
'gross_salary': annual_gross_salary,
'other_income': self.other_income,
'house_property_income': hp_income,
- 'gross_total_income': (annual_gross_salary + self.other_income + hp_income) - selected_standard_deduction,
+ 'gross_total_income': (annual_gross_salary + self.other_income + hp_income) - total_sec_16_deduction,
},
'taxable_income': {
diff --git a/addons_extensions/employee_it_declaration/report/it_tax_template.xml b/addons_extensions/employee_it_declaration/report/it_tax_template.xml
index 055e040f2..70acae040 100644
--- a/addons_extensions/employee_it_declaration/report/it_tax_template.xml
+++ b/addons_extensions/employee_it_declaration/report/it_tax_template.xml
@@ -30,6 +30,7 @@
+
@@ -140,35 +141,35 @@
|
-
- | Reimbursement |
- |
+
+ | Reimbursement |
+ |
|
- |
-
-
- |
- |
- |
- |
-
-
- | Gross Salary |
- |
+
+
+ |
+ |
+ |
+ |
+
+
+ | Gross Salary |
+ |
|
- |
-
-
- | Advance Recovery |
+ |
+
+
+ | Advance Recovery |
|
|
| Less: Standard Deduction for Salaried Employees |
+
+ -
+ |
|
- |
| Total |
+ |
|
- |
- |
+ t-esc="total_sec_16_deduction"/>
+
+
+ | Less: Marginal Relief |
+ |
+ 0 |
+
@@ -351,9 +357,9 @@
-
-
- Tax Deduction Details
+
+
+ Tax Deduction Details
@@ -429,72 +435,72 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- TAX REGIME COMPARISON
-
-
-
-
- | Employee: |
- Emp Code: |
-
-
- | Financial Year: |
- Selected Regime: |
-
-
-
-
-
-
- | Particulars |
- Old Regime |
- New Regime |
-
-
-
-
- | Taxable Income |
- |
- |
-
-
- | Tax Payable |
- |
- |
-
-
- | Tax Difference |
- |
-
-
- | Beneficial Regime |
-
-
- |
-
-
-
-
-
- Report Time:
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TAX REGIME COMPARISON
+
+
+
+
+ | Employee: |
+ Emp Code: |
+
+
+ | Financial Year: |
+ Selected Regime: |
+
+
+
+
+
+
+ | Particulars |
+ Old Regime |
+ New Regime |
+
+
+
+
+ | Taxable Income |
+ |
+ |
+
+
+ | Tax Payable |
+ |
+ |
+
+
+ | Tax Difference |
+ |
+
+
+ | Beneficial Regime |
+
+
+ |
+
+
+
+
+
+ Report Time:
+
+
+
+
+