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

653 lines
22 KiB
Python
Executable file

from __future__ import annotations
import hashlib
import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from adapters.db.models.business import Business
from adapters.db.models.currency import Currency
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.person import Person
from adapters.db.models.person_share_link import PersonShareLink
from app.core.responses import ApiError
from app.core.settings import get_settings
from app.services.invoice_service import SUPPORTED_INVOICE_TYPES, invoice_document_to_dict
from app.services.person_service import calculate_person_balance_breakdown
from app.services.system_settings_service import resolve_share_url_http_origin
logger = logging.getLogger(__name__)
BASE62_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
DEFAULT_OPTIONS: Dict[str, Any] = {
"include_ledger": True,
"include_invoices": True,
"include_invoice_lines": True,
"documents_limit": 50,
}
MIN_DOCUMENT_LIMIT = 10
MAX_DOCUMENT_LIMIT = 200
DEFAULT_CODE_LENGTH = 9
DOCUMENT_TYPE_TITLES = {
"invoice_sales": "فاکتور فروش",
"invoice_sales_return": "برگشت از فروش",
"invoice_purchase": "فاکتور خرید",
"invoice_purchase_return": "برگشت از خرید",
"invoice_direct_consumption": "مصرف مستقیم",
"invoice_production": "تولید",
"invoice_waste": "ضایعات",
"receipt": "دریافت",
"payment": "پرداخت",
"expense": "هزینه",
"income": "درآمد",
"manual": "سند دستی",
"transfer": "انتقال",
}
def _settings():
return get_settings()
def _hash_code(code: str) -> str:
secret = _settings().share_link_secret
payload = f"{secret}:{code}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _generate_code(db: Session) -> str:
length = max(6, min(_settings().share_link_code_length or DEFAULT_CODE_LENGTH, 16))
for _ in range(10):
candidate = "".join(secrets.choice(BASE62_ALPHABET) for _ in range(length))
exists = (
db.query(PersonShareLink)
.filter(PersonShareLink.code == candidate)
.first()
)
if not exists:
return candidate
# fallback to timestamp code
return f"{int(datetime.utcnow().timestamp())}{secrets.randbelow(9999):04d}"[-length:]
def _normalize_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
s = str(value).strip().lower()
if s in {"1", "true", "on", "yes", "y"}:
return True
if s in {"0", "false", "off", "no", "n"}:
return False
return default
def _normalize_options(raw: Optional[Dict[str, Any]]) -> Dict[str, Any]:
options = dict(DEFAULT_OPTIONS)
if isinstance(raw, dict):
options.update({k: raw.get(k) for k in raw.keys()})
limit = options.get("documents_limit", DEFAULT_OPTIONS["documents_limit"])
try:
limit = int(limit)
except Exception:
limit = DEFAULT_OPTIONS["documents_limit"]
limit = max(MIN_DOCUMENT_LIMIT, min(MAX_DOCUMENT_LIMIT, limit))
options["documents_limit"] = limit
options["include_ledger"] = _normalize_bool(
options.get("include_ledger"), DEFAULT_OPTIONS["include_ledger"]
)
options["include_invoices"] = _normalize_bool(
options.get("include_invoices"), DEFAULT_OPTIONS["include_invoices"]
)
options["include_invoice_lines"] = _normalize_bool(
options.get("include_invoice_lines"), DEFAULT_OPTIONS["include_invoice_lines"]
)
return options
def _document_type_label(doc_type: Optional[str]) -> str:
if not doc_type:
return "سند"
return DOCUMENT_TYPE_TITLES.get(doc_type, doc_type.replace("_", " "))
def _safe_float(value: Any, default: float = 0.0) -> float:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _public_invoice_financials_from_extra_info(extra_info: Any) -> Dict[str, float]:
"""
مبالغ نمایشی فاکتور: ساختار استاندارد در extra_info.totals
(gross, discount, tax, net)؛ در غیر این صورت کلیدهای قدیمی ریشه extra_info.
"""
if not isinstance(extra_info, dict):
extra_info = {}
totals = extra_info.get("totals")
if isinstance(totals, dict):
gross = _safe_float(totals.get("gross"))
discount = _safe_float(totals.get("discount"))
tax = _safe_float(totals.get("tax"))
net = _safe_float(totals.get("net"))
if not net and (gross or discount):
net = gross - discount
total_final = net + tax
return {
"subtotal": gross,
"discount_amount": discount,
"tax_amount": tax,
"total": total_final,
}
tax_amount = _safe_float(extra_info.get("tax_amount"))
discount_amount = _safe_float(extra_info.get("discount_amount"))
subtotal = _safe_float(extra_info.get("subtotal"))
if subtotal or discount_amount or tax_amount:
total = subtotal - discount_amount + tax_amount
return {
"subtotal": subtotal,
"discount_amount": discount_amount,
"tax_amount": tax_amount,
"total": total,
}
return {
"subtotal": 0.0,
"discount_amount": 0.0,
"tax_amount": 0.0,
"total": 0.0,
}
def get_active_share_link_for_person(
db: Session, business_id: int, person_id: int
) -> Optional[PersonShareLink]:
now = datetime.utcnow()
return (
db.query(PersonShareLink)
.filter(
PersonShareLink.business_id == business_id,
PersonShareLink.person_id == person_id,
PersonShareLink.revoked_at.is_(None),
or_(
PersonShareLink.expires_at.is_(None),
PersonShareLink.expires_at > now,
),
or_(
PersonShareLink.max_view_count.is_(None),
PersonShareLink.view_count < PersonShareLink.max_view_count,
),
)
.order_by(PersonShareLink.created_at.desc())
.first()
)
def build_person_share_p_url(
code: str,
request_base_url: Optional[str] = None,
db: Optional[Session] = None,
) -> str:
base = resolve_share_url_http_origin(db, request_base_url)
if base:
return f"{base}/p/{code}"
return f"/p/{code}"
def serialize_share_link(
link: Optional[PersonShareLink],
request_base_url: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[Dict[str, Any]]:
if not link:
return None
public_url = build_person_share_p_url(
link.code, request_base_url=request_base_url, db=db
)
options = _normalize_options(link.options or {})
now = datetime.utcnow()
expires_in = None
if link.expires_at:
expires_in = (link.expires_at - now).total_seconds()
remaining_hours = (
None if expires_in is None else round(max(expires_in, 0) / 3600, 2)
)
status = "فعال"
if link.is_revoked:
status = "لغو شده"
elif link.is_expired:
status = "منقضی"
elif link.is_view_limited:
status = "به سقف بازدید رسیده"
return {
"id": link.id,
"business_id": link.business_id,
"person_id": link.person_id,
"code": link.code,
"short_url": public_url,
"created_at": link.created_at.isoformat(),
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
"revoked_at": link.revoked_at.isoformat() if link.revoked_at else None,
"last_view_at": link.last_view_at.isoformat() if link.last_view_at else None,
"view_count": link.view_count,
"max_view_count": link.max_view_count,
"is_active": link.is_active,
"is_expired": link.is_expired,
"status": status,
"remaining_hours": remaining_hours,
"options": options,
}
def create_share_link(
db: Session,
*,
business_id: int,
person_id: int,
user_id: int,
expires_in_hours: Optional[int],
max_view_count: Optional[int],
options: Optional[Dict[str, Any]],
replace_existing: bool = True,
) -> PersonShareLink:
person = db.query(Person).filter(Person.id == person_id).first()
if not person or person.business_id != business_id:
raise ApiError(
"PERSON_NOT_FOUND",
"شخص یافت نشد یا به این کسب‌وکار تعلق ندارد",
http_status=404,
)
normalized_options = _normalize_options(options or {})
ttl_hours = expires_in_hours or _settings().share_link_default_ttl_hours
max_allowed_ttl = _settings().share_link_max_ttl_hours or (24 * 30)
if ttl_hours:
ttl_hours = max(1, min(ttl_hours, max_allowed_ttl))
expires_at = (
datetime.utcnow() + timedelta(hours=ttl_hours) if ttl_hours else None
)
normalized_max_view = None
if max_view_count is not None:
try:
normalized_max_view = max(1, min(int(max_view_count), 1000))
except Exception:
normalized_max_view = None
try:
if replace_existing:
existing = get_active_share_link_for_person(db, business_id, person_id)
if existing:
existing.revoked_at = datetime.utcnow()
existing.revoked_by_user_id = user_id
db.add(existing)
code = _generate_code(db)
link = PersonShareLink(
business_id=business_id,
person_id=person_id,
created_by_user_id=user_id,
code=code,
token_hash=_hash_code(code),
expires_at=expires_at,
view_count=0,
max_view_count=normalized_max_view,
options=normalized_options,
)
db.add(link)
db.commit()
db.refresh(link)
return link
except ApiError:
db.rollback()
raise
except Exception as exc:
logger.exception("Failed to create share link", exc_info=exc)
db.rollback()
raise ApiError(
"CREATE_SHARE_LINK_FAILED",
"ایجاد لینک اشتراک ممکن نشد",
http_status=500,
)
def revoke_share_link(
db: Session,
*,
business_id: int,
person_id: int,
user_id: int,
) -> bool:
link = get_active_share_link_for_person(db, business_id, person_id)
if not link:
return False
link.revoked_at = datetime.utcnow()
link.revoked_by_user_id = user_id
db.add(link)
db.commit()
return True
def get_share_link_by_code(db: Session, code: str) -> Optional[PersonShareLink]:
normalized = (code or "").strip()
if not normalized:
return None
return db.query(PersonShareLink).filter(PersonShareLink.code == normalized).first()
def record_share_link_view(db: Session, link: PersonShareLink) -> PersonShareLink:
link.view_count = int(link.view_count or 0) + 1
link.last_view_at = datetime.utcnow()
if (
link.max_view_count is not None
and link.view_count >= link.max_view_count
and link.revoked_at is None
):
link.revoked_at = datetime.utcnow()
db.add(link)
db.commit()
db.refresh(link)
return link
def _current_fiscal_year_id(db: Session, business_id: int) -> Optional[int]:
fy = (
db.query(FiscalYear)
.filter(and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)) # noqa: E712
.first()
)
return int(fy.id) if fy else None
def _fetch_ledger_items(
db: Session,
business_id: int,
person_id: int,
limit: int,
*,
include_invoice_lines: bool = True,
fiscal_year_id: Optional[int] = None,
) -> list[Dict[str, Any]]:
from app.services.person_service import get_people_transactions_report
# همه ردیف‌های مرتبط را می‌گیریم، سپس آخرین‌ها را برای نمایش عمومی نگه می‌داریم
result = get_people_transactions_report(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
person_ids=[person_id],
skip=0,
take=10000,
detail_level="comprehensive" if include_invoice_lines else "summary",
)
items = list(result.get("items") or [])
if limit > 0 and len(items) > limit:
items = items[-limit:]
items.reverse() # جدیدترین اول
out: list[Dict[str, Any]] = []
for item in items:
out.append(
{
"line_id": item.get("line_id"),
"document_id": item.get("document_id"),
"document_code": item.get("document_code"),
"document_type": item.get("document_type"),
"document_type_name": item.get("document_type_name")
or _document_type_label(item.get("document_type")),
"document_date": item.get("document_date"),
"description": item.get("description"),
"debit": float(item.get("debit") or 0),
"credit": float(item.get("credit") or 0),
"running_balance": item.get("running_balance"),
"row_kind": item.get("row_kind") or "document",
"product_id": item.get("product_id"),
"product_code": item.get("product_code"),
"product_name": item.get("product_name"),
"quantity": item.get("quantity"),
"unit_price": item.get("unit_price"),
"line_amount": item.get("line_amount"),
# مبالغ ledger معادل ارز پایه است؛ کد ارز سند را نمی‌چسبانیم تا با مبلغ پایه قاطی نشود
"currency_code": None,
"extra_info": {},
}
)
return out
def _fetch_invoice_items(
db: Session,
business_id: int,
person_id: int,
limit: int,
*,
fiscal_year_id: Optional[int] = None,
) -> list[Dict[str, Any]]:
query = (
db.query(
Document.id.label("document_id"),
Document.code.label("document_code"),
Document.document_type,
Document.document_date,
Document.description,
Document.extra_info,
Currency.code.label("currency_code"),
func.coalesce(func.sum(DocumentLine.debit), 0).label("total_debit"),
func.coalesce(func.sum(DocumentLine.credit), 0).label("total_credit"),
)
.join(DocumentLine, DocumentLine.document_id == Document.id)
.join(Currency, Currency.id == Document.currency_id)
.filter(
Document.business_id == business_id,
Document.is_proforma == False, # noqa: E712
Document.document_type.in_(tuple(SUPPORTED_INVOICE_TYPES)),
DocumentLine.person_id == person_id,
)
)
if fiscal_year_id:
query = query.filter(Document.fiscal_year_id == fiscal_year_id)
rows = (
query
# GROUP BY فقط id و currency: در PostgreSQL ستون‌های دیگر سند تابع id هستند.
# قرار دادن extra_info (نوع json) در GROUP BY در PostgreSQL خطای عدم وجود عملگر برابری می‌دهد.
.group_by(Document.id, Currency.code)
.order_by(Document.document_date.desc(), Document.id.desc())
.limit(min(limit, 40))
.all()
)
items: list[Dict[str, Any]] = []
for row in rows:
total_debit = float(getattr(row, "total_debit", 0) or 0)
total_credit = float(getattr(row, "total_credit", 0) or 0)
# برای فاکتور فروش سطر شخص معمولاً بدهکار است؛ مبلغ نمایشی مثبت می‌خواهیم
net_amount = abs(total_credit - total_debit)
extra_info = getattr(row, "extra_info", {}) or {}
items.append(
{
"document_id": row.document_id,
"document_code": row.document_code,
"document_type": row.document_type,
"document_type_name": _document_type_label(row.document_type),
# date object تا format_datetime_fields بتواند جلالی/میلادی کند
"document_date": row.document_date,
"description": row.description,
"amount": net_amount,
"currency_code": row.currency_code,
"status": extra_info.get("lifecycle_status") or extra_info.get("status"),
"extra_info": extra_info,
}
)
return items
def build_public_payload(
db: Session, link: PersonShareLink, *, allow_inactive: bool = False
) -> Dict[str, Any]:
if not allow_inactive and not link.is_active:
raise ApiError(
"LINK_INACTIVE",
"این لینک منقضی یا غیرفعال شده است",
http_status=404,
)
person = db.query(Person).filter(Person.id == link.person_id).first()
business = db.query(Business).filter(Business.id == link.business_id).first()
if not person or not business:
raise ApiError(
"LINK_TARGET_NOT_FOUND",
"اطلاعات شخص یا کسب‌وکار یافت نشد",
http_status=404,
)
options = _normalize_options(link.options or {})
fy_id = _current_fiscal_year_id(db, int(link.business_id))
breakdown = calculate_person_balance_breakdown(
db, person.id, fiscal_year_id=fy_id
)
ledger_items = (
_fetch_ledger_items(
db,
link.business_id,
link.person_id,
options["documents_limit"],
include_invoice_lines=bool(options.get("include_invoice_lines", True)),
fiscal_year_id=fy_id,
)
if options["include_ledger"]
else []
)
invoice_items = (
_fetch_invoice_items(
db,
link.business_id,
link.person_id,
options["documents_limit"],
fiscal_year_id=fy_id,
)
if options["include_invoices"]
else []
)
return {
"share_link": serialize_share_link(link, db=db),
"person": {
"id": person.id,
"code": person.code,
"alias_name": person.alias_name,
"company_name": person.company_name,
"mobile": person.mobile,
"mobile_2": person.mobile_2,
"mobile_3": person.mobile_3,
"phone": person.phone,
"email": person.email,
"city": person.city,
},
"business": {
"id": business.id,
"name": business.name,
"phone": business.phone,
"mobile": business.mobile,
"address": business.address,
"city": business.city,
"has_logo": bool(getattr(business, "logo_file_id", None)),
},
"summary": {
"balance": breakdown["balance"],
"status": breakdown["status"],
"total_credit": breakdown["total_credit"],
"total_debit": breakdown["total_debit"],
"amounts_in_base": True,
},
"ledger": ledger_items,
"invoices": invoice_items,
"options": options,
}
def resolve_public_payload_by_code(db: Session, code: str) -> Dict[str, Any]:
link = get_share_link_by_code(db, code)
if not link or not link.is_active:
raise ApiError(
"LINK_NOT_FOUND",
"لینک اشتراک معتبر نیست یا منقضی شده",
http_status=404,
)
refreshed = record_share_link_view(db, link)
return build_public_payload(db, refreshed, allow_inactive=True)
def get_public_invoice_details(db: Session, code: str, document_id: int) -> Dict[str, Any]:
"""
دریافت جزئیات یک فاکتور از طریق کد لینک اشتراک
بررسی می‌کند که فاکتور متعلق به شخص در لینک اشتراک باشد
"""
link = get_share_link_by_code(db, code)
if not link or not link.is_active:
raise ApiError(
"LINK_NOT_FOUND",
"لینک اشتراک معتبر نیست یا منقضی شده",
http_status=404,
)
# بررسی اینکه فاکتور متعلق به کسب‌وکار و شخص در لینک باشد
document = db.query(Document).filter(
and_(
Document.id == document_id,
Document.business_id == link.business_id,
Document.document_type.in_(tuple(SUPPORTED_INVOICE_TYPES)),
)
).first()
if not document:
raise ApiError(
"INVOICE_NOT_FOUND",
"فاکتور یافت نشد",
http_status=404,
)
# بررسی اینکه فاکتور متعلق به شخص در لینک باشد
person_line = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == document_id,
DocumentLine.person_id == link.person_id,
)
).first()
if not person_line:
raise ApiError(
"INVOICE_ACCESS_DENIED",
"این فاکتور متعلق به این شخص نیست",
http_status=403,
)
# دریافت جزئیات کامل فاکتور (بدون commit جانبی برای پاکسازی لینک‌ها)
details = invoice_document_to_dict(db, document, persist_link_cleanup=False)
fin = _public_invoice_financials_from_extra_info(document.extra_info)
details["tax_amount"] = fin["tax_amount"]
details["discount_amount"] = fin["discount_amount"]
details["subtotal"] = fin["subtotal"]
details["total"] = fin["total"]
return details