Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/tax_reference_service.py

288 lines
9.9 KiB
Python

"""
مرجع‌یابی و مدیریت شناسه‌های مالیاتی مودیان (taxid / irtaxid).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from adapters.db.models.document import Document
from adapters.db.models.tax_setting import TaxSetting
from app.integrations.moadian.utils import coerce_to_datetime, generate_tax_id
TAX_SENT_STATUSES = frozenset({"sent", "finalized", "accepted"})
TAX_TYPE_CHANGE_BLOCKED_STATUSES = TAX_SENT_STATUSES
TAX_WORKSPACE_ALLOWED_TYPES = frozenset({"invoice_sales", "invoice_sales_return"})
TAX_REFERENCE_INVOICE_KEYS = (
"reference_invoice_id",
"source_invoice_id",
"original_invoice_id",
"return_from_invoice_id",
)
def get_document_extra(document: Document) -> dict:
return dict(document.extra_info or {})
def get_stored_taxid(extra: dict) -> Optional[str]:
raw = (extra.get("tax_moadian_taxid") or extra.get("taxid") or "").strip()
return raw or None
def compute_taxid_for_document(document: Document, tax_setting: TaxSetting) -> str:
extra = get_document_extra(document)
stored = get_stored_taxid(extra)
if stored:
return stored
doc_date = coerce_to_datetime(document.document_date)
client_id = tax_setting.tax_memory_id or tax_setting.economic_code or ""
return generate_tax_id(
client_id=client_id,
timestamp=doc_date,
internal_id=int(document.id),
)
def persist_taxid_on_document(document: Document, taxid: str, db: Session) -> None:
extra = get_document_extra(document)
extra["tax_moadian_taxid"] = taxid
document.extra_info = extra
db.add(document)
def resolve_reference_document(
db: Session,
business_id: int,
document: Document,
) -> Optional[Document]:
extra = get_document_extra(document)
ref_id = None
for key in TAX_REFERENCE_INVOICE_KEYS:
val = extra.get(key)
if val is not None:
try:
ref_id = int(val)
break
except (TypeError, ValueError):
continue
if ref_id is None:
return None
return (
db.query(Document)
.filter(Document.id == ref_id, Document.business_id == business_id)
.first()
)
def resolve_irtaxid(
db: Session,
business_id: int,
document: Document,
tax_setting: TaxSetting,
) -> Optional[str]:
extra = get_document_extra(document)
manual = (extra.get("reference_tax_id") or extra.get("irtaxid") or "").strip()
if manual:
return manual
ref_doc = resolve_reference_document(db, business_id, document)
if ref_doc is None:
return None
ref_extra = get_document_extra(ref_doc)
stored = get_stored_taxid(ref_extra)
if stored:
return stored
if ref_doc.document_date and ref_doc.id:
return compute_taxid_for_document(ref_doc, tax_setting)
return None
def document_tax_status(extra: dict) -> str:
return str(extra.get("tax_status") or "").strip().lower()
def is_modian_submitted(extra: dict) -> bool:
status = document_tax_status(extra)
if status in TAX_SENT_STATUSES:
return True
return bool(extra.get("tax_tracking_code"))
def can_cancel_in_modian(document: Document) -> Tuple[bool, str]:
extra = get_document_extra(document)
if not bool(extra.get("tax_workspace")):
return False, "فاکتور در کارپوشه مالیاتی نیست."
if not is_modian_submitted(extra):
return False, "فقط فاکتورهای ارسال‌شده به مودیان قابل ابطال هستند."
if extra.get("tax_cancelled_in_modian"):
return False, "این فاکتور قبلاً در سامانه مودیان ابطال شده است."
return True, ""
def can_send_corrective(document: Document) -> Tuple[bool, str]:
extra = get_document_extra(document)
if not bool(extra.get("tax_workspace")):
return False, "فاکتور در کارپوشه مالیاتی نیست."
if not is_modian_submitted(extra):
return False, "فقط پس از ارسال اولیه می‌توان صورتحساب اصلاحی صادر کرد."
if not get_stored_taxid(extra) and not extra.get("tax_tracking_code"):
return False, "شناسه مالیاتی فاکتور مرجع یافت نشد."
return True, ""
def link_reference_invoice(
db: Session,
business_id: int,
document: Document,
reference_invoice_id: int,
) -> Document:
ref = (
db.query(Document)
.filter(Document.id == reference_invoice_id, Document.business_id == business_id)
.first()
)
if not ref:
from app.core.responses import ApiError
raise ApiError("REFERENCE_INVOICE_NOT_FOUND", "فاکتور مرجع یافت نشد.", http_status=404)
extra = get_document_extra(document)
extra["reference_invoice_id"] = int(reference_invoice_id)
document.extra_info = extra
db.add(document)
db.flush()
return ref
def build_tax_edit_constraints_for_api(document: Document) -> Dict[str, Any]:
"""
قیود ویرایش فاکتور از منظر کارپوشه/ارسال مودیان برای API و UI.
"""
extra = get_document_extra(document)
in_workspace = bool(extra.get("tax_workspace"))
submitted = is_modian_submitted(extra)
cancelled = bool(extra.get("tax_cancelled_in_modian"))
status = document_tax_status(extra)
if not status:
status = "in_workspace" if in_workspace else "not_in_workspace"
type_change_allowed = True
type_change_block_reason: Optional[str] = None
header_locked = False
locked_fields: List[str] = []
if cancelled:
type_change_allowed = False
header_locked = True
type_change_block_reason = (
"این فاکتور در سامانه مودیان ابطال شده است. تغییر نوع مجاز نیست؛ فاکتور جدید ثبت کنید."
)
locked_fields.extend(["invoice_type", "is_proforma"])
elif submitted:
type_change_allowed = False
header_locked = True
type_change_block_reason = (
"این فاکتور به سامانه مودیان ارسال شده است. تغییر نوع مجاز نیست. "
"برای ابطال از «ابطال در سامانه مودیان»، برای اصلاح جزئی از «صورتحساب اصلاحی»، "
"و برای تغییر اساسی (مثل خرید) پس از ابطال، فاکتور جدید ایجاد کنید."
)
locked_fields.append("invoice_type")
elif in_workspace:
locked_fields.append("invoice_type_outside_sales_family")
allowed_actions: List[str] = []
ok_cancel, _ = can_cancel_in_modian(document)
if ok_cancel:
allowed_actions.append("cancel_in_modian")
ok_corrective, _ = can_send_corrective(document)
if ok_corrective:
allowed_actions.append("send_corrective")
if in_workspace and not submitted and not cancelled:
allowed_actions.append("remove_from_tax_workspace")
return {
"is_in_tax_workspace": in_workspace,
"tax_status": status,
"tax_tracking_code": extra.get("tax_tracking_code"),
"tax_type_change_allowed": type_change_allowed,
"tax_type_change_block_reason": type_change_block_reason,
"tax_header_locked": header_locked,
"tax_locked_fields": sorted(set(locked_fields)),
"tax_allowed_actions": allowed_actions,
}
def validate_invoice_type_change_for_tax(
document: Document,
old_type: str,
new_type: str,
) -> None:
"""جلوگیری از تغییر نوع فاکتور ناسازگار با وضعیت مودیان."""
from app.core.responses import ApiError
old_t = str(old_type or "").strip()
new_t = str(new_type or "").strip()
if not new_t or old_t == new_t:
return
extra = get_document_extra(document)
constraints = build_tax_edit_constraints_for_api(document)
if extra.get("tax_cancelled_in_modian"):
raise ApiError(
"TAX_TYPE_CHANGE_BLOCKED",
constraints.get("tax_type_change_block_reason")
or "این فاکتور در سامانه مودیان ابطال شده است.",
http_status=409,
details=constraints,
)
if is_modian_submitted(extra):
raise ApiError(
"TAX_TYPE_CHANGE_BLOCKED",
constraints.get("tax_type_change_block_reason")
or "تغییر نوع فاکتور ارسال‌شده به مودیان مجاز نیست.",
http_status=409,
details=constraints,
)
if bool(extra.get("tax_workspace")) and new_t not in TAX_WORKSPACE_ALLOWED_TYPES:
raise ApiError(
"TAX_WORKSPACE_TYPE_RESTRICTED",
"فاکتور در کارپوشه مالیاتی است و فقط می‌تواند «فروش» یا «برگشت از فروش» باشد. "
"برای تغییر به نوع دیگر (مثل خرید)، ابتدا از کارپوشه مودیان خارج کنید.",
http_status=409,
details=constraints,
)
def validate_invoice_proforma_change_for_tax(
document: Document,
*,
old_is_proforma: bool,
new_is_proforma: bool,
) -> None:
"""پس از ارسال به مودیان، تبدیل قطعی/پیش‌فاکتور مجاز نیست."""
from app.core.responses import ApiError
if bool(old_is_proforma) == bool(new_is_proforma):
return
extra = get_document_extra(document)
if extra.get("tax_cancelled_in_modian") or is_modian_submitted(extra):
constraints = build_tax_edit_constraints_for_api(document)
raise ApiError(
"TAX_EDIT_LOCKED",
"فاکتور ارسال‌شده یا ابطال‌شده در مودیان قابل تبدیل به پیش‌فاکتور/قطعی نیست.",
http_status=409,
details=constraints,
)