From 4db7e5ade2f7097a8a4370ec3ef67adfb7c67b37 Mon Sep 17 00:00:00 2001 From: pranaysaidurga Date: Wed, 20 May 2026 18:59:16 +0530 Subject: [PATCH] Document parser upload --- addons_extensions/document_parser/__init__.py | 1 + .../document_parser/__manifest__.py | 19 + .../document_parser/models/__init__.py | 2 + .../models/document_parser_service.py | 444 ++++++++++++++++++ .../models/res_config_settings.py | 67 +++ .../views/res_config_settings_views.xml | 39 ++ .../models/it_tax_statement_wiz.py | 306 ++++++------ .../report/it_tax_template.xml | 204 ++++---- 8 files changed, 831 insertions(+), 251 deletions(-) create mode 100644 addons_extensions/document_parser/__init__.py create mode 100644 addons_extensions/document_parser/__manifest__.py create mode 100644 addons_extensions/document_parser/models/__init__.py create mode 100644 addons_extensions/document_parser/models/document_parser_service.py create mode 100644 addons_extensions/document_parser/models/res_config_settings.py create mode 100644 addons_extensions/document_parser/views/res_config_settings_views.xml 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 @@ - - - - - + + + + +