Document parser upload

This commit is contained in:
pranaysaidurga 2026-05-20 18:59:16 +05:30
parent f2788e025d
commit 4db7e5ade2
8 changed files with 831 additions and 251 deletions

View File

@ -0,0 +1 @@
from . import models

View File

@ -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"],
},
}

View File

@ -0,0 +1,2 @@
from . import document_parser_service
from . import res_config_settings

View File

@ -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)

View File

@ -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))

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_document_parser" model="ir.ui.view">
<field name="name">res.config.settings.view.form.document.parser</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="80"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app string="Document Parser" name="document_parser" groups="base.group_system">
<block title="AI Providers" name="document_parser_ai_provider_block">
<setting string="Together AI Key"
help="Primary provider used first for structured document extraction."
id="document_parser_together_ai_key">
<div class="d-flex align-items-center gap-2">
<field name="together_ai_key" password="True" placeholder="together.ai API key"/>
<button name="action_test_together_ai_connection"
string="Test Connection"
type="object"
class="btn btn-secondary"/>
</div>
</setting>
<setting string="OpenRouter AI Key"
help="Fallback provider used when Together AI is unavailable or quota is exhausted."
id="document_parser_openrouter_ai_key">
<div class="d-flex align-items-center gap-2">
<field name="openrouter_ai_key" password="True" placeholder="openrouter API key"/>
<button name="action_test_openrouter_ai_connection"
string="Test Connection"
type="object"
class="btn btn-secondary"/>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@ -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': {

View File

@ -30,6 +30,7 @@
<t t-set="tax_employment" t-value="'{:,.0f}'.format(deductions.get('professional_tax', 0))"/>
<t t-set="standard_deduction" t-value="'{:,.0f}'.format(deductions.get('standard_deduction', 0))"/>
<t t-set="other_income" t-value="'{:,.0f}'.format(income_details.get('other_income', 0))"/>
<t t-set="total_sec_16_deduction" t-value="'{:,.0f}'.format(deductions.get('total_sec_16_deduction', 0))"/>
<t t-set="gross_total_income" t-value="'{:,.0f}'.format(income_details.get('gross_total_income', 0))"/>
<t t-set="taxable_income" t-value="'{:,.0f}'.format(tax_computation.get('taxable_income', 0))"/>
<t t-set="roundoff_taxable_income" t-value="'{:,.0f}'.format(tax_computation.get('roundoff_taxable_income', 0))"/>
@ -140,35 +141,35 @@
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('special_allowance', {}).get('total', 0))"/>
</tr>
<tr>
<td>Reimbursement</td>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('reimbursement', {}).get('actual', 0))"/>
<tr>
<td>Reimbursement</td>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('reimbursement', {}).get('actual', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('reimbursement', {}).get('projected', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('reimbursement', {}).get('total', 0))"/>
</tr>
<tr t-foreach="salary_components.get('other_components', [])" t-as="component">
<td t-esc="component.get('name')"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('actual', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('projected', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('total', 0))"/>
</tr>
<tr style="border-top: 2px solid #ddd; font-weight: bold;">
<td>Gross Salary</td>
<td style="text-align: right;"
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('reimbursement', {}).get('total', 0))"/>
</tr>
<tr t-foreach="salary_components.get('other_components', [])" t-as="component">
<td t-esc="component.get('name')"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('actual', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('projected', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(component.get('total', 0))"/>
</tr>
<tr style="border-top: 2px solid #ddd; font-weight: bold;">
<td>Gross Salary</td>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('gross_salary', {}).get('actual', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('gross_salary', {}).get('projected', 0))"/>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('gross_salary', {}).get('total', 0))"/>
</tr>
<tr t-if="salary_components.get('advance_recovery', {}).get('total', 0)">
<td>Advance Recovery</td>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('gross_salary', {}).get('total', 0))"/>
</tr>
<tr t-if="salary_components.get('advance_recovery', {}).get('total', 0)">
<td>Advance Recovery</td>
<td style="text-align: right;"
t-esc="'{:,.0f}'.format(salary_components.get('advance_recovery', {}).get('actual', 0))"/>
<td style="text-align: right;"
@ -198,17 +199,22 @@
</tr>
<tr style="border-top: 2px solid #ddd; margin-bottom: 8px; margin-top: 10px;">
<td style="padding-left: 30px;">Less: Standard Deduction for Salaried Employees</td>
<td style="text-align: right;">
-<span t-esc="standard_deduction"/>
</td>
<td colspan="2"></td>
<td style="text-align: right;"
t-esc="standard_deduction"/>
</tr>
<tr style="border-top: 2px solid #ddd; margin-bottom: 8px; margin-top: 10px;">
<td style="padding-left: 30px;">Total</td>
<td colspan="2"></td>
<td style="text-align: right;"
t-esc="tax_employment"/>
<td colspan="1"></td>
<td style="text-align: right;"
t-esc="standard_deduction"/>
t-esc="total_sec_16_deduction"/>
</tr>
<tr style="border-top: 2px solid #ddd; margin-bottom: 8px; margin-top: 10px;">
<td><strong>Less:</strong> Marginal Relief</td>
<td colspan="3"></td>
<td style="text-align: right;">0</td>
</tr>
<tr style="border-top: 2px solid #ddd; font-weight: bold; margin-bottom: 8px; margin-top: 10px;">
@ -351,9 +357,9 @@
</tr>
</table>
<!-- Tax Breakdown -->
<h4 style="font-weight: bold; border-bottom: 2px solid #ddd; padding-bottom: 5px; margin-bottom: 15px;">
Tax Deduction Details
<!-- Tax Breakdown -->
<h4 style="font-weight: bold; border-bottom: 2px solid #ddd; padding-bottom: 5px; margin-bottom: 15px;">
Tax Deduction Details
</h4>
<table class="table table-bordered table-sm" style="width: 100%; margin-bottom: 20px;">
<thead>
@ -429,72 +435,72 @@
</div>
</div>
</div>
</t>
</template>
<template id="generate_income_tax_comparison_rpt">
<t t-call="web.html_container">
<div class="page">
<t t-set="data" t-value="report_data['data']"/>
<t t-set="profile" t-value="data.get('profile')"/>
<t t-set="comparison" t-value="data.get('comparison')"/>
<div style="text-align: center; margin-bottom: 24px;">
<h2 style="font-weight: bold; margin-bottom: 5px;"><t t-esc="data.get('company_name')"/></h2>
<h3 style="font-weight: bold; margin-top: 0;">TAX REGIME COMPARISON</h3>
</div>
<table class="table table-sm" style="width: 100%; margin-bottom: 24px;">
<tr>
<td><strong>Employee:</strong> <t t-esc="profile.get('name', '')"/></td>
<td><strong>Emp Code:</strong> <t t-esc="data.get('emp_code', '')"/></td>
</tr>
<tr>
<td><strong>Financial Year:</strong> <t t-esc="data.get('financial_year', '')"/></td>
<td><strong>Selected Regime:</strong> <t t-esc="data.get('regime_info', {}).get('tax_regime', '')"/></td>
</tr>
</table>
<table class="table table-bordered table-sm" style="width: 100%; margin-bottom: 20px;">
<thead>
<tr style="background-color: #f8f9fa;">
<th style="font-weight: bold; text-align: left;">Particulars</th>
<th style="font-weight: bold; text-align: right;">Old Regime</th>
<th style="font-weight: bold; text-align: right;">New Regime</th>
</tr>
</thead>
<tbody>
<tr>
<td>Taxable Income</td>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('old_taxable_income', 0))"/>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('new_taxable_income', 0))"/>
</tr>
<tr>
<td>Tax Payable</td>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('old_regime_tax', 0))"/>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('new_regime_tax', 0))"/>
</tr>
<tr style="font-weight: bold;">
<td>Tax Difference</td>
<td colspan="2" style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('tax_savings', 0))"/>
</tr>
<tr style="font-weight: bold;">
<td>Beneficial Regime</td>
<td colspan="2" style="text-align: right;">
<t t-esc="(comparison.get('beneficial_regime') or '').upper()"/>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 24px;">
<strong>Report Time:</strong>
<t t-esc="data.get('report_time', '')"/>
</div>
</div>
</t>
</template>
</div>
</t>
</template>
<template id="generate_income_tax_comparison_rpt">
<t t-call="web.html_container">
<div class="page">
<t t-set="data" t-value="report_data['data']"/>
<t t-set="profile" t-value="data.get('profile')"/>
<t t-set="comparison" t-value="data.get('comparison')"/>
<div style="text-align: center; margin-bottom: 24px;">
<h2 style="font-weight: bold; margin-bottom: 5px;"><t t-esc="data.get('company_name')"/></h2>
<h3 style="font-weight: bold; margin-top: 0;">TAX REGIME COMPARISON</h3>
</div>
<table class="table table-sm" style="width: 100%; margin-bottom: 24px;">
<tr>
<td><strong>Employee:</strong> <t t-esc="profile.get('name', '')"/></td>
<td><strong>Emp Code:</strong> <t t-esc="data.get('emp_code', '')"/></td>
</tr>
<tr>
<td><strong>Financial Year:</strong> <t t-esc="data.get('financial_year', '')"/></td>
<td><strong>Selected Regime:</strong> <t t-esc="data.get('regime_info', {}).get('tax_regime', '')"/></td>
</tr>
</table>
<table class="table table-bordered table-sm" style="width: 100%; margin-bottom: 20px;">
<thead>
<tr style="background-color: #f8f9fa;">
<th style="font-weight: bold; text-align: left;">Particulars</th>
<th style="font-weight: bold; text-align: right;">Old Regime</th>
<th style="font-weight: bold; text-align: right;">New Regime</th>
</tr>
</thead>
<tbody>
<tr>
<td>Taxable Income</td>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('old_taxable_income', 0))"/>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('new_taxable_income', 0))"/>
</tr>
<tr>
<td>Tax Payable</td>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('old_regime_tax', 0))"/>
<td style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('new_regime_tax', 0))"/>
</tr>
<tr style="font-weight: bold;">
<td>Tax Difference</td>
<td colspan="2" style="text-align: right;" t-esc="'{:,.0f}'.format(comparison.get('tax_savings', 0))"/>
</tr>
<tr style="font-weight: bold;">
<td>Beneficial Regime</td>
<td colspan="2" style="text-align: right;">
<t t-esc="(comparison.get('beneficial_regime') or '').upper()"/>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 24px;">
<strong>Report Time:</strong>
<t t-esc="data.get('report_time', '')"/>
</div>
</div>
</t>
</template>
<!-- <template id="generate_income_tax_statement_rpt">-->
<!-- <t t-call="web.basic_layout">-->
<!-- <main class="page">-->