forked from hesabix/arc
723 lines
29 KiB
Python
Executable file
723 lines
29 KiB
Python
Executable file
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
import time
|
|
from typing import Dict, Any, List
|
|
|
|
from sqlalchemy import cast
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Session
|
|
|
|
from adapters.db.models.document import Document
|
|
from adapters.db.models.tax_setting import TaxSetting
|
|
from app.core.responses import ApiError
|
|
from app.core.settings import get_settings
|
|
from app.integrations.moadian.client import MoadianClient
|
|
from app.integrations.moadian.invoice_builder import (
|
|
build_invoice_for_moadian,
|
|
ensure_person_snapshot_on_document_dict,
|
|
)
|
|
from app.services.invoice_service import invoice_document_to_dict, refresh_invoice_line_tax_snapshots
|
|
from app.services.tax_validation_service import validate_document_for_tax, validate_tax_submission_scenario
|
|
from app.integrations.moadian.utils import extract_moadian_error_message
|
|
from app.services.tax_reference_service import (
|
|
resolve_irtaxid,
|
|
compute_taxid_for_document,
|
|
persist_taxid_on_document,
|
|
get_document_extra,
|
|
)
|
|
from app.core.moadian_plugin_dependency import ensure_moadian_plugin_active
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _documents_by_tracking_codes(
|
|
db: Session,
|
|
business_id: int,
|
|
tracking_codes: List[str],
|
|
) -> Dict[str, Document]:
|
|
"""یافتن اسناد بر اساس کد رهگیری ذخیرهشده در extra_info."""
|
|
codes = {str(c).strip() for c in tracking_codes if c and str(c).strip()}
|
|
if not codes:
|
|
return {}
|
|
|
|
_extra_info_jb = cast(Document.extra_info, JSONB)
|
|
docs = (
|
|
db.query(Document)
|
|
.filter(
|
|
Document.business_id == business_id,
|
|
_extra_info_jb["tax_tracking_code"].astext.in_(sorted(codes)),
|
|
)
|
|
.all()
|
|
)
|
|
mapping: Dict[str, Document] = {}
|
|
for doc in docs:
|
|
code = (doc.extra_info or {}).get("tax_tracking_code")
|
|
if code:
|
|
mapping[str(code)] = doc
|
|
return mapping
|
|
|
|
|
|
def _extract_inquiry_error_message(item: Dict[str, Any]) -> str | None:
|
|
if item.get("error_message"):
|
|
return str(item["error_message"])
|
|
raw = item.get("raw_data")
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
data = raw.get("data")
|
|
if not isinstance(data, dict):
|
|
return None
|
|
errors = data.get("error")
|
|
if isinstance(errors, list) and errors:
|
|
first = errors[0] if isinstance(errors[0], dict) else {}
|
|
return extract_moadian_error_message(first)
|
|
return None
|
|
|
|
|
|
def _apply_inquiry_result_to_document(doc: Document, item: Dict[str, Any], *, now: str) -> None:
|
|
extra = dict(doc.extra_info or {})
|
|
current_status = str(extra.get("tax_status") or "").strip().lower()
|
|
mapped_status = _map_inquiry_status(item.get("status"))
|
|
# NOT_FOUND / وضعیتهای ناشناخته نباید ارسال موفق را به «ارسال نشده» برگردانند.
|
|
# استعلام فوری بعد از send اغلب NOT_FOUND است چون هنوز در صف پردازش مودیان است.
|
|
if mapped_status:
|
|
if mapped_status == "failed" or current_status not in _NON_REGRESSIBLE_TAX_STATUSES:
|
|
extra["tax_status"] = mapped_status
|
|
elif mapped_status in _NON_REGRESSIBLE_TAX_STATUSES and _tax_status_rank(
|
|
mapped_status
|
|
) >= _tax_status_rank(current_status):
|
|
extra["tax_status"] = mapped_status
|
|
elif current_status in ("not_found", "unknown", "") and extra.get("tax_tracking_code"):
|
|
# پاسخ استعلام بیاثر بود ولی کد رهگیری داریم → حداقل «ارسال شده»
|
|
extra["tax_status"] = "sent"
|
|
error_message = _extract_inquiry_error_message(item)
|
|
status_norm = str(item.get("status") or "").lower()
|
|
if error_message:
|
|
extra["tax_error_message"] = error_message
|
|
elif status_norm in ("failed", "error"):
|
|
extra["tax_error_message"] = extra.get("tax_error_message") or "رد شده توسط سامانه مودیان"
|
|
elif mapped_status not in ("failed",) and status_norm not in ("not_found", "unknown"):
|
|
extra.pop("tax_error_message", None)
|
|
extra["tax_last_inquiry_at"] = now
|
|
if item.get("raw_data"):
|
|
extra["tax_last_inquiry_response"] = item.get("raw_data")
|
|
doc.extra_info = extra
|
|
|
|
|
|
_NON_REGRESSIBLE_TAX_STATUSES = frozenset({"sent", "pending", "finalized", "accepted", "success"})
|
|
|
|
|
|
def _tax_status_rank(status: str | None) -> int:
|
|
"""رتبه پیشرفت وضعیت برای جلوگیری از پسرفت هنگام استعلام."""
|
|
s = str(status or "").strip().lower()
|
|
return {
|
|
"not_sent": 0,
|
|
"not_found": 0,
|
|
"unknown": 0,
|
|
"pending": 1,
|
|
"sent": 2,
|
|
"finalized": 3,
|
|
"accepted": 3,
|
|
"success": 3,
|
|
"failed": 4,
|
|
}.get(s, 0)
|
|
|
|
|
|
def normalize_stored_tax_status(extra: dict | None) -> str:
|
|
"""
|
|
وضعیت قابل نمایش/فیلتر در کارپوشه.
|
|
not_found بعد از دریافت کد رهگیری را به sent نگاشت میکند.
|
|
"""
|
|
extra = extra or {}
|
|
status = str(extra.get("tax_status") or "").strip().lower()
|
|
tracking = str(extra.get("tax_tracking_code") or "").strip()
|
|
if status in ("not_found", "unknown") and tracking:
|
|
return "sent"
|
|
if not status:
|
|
return "sent" if tracking else "not_sent"
|
|
return status
|
|
|
|
|
|
def _resolve_submission_mode(document: Document, submission_mode: str | None) -> str:
|
|
mode = (submission_mode or "").strip().lower()
|
|
doc_type = (document.document_type or "").lower()
|
|
if mode in ("normal", "return", "cancel", "corrective"):
|
|
return mode
|
|
if "return" in doc_type:
|
|
return "return"
|
|
return "normal"
|
|
|
|
|
|
def send_document_to_tax_system(
|
|
db: Session,
|
|
document: Document,
|
|
*,
|
|
submission_mode: str | None = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
نقطه ورود اصلی ارسال سند به سامانه مودیان.
|
|
"""
|
|
ensure_moadian_plugin_active(db, int(document.business_id))
|
|
mode = _resolve_submission_mode(document, submission_mode)
|
|
|
|
# snapshot مالیاتی خطوط ممکن است قدیمی باشد (فاکتور قبل از تکمیل کد مالیاتی کالا)
|
|
refresh_invoice_line_tax_snapshots(db, document)
|
|
|
|
validation = validate_document_for_tax(db, document)
|
|
if not validation["valid"]:
|
|
raise ApiError(
|
|
"TAX_VALIDATION_FAILED",
|
|
"فاکتور حداقل الزامات سامانه مودیان را ندارد.",
|
|
http_status=400,
|
|
details={"issues": validation["issues"]},
|
|
)
|
|
|
|
tax_setting = (
|
|
db.query(TaxSetting)
|
|
.filter(TaxSetting.business_id == document.business_id)
|
|
.first()
|
|
)
|
|
if not tax_setting:
|
|
raise ApiError(
|
|
"TAX_SETTINGS_NOT_CONFIGURED",
|
|
"تنظیمات سامانه مودیان برای این کسبوکار ثبت نشده است.",
|
|
http_status=400,
|
|
)
|
|
|
|
if not (
|
|
tax_setting.tax_memory_id
|
|
and tax_setting.private_key
|
|
and tax_setting.economic_code
|
|
):
|
|
raise ApiError(
|
|
"TAX_SETTINGS_INCOMPLETE",
|
|
"تنظیمات سامانه مودیان ناقص است. شناسه حافظه، کد اقتصادی و کلید خصوصی الزامی است.",
|
|
http_status=400,
|
|
)
|
|
|
|
scenario = validate_tax_submission_scenario(db, document, tax_setting, mode)
|
|
if not scenario["valid"]:
|
|
raise ApiError(
|
|
"TAX_VALIDATION_FAILED",
|
|
"اعتبارسنجی سناریوی ارسال به مودیان ناموفق بود.",
|
|
http_status=400,
|
|
details={"issues": scenario["issues"], "submission_mode": mode},
|
|
)
|
|
|
|
irtaxid: str | None = None
|
|
if mode == "return":
|
|
irtaxid = resolve_irtaxid(db, document.business_id, document, tax_setting)
|
|
elif mode == "cancel":
|
|
irtaxid = compute_taxid_for_document(document, tax_setting)
|
|
elif mode == "corrective":
|
|
irtaxid = compute_taxid_for_document(document, tax_setting)
|
|
|
|
raw_document = invoice_document_to_dict(db, document)
|
|
raw_document = ensure_person_snapshot_on_document_dict(db, raw_document)
|
|
if mode in ("cancel", "corrective"):
|
|
# صورتحساب ابطال/اصلاح باید taxid جدید داشته باشد؛ مرجع در irtaxid است.
|
|
raw_document["_tax_internal_id_override"] = int(
|
|
f"{document.id}{1 if mode == 'cancel' else 2}"
|
|
)
|
|
|
|
invoice_dto = build_invoice_for_moadian(
|
|
raw_document,
|
|
tax_setting,
|
|
submission_mode=mode,
|
|
irtaxid=irtaxid,
|
|
)
|
|
|
|
if mode == "normal":
|
|
taxid = compute_taxid_for_document(document, tax_setting)
|
|
persist_taxid_on_document(document, taxid, db)
|
|
|
|
# 3.5. بررسی rate limit
|
|
from app.services.tax_rate_limiter import get_tax_rate_limiter
|
|
settings = get_settings()
|
|
rate_limiter = get_tax_rate_limiter()
|
|
allowed, rate_info = rate_limiter.check_rate_limit(
|
|
business_id=document.business_id,
|
|
operation="send_invoice",
|
|
max_requests=settings.tax_system_rate_limit_max_requests,
|
|
window_seconds=settings.tax_system_rate_limit_window_seconds,
|
|
)
|
|
if not allowed:
|
|
raise ApiError(
|
|
"RATE_LIMIT_EXCEEDED",
|
|
"تعداد درخواستهای ارسال به سامانه مالیاتی از حد مجاز تجاوز کرده است.",
|
|
http_status=429,
|
|
details=rate_info,
|
|
)
|
|
|
|
# 3.6. ثبت لاگ شروع
|
|
from app.services.tax_logging import log_tax_operation
|
|
log_tax_operation(
|
|
operation="send_invoice",
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
status="started",
|
|
)
|
|
|
|
# 4. وضعیت در حال ارسال
|
|
_mark_document_pending(document, db)
|
|
|
|
# 5. ارسال به سامانه با retry mechanism
|
|
client = None
|
|
settings = get_settings()
|
|
max_retries = settings.tax_system_retry_max_attempts
|
|
initial_retry_delay = settings.tax_system_retry_initial_delay_seconds
|
|
|
|
try:
|
|
client = MoadianClient(settings=settings, tax_setting=tax_setting)
|
|
|
|
for attempt in range(max_retries):
|
|
retry_delay = initial_retry_delay * (2 ** attempt) # exponential backoff
|
|
try:
|
|
submission = client.send_invoice(invoice_dto)
|
|
# 6. ذخیره نتیجه اولیه (صف async)
|
|
_apply_submission_result(document, submission, db, submission_mode=mode)
|
|
|
|
# 6.5 استعلام فوری: «sent» یعنی پذیرش در صف، نه ثبت نهایی در کارپوشه مودیان
|
|
tracking_code = submission.get("tracking_code")
|
|
if tracking_code and submission.get("mode") == "live":
|
|
try:
|
|
inquiry = client.inquire_status([str(tracking_code)])
|
|
for item in inquiry.get("results") or []:
|
|
_apply_inquiry_result_to_document(
|
|
document,
|
|
item,
|
|
now=datetime.datetime.utcnow().isoformat(),
|
|
)
|
|
db.add(document)
|
|
db.flush()
|
|
mapped = _map_inquiry_status(item.get("status"))
|
|
if mapped == "failed":
|
|
err = _extract_inquiry_error_message(item)
|
|
submission["status"] = "failed"
|
|
if err:
|
|
submission["error_message"] = err
|
|
submission["inquiry"] = item
|
|
except Exception as inquiry_exc:
|
|
logger.warning(
|
|
"Post-send tax inquiry failed for document %s: %s",
|
|
document.id,
|
|
inquiry_exc,
|
|
)
|
|
|
|
final_status = (document.extra_info or {}).get("tax_status") or submission.get("status")
|
|
log_status = "failed" if final_status == "failed" else "completed"
|
|
from app.services.tax_logging import log_tax_operation
|
|
log_tax_operation(
|
|
operation="send_invoice",
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
tracking_code=tracking_code,
|
|
status=log_status,
|
|
details={"status": final_status, "mode": submission.get("mode")},
|
|
error=submission.get("error_message") if log_status == "failed" else None,
|
|
)
|
|
|
|
# وضعیت failed در extra_info ذخیره شده؛ endpoint پس از commit خطا برمیگرداند
|
|
if final_status == "failed":
|
|
submission["status"] = "failed"
|
|
submission.setdefault(
|
|
"error_message",
|
|
"فاکتور توسط سامانه مودیان رد شد.",
|
|
)
|
|
return submission
|
|
|
|
except ApiError as e:
|
|
# خطاهای validation یا business logic را retry نمیکنیم
|
|
status_code = getattr(e, "status_code", None)
|
|
if status_code in (400, 401, 403, 404):
|
|
# در صورت خطای validation، وضعیت را به failed تغییر میدهیم
|
|
_mark_document_failed(document, str(e), db)
|
|
|
|
# ثبت لاگ خطا
|
|
from app.services.tax_logging import log_tax_operation
|
|
log_tax_operation(
|
|
operation="send_invoice",
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
status="failed",
|
|
error=str(e),
|
|
)
|
|
|
|
# افزودن به Dead Letter Queue
|
|
from app.services.tax_dead_letter_queue import add_to_dead_letter_queue
|
|
try:
|
|
error_code = (getattr(e, "detail", {}) or {}).get("error", {}).get("code") if hasattr(e, "detail") else str(e)
|
|
add_to_dead_letter_queue(
|
|
db,
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
error_code=error_code or "UNKNOWN_ERROR",
|
|
error_message=str(e),
|
|
error_details={"http_status": getattr(e, "status_code", None)} if hasattr(e, "status_code") else None,
|
|
)
|
|
except Exception as dlq_error:
|
|
logger.warning(f"Failed to add to dead letter queue: {dlq_error}")
|
|
|
|
raise
|
|
|
|
# خطاهای شبکه یا سرور را retry میکنیم
|
|
if attempt < max_retries - 1:
|
|
logger.warning(
|
|
f"Attempt {attempt + 1}/{max_retries} failed for document {document.id}: {e}. "
|
|
f"Retrying in {retry_delay}s..."
|
|
)
|
|
time.sleep(retry_delay)
|
|
else:
|
|
# در صورت خطای نهایی، وضعیت را به failed تغییر میدهیم
|
|
_mark_document_failed(document, str(e), db)
|
|
|
|
# ثبت لاگ خطا
|
|
from app.services.tax_logging import log_tax_operation
|
|
log_tax_operation(
|
|
operation="send_invoice",
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
status="failed",
|
|
error=str(e),
|
|
)
|
|
|
|
# افزودن به Dead Letter Queue
|
|
from app.services.tax_dead_letter_queue import add_to_dead_letter_queue
|
|
try:
|
|
error_code = (getattr(e, "detail", {}) or {}).get("error", {}).get("code") if hasattr(e, "detail") else str(e)
|
|
add_to_dead_letter_queue(
|
|
db,
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
error_code=error_code or "RETRY_EXHAUSTED",
|
|
error_message=str(e),
|
|
error_details={"http_status": getattr(e, "status_code", None), "attempts": max_retries} if hasattr(e, "status_code") else {"attempts": max_retries},
|
|
)
|
|
except Exception as dlq_error:
|
|
logger.warning(f"Failed to add to dead letter queue: {dlq_error}")
|
|
|
|
raise
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
logger.warning(
|
|
f"Attempt {attempt + 1}/{max_retries} failed for document {document.id}: {e}. "
|
|
f"Retrying in {retry_delay}s..."
|
|
)
|
|
time.sleep(retry_delay)
|
|
else:
|
|
# در صورت خطای نهایی، وضعیت را به failed تغییر میدهیم
|
|
_mark_document_failed(document, str(e), db)
|
|
|
|
# ثبت لاگ خطا
|
|
from app.services.tax_logging import log_tax_operation
|
|
log_tax_operation(
|
|
operation="send_invoice",
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
status="failed",
|
|
error=str(e),
|
|
)
|
|
|
|
# افزودن به Dead Letter Queue
|
|
from app.services.tax_dead_letter_queue import add_to_dead_letter_queue
|
|
try:
|
|
add_to_dead_letter_queue(
|
|
db,
|
|
business_id=document.business_id,
|
|
invoice_id=document.id,
|
|
error_code="RETRY_EXHAUSTED",
|
|
error_message=str(e),
|
|
error_details={"attempts": max_retries},
|
|
)
|
|
except Exception as dlq_error:
|
|
logger.warning(f"Failed to add to dead letter queue: {dlq_error}")
|
|
|
|
raise
|
|
|
|
# این خط نباید اجرا شود
|
|
raise ApiError("TAX_SUBMISSION_FAILED", "خطا در ارسال فاکتور پس از چندین تلاش", http_status=500)
|
|
|
|
finally:
|
|
if client:
|
|
try:
|
|
client.close()
|
|
except Exception as close_error:
|
|
logger.warning(f"Error closing MoadianClient: {close_error}")
|
|
|
|
|
|
def inquire_tax_status(
|
|
db: Session,
|
|
business_id: int,
|
|
*,
|
|
invoice_ids: List[int] | None = None,
|
|
tracking_codes: List[str] | None = None,
|
|
) -> Dict[str, Any]:
|
|
ensure_moadian_plugin_active(db, business_id)
|
|
invoice_ids = invoice_ids or []
|
|
tracking_codes = tracking_codes or []
|
|
|
|
if not invoice_ids and not tracking_codes:
|
|
raise ApiError("INVALID_REQUEST", "لیست فاکتورها یا کد رهگیری لازم است.", http_status=400)
|
|
|
|
doc_by_tracking: Dict[str, Document] = {}
|
|
if invoice_ids:
|
|
docs = (
|
|
db.query(Document)
|
|
.filter(Document.business_id == business_id, Document.id.in_(invoice_ids))
|
|
.all()
|
|
)
|
|
for doc in docs:
|
|
code = (doc.extra_info or {}).get("tax_tracking_code")
|
|
if code:
|
|
doc_by_tracking[str(code)] = doc
|
|
|
|
merged_codes = {str(code).strip() for code in tracking_codes if code and str(code).strip()}
|
|
merged_codes.update(doc_by_tracking.keys())
|
|
if merged_codes:
|
|
doc_by_tracking.update(_documents_by_tracking_codes(db, business_id, sorted(merged_codes)))
|
|
|
|
if not merged_codes:
|
|
raise ApiError("TAX_TRACKING_CODE_MISSING", "هیچ کد رهگیری معتبری یافت نشد.", http_status=400)
|
|
|
|
tax_setting = (
|
|
db.query(TaxSetting)
|
|
.filter(TaxSetting.business_id == business_id)
|
|
.first()
|
|
)
|
|
if not tax_setting:
|
|
raise ApiError(
|
|
"TAX_SETTINGS_NOT_CONFIGURED",
|
|
"تنظیمات سامانه مودیان برای این کسبوکار ثبت نشده است.",
|
|
http_status=400,
|
|
)
|
|
|
|
client = MoadianClient(settings=get_settings(), tax_setting=tax_setting)
|
|
try:
|
|
response = client.inquire_status(sorted(merged_codes))
|
|
finally:
|
|
client.close()
|
|
|
|
results = [_enrich_inquiry_result_item(item) for item in (response.get("results") or [])]
|
|
now = datetime.datetime.utcnow().isoformat()
|
|
for item in results:
|
|
reference = item.get("reference_number") or item.get("tracking_code")
|
|
if not reference:
|
|
continue
|
|
doc = doc_by_tracking.get(str(reference))
|
|
if not doc:
|
|
continue
|
|
_apply_inquiry_result_to_document(doc, item, now=now)
|
|
db.add(doc)
|
|
|
|
return {
|
|
"mode": response.get("mode"),
|
|
"results": results,
|
|
}
|
|
|
|
|
|
def enrich_tax_timeline_event(event: Dict[str, Any], extra: dict | None) -> Dict[str, Any]:
|
|
"""افزودن خطاها به رویدادهای تایملاین."""
|
|
enriched = dict(event)
|
|
if event.get("event") in ("send_attempt", "status_inquiry", "failed"):
|
|
for err in extract_moadian_errors_from_extra(extra):
|
|
if err.get("message") and not enriched.get("error_message"):
|
|
enriched["error_message"] = err["message"]
|
|
break
|
|
errors = extract_moadian_errors_from_extra(extra)
|
|
if errors:
|
|
enriched["moadian_errors"] = errors
|
|
return enriched
|
|
|
|
|
|
def _mark_document_pending(doc: Document, db: Session) -> None:
|
|
extra = dict(doc.extra_info or {})
|
|
extra["tax_workspace"] = True
|
|
extra["tax_status"] = "pending"
|
|
extra["tax_last_send_at"] = datetime.datetime.utcnow().isoformat()
|
|
doc.extra_info = extra
|
|
db.add(doc)
|
|
db.flush()
|
|
|
|
|
|
def _mark_document_failed(doc: Document, error_message: str, db: Session) -> None:
|
|
"""علامتگذاری سند به عنوان failed در صورت خطا"""
|
|
extra = dict(doc.extra_info or {})
|
|
extra["tax_workspace"] = True
|
|
extra["tax_status"] = "failed"
|
|
extra["tax_error_message"] = error_message
|
|
extra["tax_last_send_at"] = datetime.datetime.utcnow().isoformat()
|
|
doc.extra_info = extra
|
|
db.add(doc)
|
|
db.flush()
|
|
|
|
|
|
def _apply_submission_result(
|
|
doc: Document,
|
|
submission: Dict[str, Any],
|
|
db: Session,
|
|
*,
|
|
submission_mode: str = "normal",
|
|
) -> None:
|
|
extra = dict(doc.extra_info or {})
|
|
extra["tax_workspace"] = True
|
|
extra["tax_status"] = submission.get("status") or "sent"
|
|
extra["tax_tracking_code"] = submission.get("tracking_code") or extra.get("tax_tracking_code")
|
|
extra["tax_last_send_at"] = submission.get("sent_at") or datetime.datetime.utcnow().isoformat()
|
|
extra["tax_last_submission_mode"] = submission_mode
|
|
if submission_mode == "cancel":
|
|
extra["tax_cancelled_in_modian"] = True
|
|
extra["tax_cancelled_at"] = extra["tax_last_send_at"]
|
|
if submission_mode == "corrective":
|
|
extra["tax_corrective_sent_at"] = extra["tax_last_send_at"]
|
|
if "raw_response" in submission:
|
|
extra["tax_last_response"] = submission["raw_response"]
|
|
if submission.get("error_message"):
|
|
extra["tax_error_message"] = submission["error_message"]
|
|
else:
|
|
extra.pop("tax_error_message", None)
|
|
doc.extra_info = extra
|
|
db.add(doc)
|
|
|
|
|
|
def cancel_document_in_tax_system(db: Session, document: Document) -> Dict[str, Any]:
|
|
"""ابطال صورتحساب ارسالشده در سامانه مودیان (inp=3)."""
|
|
return send_document_to_tax_system(db, document, submission_mode="cancel")
|
|
|
|
|
|
def send_corrective_to_tax_system(db: Session, document: Document) -> Dict[str, Any]:
|
|
"""ارسال صورتحساب اصلاحی (inp=4) برای فاکتور ارسالشده."""
|
|
return send_document_to_tax_system(db, document, submission_mode="corrective")
|
|
|
|
|
|
def extract_moadian_errors_from_extra(extra: dict | None) -> List[Dict[str, Any]]:
|
|
"""استخراج خطاهای سامانه از extra_info برای نمایش در API/UI."""
|
|
if not extra:
|
|
return []
|
|
seen: set[str] = set()
|
|
errors: List[Dict[str, Any]] = []
|
|
|
|
def _add(code: Any, message: Any) -> None:
|
|
msg = str(message or "").strip()
|
|
if not msg:
|
|
return
|
|
key = f"{code}|{msg}"
|
|
if key in seen:
|
|
return
|
|
seen.add(key)
|
|
errors.append({"code": str(code).strip() if code else None, "message": msg})
|
|
|
|
summary = extra.get("tax_error_message")
|
|
if summary:
|
|
_add(None, summary)
|
|
|
|
for source_key in ("tax_last_inquiry_response", "tax_last_response"):
|
|
raw = extra.get(source_key)
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
data = raw.get("data")
|
|
if isinstance(data, dict):
|
|
err_list = data.get("error")
|
|
if isinstance(err_list, list):
|
|
for item in err_list:
|
|
if isinstance(item, dict):
|
|
_add(item.get("code"), item.get("message") or item.get("errorDetail"))
|
|
top_status = raw.get("status")
|
|
if str(top_status or "").upper() == "FAILED" and not errors:
|
|
_add(raw.get("errorCode"), raw.get("errorDetail"))
|
|
|
|
from app.integrations.moadian.error_playbook import enrich_moadian_errors
|
|
|
|
return enrich_moadian_errors(errors)
|
|
|
|
|
|
def build_tax_status_fields_for_api(extra: dict | None) -> Dict[str, Any]:
|
|
"""فیلدهای مالیاتی قابل نمایش در لیست/جزئیات."""
|
|
extra = extra or {}
|
|
fields: Dict[str, Any] = {
|
|
"tax_status": normalize_stored_tax_status(extra),
|
|
"tax_error_message": extra.get("tax_error_message"),
|
|
"tax_last_inquiry_at": extra.get("tax_last_inquiry_at"),
|
|
"tax_moadian_taxid": extra.get("tax_moadian_taxid"),
|
|
"tax_last_submission_mode": extra.get("tax_last_submission_mode"),
|
|
"tax_cancelled_in_modian": bool(extra.get("tax_cancelled_in_modian")),
|
|
"tax_cancelled_at": extra.get("tax_cancelled_at"),
|
|
"tax_corrective_sent_at": extra.get("tax_corrective_sent_at"),
|
|
"reference_invoice_id": extra.get("reference_invoice_id"),
|
|
"reference_tax_id": extra.get("reference_tax_id"),
|
|
}
|
|
moadian_errors = extract_moadian_errors_from_extra(extra)
|
|
if moadian_errors:
|
|
fields["tax_moadian_errors"] = moadian_errors
|
|
return fields
|
|
|
|
|
|
def build_tax_failure_details(
|
|
extra: dict | None,
|
|
submission: Dict[str, Any] | None = None,
|
|
) -> Dict[str, Any]:
|
|
"""جزئیات خطای ارسال برای پاسخ API."""
|
|
extra = extra or {}
|
|
submission = submission or {}
|
|
details: Dict[str, Any] = {
|
|
"tax_status": extra.get("tax_status"),
|
|
"tax_tracking_code": extra.get("tax_tracking_code") or submission.get("tracking_code"),
|
|
"tax_error_message": extra.get("tax_error_message") or submission.get("error_message"),
|
|
"moadian_errors": extract_moadian_errors_from_extra(extra),
|
|
}
|
|
inquiry = submission.get("inquiry")
|
|
if isinstance(inquiry, dict):
|
|
details["inquiry"] = inquiry
|
|
if not details["moadian_errors"]:
|
|
err = _extract_inquiry_error_message(inquiry)
|
|
if err:
|
|
from app.integrations.moadian.error_playbook import enrich_moadian_errors
|
|
|
|
details["moadian_errors"] = enrich_moadian_errors([{"code": None, "message": err}])
|
|
raw_inquiry = extra.get("tax_last_inquiry_response")
|
|
if isinstance(raw_inquiry, dict):
|
|
details["inquiry_response"] = raw_inquiry
|
|
raw_send = extra.get("tax_last_response")
|
|
if isinstance(raw_send, dict):
|
|
details["send_response"] = raw_send
|
|
return details
|
|
|
|
|
|
def _enrich_inquiry_result_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""تکمیل پیام خطا در هر آیتم استعلام."""
|
|
enriched = dict(item)
|
|
if not enriched.get("error_message"):
|
|
err = _extract_inquiry_error_message(enriched)
|
|
if err:
|
|
enriched["error_message"] = err
|
|
raw = enriched.get("raw_data")
|
|
if isinstance(raw, dict) and not enriched.get("moadian_errors"):
|
|
errors = extract_moadian_errors_from_extra({"tax_last_inquiry_response": raw})
|
|
if errors:
|
|
enriched["moadian_errors"] = errors
|
|
return enriched
|
|
|
|
|
|
def _map_inquiry_status(status: Any) -> str | None:
|
|
if not status:
|
|
return None
|
|
normalized = str(status).strip().lower().replace("-", "_")
|
|
mapping = {
|
|
"sent": "sent",
|
|
"pending": "pending",
|
|
"inprogress": "pending",
|
|
"in_progress": "pending",
|
|
"processing": "pending",
|
|
"finalized": "finalized",
|
|
"accepted": "finalized",
|
|
"success": "finalized",
|
|
"failed": "failed",
|
|
"error": "failed",
|
|
"rejected": "failed",
|
|
# هنوز در سامانه قابل استعلام نیست — وضعیت قبلی (معمولاً sent) حفظ شود
|
|
"not_found": None,
|
|
"unknown": None,
|
|
}
|
|
return mapping.get(normalized)
|
|
|