forked from hesabix/arc
273 lines
12 KiB
Python
273 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Dict, Any, List
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from adapters.db.models.person import Person
|
||
from adapters.db.models.product import Product
|
||
from adapters.db.models.invoice_item_line import InvoiceItemLine
|
||
from app.integrations.moadian.utils import (
|
||
validate_tax_code,
|
||
)
|
||
|
||
|
||
def validate_document_for_tax(db: Session, document) -> Dict[str, Any]:
|
||
"""
|
||
بررسی کامل دادههای لازم برای ارسال فاکتور به سامانه مودیان.
|
||
|
||
بررسیها:
|
||
- وجود طرف حساب (برای ثبت حسابداری فاکتور)
|
||
- کد مالیاتی کالاها (13 رقم)
|
||
- واحد مالیاتی کالاها
|
||
- صحیح بودن مبالغ مالیات (بدون اعشار)
|
||
- محدودیتهای دیگر
|
||
|
||
شناسه مالیاتی خریدار (کد ملی / کد اقتصادی) الزامی نیست:
|
||
در صورت نبود هویت کامل خریدار، سازنده فاکتور آن را بهصورت نوع ۲
|
||
(مصرفکننده نهایی، بدون فیلد خریدار) به مودیان میفرستد.
|
||
کدهای نامعتبر نیز در builder نادیده گرفته میشوند و به نوع ۲ میانجامند؛
|
||
اینجا بلاک نمیشوند تا با رفتار سامانه و builder همخوان باشد.
|
||
|
||
Returns:
|
||
{
|
||
"valid": bool,
|
||
"issues": [{"code": str, "message": str, "meta": dict}]
|
||
}
|
||
"""
|
||
issues: List[Dict[str, Any]] = []
|
||
extra = document.extra_info or {}
|
||
|
||
# 1. بررسی اطلاعات شخص (وجود طرفحساب؛ نه الزام شناسه مالیاتی)
|
||
person_id = extra.get("person_id")
|
||
person: Person | None = None
|
||
|
||
if not person_id:
|
||
issues.append({
|
||
"code": "PERSON_MISSING",
|
||
"message": "برای فاکتورهای فروش/خرید، انتخاب طرف حساب الزامی است.",
|
||
})
|
||
else:
|
||
person = db.query(Person).filter(Person.id == int(person_id)).first()
|
||
|
||
if not person:
|
||
issues.append({
|
||
"code": "PERSON_NOT_FOUND",
|
||
"message": "طرف حساب انتخاب شده یافت نشد.",
|
||
})
|
||
# else: کد ملی/اقتصادی اختیاری است → نوع ۱ یا ۲ در invoice_builder تعیین میشود
|
||
|
||
# 2. بررسی اقلام فاکتور
|
||
item_lines: List[InvoiceItemLine] = (
|
||
db.query(InvoiceItemLine)
|
||
.filter(InvoiceItemLine.document_id == document.id)
|
||
.all()
|
||
)
|
||
|
||
if not item_lines:
|
||
issues.append({
|
||
"code": "LINES_MISSING",
|
||
"message": "فاکتور فاقد ردیف کالا/خدمت است.",
|
||
})
|
||
|
||
for idx, line in enumerate(item_lines, 1):
|
||
product = db.query(Product).filter(Product.id == line.product_id).first()
|
||
|
||
if not product:
|
||
issues.append({
|
||
"code": "PRODUCT_NOT_FOUND",
|
||
"message": f"کالای ردیف {idx} حذف شده است.",
|
||
"meta": {"line_id": line.id, "line_number": idx},
|
||
})
|
||
continue
|
||
|
||
# بررسی کد مالیاتی
|
||
tax_code = (product.tax_code or "").strip()
|
||
if not tax_code:
|
||
issues.append({
|
||
"code": "PRODUCT_TAX_CODE_MISSING",
|
||
"message": f"کالای '{product.name}' (ردیف {idx}) فاقد کد مالیاتی است.",
|
||
"meta": {"product_id": product.id, "product_name": product.name, "line_number": idx},
|
||
})
|
||
elif not validate_tax_code(tax_code):
|
||
issues.append({
|
||
"code": "PRODUCT_TAX_CODE_INVALID",
|
||
"message": f"کد مالیاتی کالای '{product.name}' (ردیف {idx}) نامعتبر است (باید دقیقا 13 رقم باشد).",
|
||
"meta": {"product_id": product.id, "tax_code": tax_code, "line_number": idx},
|
||
})
|
||
|
||
# بررسی واحد مالیاتی
|
||
if not product.tax_unit_id:
|
||
issues.append({
|
||
"code": "PRODUCT_TAX_UNIT_MISSING",
|
||
"message": f"کالای '{product.name}' (ردیف {idx}) فاقد واحد مالیاتی است.",
|
||
"meta": {"product_id": product.id, "product_name": product.name, "line_number": idx},
|
||
})
|
||
|
||
# بررسی مالیات (نباید اعشار داشته باشد)
|
||
line_extra = line.extra_info or {}
|
||
tax_amount = line_extra.get("tax_amount", 0)
|
||
|
||
try:
|
||
tax_float = float(tax_amount)
|
||
# بررسی اعشار
|
||
if tax_float != int(tax_float):
|
||
issues.append({
|
||
"code": "TAX_AMOUNT_HAS_DECIMAL",
|
||
"message": f"مبلغ مالیات ردیف {idx} (کالای '{product.name}') نباید اعشار داشته باشد.",
|
||
"meta": {"line_id": line.id, "tax_amount": tax_amount, "line_number": idx},
|
||
})
|
||
|
||
# بررسی منفی نبودن
|
||
if tax_float < 0:
|
||
issues.append({
|
||
"code": "TAX_AMOUNT_NEGATIVE",
|
||
"message": f"مبلغ مالیات ردیف {idx} (کالای '{product.name}') نمیتواند منفی باشد.",
|
||
"meta": {"line_id": line.id, "tax_amount": tax_amount, "line_number": idx},
|
||
})
|
||
except (ValueError, TypeError):
|
||
issues.append({
|
||
"code": "TAX_AMOUNT_INVALID",
|
||
"message": f"مبلغ مالیات ردیف {idx} نامعتبر است.",
|
||
"meta": {"line_id": line.id, "line_number": idx},
|
||
})
|
||
|
||
# 3. بررسی هزینه حمل (اختیاری - اگر وجود داشته باشد باید معتبر باشد)
|
||
shipping_cost = (extra or {}).get("shipping_cost", 0)
|
||
try:
|
||
shipping_cost_val = float(shipping_cost or 0)
|
||
# اگر هزینه حمل منفی باشد، خطا میدهیم
|
||
if shipping_cost_val < 0:
|
||
issues.append({
|
||
"code": "SHIPPING_COST_NEGATIVE",
|
||
"message": "هزینه حمل نمیتواند منفی باشد.",
|
||
"meta": {"shipping_cost": shipping_cost},
|
||
})
|
||
# اگر هزینه حمل اعشار داشته باشد، خطا میدهیم (سامانه اعشار قبول نمیکند)
|
||
elif shipping_cost_val > 0 and shipping_cost_val != int(shipping_cost_val):
|
||
issues.append({
|
||
"code": "SHIPPING_COST_HAS_DECIMAL",
|
||
"message": "هزینه حمل نباید اعشار داشته باشد.",
|
||
"meta": {"shipping_cost": shipping_cost},
|
||
})
|
||
except (ValueError, TypeError):
|
||
if shipping_cost: # فقط اگر مقدار غیر صفر باشد خطا میدهیم
|
||
issues.append({
|
||
"code": "SHIPPING_COST_INVALID",
|
||
"message": "مبلغ هزینه حمل نامعتبر است.",
|
||
"meta": {"shipping_cost": shipping_cost},
|
||
})
|
||
|
||
# 4. بررسی مبلغ کل فاکتور
|
||
totals = extra.get("totals") or {}
|
||
total_amount = totals.get("net", 0)
|
||
|
||
try:
|
||
total_float = float(total_amount)
|
||
if total_float <= 0:
|
||
issues.append({
|
||
"code": "TOTAL_AMOUNT_INVALID",
|
||
"message": "مبلغ کل فاکتور باید بیشتر از صفر باشد.",
|
||
"meta": {"total_amount": total_amount},
|
||
})
|
||
except (ValueError, TypeError):
|
||
issues.append({
|
||
"code": "TOTAL_AMOUNT_INVALID",
|
||
"message": "مبلغ کل فاکتور نامعتبر است.",
|
||
})
|
||
|
||
return {
|
||
"valid": len(issues) == 0,
|
||
"issues": issues,
|
||
"total_issues": len(issues),
|
||
}
|
||
|
||
|
||
def validate_tax_submission_scenario(
|
||
db: Session,
|
||
document,
|
||
tax_setting,
|
||
submission_mode: str | None = None,
|
||
) -> Dict[str, Any]:
|
||
"""اعتبارسنجی سناریوی ارسال (فروش / برگشت / ابطال / اصلاح)."""
|
||
from app.services.tax_reference_service import (
|
||
resolve_irtaxid,
|
||
resolve_reference_document,
|
||
is_modian_submitted,
|
||
get_document_extra,
|
||
compute_taxid_for_document,
|
||
TAX_SENT_STATUSES,
|
||
)
|
||
|
||
issues: List[Dict[str, Any]] = []
|
||
extra = get_document_extra(document)
|
||
mode = (submission_mode or "").strip().lower()
|
||
doc_type = (document.document_type or "").lower()
|
||
|
||
if mode == "cancel":
|
||
mode = "cancel"
|
||
elif mode == "corrective":
|
||
pass
|
||
elif "return" in doc_type or mode == "return":
|
||
mode = "return"
|
||
else:
|
||
mode = "normal"
|
||
|
||
if mode == "return":
|
||
ref_doc = resolve_reference_document(db, document.business_id, document)
|
||
if ref_doc is None:
|
||
issues.append({
|
||
"code": "TAX_REFERENCE_INVOICE_MISSING",
|
||
"message": "برای برگشت از فروش، فاکتور مرجع (فروش اصلی) باید مشخص شود.",
|
||
})
|
||
else:
|
||
ref_extra = get_document_extra(ref_doc)
|
||
if not is_modian_submitted(ref_extra):
|
||
issues.append({
|
||
"code": "TAX_REFERENCE_NOT_SENT",
|
||
"message": "فاکتور مرجع هنوز در سامانه مودیان ارسال/قطعی نشده است.",
|
||
"meta": {"reference_invoice_id": ref_doc.id, "reference_code": ref_doc.code},
|
||
})
|
||
irtaxid = resolve_irtaxid(db, document.business_id, document, tax_setting)
|
||
if not irtaxid:
|
||
issues.append({
|
||
"code": "TAX_REFERENCE_TAXID_MISSING",
|
||
"message": "شناسه مالیاتی (taxid) فاکتور مرجع قابل محاسبه نیست.",
|
||
"meta": {"reference_invoice_id": ref_doc.id},
|
||
})
|
||
|
||
elif mode == "cancel":
|
||
status = str(extra.get("tax_status") or "").strip().lower()
|
||
if status not in TAX_SENT_STATUSES and not extra.get("tax_tracking_code"):
|
||
issues.append({
|
||
"code": "TAX_CANCEL_NOT_SENT",
|
||
"message": "فقط فاکتورهای ارسالشده به مودیان قابل ابطال در سامانه هستند.",
|
||
})
|
||
if extra.get("tax_cancelled_in_modian"):
|
||
issues.append({
|
||
"code": "TAX_ALREADY_CANCELLED",
|
||
"message": "این فاکتور قبلاً در سامانه مودیان ابطال شده است.",
|
||
})
|
||
|
||
elif mode == "corrective":
|
||
status = str(extra.get("tax_status") or "").strip().lower()
|
||
if status not in TAX_SENT_STATUSES and not extra.get("tax_tracking_code"):
|
||
issues.append({
|
||
"code": "TAX_CORRECTIVE_REQUIRES_SENT",
|
||
"message": "صورتحساب اصلاحی فقط پس از ارسال اولیه مجاز است.",
|
||
})
|
||
irtaxid = resolve_irtaxid(db, document.business_id, document, tax_setting)
|
||
if not irtaxid:
|
||
irtaxid = compute_taxid_for_document(document, tax_setting)
|
||
if not irtaxid:
|
||
issues.append({
|
||
"code": "TAX_CORRECTIVE_TAXID_MISSING",
|
||
"message": "شناسه مالیاتی مرجع برای اصلاح یافت نشد.",
|
||
})
|
||
|
||
return {
|
||
"valid": len(issues) == 0,
|
||
"issues": issues,
|
||
"submission_mode": mode,
|
||
}
|
||
|