forked from hesabix/arc
5684 lines
234 KiB
Python
Executable file
5684 lines
234 KiB
Python
Executable file
from typing import Dict, Any, List, Optional
|
||
from fastapi import APIRouter, Depends, Request, Body, UploadFile, File, Form, HTTPException, Query
|
||
from fastapi.responses import Response
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import and_, or_, cast, Integer
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
from sqlalchemy.exc import IntegrityError
|
||
from decimal import Decimal
|
||
import io
|
||
import json
|
||
import datetime
|
||
import re
|
||
import base64
|
||
from pathlib import Path
|
||
import logging
|
||
|
||
from adapters.db.session import get_db
|
||
from app.core.auth_dependency import get_current_user, AuthContext
|
||
from app.core.permissions import (
|
||
require_business_access,
|
||
require_business_management_dep,
|
||
require_business_permission_dep,
|
||
require_business_permission_by_entity_dep,
|
||
require_invoice_type_permission_from_payload_dep,
|
||
has_invoice_type_permission_for_business,
|
||
allowed_invoice_types_for_business,
|
||
user_can_change_invoice_unit_price,
|
||
)
|
||
from app.core.responses import success_response, format_datetime_fields
|
||
from app.core.cache import get_cache
|
||
from adapters.api.v1.schemas import QueryInfo, InvoiceListQuery
|
||
from adapters.api.v1.list_query_common import model_to_legacy_body
|
||
from adapters.db.models.document import Document
|
||
from adapters.db.models.document_line import DocumentLine
|
||
from adapters.db.models.account import Account
|
||
from adapters.db.models.currency import Currency
|
||
from adapters.db.models.product import Product
|
||
from adapters.db.models.warehouse import Warehouse
|
||
from adapters.db.models.fiscal_year import FiscalYear
|
||
from adapters.db.models.business import Business
|
||
from adapters.db.models.business_print_settings import BusinessPrintSettings
|
||
from adapters.db.models.user import User
|
||
from app.core.responses import ApiError
|
||
from app.services.invoice_adjustments_service import payable_total_from_extra_info
|
||
from app.services.invoice_bulk_upsert_service import bulk_upsert_invoices_integration
|
||
from app.services.invoice_service import (
|
||
create_invoice,
|
||
update_invoice,
|
||
delete_invoice,
|
||
bulk_delete_invoices,
|
||
invoice_document_to_dict,
|
||
invoice_documents_to_list_dicts,
|
||
batch_calculate_invoices_remaining,
|
||
batch_add_counterparty_to_invoice_items,
|
||
calculate_invoice_remaining,
|
||
SUPPORTED_INVOICE_TYPES,
|
||
get_invoice_installment_plan,
|
||
search_installments,
|
||
export_installments_csv,
|
||
export_installments_xlsx,
|
||
)
|
||
from app.services.invoice_tag_service import (
|
||
apply_invoice_tag_filters_to_query,
|
||
attach_tags_to_invoice_items,
|
||
create_invoice_tag,
|
||
list_invoice_tags,
|
||
update_invoice_tag,
|
||
)
|
||
from app.services.tax_submission_service import (
|
||
send_document_to_tax_system,
|
||
cancel_document_in_tax_system,
|
||
send_corrective_to_tax_system,
|
||
inquire_tax_status,
|
||
build_tax_status_fields_for_api,
|
||
build_tax_failure_details,
|
||
enrich_tax_timeline_event,
|
||
normalize_stored_tax_status,
|
||
)
|
||
from app.services.tax_reference_service import (
|
||
link_reference_invoice,
|
||
can_cancel_in_modian,
|
||
can_send_corrective,
|
||
compute_taxid_for_document,
|
||
get_document_extra,
|
||
)
|
||
from app.core.moadian_plugin_dependency import ensure_moadian_plugin_active
|
||
from app.services.pdf.template_renderer import render_template
|
||
from app.core.calendar import CalendarConverter
|
||
from adapters.db.models.person import Person
|
||
from app.services.receipt_payment_service import get_receipt_payment
|
||
from app.services.file_storage_service import FileStorageService
|
||
from app.services.person_service import calculate_person_balance
|
||
from app.services.document_share_link_service import (
|
||
create_share_link as create_document_share_link,
|
||
get_active_share_link_for_document,
|
||
get_or_create_link_for_print,
|
||
revoke_share_link as revoke_document_share_link,
|
||
serialize_document_share_link,
|
||
build_invoice_share_i_url,
|
||
update_document_share_link_payment_options,
|
||
)
|
||
from adapters.api.v1.schema_models.invoice import InvoiceShareLinkCreateRequest, InvoiceShareLinkPaymentPatchRequest
|
||
from app.services.document_list_sort import apply_invoice_search_ordering, apply_invoice_search_ordering_from_body
|
||
from adapters.db.models.bank_account import BankAccount
|
||
from adapters.db.models.cash_register import CashRegister
|
||
from adapters.db.models.petty_cash import PettyCash
|
||
from sqlalchemy import func
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _user_can_select_fx_rate_for_business(
|
||
db: Session,
|
||
ctx: AuthContext,
|
||
business_id: int,
|
||
) -> bool:
|
||
"""انتخاب دستی نرخ تسعیر فقط با مجوز مشاهدهٔ تاریخچه نرخ (currency_revaluation.view)."""
|
||
if ctx.is_superadmin():
|
||
return True
|
||
if ctx.is_business_owner(business_id):
|
||
return True
|
||
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
|
||
|
||
repo = BusinessPermissionRepository(db)
|
||
perm_obj = repo.get_by_user_and_business(ctx.get_user_id(), business_id)
|
||
if not perm_obj or not perm_obj.business_permissions:
|
||
return False
|
||
perms = ctx._normalize_permissions_value(perm_obj.business_permissions) or {}
|
||
sec = perms.get("currency_revaluation") or {}
|
||
return bool(sec.get("view") or sec.get("read"))
|
||
|
||
|
||
def _format_line_custom_attributes_for_pdf(extra_info: Any) -> Optional[str]:
|
||
if not isinstance(extra_info, dict):
|
||
return None
|
||
raw = extra_info.get("line_custom_attributes")
|
||
if not raw or not isinstance(raw, dict):
|
||
return None
|
||
parts: List[str] = []
|
||
for k, v in raw.items():
|
||
if v is None:
|
||
continue
|
||
parts.append(f"{k}: {v}")
|
||
if not parts:
|
||
return None
|
||
return "؛ ".join(parts)
|
||
|
||
|
||
def _invoice_verify_qr_data_uri(verify_url: str) -> Optional[str]:
|
||
"""تصویر PNG بهصورت data URI برای درج در PDF."""
|
||
try:
|
||
import io
|
||
import qrcode
|
||
from qrcode.constants import ERROR_CORRECT_M
|
||
|
||
buf = io.BytesIO()
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=ERROR_CORRECT_M,
|
||
box_size=3,
|
||
border=2,
|
||
)
|
||
qr.add_data(verify_url)
|
||
qr.make(fit=True)
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
img.save(buf, format="PNG")
|
||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||
return f"data:image/png;base64,{b64}"
|
||
except Exception:
|
||
logger.exception("Failed to build invoice verify QR")
|
||
return None
|
||
|
||
|
||
def _invoice_line_unit_display_for_pdf(pl: Dict[str, Any]) -> str:
|
||
"""
|
||
متن واحد برای چاپ PDF: extra_info.unit میتواند main/secondary (ایمپورت) یا نام واحد (UI) باشد.
|
||
"""
|
||
info = pl.get("extra_info") or {}
|
||
if not isinstance(info, dict):
|
||
info = {}
|
||
raw = info.get("unit")
|
||
main_u = str(pl.get("product_main_unit") or "").strip()
|
||
sec_u = str(pl.get("product_secondary_unit") or "").strip()
|
||
if isinstance(raw, str):
|
||
key = raw.strip().lower()
|
||
if key == "main":
|
||
return main_u or "-"
|
||
if key == "secondary":
|
||
return sec_u or "-"
|
||
if raw is not None:
|
||
s = str(raw).strip()
|
||
if s:
|
||
return s
|
||
return main_u or "-"
|
||
|
||
|
||
router = APIRouter(prefix="/invoices", tags=["اسناد فروش", "اسناد خرید"])
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}",
|
||
summary="ایجاد فاکتور",
|
||
description=(
|
||
"ثبت فاکتور جدید برای کسبوکار. بدنه درخواست همان ساختار پذیرفتهشده توسط سرویس ایجاد فاکتور است "
|
||
"(نوع سند، تاریخ، طرف حساب، خطوط کالا/خدمت، استعلام انبار، اقساط و غیره در `extra_info`). "
|
||
"انتخاب دستی نرخ تسعیر ارز فقط با مجوز مشاهدهٔ تاریخچه نرخ (`currency_revaluation.view`) مجاز است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def create_invoice_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "add")),
|
||
__: None = Depends(require_invoice_type_permission_from_payload_dep("add")),
|
||
) -> Dict[str, Any]:
|
||
can_pick = _user_can_select_fx_rate_for_business(db, ctx, business_id)
|
||
can_change_unit = user_can_change_invoice_unit_price(ctx, db, business_id)
|
||
result = create_invoice(
|
||
db=db,
|
||
business_id=business_id,
|
||
user_id=ctx.get_user_id(),
|
||
data=payload,
|
||
user_can_select_fx_rate=can_pick,
|
||
user_can_change_invoice_unit_price=can_change_unit,
|
||
)
|
||
return success_response(data=result, request=request, message="INVOICE_CREATED")
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}/installments",
|
||
summary="طرح اقساط فاکتور",
|
||
description="در صورت ثبت طرح اقساط روی فاکتور، جزئیات برنامه (اقساط، مبالغ، سررسیدها) برگردانده میشود.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_invoice_installments_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "view", Document, "invoice_id")),
|
||
):
|
||
data = get_invoice_installment_plan(db=db, business_id=business_id, invoice_id=invoice_id)
|
||
return success_response(data=data, request=request, message="INSTALLMENT_PLAN_FETCHED")
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}/share-link",
|
||
summary="وضعیت لینک اشتراکگذاری فاکتور",
|
||
description="اگر لینک عمومی فعال برای مشاهدهٔ فاکتور وجود داشته باشد، آدرس و محدودیتهای آن؛ در غیر این صورت بدون خطا با دادهٔ خالی.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_invoice_share_link_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "view", Document, "invoice_id")),
|
||
):
|
||
link = get_active_share_link_for_document(db, business_id, invoice_id)
|
||
return success_response(
|
||
data={"link": serialize_document_share_link(
|
||
link, str(request.base_url), db=db
|
||
)},
|
||
request=request,
|
||
message="INVOICE_SHARE_LINK_STATUS",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/share-link",
|
||
summary="ایجاد یا تمدید لینک اشتراکگذاری فاکتور",
|
||
description=(
|
||
"ایجاد لینک عمومی با امکان تعیین مدت اعتبار، حداکثر بازدید و جایگزینی لینک قبلی. "
|
||
"بدنه مطابق مدل `InvoiceShareLinkCreateRequest` است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def create_invoice_share_link_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
payload: InvoiceShareLinkCreateRequest = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "edit", Document, "invoice_id")),
|
||
):
|
||
link = create_document_share_link(
|
||
db,
|
||
business_id=business_id,
|
||
document_id=invoice_id,
|
||
user_id=ctx.get_user_id(),
|
||
expires_in_hours=payload.expires_in_hours,
|
||
max_view_count=payload.max_view_count,
|
||
replace_existing=payload.replace_existing,
|
||
online_payment_enabled=payload.online_payment_enabled,
|
||
online_payment_gateway_id=payload.online_payment_gateway_id,
|
||
)
|
||
return success_response(
|
||
data=serialize_document_share_link(
|
||
link, str(request.base_url), db=db
|
||
),
|
||
request=request,
|
||
message="INVOICE_SHARE_LINK_CREATED",
|
||
)
|
||
|
||
|
||
@router.delete(
|
||
"/business/{business_id}/{invoice_id}/share-link",
|
||
summary="لغو لینک اشتراکگذاری فاکتور",
|
||
description="ابطال لینک عمومی فعال؛ در صورت نبود لینک فعال پاسخ 404 برمیگردد.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def delete_invoice_share_link_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "edit", Document, "invoice_id")),
|
||
):
|
||
ok = revoke_document_share_link(
|
||
db,
|
||
business_id=business_id,
|
||
document_id=invoice_id,
|
||
user_id=ctx.get_user_id(),
|
||
)
|
||
if not ok:
|
||
raise HTTPException(status_code=404, detail="لینک فعالی برای لغو وجود ندارد")
|
||
return success_response(data=None, request=request, message="INVOICE_SHARE_LINK_REVOKED")
|
||
|
||
|
||
@router.patch(
|
||
"/business/{business_id}/{invoice_id}/share-link/payment",
|
||
summary="تنظیم پرداخت آنلاین روی لینک عمومی فاکتور",
|
||
description="فعال/غیرفعال کردن پرداخت آنلاین و انتخاب درگاه برای لینک فعال.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def patch_invoice_share_link_payment_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
payload: InvoiceShareLinkPaymentPatchRequest,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "edit", Document, "invoice_id")),
|
||
):
|
||
try:
|
||
patch = payload.model_dump(exclude_unset=True)
|
||
link = update_document_share_link_payment_options(
|
||
db,
|
||
business_id=business_id,
|
||
document_id=invoice_id,
|
||
user_id=ctx.get_user_id(),
|
||
patch=patch,
|
||
)
|
||
except ApiError as exc:
|
||
raise HTTPException(status_code=exc.status_code, detail=exc.detail)
|
||
return success_response(
|
||
data=serialize_document_share_link(
|
||
link, str(request.base_url), db=db
|
||
),
|
||
request=request,
|
||
message="INVOICE_SHARE_LINK_PAYMENT_UPDATED",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/installments/search",
|
||
summary="جستجوی اقساط",
|
||
description=(
|
||
"لیست اقساط با فیلترهای اختیاری: سال مالی، بازه سررسید، وضعیت، شخص، فاکتور، صفحهبندی (`take`/`skip`) و غیره. "
|
||
"جزئیات فیلدها در docstring تابع آمده است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def search_installments_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
):
|
||
"""
|
||
جستجوی اقساط با فیلترهای:
|
||
{
|
||
"fiscal_year_id": int?,
|
||
"due_from": "YYYY-MM-DD"?,
|
||
"due_to": "YYYY-MM-DD"?,
|
||
"status": "pending|partial|paid|overdue"?,
|
||
"status_in": ["pending","overdue"] | "pending,overdue"?,
|
||
"bucket": "unpaid"|"upcoming"|"overdue_only"?,
|
||
"min_overdue_days": int?,
|
||
"group_by": "invoice"?,
|
||
"person_id": int?,
|
||
"invoice_id": int?,
|
||
"currency_id": int?, // null/absent: base-equivalent amounts; set: native document currency
|
||
"take": 200,
|
||
"skip": 0
|
||
}
|
||
"""
|
||
result = search_installments(db=db, business_id=business_id, query=payload or {})
|
||
formatted = format_datetime_fields(result, request)
|
||
return success_response(data=formatted, request=request, message="INSTALLMENTS_LIST_FETCHED")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/installments/export/excel",
|
||
summary="خروجی Excel اقساط",
|
||
description="همان فیلترهای `installments/search`؛ در صورت نبود کتابخانه XLSX ممکن است CSV برگردد.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def export_installments_excel_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "export")),
|
||
) -> Response:
|
||
"""
|
||
خروجی XLSX از اقساط (در صورت نبودن کتابخانه، CSV بازگردانده میشود).
|
||
"""
|
||
content, mime, ext = export_installments_xlsx(
|
||
db=db,
|
||
business_id=business_id,
|
||
query=payload or {},
|
||
calendar_type=ctx.get_calendar_type(),
|
||
)
|
||
filename = f"installments_{business_id}.{ext}"
|
||
headers = {
|
||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||
"Content-Type": mime,
|
||
# Flutter Web needs this to read filename/content-type headers
|
||
"Access-Control-Expose-Headers": "Content-Disposition, Content-Type",
|
||
}
|
||
return Response(content=content, media_type=mime, headers=headers)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/installments/export/pdf",
|
||
summary="خروجی PDF گزارش اقساط",
|
||
description="PDF گزارش اقساط با همان فیلترهای جستجو؛ پارامترهای `paper_size` و `orientation` در بدنه اختیاری هستند.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def export_installments_pdf_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "export")),
|
||
) -> Response:
|
||
"""
|
||
خروجی PDF گزارش اقساط بر اساس همان فیلترهای search_installments.
|
||
"""
|
||
from weasyprint import HTML
|
||
from weasyprint.text.fonts import FontConfiguration
|
||
from app.core.i18n import negotiate_locale
|
||
from app.services.pdf.template_renderer import load_farsi_font_data_uris
|
||
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == "fa"
|
||
calendar_type = ctx.get_calendar_type()
|
||
|
||
body = dict(payload or {})
|
||
body.pop("group_by", None)
|
||
data = search_installments(db=db, business_id=business_id, query=body, disable_pagination=True)
|
||
items = (data.get("items") or [])
|
||
|
||
# Resolve business name
|
||
business_name = ""
|
||
try:
|
||
b = db.query(Business).filter(Business.id == business_id).first()
|
||
if b and getattr(b, "name", None):
|
||
business_name = b.name
|
||
except Exception:
|
||
business_name = ""
|
||
|
||
# Format dates based on calendar
|
||
def _fmt_date(v: Any) -> str:
|
||
if v is None:
|
||
return ""
|
||
try:
|
||
if calendar_type == "jalali":
|
||
fd = CalendarConverter.format_datetime(v, "jalali")
|
||
else:
|
||
fd = CalendarConverter.format_datetime(v, "gregorian")
|
||
return fd.get("date_only") or fd.get("formatted") or str(v)
|
||
except Exception:
|
||
try:
|
||
# Already string
|
||
return str(v)
|
||
except Exception:
|
||
return ""
|
||
|
||
for it in items:
|
||
try:
|
||
it["document_date"] = _fmt_date(it.get("document_date"))
|
||
it["due_date"] = _fmt_date(it.get("due_date"))
|
||
except Exception:
|
||
pass
|
||
|
||
# Filters summary (human readable)
|
||
filters_summary: List[Dict[str, str]] = []
|
||
try:
|
||
fy_id = body.get("fiscal_year_id")
|
||
if fy_id:
|
||
filters_summary.append({"label": "سال مالی" if is_fa else "Fiscal year", "value": str(fy_id)})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
due_from = body.get("due_from")
|
||
if isinstance(due_from, str) and due_from:
|
||
filters_summary.append({"label": "از سررسید" if is_fa else "Due from", "value": due_from[:10]})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
due_to = body.get("due_to")
|
||
if isinstance(due_to, str) and due_to:
|
||
filters_summary.append({"label": "تا سررسید" if is_fa else "Due to", "value": due_to[:10]})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
status = body.get("status")
|
||
if isinstance(status, str) and status:
|
||
filters_summary.append({"label": "وضعیت" if is_fa else "Status", "value": status})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
person_id = body.get("person_id")
|
||
if person_id:
|
||
person_name = None
|
||
try:
|
||
p = db.query(Person).filter(Person.id == int(person_id), Person.business_id == business_id).first()
|
||
person_name = getattr(p, "name", None) if p else None
|
||
except Exception:
|
||
person_name = None
|
||
filters_summary.append({
|
||
"label": "شخص" if is_fa else "Person",
|
||
"value": (person_name or str(person_id)),
|
||
})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
invoice_id = body.get("invoice_id")
|
||
if invoice_id:
|
||
inv_code = None
|
||
try:
|
||
doc = db.query(Document).filter(Document.id == int(invoice_id), Document.business_id == business_id).first()
|
||
inv_code = getattr(doc, "code", None) if doc else None
|
||
except Exception:
|
||
inv_code = None
|
||
filters_summary.append({
|
||
"label": "فاکتور" if is_fa else "Invoice",
|
||
"value": (inv_code or str(invoice_id)),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# Summary totals (reuse service stats when available)
|
||
stats = data.get("stats") or {}
|
||
|
||
now = datetime.datetime.now()
|
||
try:
|
||
from app.services.print_footer_settings import build_print_meta_footer_line
|
||
|
||
pn = ""
|
||
try:
|
||
pn = ctx.get_user_name() or ""
|
||
except Exception:
|
||
pn = ""
|
||
cal = "jalali" if calendar_type == "jalali" else "gregorian"
|
||
footer_text = build_print_meta_footer_line(
|
||
db,
|
||
business_id,
|
||
now=now,
|
||
preparer_name=pn or None,
|
||
is_fa=is_fa,
|
||
calendar_type=cal,
|
||
)
|
||
except Exception:
|
||
footer_text = ""
|
||
|
||
fa_font_url_regular = None
|
||
fa_font_url_bold = None
|
||
if is_fa:
|
||
fa_font_url_regular, fa_font_url_bold = load_farsi_font_data_uris()
|
||
|
||
html_content = render_template(
|
||
"pdf/installments/list.html",
|
||
{
|
||
"title_text": "گزارش اقساط" if is_fa else "Installments report",
|
||
"business_name": business_name,
|
||
"generated_at": now,
|
||
"is_fa": is_fa,
|
||
"footer_text": footer_text,
|
||
"paper_size": body.get("paper_size"),
|
||
"orientation": body.get("orientation"),
|
||
"fa_font_url_regular": fa_font_url_regular,
|
||
"fa_font_url_bold": fa_font_url_bold,
|
||
"filters_summary": filters_summary,
|
||
"items": items,
|
||
"stats": stats,
|
||
},
|
||
)
|
||
|
||
font_config = FontConfiguration()
|
||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
|
||
|
||
filename = f"installments_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||
"Content-Length": str(len(pdf_bytes)),
|
||
"Access-Control-Expose-Headers": "Content-Disposition, Content-Type",
|
||
},
|
||
)
|
||
|
||
|
||
@router.put(
|
||
"/business/{business_id}/{invoice_id}",
|
||
summary="ویرایش فاکتور",
|
||
description=(
|
||
"بهروزرسانی فاکتور موجود. خطاهای رایج: شخص نامعتبر (`INVALID_PERSON`)، تکراری بودن شماره فاکتور (`DUPLICATE_DOCUMENT_CODE`). "
|
||
"همان قواعد انتخاب نرخ ارز دستی مانند ایجاد فاکتور اعمال میشود."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def update_invoice_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "edit", Document, "invoice_id")),
|
||
) -> Dict[str, Any]:
|
||
# Optional safety: ensure ownership
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
# Lazy import to avoid circular
|
||
from app.core.responses import ApiError
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
if not has_invoice_type_permission_for_business(ctx, db, business_id, doc.document_type, "edit"):
|
||
raise ApiError("FORBIDDEN", f"Missing invoice type permission for {doc.document_type}", http_status=403)
|
||
requested_type = str(payload.get("invoice_type") or doc.document_type or "").strip()
|
||
if requested_type not in SUPPORTED_INVOICE_TYPES:
|
||
requested_type = str(doc.document_type or "")
|
||
if (
|
||
requested_type != doc.document_type
|
||
and not has_invoice_type_permission_for_business(ctx, db, business_id, requested_type, "edit")
|
||
):
|
||
raise ApiError("FORBIDDEN", f"Missing invoice type permission for {requested_type}", http_status=403)
|
||
try:
|
||
can_pick = _user_can_select_fx_rate_for_business(db, ctx, business_id)
|
||
can_change_unit = user_can_change_invoice_unit_price(ctx, db, business_id)
|
||
result = update_invoice(
|
||
db=db,
|
||
document_id=invoice_id,
|
||
user_id=ctx.get_user_id(),
|
||
data=payload,
|
||
user_can_select_fx_rate=can_pick,
|
||
user_can_change_invoice_unit_price=can_change_unit,
|
||
)
|
||
except IntegrityError as e:
|
||
db.rollback()
|
||
err_msg = str(getattr(e, "orig", e) or e)
|
||
err_lower = err_msg.lower()
|
||
if "person_id" in err_lower or "document_lines" in err_lower:
|
||
raise ApiError(
|
||
"INVALID_PERSON",
|
||
"شخص انتخابشده معتبر نیست یا به این کسبوکار تعلق ندارد. لطفاً شخص دیگری انتخاب کنید.",
|
||
http_status=400,
|
||
)
|
||
if "uq_documents_business_code" in err_lower:
|
||
raise ApiError(
|
||
"DUPLICATE_DOCUMENT_CODE",
|
||
"این شماره فاکتور برای این کسبوکار قبلاً ثبت شده است",
|
||
http_status=400,
|
||
)
|
||
raise ApiError(
|
||
"UPDATE_FAILED",
|
||
"خطای یکتایی یا ارجاع در دیتابیس. دادههای ارسالی را بررسی کنید.",
|
||
http_status=400,
|
||
)
|
||
return success_response(data=result, request=request, message="INVOICE_UPDATED")
|
||
|
||
|
||
@router.delete(
|
||
"/business/{business_id}/{invoice_id}",
|
||
summary="حذف فاکتور",
|
||
description="حذف یک فاکتور پس از اعتبارسنجی مالکیت و نوع سند؛ در صورت وجود وابستگی به اسناد دیگر ممکن است خطای منطقی برگردد.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def delete_invoice_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "delete")),
|
||
) -> Dict[str, Any]:
|
||
"""حذف یک فاکتور"""
|
||
# بررسی مالکیت
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
|
||
# حذف فاکتور
|
||
success = delete_invoice(db, invoice_id)
|
||
|
||
if not success:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("DELETE_FAILED", "Failed to delete invoice", http_status=500)
|
||
|
||
return success_response(
|
||
data={"deleted": True, "invoice_id": invoice_id},
|
||
request=request,
|
||
message="INVOICE_DELETED"
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/bulk-delete",
|
||
summary="حذف گروهی فاکتورها",
|
||
description=(
|
||
'بدنه: `{"invoice_ids": [int, ...]}` — حذف چند فاکتور همزمان؛ '
|
||
"شناسههای حذفشده در `deleted` و موارد رد شده با دلیل در `skipped` برمیگردد."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def bulk_delete_invoices_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "delete")),
|
||
) -> Dict[str, Any]:
|
||
"""حذف گروهی فاکتورها"""
|
||
invoice_ids = body.get("invoice_ids") or []
|
||
if not isinstance(invoice_ids, list):
|
||
raise ApiError("INVALID_REQUEST", "invoice_ids must be a list", http_status=400)
|
||
invoice_ids = [int(x) for x in invoice_ids if isinstance(x, (int, str)) and str(x).strip().isdigit()]
|
||
if not invoice_ids:
|
||
return success_response(
|
||
data={"deleted": [], "skipped": []},
|
||
request=request,
|
||
message="INVOICE_BULK_DELETED",
|
||
)
|
||
result = bulk_delete_invoices(db, business_id, invoice_ids)
|
||
return success_response(
|
||
data=result,
|
||
request=request,
|
||
message="INVOICE_BULK_DELETED",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/bulk-upsert",
|
||
summary="ایجاد/ویرایش گروهی فاکتورها (یکپارچهسازی)",
|
||
description=(
|
||
"بدنه شامل `items`: آرایهای از `{client_ref?، invoice_id?، payload}`؛ برای ایجاد `invoice_id` خالی؛ "
|
||
"برای ویرایش عدد همان شناسه سند؛ `payload` همان بدنهٔ تکی ایجاد/ویرایش فاکتور. "
|
||
"حداکثر ۱۰۰۰ آیتم در هر درخواست؛ نتیجهٔ per-item در `results` با status=created|updated|failed است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def bulk_upsert_invoices_integration_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
can_pick = _user_can_select_fx_rate_for_business(db, ctx, business_id)
|
||
data = bulk_upsert_invoices_integration(
|
||
db,
|
||
business_id,
|
||
ctx,
|
||
body,
|
||
user_can_select_fx_rate=can_pick,
|
||
)
|
||
return success_response(data=data, request=request, message="BULK_INVOICE_UPSERT_COMPLETED")
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}/delete-info",
|
||
summary="پیشنمایش اثرات حذف فاکتور",
|
||
description=(
|
||
"قبل از حذف، ارتباطات سند را برمیگرداند: کارپوشه مودیان، اقساط، اسناد دریافت/پرداخت و حوالههای انبار. "
|
||
"برای نمایش هشدار در رابط کاربری استفاده میشود."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_invoice_delete_info(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""دریافت اطلاعات مرتبط با فاکتور برای نمایش در هشدار حذف"""
|
||
from app.core.responses import ApiError
|
||
|
||
# بررسی مالکیت
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
|
||
extra_info = doc.extra_info or {}
|
||
links = extra_info.get("links") or {}
|
||
|
||
# بررسی کارپوشه مودیان
|
||
is_in_tax_workspace = bool(extra_info.get("tax_workspace"))
|
||
tax_status = extra_info.get("tax_status", "")
|
||
|
||
# بررسی اقساط
|
||
has_installments = bool(extra_info.get("installment_plan"))
|
||
installment_info = None
|
||
if has_installments:
|
||
plan = extra_info.get("installment_plan", {})
|
||
schedule = plan.get("schedule", [])
|
||
installment_info = {
|
||
"count": len(schedule),
|
||
"total_amount": plan.get("principal_total", 0),
|
||
}
|
||
|
||
# پاکسازی لینکهای مرده قبل از بررسی اسناد دریافت/پرداخت
|
||
logger = logging.getLogger(__name__)
|
||
try:
|
||
from app.services.invoice_service import _cleanup_dead_receipt_payment_links
|
||
_cleanup_dead_receipt_payment_links(db, doc)
|
||
db.commit()
|
||
db.refresh(doc)
|
||
# بهروزرسانی extra_info و links
|
||
extra_info = doc.extra_info or {}
|
||
links = extra_info.get("links") or {}
|
||
except Exception as e:
|
||
logger.warning(f"خطا در پاکسازی لینکهای مرده در get_invoice_delete_info: {e}")
|
||
|
||
# بررسی اسناد دریافت/پرداخت
|
||
receipt_payment_document_ids = links.get("receipt_payment_document_ids") or []
|
||
receipt_payment_info = []
|
||
if receipt_payment_document_ids:
|
||
related_docs = db.query(Document).filter(
|
||
Document.id.in_(receipt_payment_document_ids)
|
||
).all()
|
||
for rp_doc in related_docs:
|
||
total_amount = Decimal(0)
|
||
try:
|
||
from adapters.db.models.document_line import DocumentLine
|
||
lines = db.query(DocumentLine).filter(
|
||
DocumentLine.document_id == rp_doc.id
|
||
).all()
|
||
for line in lines:
|
||
debit = Decimal(str(line.debit or 0))
|
||
credit = Decimal(str(line.credit or 0))
|
||
total_amount += max(debit, credit)
|
||
except Exception:
|
||
pass
|
||
|
||
receipt_payment_info.append({
|
||
"id": rp_doc.id,
|
||
"code": rp_doc.code,
|
||
"type": rp_doc.document_type,
|
||
"amount": float(total_amount),
|
||
"is_zero": total_amount == Decimal(0),
|
||
})
|
||
|
||
# بررسی حوالههای انبار
|
||
warehouse_document_ids = links.get("warehouse_document_ids") or []
|
||
warehouse_info = []
|
||
if warehouse_document_ids:
|
||
try:
|
||
from adapters.db.models.warehouse_document import WarehouseDocument
|
||
warehouse_docs = db.query(WarehouseDocument).filter(
|
||
WarehouseDocument.id.in_(warehouse_document_ids)
|
||
).all()
|
||
for wd in warehouse_docs:
|
||
status = getattr(wd, "status", None)
|
||
warehouse_info.append({
|
||
"id": wd.id,
|
||
"code": getattr(wd, "code", ""),
|
||
"status": status,
|
||
"is_finalized": status == "finalized",
|
||
})
|
||
except ImportError:
|
||
pass
|
||
|
||
return success_response(
|
||
data={
|
||
"invoice_id": invoice_id,
|
||
"invoice_code": doc.code,
|
||
"is_in_tax_workspace": is_in_tax_workspace,
|
||
"tax_status": tax_status,
|
||
"has_installments": has_installments,
|
||
"installment_info": installment_info,
|
||
"receipt_payment_documents": receipt_payment_info,
|
||
"warehouse_documents": warehouse_info,
|
||
},
|
||
request=request,
|
||
message="INVOICE_DELETE_INFO"
|
||
)
|
||
|
||
|
||
# مسیرهای ثابت مثل /tags باید قبل از /{invoice_id} تعریف شوند وگرنه «tags» بهعنوان شناسه فاکتور گرفته میشود.
|
||
@router.get(
|
||
"/business/{business_id}/tags",
|
||
summary="لیست برچسبهای فاکتور",
|
||
description="برچسبهای قابل انتساب به فاکتور برای این کسبوکار (با ایجاد خودکار پیشفرض در اولین بار)",
|
||
)
|
||
@require_business_access("business_id")
|
||
def list_invoice_tags_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
include_inactive: bool = Query(
|
||
default=False,
|
||
description="در صورت true برچسبهای غیرفعال نیز در لیست میآیند",
|
||
),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
):
|
||
items = list_invoice_tags(db, business_id, include_inactive=include_inactive)
|
||
return success_response(data={"items": items}, request=request, message="INVOICE_TAGS_LIST")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tags",
|
||
summary="ایجاد برچسب فاکتور",
|
||
description='بدنه: `{"name": "...", "color": "#RRGGBB?"}` — برچسب برای فیلتر و نمایش روی فاکتورها.',
|
||
)
|
||
@require_business_access("business_id")
|
||
def create_invoice_tag_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "edit")),
|
||
):
|
||
name = body.get("name")
|
||
color = body.get("color")
|
||
item = create_invoice_tag(db, business_id, str(name or ""), color=(str(color) if color else None))
|
||
return success_response(data={"item": item}, request=request, message="INVOICE_TAG_CREATED")
|
||
|
||
|
||
@router.patch(
|
||
"/business/{business_id}/tags/{tag_id}",
|
||
summary="ویرایش برچسب فاکتور",
|
||
description="فیلدهای ارسالی (نام، رنگ، فعال/غیرفعال و ...) طبق بدنه درخواست بهروز میشوند.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def update_invoice_tag_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
tag_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "edit")),
|
||
):
|
||
item = update_invoice_tag(db, business_id, tag_id, body)
|
||
return success_response(data={"item": item}, request=request, message="INVOICE_TAG_UPDATED")
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}",
|
||
summary="جزئیات یک فاکتور",
|
||
description="خروجی کامل سند به شکل دیکشنری (شامل خطوط، مبالغ، اضافهاطلاعات، برچسبها پس از فرمتدهی).",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_invoice_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "view", Document, "invoice_id")),
|
||
) -> Dict[str, Any]:
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
if not has_invoice_type_permission_for_business(ctx, db, business_id, doc.document_type, "view"):
|
||
raise ApiError("FORBIDDEN", f"Missing invoice type permission for {doc.document_type}", http_status=403)
|
||
# پاکسازی لینکهای مرده در invoice_document_to_dict انجام میشود
|
||
result = invoice_document_to_dict(db, doc)
|
||
return success_response(data={"item": result}, request=request, message="INVOICE")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/backfill-profit-ledger",
|
||
summary="پر کردن دفتر سود قطعی (شناسایی)",
|
||
description=(
|
||
"برای اسناد قبلی، مقادیر بهای تمامشده و سود ناخالص قطعی را مطابق تنظیم «زمان شناسایی قطعی» کسبوکار ذخیره میکند. "
|
||
"اختیاری: `fiscal_year_id`, `invoice_ids`, `limit`, `use_background` برای صف صف پسزمینه."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def backfill_invoice_profit_ledger_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(default_factory=dict),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "edit")),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
ذخیرهٔ مقادیر بهای تمامشده و سود ناخالص قطعی (دفتر) برای اسناد قبلی،
|
||
مطابق تنظیم «زمان شناسایی قطعی» روی کسبوکار.
|
||
"""
|
||
from adapters.db.models.business import Business
|
||
|
||
business = db.query(Business).filter(Business.id == business_id).first()
|
||
if not business:
|
||
raise ApiError("BUSINESS_NOT_FOUND", "کسبوکار یافت نشد", http_status=404)
|
||
|
||
if business.invoice_profit_calculation_method == "disabled":
|
||
return success_response(
|
||
data={
|
||
"message": "محاسبه سود برای این کسب و کار غیرفعال است",
|
||
"processed": 0,
|
||
"skipped": 0,
|
||
},
|
||
request=request,
|
||
message="PROFIT_CALCULATION_DISABLED",
|
||
)
|
||
|
||
fiscal_year_id = payload.get("fiscal_year_id")
|
||
invoice_ids = payload.get("invoice_ids")
|
||
limit = payload.get("limit")
|
||
use_background = bool(payload.get("use_background", False))
|
||
|
||
if use_background:
|
||
try:
|
||
from app.core.queue import get_queue_service, QUEUE_DEFAULT
|
||
from app.services.jobs.invoice_profit_ledger_backfill_job import (
|
||
backfill_invoice_profit_ledger_job,
|
||
)
|
||
|
||
qs = get_queue_service()
|
||
if qs and qs.enabled:
|
||
job = qs.enqueue(
|
||
backfill_invoice_profit_ledger_job,
|
||
business_id=business_id,
|
||
user_id=ctx.get_user_id(),
|
||
fiscal_year_id=fiscal_year_id,
|
||
invoice_ids=invoice_ids,
|
||
limit=limit,
|
||
queue_name=QUEUE_DEFAULT,
|
||
timeout=7200,
|
||
result_ttl=7200,
|
||
)
|
||
if job:
|
||
return success_response(
|
||
data={
|
||
"job_id": job.id,
|
||
"status": "queued",
|
||
"message": "بهروزرسانی شناسایی قطعی در پسزمینه آغاز شد.",
|
||
},
|
||
request=request,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("profit ledger queue failed, fallback sync: %s", exc)
|
||
|
||
from app.services.invoice_profit_ledger_service import (
|
||
backfill_recognized_profit_for_business,
|
||
)
|
||
|
||
result = backfill_recognized_profit_for_business(
|
||
db,
|
||
business_id,
|
||
fiscal_year_id=int(fiscal_year_id) if fiscal_year_id is not None else None,
|
||
invoice_ids=[int(x) for x in invoice_ids] if invoice_ids else None,
|
||
limit=int(limit) if limit is not None else None,
|
||
)
|
||
return success_response(data=result, request=request, message="PROFIT_LEDGER_BACKFILLED")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/recalculate-all-profits",
|
||
summary="محاسبه مجدد سود همه فاکتورها",
|
||
description=(
|
||
"معادل فراخوانی `recalculate-profits` بدون فیلتر؛ پس از تغییر تنظیمات محاسبه سود کسبوکار استفاده میشود. "
|
||
"در صورت حجم بالا، صف پسزمینه بهکار میرود."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def recalculate_all_invoice_profits_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
محاسبه مجدد سود تمام فاکتورهای یک کسب و کار
|
||
|
||
این endpoint برای استفاده بعد از تغییر تنظیمات محاسبه سود طراحی شده است.
|
||
به صورت خودکار از background job استفاده میکند.
|
||
"""
|
||
from adapters.db.models.business import Business
|
||
|
||
business = db.query(Business).filter(Business.id == business_id).first()
|
||
if not business:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("BUSINESS_NOT_FOUND", "Business not found", http_status=404)
|
||
|
||
# بررسی اینکه محاسبه سود فعال است
|
||
if business.invoice_profit_calculation_method == "disabled":
|
||
return success_response(
|
||
data={
|
||
"message": "محاسبه سود برای این کسب و کار غیرفعال است",
|
||
"processed": 0,
|
||
"skipped": 0
|
||
},
|
||
request=request,
|
||
message="PROFIT_CALCULATION_DISABLED"
|
||
)
|
||
|
||
# استفاده از endpoint اصلی با پارامترهای پیشفرض
|
||
return recalculate_invoice_profits_endpoint(
|
||
request=request,
|
||
business_id=business_id,
|
||
payload={}, # بدون فیلتر - همه فاکتورها
|
||
ctx=ctx,
|
||
db=db,
|
||
_=_
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/recalculate-profits",
|
||
summary="محاسبه مجدد سود فاکتورها (با فیلتر)",
|
||
description=(
|
||
"بدنه: `invoice_ids` اختیاری، `document_type`, `fiscal_year_id`, `batch_size` (پیشفرض ۱۰۰)، "
|
||
"`use_background` (پیشفرض true). فقط انواع فروش/برگشت از فروش/تولید در منطق فعلی پردازش میشوند."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def recalculate_invoice_profits_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
payload: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
محاسبه مجدد سود فاکتورهای قدیمی
|
||
|
||
پارامترها:
|
||
- invoice_ids: لیست شناسه فاکتورها (اختیاری - اگر نباشد، تمام فاکتورهای کسب و کار)
|
||
- document_type: نوع فاکتور (اختیاری - برای فیلتر)
|
||
- fiscal_year_id: سال مالی (اختیاری - برای فیلتر)
|
||
- batch_size: تعداد فاکتورها در هر batch (پیشفرض: 100)
|
||
- use_background: استفاده از background job برای پردازش (پیشفرض: true)
|
||
"""
|
||
from adapters.db.models.business import Business
|
||
from adapters.db.models.fiscal_year import FiscalYear
|
||
|
||
business = db.query(Business).filter(Business.id == business_id).first()
|
||
if not business:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("BUSINESS_NOT_FOUND", "Business not found", http_status=404)
|
||
|
||
# بررسی اینکه محاسبه سود فعال است
|
||
if business.invoice_profit_calculation_method == "disabled":
|
||
return success_response(
|
||
data={
|
||
"message": "محاسبه سود برای این کسب و کار غیرفعال است",
|
||
"processed": 0,
|
||
"skipped": 0
|
||
},
|
||
request=request,
|
||
message="PROFIT_CALCULATION_DISABLED"
|
||
)
|
||
|
||
invoice_ids = payload.get("invoice_ids")
|
||
document_type = payload.get("document_type")
|
||
fiscal_year_id = payload.get("fiscal_year_id")
|
||
batch_size = payload.get("batch_size", 100)
|
||
use_background = payload.get("use_background", True)
|
||
|
||
# ساخت query برای فاکتورها
|
||
query = db.query(Document).filter(Document.business_id == business_id)
|
||
query = query.filter(Document.document_type.in_(SUPPORTED_INVOICE_TYPES))
|
||
|
||
if invoice_ids:
|
||
query = query.filter(Document.id.in_(invoice_ids))
|
||
if document_type:
|
||
query = query.filter(Document.document_type == document_type)
|
||
if fiscal_year_id:
|
||
query = query.filter(Document.fiscal_year_id == fiscal_year_id)
|
||
|
||
# فقط فاکتورهای فروش و تولید
|
||
query = query.filter(
|
||
Document.document_type.in_(["invoice_sales", "invoice_sales_return", "invoice_production"])
|
||
)
|
||
|
||
total_invoices = query.count()
|
||
|
||
if total_invoices == 0:
|
||
return success_response(
|
||
data={
|
||
"message": "هیچ فاکتوری برای محاسبه سود یافت نشد",
|
||
"processed": 0,
|
||
"skipped": 0,
|
||
"total": 0
|
||
},
|
||
request=request,
|
||
message="NO_INVOICES_FOUND"
|
||
)
|
||
|
||
# اگر تعداد فاکتورها زیاد است یا use_background فعال است، از background job استفاده کن
|
||
if use_background and total_invoices > batch_size:
|
||
from app.core.queue import get_queue_service, QUEUE_DEFAULT
|
||
from app.services.jobs.invoice_profit_job import recalculate_invoice_profits_job
|
||
|
||
queue_service = get_queue_service()
|
||
if queue_service and queue_service.enabled:
|
||
# دریافت invoice_ids برای job
|
||
invoice_id_list = [d.id for d in query.limit(10000).all()] if not invoice_ids else invoice_ids
|
||
|
||
job = queue_service.enqueue(
|
||
recalculate_invoice_profits_job,
|
||
business_id=business_id,
|
||
user_id=ctx.get_user_id(),
|
||
invoice_ids=invoice_id_list if invoice_ids else None,
|
||
document_type=document_type,
|
||
fiscal_year_id=fiscal_year_id,
|
||
batch_size=batch_size,
|
||
queue_name=QUEUE_DEFAULT,
|
||
timeout=3600, # 1 ساعت timeout
|
||
result_ttl=7200, # نتیجه را 2 ساعت نگه دار
|
||
)
|
||
|
||
if job:
|
||
return success_response({
|
||
"job_id": job.id,
|
||
"status": "queued",
|
||
"total_invoices": total_invoices,
|
||
"message": f"محاسبه سود {total_invoices} فاکتور در پسزمینه شروع شد. از GET /api/v1/jobs/{job.id} برای بررسی وضعیت استفاده کنید."
|
||
}, request)
|
||
|
||
# اجرای sync برای تعداد کم فاکتورها
|
||
from decimal import Decimal
|
||
|
||
# پردازش همه فاکتورها (نه فقط batch_size)
|
||
invoices = query.all()
|
||
processed = 0
|
||
skipped = 0
|
||
errors = []
|
||
|
||
logger.info(f"Starting profit recalculation for {len(invoices)} invoices (business_id={business_id})")
|
||
|
||
for doc in invoices:
|
||
try:
|
||
# محاسبه سود (فقط برای بررسی - نتیجه ذخیره نمیشود چون on-demand است)
|
||
from app.services.invoice_service import _calculate_invoice_profit
|
||
profit_data = _calculate_invoice_profit(
|
||
db,
|
||
business_id,
|
||
doc.id,
|
||
business.invoice_profit_calculation_method or "automatic",
|
||
business.invoice_profit_calculation_basis or "purchase_price",
|
||
business.invoice_profit_include_overhead or False,
|
||
business.invoice_profit_overhead_type or "none",
|
||
Decimal(str(business.invoice_profit_overhead_percent or 0)) if business.invoice_profit_overhead_percent else None,
|
||
business.invoice_profit_calculation_type or "gross",
|
||
fifo_shortage_mode=getattr(business, "invoice_profit_fifo_shortage_mode", None),
|
||
)
|
||
|
||
# بررسی اینکه آیا سود محاسبه شده است
|
||
if profit_data and (profit_data.get("gross_profit") is not None or profit_data.get("net_profit") is not None):
|
||
processed += 1
|
||
logger.debug(f"Successfully calculated profit for invoice {doc.id} (code: {doc.code})")
|
||
else:
|
||
skipped += 1
|
||
errors.append({
|
||
"invoice_id": doc.id,
|
||
"invoice_code": doc.code,
|
||
"error": "سود محاسبه نشد (نتیجه خالی)"
|
||
})
|
||
logger.warning(f"Empty profit result for invoice {doc.id} (code: {doc.code})")
|
||
except Exception as e:
|
||
skipped += 1
|
||
error_msg = str(e)
|
||
errors.append({
|
||
"invoice_id": doc.id,
|
||
"invoice_code": doc.code,
|
||
"error": error_msg
|
||
})
|
||
logger.error(f"Error calculating profit for invoice {doc.id} (code: {doc.code}): {e}", exc_info=True)
|
||
|
||
logger.info(f"Profit recalculation completed: processed={processed}, skipped={skipped}, total={total_invoices}")
|
||
|
||
return success_response(
|
||
data={
|
||
"message": f"محاسبه سود برای {processed} فاکتور انجام شد",
|
||
"processed": processed,
|
||
"skipped": skipped,
|
||
"total": total_invoices,
|
||
"errors": errors[:20] if errors else [] # 20 خطای اول برای بررسی بهتر
|
||
},
|
||
request=request,
|
||
message="PROFIT_RECALCULATED"
|
||
)
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}/pdf",
|
||
summary="PDF یک فاکتور",
|
||
description=(
|
||
"دریافت فایل PDF تکفاکتور. اولویت قالب: `template_id` منتشرشده، سپس قالب پیشفرض همان scope "
|
||
"(`invoices/detail` یا `invoices/receipt` برای سایز فیش)، در نهایت HTML پیشفرض. "
|
||
"پارامترهای query مانند `paper_size` (A4/A5/A6 یا 60mm/80mm/100mm)، `orientation` و `show_stamp` چاپ را کنترل میکنند."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def export_single_invoice_pdf(
|
||
business_id: int,
|
||
invoice_id: int,
|
||
request: Request,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
template_id: int | None = Query(
|
||
default=None,
|
||
description="شناسه قالب گزارش منتشرشده برای جزئیات فاکتور؛ در صورت خالی بودن از پیشفرض کسبوکار استفاده میشود.",
|
||
),
|
||
_: None = Depends(require_business_permission_by_entity_dep("invoices", "view", Document, "invoice_id")),
|
||
):
|
||
"""
|
||
خروجی PDF تکسند فاکتور با پشتیبانی از قالب سفارشی:
|
||
- اگر template_id داده شود و منتشرشده و همخوان با scope باشد، همان استفاده میشود.
|
||
- سایزهای فیش (60mm/80mm/100mm) از قالب invoices/receipt و HTML فیش استفاده میکنند.
|
||
- در غیر این صورت اگر قالب پیشفرض منتشرشده برای همان scope موجود باشد، استفاده میشود.
|
||
- در نبود قالب، خروجی HTML پیشفرض (جزئیات یا فیش) تولید میشود.
|
||
"""
|
||
from weasyprint import HTML
|
||
from weasyprint.text.fonts import FontConfiguration
|
||
from app.core.i18n import negotiate_locale
|
||
from html import escape
|
||
import datetime
|
||
|
||
# دریافت سند و اعتبارسنجی
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
from app.core.responses import ApiError
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
|
||
# جزئیات کامل فاکتور (بهصورت دیکشنری قابل ارسال به قالب)
|
||
item = invoice_document_to_dict(db, doc)
|
||
item = dict(item or {})
|
||
|
||
# اطلاعات کسبوکار (اختیاری) + فایلهای گرافیکی (لوگو/مهر) و امضای مالک
|
||
business_name = ""
|
||
business_info: Dict[str, Any] = {}
|
||
business_logo_data_uri: Optional[str] = None
|
||
business_stamp_data_uri: Optional[str] = None
|
||
owner_signature_data_uri: Optional[str] = None
|
||
|
||
storage = FileStorageService(db)
|
||
|
||
async def _load_image_data_uri(file_id_str: Optional[str]) -> Optional[str]:
|
||
"""دریافت داده فایل و تبدیل به data URI برای استفاده در HTML/PDF."""
|
||
if not file_id_str:
|
||
return None
|
||
try:
|
||
from uuid import UUID
|
||
|
||
try:
|
||
file_data = await storage.download_file(UUID(str(file_id_str)))
|
||
except Exception:
|
||
# در صورت بروز خطا، None برمیگردانیم تا قالب بدون تصویر ادامه دهد
|
||
return None
|
||
content: bytes = file_data.get("content") or b""
|
||
if not content:
|
||
return None
|
||
mime = file_data.get("mime_type") or "image/png"
|
||
b64 = base64.b64encode(content).decode("ascii")
|
||
return f"data:{mime};base64,{b64}"
|
||
except Exception:
|
||
return None
|
||
|
||
show_stamp_override = None
|
||
|
||
from app.services.business_print_settings_resolver import (
|
||
default_print_settings_dict,
|
||
pick_print_settings,
|
||
)
|
||
from app.services.print_tax_discount_display import (
|
||
build_global_discount_print_info,
|
||
build_invoice_tax_discount_display_flags,
|
||
enrich_line_amount_fields,
|
||
line_amounts_before_global_discount,
|
||
parse_discount_meta,
|
||
should_restore_line_amounts_before_global,
|
||
)
|
||
|
||
# تنظیمات چاپ کسبوکار (لوگو، مهر، پرداختها، اقساط و متن انتهایی)
|
||
# یک کانفیگ پیشفرض تعریف میکنیم تا در صورت بروز خطا یا نبود کسبوکار، همچنان در دسترس باشد
|
||
print_settings: Dict[str, Any] = default_print_settings_dict()
|
||
invoice_footer_note: Optional[str] = None
|
||
|
||
try:
|
||
b = db.query(Business).filter(Business.id == business_id).first()
|
||
if b is not None:
|
||
business_name = b.name or ""
|
||
# اطلاعات اقتصادی و تماس کسبوکار
|
||
economic_id = getattr(b, "economic_id", None)
|
||
economic_code = getattr(b, "economic_code", None)
|
||
business_info = {
|
||
"name": getattr(b, "name", None),
|
||
# برای سازگاری با قالبهای قدیمی
|
||
"economic_id": economic_id or economic_code,
|
||
"economic_code": economic_code or economic_id,
|
||
"national_id": getattr(b, "national_id", None),
|
||
"registration_number": getattr(b, "registration_number", None),
|
||
"address": getattr(b, "address", None),
|
||
"postal_code": getattr(b, "postal_code", None),
|
||
"phone": getattr(b, "phone", None),
|
||
"mobile": getattr(b, "mobile", None),
|
||
}
|
||
|
||
# ابتدا تنظیمات چاپ را (در صورت وجود) برای این کسبوکار و نوع سند میخوانیم
|
||
try:
|
||
print_rows = (
|
||
db.query(BusinessPrintSettings)
|
||
.filter(BusinessPrintSettings.business_id == business_id)
|
||
.all()
|
||
)
|
||
except Exception:
|
||
print_rows = []
|
||
|
||
def _pick_print_settings() -> dict:
|
||
return pick_print_settings(print_rows, doc.document_type, base=print_settings)
|
||
|
||
print_settings = _pick_print_settings()
|
||
|
||
def _normalize_bool(value):
|
||
if isinstance(value, bool):
|
||
return value
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
if isinstance(value, str):
|
||
v = value.strip().lower()
|
||
if v in {"1", "true", "yes", "on"}:
|
||
return True
|
||
if v in {"0", "false", "no", "off"}:
|
||
return False
|
||
return None
|
||
|
||
# پارامترهای query: مهر، QR و مقیاس مهر/امضا
|
||
try:
|
||
_qp_print = request.query_params
|
||
_st_q = _normalize_bool(_qp_print.get("show_stamp"))
|
||
if _st_q is not None:
|
||
print_settings["show_stamp"] = _st_q
|
||
_shq = _normalize_bool(_qp_print.get("show_share_qr"))
|
||
if _shq is not None:
|
||
print_settings["show_share_qr"] = _shq
|
||
_sft = _normalize_bool(_qp_print.get("show_footer_print_time"))
|
||
if _sft is not None:
|
||
print_settings["show_footer_print_time"] = _sft
|
||
_sfp = _normalize_bool(_qp_print.get("show_footer_preparer"))
|
||
if _sfp is not None:
|
||
print_settings["show_footer_preparer"] = _sfp
|
||
from app.services.print_stamp_scale import clamp_scale_percent
|
||
|
||
if _qp_print.get("stamp_scale_percent") is not None:
|
||
print_settings["stamp_scale_percent"] = clamp_scale_percent(
|
||
_qp_print.get("stamp_scale_percent"),
|
||
print_settings.get("stamp_scale_percent", 100),
|
||
)
|
||
if _qp_print.get("signature_scale_percent") is not None:
|
||
print_settings["signature_scale_percent"] = clamp_scale_percent(
|
||
_qp_print.get("signature_scale_percent"),
|
||
print_settings.get("signature_scale_percent", 100),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# لوگو و مهر کسبوکار بر اساس تنظیمات چاپ
|
||
if print_settings.get("show_logo", True):
|
||
business_logo_data_uri = await _load_image_data_uri(
|
||
getattr(b, "logo_file_id", None)
|
||
)
|
||
else:
|
||
business_logo_data_uri = None
|
||
|
||
if print_settings.get("show_stamp", True):
|
||
business_stamp_data_uri = await _load_image_data_uri(
|
||
getattr(b, "stamp_file_id", None)
|
||
)
|
||
else:
|
||
business_stamp_data_uri = None
|
||
|
||
# امضای مالک کسبوکار (بر اساس owner_id) فقط اگر show_stamp فعال باشد
|
||
try:
|
||
owner_user = db.query(User).filter(User.id == b.owner_id).first()
|
||
except Exception:
|
||
owner_user = None
|
||
if owner_user is not None and print_settings.get("show_stamp", True):
|
||
owner_signature_data_uri = await _load_image_data_uri(
|
||
getattr(owner_user, "signature_file_id", None)
|
||
)
|
||
|
||
invoice_footer_note = print_settings.get("footer_note")
|
||
except Exception:
|
||
business_name = ""
|
||
business_info = {}
|
||
business_logo_data_uri = None
|
||
business_stamp_data_uri = None
|
||
owner_signature_data_uri = None
|
||
invoice_footer_note = None
|
||
|
||
# اگر بلوک بالا خطا خورد، باز هم پارامترهای query چاپ را روی print_settings اعمال کن
|
||
try:
|
||
_qp_fb = request.query_params
|
||
|
||
def _norm_bool_pdf(v):
|
||
if isinstance(v, bool):
|
||
return v
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, (int, float)):
|
||
return bool(v)
|
||
if isinstance(v, str):
|
||
s = v.strip().lower()
|
||
if s in {"1", "true", "yes", "on"}:
|
||
return True
|
||
if s in {"0", "false", "no", "off"}:
|
||
return False
|
||
return None
|
||
|
||
_sf = _norm_bool_pdf(_qp_fb.get("show_stamp"))
|
||
if _sf is not None:
|
||
print_settings["show_stamp"] = _sf
|
||
_qf = _norm_bool_pdf(_qp_fb.get("show_share_qr"))
|
||
if _qf is not None:
|
||
print_settings["show_share_qr"] = _qf
|
||
_qft = _norm_bool_pdf(_qp_fb.get("show_footer_print_time"))
|
||
if _qft is not None:
|
||
print_settings["show_footer_print_time"] = _qft
|
||
_qfp = _norm_bool_pdf(_qp_fb.get("show_footer_preparer"))
|
||
if _qfp is not None:
|
||
print_settings["show_footer_preparer"] = _qfp
|
||
from app.services.print_stamp_scale import clamp_scale_percent
|
||
|
||
if _qp_fb.get("stamp_scale_percent") is not None:
|
||
print_settings["stamp_scale_percent"] = clamp_scale_percent(
|
||
_qp_fb.get("stamp_scale_percent"),
|
||
print_settings.get("stamp_scale_percent", 100),
|
||
)
|
||
if _qp_fb.get("signature_scale_percent") is not None:
|
||
print_settings["signature_scale_percent"] = clamp_scale_percent(
|
||
_qp_fb.get("signature_scale_percent"),
|
||
print_settings.get("signature_scale_percent", 100),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Locale و نوع تقویم
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == "fa"
|
||
calendar_header = (request.headers.get("X-Calendar-Type") or "").strip().lower()
|
||
calendar_type = calendar_header or ("jalali" if is_fa else "gregorian")
|
||
|
||
# تاریخ فاکتور با هر دو فرمت
|
||
invoice_date_raw = item.get("document_date")
|
||
invoice_date_jalali = None
|
||
invoice_date_gregorian = None
|
||
if invoice_date_raw:
|
||
try:
|
||
dt = datetime.datetime.fromisoformat(str(invoice_date_raw).replace("Z", "+00:00"))
|
||
jalali = CalendarConverter.format_datetime(dt, "jalali")
|
||
greg = CalendarConverter.format_datetime(dt, "gregorian")
|
||
# فقط تاریخ بدون زمان را برای نمایش استفاده میکنیم
|
||
invoice_date_jalali = jalali.get("date_only") or jalali.get("formatted", "")
|
||
invoice_date_gregorian = greg.get("date_only") or greg.get("formatted", "")
|
||
except Exception:
|
||
invoice_date_gregorian = str(invoice_date_raw)
|
||
|
||
if calendar_type == "jalali" and invoice_date_jalali:
|
||
invoice_date_display = invoice_date_jalali
|
||
else:
|
||
invoice_date_display = invoice_date_gregorian or invoice_date_raw
|
||
|
||
# نوع فاکتور بهصورت خوانا
|
||
def _type_name(tp: str) -> str:
|
||
mapping = {
|
||
"invoice_sales": ("فروش" if is_fa else "Sales"),
|
||
"invoice_sales_return": ("برگشت از فروش" if is_fa else "Sales return"),
|
||
"invoice_purchase": ("خرید" if is_fa else "Purchase"),
|
||
"invoice_purchase_return": ("برگشت از خرید" if is_fa else "Purchase return"),
|
||
"invoice_direct_consumption": ("مصرف مستقیم" if is_fa else "Direct consumption"),
|
||
"invoice_production": ("تولید" if is_fa else "Production"),
|
||
"invoice_waste": ("ضایعات" if is_fa else "Waste"),
|
||
}
|
||
return mapping.get(str(tp), str(tp))
|
||
|
||
invoice_type_name = _type_name(item.get("document_type"))
|
||
is_proforma = bool(item.get("is_proforma"))
|
||
|
||
# اطلاعات طرف حساب (خریدار/فروشنده) بر اساس نوع فاکتور
|
||
extra = item.get("extra_info") or {}
|
||
person_id = extra.get("person_id")
|
||
buyer_info: Dict[str, Any] = {}
|
||
seller_info: Dict[str, Any] = {}
|
||
|
||
person_obj = None
|
||
try:
|
||
if person_id is not None:
|
||
person_obj = db.query(Person).filter(Person.id == int(person_id)).first()
|
||
except Exception:
|
||
person_obj = None
|
||
|
||
person_info: Dict[str, Any] = {}
|
||
if person_obj is not None:
|
||
# اطلاعات اقتصادی و هویتی شخص (با پشتیبانی از فیلدهای قدیمی و جدید)
|
||
national_id = getattr(person_obj, "national_id", None)
|
||
national_code = getattr(person_obj, "national_code", None)
|
||
registration_number = getattr(person_obj, "registration_number", None)
|
||
economic_id = getattr(person_obj, "economic_id", None)
|
||
economic_code = getattr(person_obj, "economic_code", None)
|
||
|
||
# تعیین نام: اول display_name یا name، سپس first_name + last_name، در نهایت alias_name
|
||
display_name = getattr(person_obj, "display_name", None)
|
||
name = getattr(person_obj, "name", None)
|
||
first_name = getattr(person_obj, "first_name", None)
|
||
last_name = getattr(person_obj, "last_name", None)
|
||
alias_name = getattr(person_obj, "alias_name", None)
|
||
|
||
person_name = display_name or name
|
||
if not person_name:
|
||
# اگر display_name و name خالی بودند، از first_name و last_name استفاده میکنیم
|
||
if first_name or last_name:
|
||
name_parts = []
|
||
if first_name:
|
||
name_parts.append(first_name)
|
||
if last_name:
|
||
name_parts.append(last_name)
|
||
person_name = " ".join(name_parts) if name_parts else None
|
||
|
||
# اگر هنوز خالی است (یعنی first_name و last_name هم خالی بودند)، از alias_name استفاده میکنیم
|
||
if not person_name:
|
||
person_name = alias_name
|
||
|
||
le_type = getattr(person_obj, "legal_entity_type", None) or "natural"
|
||
name_prefix = getattr(person_obj, "name_prefix", None)
|
||
person_info = {
|
||
"id": getattr(person_obj, "id", None),
|
||
"code": getattr(person_obj, "code", None),
|
||
"name": person_name,
|
||
"name_prefix": name_prefix,
|
||
"legal_entity_type": le_type,
|
||
"legal_entity_type_label": ("حقوقی" if is_fa else "Legal entity") if le_type == "legal" else ("حقیقی" if is_fa else "Natural person"),
|
||
# برای سازگاری با قالبهای قدیمی، هر دو کلید نگه داشته میشوند
|
||
"national_id": national_id or national_code,
|
||
"national_code": national_code or national_id,
|
||
"registration_number": registration_number,
|
||
"economic_id": economic_id or economic_code,
|
||
"economic_code": economic_code or economic_id,
|
||
"address": getattr(person_obj, "address", None),
|
||
"postal_code": getattr(person_obj, "postal_code", None),
|
||
"mobile": getattr(person_obj, "mobile", None),
|
||
"phone": getattr(person_obj, "phone", None),
|
||
}
|
||
|
||
inv_type = str(item.get("document_type") or "")
|
||
# برای فروش، کسبوکار فروشنده و شخص خریدار است
|
||
if inv_type in ("invoice_sales", "invoice_sales_return"):
|
||
seller_info = business_info if business_info else {"name": business_name}
|
||
buyer_info = person_info if person_info else {}
|
||
# برای خرید، شخص فروشنده و کسبوکار خریدار است
|
||
elif inv_type in ("invoice_purchase", "invoice_purchase_return"):
|
||
seller_info = person_info if person_info else {}
|
||
buyer_info = business_info if business_info else {"name": business_name}
|
||
else:
|
||
# سایر انواع: فقط کسبوکار را بهعنوان صاحب فاکتور نمایش میدهیم
|
||
seller_info = business_info if business_info else {"name": business_name}
|
||
buyer_info = person_info if person_info else {}
|
||
|
||
# لاگ برای دیباگ آدرس
|
||
logger.info(
|
||
"Invoice PDF addresses: invoice_id=%s, seller.address=%s, buyer.address=%s",
|
||
invoice_id,
|
||
seller_info.get("address"),
|
||
buyer_info.get("address"),
|
||
)
|
||
|
||
# خطوط فاکتور (کالا/خدمت)
|
||
# اگر تخفیف کلی با حالت مالیات متناسب روی سطرها پخش شده باشد،
|
||
# برای نمایش پرینت مبالغ سطر را به حالت قبل از تخفیف کلی برمیگردانیم
|
||
# تا کاربر حس نکند تخفیف کلی روی فیلد تخفیف سطری نشسته است.
|
||
_gd_preview = (extra.get("global_discount") if isinstance(extra, dict) else None) or {}
|
||
_restore_pre_global = should_restore_line_amounts_before_global(
|
||
_gd_preview if isinstance(_gd_preview, dict) else None
|
||
)
|
||
|
||
normalized_lines: list[dict[str, Any]] = []
|
||
try:
|
||
for pl in item.get("product_lines", []) or []:
|
||
info = (pl.get("extra_info") or {}) if isinstance(pl, dict) else {}
|
||
qty = pl.get("quantity")
|
||
unit_price = info.get("unit_price")
|
||
line_discount = info.get("line_discount") or 0
|
||
tax_amount = info.get("tax_amount") or 0
|
||
line_total = info.get("line_total")
|
||
discount_type, discount_value = parse_discount_meta(info)
|
||
qty_display = None
|
||
try:
|
||
qf = float(qty or 0)
|
||
upf = float(unit_price or 0)
|
||
discf = float(line_discount or 0)
|
||
taxf = float(tax_amount or 0)
|
||
if line_total is None:
|
||
line_total = (qf * upf) - discf + taxf
|
||
if _restore_pre_global:
|
||
restored = line_amounts_before_global_discount(
|
||
quantity=qf,
|
||
unit_price=upf,
|
||
line_discount=discf,
|
||
tax_rate=info.get("tax_rate"),
|
||
)
|
||
tax_amount = restored["tax_amount"]
|
||
line_total = restored["line_total"]
|
||
# نمایش تعداد: بدون اعشار اگر عدد صحیح باشد
|
||
if qf.is_integer():
|
||
qty_display = f"{int(qf):,}"
|
||
else:
|
||
qty_display = f"{qf:,.3f}".rstrip("0").rstrip(".")
|
||
except Exception:
|
||
qty_display = qty
|
||
attrs_display = _format_line_custom_attributes_for_pdf(info)
|
||
lc_attrs = info.get("line_custom_attributes") if isinstance(info, dict) else None
|
||
if not isinstance(lc_attrs, dict):
|
||
lc_attrs = {}
|
||
normalized_lines.append(
|
||
enrich_line_amount_fields(
|
||
{
|
||
"product_code": pl.get("product_code"),
|
||
"product_name": pl.get("product_name"),
|
||
"description": pl.get("description"),
|
||
"quantity": qty,
|
||
"quantity_display": qty_display,
|
||
"unit_display": _invoice_line_unit_display_for_pdf(pl if isinstance(pl, dict) else {}),
|
||
"unit_price": unit_price,
|
||
"discount": line_discount,
|
||
"discount_type": discount_type,
|
||
"discount_value": discount_value,
|
||
"tax_amount": tax_amount,
|
||
"line_total": line_total,
|
||
"line_custom_attributes": lc_attrs,
|
||
"attributes_display": attrs_display,
|
||
},
|
||
is_fa=is_fa,
|
||
)
|
||
)
|
||
except Exception:
|
||
normalized_lines = []
|
||
|
||
# جمع مبالغ فاکتور از totals یا محاسبه مجدد
|
||
totals = (extra.get("totals") or {}) if isinstance(extra, dict) else {}
|
||
subtotal = totals.get("gross")
|
||
discount_total = totals.get("discount")
|
||
tax_total = totals.get("tax")
|
||
payable_total = totals.get("net")
|
||
|
||
try:
|
||
if subtotal is None or discount_total is None or tax_total is None or payable_total is None:
|
||
gross = 0.0
|
||
discount_sum = 0.0
|
||
tax_sum = 0.0
|
||
net_sum = 0.0
|
||
for ln in normalized_lines:
|
||
try:
|
||
qf = float(ln.get("quantity") or 0)
|
||
upf = float(ln.get("unit_price") or 0)
|
||
discf = float(ln.get("discount") or 0)
|
||
taxf = float(ln.get("tax_amount") or 0)
|
||
line_total = ln.get("line_total")
|
||
if line_total is None:
|
||
line_total = (qf * upf) - discf + taxf
|
||
gross += qf * upf
|
||
discount_sum += discf
|
||
tax_sum += taxf
|
||
net_sum += float(line_total)
|
||
except Exception:
|
||
continue
|
||
if subtotal is None:
|
||
subtotal = gross
|
||
if discount_total is None:
|
||
discount_total = discount_sum
|
||
if tax_total is None:
|
||
tax_total = tax_sum
|
||
if payable_total is None:
|
||
payable_total = net_sum
|
||
except Exception:
|
||
pass
|
||
|
||
# تشخیص وجود تخفیف/مالیات در سطح سطرها برای نمایش ستونهای جداگانه
|
||
has_line_discount = False
|
||
has_line_tax = False
|
||
try:
|
||
for ln in normalized_lines:
|
||
try:
|
||
if float(ln.get("discount") or 0) != 0:
|
||
has_line_discount = True
|
||
if float(ln.get("tax_amount") or 0) != 0:
|
||
has_line_tax = True
|
||
if has_line_discount and has_line_tax:
|
||
break
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
has_line_discount = False
|
||
has_line_tax = False
|
||
|
||
# مبالغ تکمیلی: قبل از تخفیف و مالیات، و بدون مالیات
|
||
amount_before_discount_and_tax = subtotal
|
||
amount_without_tax = None
|
||
try:
|
||
base = float(subtotal or 0)
|
||
disc = float(discount_total or 0)
|
||
taxv = float(tax_total or 0)
|
||
# مبلغ بدون مالیات = مبلغ بعد از تخفیف و قبل از مالیات
|
||
amount_without_tax = base - disc
|
||
except Exception:
|
||
try:
|
||
if payable_total is not None and tax_total is not None:
|
||
amount_without_tax = float(payable_total) - float(tax_total or 0)
|
||
except Exception:
|
||
amount_without_tax = None
|
||
|
||
# محاسبه وضعیت حساب مشتری (فقط برای فاکتورهای دارای person_id و با همان ارز فاکتور)
|
||
customer_balance_info: Dict[str, Any] = {}
|
||
try:
|
||
if person_id is not None:
|
||
# محاسبه تراز فعلی مشتری (فقط اسناد قطعی و با همان ارز فاکتور)
|
||
invoice_currency_id = item.get("currency_id")
|
||
if invoice_currency_id:
|
||
# محاسبه تراز با فیلتر ارز
|
||
query = db.query(
|
||
func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit'),
|
||
func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit')
|
||
).join(
|
||
Document, DocumentLine.document_id == Document.id
|
||
).filter(
|
||
DocumentLine.person_id == int(person_id),
|
||
Document.is_proforma == False, # فقط اسناد قطعی
|
||
Document.currency_id == int(invoice_currency_id) # فقط همان ارز فاکتور
|
||
)
|
||
result = query.first()
|
||
if result is not None:
|
||
total_credit = float(result.total_credit or 0)
|
||
total_debit = float(result.total_debit or 0)
|
||
current_balance = total_credit - total_debit
|
||
if total_credit == 0 and total_debit == 0:
|
||
current_status = "بدون تراکنش"
|
||
elif current_balance > 0:
|
||
current_status = "بستانکار"
|
||
elif current_balance < 0:
|
||
current_status = "بدهکار"
|
||
else:
|
||
current_status = "بالانس"
|
||
else:
|
||
current_balance = 0.0
|
||
current_status = "بدون تراکنش"
|
||
else:
|
||
# اگر ارز فاکتور مشخص نبود، از تابع قبلی استفاده میکنیم
|
||
current_balance, current_status = calculate_person_balance(db, int(person_id))
|
||
|
||
# اگر فاکتور پیشفاکتور است، تراز احتمالی بعد از قطعی شدن را محاسبه میکنیم
|
||
if is_proforma:
|
||
# محاسبه تاثیر این فاکتور بر تراز
|
||
# برای فاکتور فروش: بدهکار میشود (debit)
|
||
# برای فاکتور برگشت از فروش: بستانکار میشود (credit)
|
||
# برای فاکتور خرید: بستانکار میشود (credit)
|
||
# برای فاکتور برگشت از خرید: بدهکار میشود (debit)
|
||
invoice_impact = 0.0
|
||
if inv_type in ("invoice_sales", "invoice_purchase_return"):
|
||
# بدهکار میشود
|
||
invoice_impact = -float(payable_total or 0)
|
||
elif inv_type in ("invoice_sales_return", "invoice_purchase"):
|
||
# بستانکار میشود
|
||
invoice_impact = float(payable_total or 0)
|
||
|
||
potential_balance = current_balance + invoice_impact
|
||
|
||
# تعیین وضعیت احتمالی
|
||
if potential_balance > 0:
|
||
potential_status = "بستانکار" if is_fa else "Creditor"
|
||
elif potential_balance < 0:
|
||
potential_status = "بدهکار" if is_fa else "Debtor"
|
||
else:
|
||
potential_status = "بالانس" if is_fa else "Balanced"
|
||
|
||
customer_balance_info = {
|
||
"current_balance": current_balance,
|
||
"current_status": current_status,
|
||
"potential_balance": potential_balance,
|
||
"potential_status": potential_status,
|
||
"invoice_impact": invoice_impact,
|
||
}
|
||
else:
|
||
# فاکتور قطعی است، تراز فعلی شامل این فاکتور است
|
||
customer_balance_info = {
|
||
"current_balance": current_balance,
|
||
"current_status": current_status,
|
||
}
|
||
except Exception:
|
||
logger.exception("Error calculating customer balance for invoice_id=%s", invoice_id)
|
||
customer_balance_info = {}
|
||
|
||
if not bool(print_settings.get("show_customer_balance", True)):
|
||
customer_balance_info = {}
|
||
|
||
# تراکنشهای پرداخت مرتبط با فاکتور (رسید/پرداختها)
|
||
payments: list[dict[str, Any]] = []
|
||
try:
|
||
show_payments = bool(print_settings.get("show_payments", True))
|
||
logger.info(
|
||
"Invoice PDF payments: show_payments=%s for invoice_id=%s", show_payments, invoice_id
|
||
)
|
||
if show_payments:
|
||
links = (extra.get("links") or {}) if isinstance(extra, dict) else {}
|
||
receipt_payment_ids = links.get("receipt_payment_document_ids") or []
|
||
logger.info(
|
||
"Invoice PDF payments: receipt_payment_document_ids=%s for invoice_id=%s",
|
||
receipt_payment_ids,
|
||
invoice_id,
|
||
)
|
||
for rid in receipt_payment_ids:
|
||
try:
|
||
rp = get_receipt_payment(db, int(rid))
|
||
except Exception:
|
||
rp = None
|
||
if not rp:
|
||
continue
|
||
# تاریخ پرداخت با تقویم کاربر
|
||
pay_date_raw = rp.get("document_date")
|
||
pay_date_display = None
|
||
try:
|
||
if pay_date_raw:
|
||
dt = datetime.datetime.fromisoformat(str(pay_date_raw).replace("Z", "+00:00"))
|
||
if calendar_type == "jalali":
|
||
fd = CalendarConverter.format_datetime(dt, "jalali")
|
||
pay_date_display = fd.get("date_only") or fd.get("formatted", "")
|
||
else:
|
||
fd = CalendarConverter.format_datetime(dt, "gregorian")
|
||
pay_date_display = fd.get("date_only") or fd.get("formatted", "")
|
||
except Exception:
|
||
pay_date_display = str(pay_date_raw) if pay_date_raw is not None else None
|
||
# استخراج اطلاعات کامل از account_lines (نوع پرداخت، نام حساب، توضیحات)
|
||
account_details: list[dict[str, Any]] = []
|
||
methods: list[str] = []
|
||
|
||
for ln in (rp.get("account_lines") or []):
|
||
ttype = (ln.get("transaction_type") or "").strip().lower()
|
||
if not ttype:
|
||
continue
|
||
|
||
# نام نوع پرداخت
|
||
if ttype == "bank":
|
||
method_label = "بانک" if is_fa else "Bank"
|
||
elif ttype == "cash_register":
|
||
method_label = "صندوق" if is_fa else "Cash"
|
||
elif ttype == "petty_cash":
|
||
method_label = "تنخواه" if is_fa else "Petty cash"
|
||
elif ttype == "check":
|
||
method_label = "چک" if is_fa else "Check"
|
||
elif ttype == "wallet":
|
||
method_label = "کیفپول" if is_fa else "Wallet"
|
||
elif ttype == "person":
|
||
method_label = "شخص" if is_fa else "Person"
|
||
else:
|
||
method_label = ttype
|
||
|
||
if method_label not in methods:
|
||
methods.append(method_label)
|
||
|
||
# استخراج نام حساب (بانک/صندوق/تنخواه)
|
||
account_name = ln.get("account_name") or ""
|
||
bank_name = ln.get("bank_name") or ""
|
||
cash_register_name = ln.get("cash_register_name") or ""
|
||
petty_cash_name = ln.get("petty_cash_name") or ""
|
||
check_number = ln.get("check_number") or ""
|
||
description = ln.get("description") or ""
|
||
|
||
# تعیین نام نمایشی
|
||
display_name = account_name
|
||
if ttype == "bank" and bank_name:
|
||
display_name = bank_name
|
||
elif ttype == "cash_register" and cash_register_name:
|
||
display_name = cash_register_name
|
||
elif ttype == "petty_cash" and petty_cash_name:
|
||
display_name = petty_cash_name
|
||
elif ttype == "check" and check_number:
|
||
display_name = f"چک {check_number}" if is_fa else f"Check {check_number}"
|
||
|
||
account_details.append({
|
||
"transaction_type": ttype,
|
||
"method_label": method_label,
|
||
"display_name": display_name,
|
||
"amount": ln.get("amount", 0),
|
||
"description": description,
|
||
})
|
||
|
||
# اطلاعات تسویه بینارزی از person_lines / extra_info سند
|
||
fx_display = None
|
||
try:
|
||
fx_src = None
|
||
for pl in (rp.get("person_lines") or []):
|
||
if not isinstance(pl, dict):
|
||
continue
|
||
if pl.get("fx_settlement"):
|
||
fx_src = pl.get("fx_settlement")
|
||
break
|
||
extra_pl = pl.get("extra_info")
|
||
if isinstance(extra_pl, dict) and extra_pl.get("fx_settlement"):
|
||
fx_src = extra_pl.get("fx_settlement")
|
||
break
|
||
doc_extra = rp.get("extra_info") if isinstance(rp.get("extra_info"), dict) else {}
|
||
if fx_src is None and isinstance(doc_extra, dict):
|
||
if isinstance(doc_extra.get("fx_settlement"), dict):
|
||
fx_src = doc_extra.get("fx_settlement")
|
||
elif isinstance(doc_extra.get("settlements"), list) and doc_extra["settlements"]:
|
||
first_st = doc_extra["settlements"][0]
|
||
if isinstance(first_st, dict) and first_st.get("fx_settlement"):
|
||
fx_src = first_st.get("fx_settlement")
|
||
if isinstance(fx_src, dict):
|
||
settles_amount = fx_src.get("settles_amount")
|
||
payment_amount = fx_src.get("payment_amount") or rp.get("total_amount")
|
||
rate = fx_src.get("rate") or fx_src.get("tx_rate")
|
||
settles_cur_id = fx_src.get("settles_currency_id")
|
||
pay_cur_id = fx_src.get("payment_currency_id")
|
||
base_cur_id = fx_src.get("base_currency_id")
|
||
from adapters.db.models.currency import Currency as _Cur
|
||
|
||
def _cur_label(cid):
|
||
if not cid:
|
||
return ""
|
||
c = db.query(_Cur).filter(_Cur.id == int(cid)).first()
|
||
if not c:
|
||
return ""
|
||
return (c.symbol or c.code or "") or ""
|
||
|
||
settles_unit = _cur_label(settles_cur_id) or (item.get("currency_code") if isinstance(item, dict) else "") or ""
|
||
pay_unit = _cur_label(pay_cur_id)
|
||
base_unit = _cur_label(base_cur_id) or "ریال"
|
||
fx_display = {
|
||
"settles_amount": settles_amount,
|
||
"settles_unit": settles_unit,
|
||
"payment_amount": payment_amount,
|
||
"payment_unit": pay_unit or base_unit,
|
||
"rate": rate,
|
||
"base_unit": base_unit,
|
||
}
|
||
except Exception:
|
||
fx_display = None
|
||
|
||
payments.append(
|
||
{
|
||
"id": rp.get("id"),
|
||
"code": rp.get("code"),
|
||
"document_type": rp.get("document_type"),
|
||
"document_type_name": rp.get("document_type_name"),
|
||
"date": pay_date_display,
|
||
"total_amount": rp.get("total_amount"),
|
||
"methods": ", ".join(methods),
|
||
"account_details": account_details,
|
||
"description": rp.get("description") or "",
|
||
"fx_display": fx_display,
|
||
}
|
||
)
|
||
logger.info(
|
||
"Invoice PDF payments: built %d payment rows for invoice_id=%s",
|
||
len(payments),
|
||
invoice_id,
|
||
)
|
||
except Exception:
|
||
logger.exception(
|
||
"Invoice PDF payments: error while building payments list for invoice_id=%s",
|
||
invoice_id,
|
||
)
|
||
payments = []
|
||
|
||
# طرح اقساط (در صورت وجود)
|
||
installment_plan: dict[str, Any] | None = None
|
||
try:
|
||
if print_settings.get("show_installment_plan", True):
|
||
extra_info = item.get("extra_info") or {}
|
||
if isinstance(extra_info, dict) and isinstance(extra_info.get("installment_plan"), dict):
|
||
# از سرویس نصب اقساط برای غنیسازی برنامه استفاده میکنیم
|
||
try:
|
||
plan_view = get_invoice_installment_plan(db=db, business_id=business_id, invoice_id=invoice_id)
|
||
except Exception:
|
||
plan_view = None
|
||
if isinstance(plan_view, dict) and isinstance(plan_view.get("plan"), dict):
|
||
plan = dict(plan_view["plan"])
|
||
schedule = []
|
||
for it in plan.get("schedule") or []:
|
||
due_raw = it.get("due_date")
|
||
due_display = None
|
||
try:
|
||
if due_raw:
|
||
dt = datetime.datetime.fromisoformat(str(due_raw).replace("Z", "+00:00"))
|
||
if calendar_type == "jalali":
|
||
fd = CalendarConverter.format_datetime(dt, "jalali")
|
||
due_display = fd.get("date_only") or fd.get("formatted", "")
|
||
else:
|
||
fd = CalendarConverter.format_datetime(dt, "gregorian")
|
||
due_display = fd.get("date_only") or fd.get("formatted", "")
|
||
except Exception:
|
||
due_display = str(due_raw) if due_raw is not None else None
|
||
new_it = dict(it)
|
||
new_it["due_date_display"] = due_display
|
||
schedule.append(new_it)
|
||
plan["schedule"] = schedule
|
||
installment_plan = {
|
||
"meta": {
|
||
"invoice_code": plan_view.get("invoice_code"),
|
||
"person_id": plan_view.get("person_id"),
|
||
},
|
||
"data": plan,
|
||
}
|
||
except Exception:
|
||
installment_plan = None
|
||
|
||
# اضافات و کسورات فاکتور (نمایش ضمیمه در PDF)
|
||
invoice_adjustments_rows: List[Dict[str, Any]] = []
|
||
adjustments_net_signed = 0.0
|
||
adjustments_tax_signed = 0.0
|
||
adjustments_additions_total = 0.0
|
||
adjustments_deductions_total = 0.0
|
||
final_payable_total = payable_total
|
||
try:
|
||
raw_adjustments = (
|
||
extra.get("invoice_adjustments") if isinstance(extra, dict) else None
|
||
)
|
||
if isinstance(raw_adjustments, list) and raw_adjustments:
|
||
acc_ids: List[int] = []
|
||
for row in raw_adjustments:
|
||
if isinstance(row, dict) and row.get("account_id") is not None:
|
||
try:
|
||
acc_ids.append(int(row["account_id"]))
|
||
except Exception:
|
||
continue
|
||
acc_name_map: Dict[int, str] = {}
|
||
acc_code_map: Dict[int, Optional[str]] = {}
|
||
if acc_ids:
|
||
try:
|
||
for acc in (
|
||
db.query(Account)
|
||
.filter(Account.id.in_(set(acc_ids)))
|
||
.all()
|
||
):
|
||
acc_name_map[int(acc.id)] = acc.name or ""
|
||
acc_code_map[int(acc.id)] = getattr(acc, "code", None)
|
||
except Exception:
|
||
logger.exception(
|
||
"Invoice PDF: failed to load adjustment account names"
|
||
)
|
||
|
||
for row in raw_adjustments:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
kind = str(row.get("kind") or "").strip().lower()
|
||
if kind not in ("addition", "deduction"):
|
||
continue
|
||
try:
|
||
amt = float(row.get("amount") or 0)
|
||
except Exception:
|
||
amt = 0.0
|
||
try:
|
||
tr = float(row.get("tax_rate") or 0)
|
||
except Exception:
|
||
tr = 0.0
|
||
try:
|
||
tax_amt = float(row.get("tax_amount") or 0)
|
||
except Exception:
|
||
tax_amt = 0.0
|
||
try:
|
||
total = float(
|
||
row.get("total") if row.get("total") is not None else (amt + tax_amt)
|
||
)
|
||
except Exception:
|
||
total = amt + tax_amt
|
||
acc_id_int: Optional[int] = None
|
||
try:
|
||
if row.get("account_id") is not None:
|
||
acc_id_int = int(row["account_id"])
|
||
except Exception:
|
||
acc_id_int = None
|
||
acc_name = acc_name_map.get(acc_id_int) if acc_id_int is not None else None
|
||
acc_code = acc_code_map.get(acc_id_int) if acc_id_int is not None else None
|
||
kind_label = (
|
||
("اضافه" if is_fa else "Addition")
|
||
if kind == "addition"
|
||
else ("کسر" if is_fa else "Deduction")
|
||
)
|
||
sign = 1 if kind == "addition" else -1
|
||
adjustments_net_signed += amt * sign
|
||
adjustments_tax_signed += tax_amt * sign
|
||
if kind == "addition":
|
||
adjustments_additions_total += total
|
||
else:
|
||
adjustments_deductions_total += total
|
||
invoice_adjustments_rows.append(
|
||
{
|
||
"kind": kind,
|
||
"kind_label": kind_label,
|
||
"amount": amt,
|
||
"tax_rate": tr,
|
||
"tax_amount": tax_amt,
|
||
"total": total,
|
||
"signed_amount": amt * sign,
|
||
"signed_tax": tax_amt * sign,
|
||
"signed_total": total * sign,
|
||
"account_id": acc_id_int,
|
||
"account_name": acc_name,
|
||
"account_code": acc_code,
|
||
"description": (row.get("description") or "").strip()
|
||
if isinstance(row.get("description"), str)
|
||
else (row.get("description") or ""),
|
||
}
|
||
)
|
||
else:
|
||
try:
|
||
adjustments_net_signed = float(
|
||
(totals or {}).get("adjustments_net", 0) or 0
|
||
)
|
||
adjustments_tax_signed = float(
|
||
(totals or {}).get("adjustments_tax", 0) or 0
|
||
)
|
||
except Exception:
|
||
adjustments_net_signed = 0.0
|
||
adjustments_tax_signed = 0.0
|
||
|
||
adjustments_net_signed = round(adjustments_net_signed, 2)
|
||
adjustments_tax_signed = round(adjustments_tax_signed, 2)
|
||
adjustments_additions_total = round(adjustments_additions_total, 2)
|
||
adjustments_deductions_total = round(adjustments_deductions_total, 2)
|
||
try:
|
||
final_payable_total = round(
|
||
float(payable_total or 0)
|
||
+ adjustments_net_signed
|
||
+ adjustments_tax_signed,
|
||
2,
|
||
)
|
||
except Exception:
|
||
final_payable_total = payable_total
|
||
except Exception:
|
||
logger.exception(
|
||
"Invoice PDF: error building adjustments rows for invoice_id=%s",
|
||
invoice_id,
|
||
)
|
||
invoice_adjustments_rows = []
|
||
adjustments_net_signed = 0.0
|
||
adjustments_tax_signed = 0.0
|
||
adjustments_additions_total = 0.0
|
||
adjustments_deductions_total = 0.0
|
||
final_payable_total = payable_total
|
||
|
||
# غنیسازی دیکشنری فاکتور برای استفاده راحتتر در قالب و لیستها
|
||
item["title"] = item.get("title") or ("فاکتور" if is_fa else "Invoice")
|
||
item["issue_date"] = invoice_date_display
|
||
item["invoice_type_name"] = invoice_type_name
|
||
item["is_proforma"] = is_proforma
|
||
item["subtotal"] = subtotal
|
||
item["discount_total"] = discount_total
|
||
item["tax_total"] = tax_total
|
||
item["payable_total"] = payable_total
|
||
item["amount_before_discount_and_tax"] = amount_before_discount_and_tax
|
||
item["amount_without_tax"] = amount_without_tax
|
||
item["invoice_adjustments"] = invoice_adjustments_rows
|
||
item["adjustments_net"] = adjustments_net_signed
|
||
item["adjustments_tax"] = adjustments_tax_signed
|
||
item["adjustments_additions_total"] = adjustments_additions_total
|
||
item["adjustments_deductions_total"] = adjustments_deductions_total
|
||
item["final_payable_total"] = final_payable_total
|
||
# فلگ فروش اقساطی: اگر طرح اقساط روی سند وجود داشته باشد
|
||
try:
|
||
extra_info_for_flag = item.get("extra_info") or {}
|
||
item["is_installment_sale"] = bool(
|
||
isinstance(extra_info_for_flag, dict)
|
||
and isinstance(extra_info_for_flag.get("installment_plan"), dict)
|
||
)
|
||
except Exception:
|
||
item["is_installment_sale"] = False
|
||
|
||
tax_discount_display_flags = build_invoice_tax_discount_display_flags(
|
||
print_settings,
|
||
has_line_discount=has_line_discount,
|
||
has_line_tax=has_line_tax,
|
||
discount_total=discount_total,
|
||
tax_total=tax_total,
|
||
amount_without_tax=amount_without_tax,
|
||
subtotal=subtotal,
|
||
)
|
||
|
||
# جمع تخفیف سطری (بدون تخفیف کلی) برای تفکیک در خلاصه مالی پرینت
|
||
line_discount_total = 0.0
|
||
try:
|
||
for ln in normalized_lines:
|
||
line_discount_total += float(ln.get("discount") or 0)
|
||
except Exception:
|
||
line_discount_total = 0.0
|
||
|
||
global_discount_print = build_global_discount_print_info(
|
||
extra if isinstance(extra, dict) else {},
|
||
line_discount_total=line_discount_total,
|
||
is_fa=is_fa,
|
||
)
|
||
item["line_discount_total"] = global_discount_print["line_discount_total"]
|
||
item["global_discount_amount"] = global_discount_print["global_discount_amount"]
|
||
item["global_discount_type"] = global_discount_print["global_discount_type"]
|
||
item["global_discount_value"] = global_discount_print["global_discount_value"]
|
||
item["global_discount_display"] = global_discount_print["global_discount_display"]
|
||
item["show_discount_breakdown"] = global_discount_print["show_discount_breakdown"]
|
||
item["discount_summary_label"] = global_discount_print["discount_summary_label"]
|
||
item["discount_line_label"] = global_discount_print["discount_line_label"]
|
||
item["discount_global_label"] = global_discount_print["discount_global_label"]
|
||
item["discount_summary_display"] = global_discount_print["discount_summary_display"]
|
||
item["has_global_discount"] = global_discount_print["has_global_discount"]
|
||
|
||
# نام کاربر صادرکننده فاکتور
|
||
issuer_name: Optional[str] = None
|
||
try:
|
||
issuer = db.query(User).filter(User.id == doc.created_by_user_id).first()
|
||
if issuer is not None:
|
||
first = getattr(issuer, "first_name", None) or ""
|
||
last = getattr(issuer, "last_name", None) or ""
|
||
full = (f"{first} {last}").strip()
|
||
issuer_name = full or (issuer.email or issuer.mobile or str(issuer.id))
|
||
except Exception:
|
||
issuer_name = None
|
||
|
||
# آدرس/داده فونت فارسی برای PDF (در صورت وجود و زبان فارسی)
|
||
fa_font_url_regular: Optional[str] = None
|
||
fa_font_url_bold: Optional[str] = None
|
||
try:
|
||
if is_fa:
|
||
project_root = Path(__file__).resolve().parents[4]
|
||
fonts_dir = project_root / "hesabixUI" / "hesabix_ui" / "assets" / "fonts"
|
||
regular_path = fonts_dir / "YekanBakhFaNum-Regular.ttf"
|
||
bold_path = fonts_dir / "YekanBakhFaNum-Bold.ttf"
|
||
logger.info("PDF Font detection: fonts_dir=%s", fonts_dir)
|
||
if regular_path.is_file():
|
||
logger.info("PDF Font detection: Regular font found at %s", regular_path)
|
||
import base64 as _b64
|
||
_data = regular_path.read_bytes()
|
||
_b64_data = _b64.b64encode(_data).decode("ascii")
|
||
fa_font_url_regular = f"data:font/ttf;base64,{_b64_data}"
|
||
else:
|
||
logger.warning("PDF Font detection: Regular font NOT found at %s", regular_path)
|
||
if bold_path.is_file():
|
||
logger.info("PDF Font detection: Bold font found at %s", bold_path)
|
||
import base64 as _b64b
|
||
_data_b = bold_path.read_bytes()
|
||
_b64_data_b = _b64b.b64encode(_data_b).decode("ascii")
|
||
fa_font_url_bold = f"data:font/ttf;base64,{_b64_data_b}"
|
||
else:
|
||
logger.warning("PDF Font detection: Bold font NOT found at %s", bold_path)
|
||
except Exception:
|
||
logger.exception("PDF Font detection: error while loading YekanBakhFaNum fonts")
|
||
fa_font_url_regular = None
|
||
fa_font_url_bold = None
|
||
|
||
invoice_verify_qr_data_uri: Optional[str] = None
|
||
show_invoice_verify_qr = bool(print_settings.get("show_share_qr", False))
|
||
if show_invoice_verify_qr:
|
||
try:
|
||
_link = get_or_create_link_for_print(
|
||
db,
|
||
business_id=business_id,
|
||
document_id=invoice_id,
|
||
user_id=ctx.get_user_id(),
|
||
)
|
||
_verify_url = build_invoice_share_i_url(
|
||
_link.code,
|
||
str(request.base_url),
|
||
db=db,
|
||
)
|
||
_qr_uri = _invoice_verify_qr_data_uri(_verify_url)
|
||
if _qr_uri:
|
||
invoice_verify_qr_data_uri = _qr_uri
|
||
else:
|
||
show_invoice_verify_qr = False
|
||
except Exception:
|
||
logger.exception("Invoice PDF: verify QR failed")
|
||
show_invoice_verify_qr = False
|
||
invoice_verify_qr_data_uri = None
|
||
|
||
# کانتکست قالب
|
||
from app.services.print_stamp_scale import invoice_stamp_signature_sizes
|
||
|
||
_stamp_sig_sizes = invoice_stamp_signature_sizes(
|
||
print_settings.get("stamp_scale_percent", 100),
|
||
print_settings.get("signature_scale_percent", 100),
|
||
)
|
||
template_context = {
|
||
"business_id": business_id,
|
||
"business_name": business_name,
|
||
"business": business_info,
|
||
"business_logo_data_uri": business_logo_data_uri,
|
||
"business_stamp_data_uri": business_stamp_data_uri,
|
||
"owner_signature_data_uri": owner_signature_data_uri,
|
||
"invoice": item,
|
||
"lines": normalized_lines,
|
||
"buyer": buyer_info,
|
||
"seller": seller_info,
|
||
"has_line_discount": tax_discount_display_flags["has_line_discount"],
|
||
"has_line_tax": tax_discount_display_flags["has_line_tax"],
|
||
"show_line_discount_column": tax_discount_display_flags["show_line_discount_column"],
|
||
"show_line_tax_column": tax_discount_display_flags["show_line_tax_column"],
|
||
"show_line_amount_before_discount_column": tax_discount_display_flags[
|
||
"show_line_amount_before_discount_column"
|
||
],
|
||
"show_line_amount_before_tax_column": tax_discount_display_flags[
|
||
"show_line_amount_before_tax_column"
|
||
],
|
||
"show_summary_discount": tax_discount_display_flags["show_summary_discount"],
|
||
"show_summary_tax": tax_discount_display_flags["show_summary_tax"],
|
||
"show_summary_amount_without_tax": tax_discount_display_flags[
|
||
"show_summary_amount_without_tax"
|
||
],
|
||
"payments": payments,
|
||
"installment_plan": installment_plan,
|
||
"invoice_date_jalali": invoice_date_jalali,
|
||
"invoice_date_gregorian": invoice_date_gregorian,
|
||
"generated_at": None, # پایینتر با timezone کسبوکار پر میشود
|
||
"is_fa": is_fa,
|
||
"issuer_name": issuer_name,
|
||
"fa_font_url_regular": fa_font_url_regular,
|
||
"fa_font_url_bold": fa_font_url_bold,
|
||
"invoice_footer_note": invoice_footer_note,
|
||
"customer_balance_info": customer_balance_info,
|
||
"show_invoice_verify_qr": show_invoice_verify_qr,
|
||
"invoice_verify_qr_data_uri": invoice_verify_qr_data_uri,
|
||
"show_footer_print_time": bool(print_settings.get("show_footer_print_time", True)),
|
||
"show_footer_preparer": bool(print_settings.get("show_footer_preparer", True)),
|
||
"show_seller_signature_area": bool(
|
||
print_settings.get("show_seller_signature_area", True)
|
||
),
|
||
"show_buyer_signature_area": bool(
|
||
print_settings.get("show_buyer_signature_area", True)
|
||
),
|
||
"stamp_scale_percent": _stamp_sig_sizes["stamp_scale_percent"],
|
||
"signature_scale_percent": _stamp_sig_sizes["signature_scale_percent"],
|
||
"stamp_max_width_px": _stamp_sig_sizes["stamp_max_width_px"],
|
||
"signature_max_width_px": _stamp_sig_sizes["signature_max_width_px"],
|
||
"is_multi_currency": False,
|
||
"base_currency": item.get("base_currency") if isinstance(item.get("base_currency"), dict) else {},
|
||
}
|
||
try:
|
||
from app.services.fx_rate_provider_service import business_is_multi_currency as _biz_mc
|
||
|
||
template_context["is_multi_currency"] = bool(_biz_mc(db, int(business_id)))
|
||
except Exception:
|
||
fx_t = item.get("fx_totals") if isinstance(item.get("fx_totals"), dict) else {}
|
||
template_context["is_multi_currency"] = bool(fx_t.get("show_dual"))
|
||
|
||
# پارامترهای صفحه از کوئری (اختیاری) — قبل از رزولوشن قالب، چون فیش scope جدا دارد
|
||
show_stamp_override = None
|
||
try:
|
||
qp = request.query_params
|
||
paper_size = qp.get("paper_size")
|
||
orientation = qp.get("orientation")
|
||
disposition = qp.get("disposition") or "attachment"
|
||
show_stamp_override = qp.get("show_stamp")
|
||
except Exception:
|
||
paper_size = None
|
||
orientation = None
|
||
disposition = "attachment"
|
||
show_stamp_override = None
|
||
|
||
from app.services.pdf.page_size import (
|
||
RECEIPT_SUBTYPE,
|
||
build_page_size_css,
|
||
is_receipt_paper,
|
||
)
|
||
|
||
explicit_template_id = None
|
||
try:
|
||
if template_id is not None:
|
||
explicit_template_id = int(template_id)
|
||
except Exception:
|
||
explicit_template_id = None
|
||
|
||
receipt_mode = is_receipt_paper(paper_size)
|
||
if not receipt_mode and explicit_template_id is not None:
|
||
try:
|
||
from app.services.report_template_service import ReportTemplateService as _RTS
|
||
|
||
_tpl = _RTS.get_template(db, explicit_template_id, business_id)
|
||
if _tpl and _tpl.module_key == "invoices" and (_tpl.subtype or "") == RECEIPT_SUBTYPE:
|
||
receipt_mode = True
|
||
if not (paper_size or "").strip():
|
||
paper_size = (_tpl.paper_size or "80mm")
|
||
except Exception:
|
||
pass
|
||
|
||
if receipt_mode:
|
||
orientation = "portrait"
|
||
if not (paper_size or "").strip():
|
||
paper_size = "80mm"
|
||
elif not orientation:
|
||
# حالت پیشفرض صفحه برای فاکتور A4: افقی
|
||
orientation = "landscape"
|
||
|
||
invoice_template_subtype = RECEIPT_SUBTYPE if receipt_mode else "detail"
|
||
|
||
# متن فوتر با زمان چاپ (timezone کسبوکار + تقویم کاربر) و نام تهیهکنندهٔ سند
|
||
try:
|
||
from app.core.datetime_utils import (
|
||
business_wall_clock_now,
|
||
export_filename_timestamp,
|
||
format_generated_at_for_pdf,
|
||
)
|
||
|
||
generated_at_now = business_wall_clock_now(business_id)
|
||
template_context["generated_at"] = generated_at_now
|
||
|
||
footer_text = ""
|
||
show_ft = bool(print_settings.get("show_footer_print_time", True))
|
||
show_prep = bool(print_settings.get("show_footer_preparer", True))
|
||
parts: List[str] = []
|
||
if show_ft:
|
||
printed_at_str = format_generated_at_for_pdf(business_id, calendar_type)
|
||
if printed_at_str:
|
||
footer_label = "زمان چاپ" if is_fa else "Printed at"
|
||
parts.append(f"{footer_label}: {printed_at_str}")
|
||
if show_prep and issuer_name:
|
||
preparer_label = "تهیهکننده" if is_fa else "Prepared by"
|
||
parts.append(f"{preparer_label}: {issuer_name}")
|
||
footer_text = " | ".join(parts)
|
||
pdf_filename_ts = export_filename_timestamp(business_id)
|
||
except Exception:
|
||
footer_text = ""
|
||
pdf_filename_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
if template_context.get("generated_at") is None:
|
||
template_context["generated_at"] = datetime.datetime.now()
|
||
|
||
# تلاش برای رندر با قالب سفارشی (بعد از پر شدن generated_at)
|
||
resolved_html = None
|
||
try:
|
||
from app.services.report_template_service import ReportTemplateService
|
||
resolved_html = ReportTemplateService.try_render_resolved(
|
||
db=db,
|
||
business_id=business_id,
|
||
module_key="invoices",
|
||
subtype=invoice_template_subtype,
|
||
context=template_context,
|
||
explicit_template_id=explicit_template_id,
|
||
page_paper_size=paper_size,
|
||
page_orientation=orientation,
|
||
)
|
||
except Exception:
|
||
resolved_html = None
|
||
|
||
default_ctx = {
|
||
**template_context,
|
||
"title_text": item.get("title") or ("فاکتور" if is_fa else "Invoice"),
|
||
"paper_size": paper_size,
|
||
"orientation": orientation,
|
||
"page_size_css": build_page_size_css(paper_size, orientation),
|
||
"hide_page_numbers": receipt_mode,
|
||
"is_receipt_paper": receipt_mode,
|
||
"footer_text": footer_text,
|
||
}
|
||
fallback_template = "pdf/invoices/receipt.html" if receipt_mode else "pdf/invoices/detail.html"
|
||
html_content = resolved_html or render_template(fallback_template, default_ctx)
|
||
|
||
font_config = FontConfiguration()
|
||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
|
||
|
||
# نام فایل
|
||
def _slugify(text: str) -> str:
|
||
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "invoice"
|
||
filename = f"invoice_{_slugify(item.get('code'))}_{pdf_filename_ts}.pdf"
|
||
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={
|
||
"Content-Disposition": f"{disposition}; filename={filename}",
|
||
"Content-Length": str(len(pdf_bytes)),
|
||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||
},
|
||
)
|
||
|
||
|
||
def _apply_invoice_list_text_search(
|
||
q,
|
||
*,
|
||
business_id: int,
|
||
search: Optional[str],
|
||
search_fields: Optional[List[Any]] = None,
|
||
extra_info_person_id_expr=None,
|
||
):
|
||
"""
|
||
جستجوی متنی لیست فاکتورها بر اساس search_fields.
|
||
اگر search_fields ارسال نشود: کد سند، توضیح، و نام طرفحساب (شخص).
|
||
فیلد counterparty با join به جدول persons روی extra_info.person_id اعمال میشود.
|
||
"""
|
||
if not isinstance(search, str) or not search.strip():
|
||
return q
|
||
s = f"%{search.strip()}%"
|
||
if search_fields:
|
||
sf_set = {str(x).lower() for x in search_fields}
|
||
else:
|
||
sf_set = {"code", "description", "counterparty"}
|
||
|
||
parts: List[Any] = []
|
||
if "code" in sf_set:
|
||
parts.append(Document.code.ilike(s))
|
||
if "description" in sf_set:
|
||
parts.append(Document.description.ilike(s))
|
||
|
||
if "counterparty" in sf_set:
|
||
pid_expr = extra_info_person_id_expr
|
||
if pid_expr is None:
|
||
_jb = cast(Document.extra_info, JSONB)
|
||
pid_expr = cast(_jb["person_id"].astext, Integer)
|
||
q = q.outerjoin(
|
||
Person,
|
||
and_(
|
||
Person.id == pid_expr,
|
||
Person.business_id == business_id,
|
||
),
|
||
)
|
||
full_name = func.nullif(func.trim(func.concat_ws(" ", Person.first_name, Person.last_name)), "")
|
||
parts.append(
|
||
or_(
|
||
Person.alias_name.ilike(s),
|
||
Person.first_name.ilike(s),
|
||
Person.last_name.ilike(s),
|
||
Person.company_name.ilike(s),
|
||
full_name.ilike(s),
|
||
)
|
||
)
|
||
|
||
if not parts:
|
||
parts = [Document.code.ilike(s), Document.description.ilike(s)]
|
||
return q.filter(or_(*parts))
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/search",
|
||
summary="لیست و جستجوی فاکتورها",
|
||
description=(
|
||
"ترکیب استاندارد `QueryInfo` (take، skip، sort، search، filters) با فیلترهای فاکتور: "
|
||
"`document_type`, `is_proforma`, `currency_id`, `fiscal_year_id` (یا هدر `X-Fiscal-Year-ID`), "
|
||
"`project_id`, `person_id`, بازه `from_date`/`to_date`, برچسبها، فاکتور اقساطی (`is_installment_sale`) و … . "
|
||
"خروجی شامل آیتمهای غنی، مانده و وضعیت مودیان است؛ نتیجه ممکن است کش کوتاهمدت داشته باشد."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def search_invoices_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body_data: InvoiceListQuery = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
) -> Dict[str, Any]:
|
||
"""لیست فاکتورها با فیلتر، جستوجو، مرتبسازی و صفحهبندی استاندارد"""
|
||
|
||
# Locale for labels
|
||
from app.core.i18n import negotiate_locale
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == "fa"
|
||
|
||
query_info = body_data
|
||
body = model_to_legacy_body(body_data)
|
||
|
||
# کش نتایج جستجوی فاکتورها
|
||
cache = get_cache()
|
||
cache_key = None
|
||
fiscal_year_id = None
|
||
document_type = None
|
||
project_id = None
|
||
|
||
if cache.enabled:
|
||
import json, hashlib
|
||
from decimal import Decimal
|
||
from datetime import datetime, date
|
||
from adapters.api.v1.schemas import FilterItem
|
||
|
||
# Helper function to convert objects to JSON-serializable format
|
||
def to_serializable(obj):
|
||
"""Convert Pydantic models and other non-serializable types to dict/primitive types"""
|
||
if hasattr(obj, 'model_dump'): # Pydantic v2
|
||
return obj.model_dump()
|
||
elif hasattr(obj, 'dict'): # Pydantic v1
|
||
return obj.dict()
|
||
elif isinstance(obj, (datetime, date)):
|
||
return obj.isoformat()
|
||
elif isinstance(obj, Decimal):
|
||
return float(obj)
|
||
elif isinstance(obj, dict):
|
||
return {k: to_serializable(v) for k, v in obj.items()}
|
||
elif isinstance(obj, list):
|
||
return [to_serializable(item) for item in obj]
|
||
else:
|
||
return obj
|
||
|
||
# استخراج fiscal_year_id از header یا body
|
||
try:
|
||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||
if fy_header:
|
||
fiscal_year_id = int(fy_header)
|
||
except Exception:
|
||
pass
|
||
if fiscal_year_id is None:
|
||
try:
|
||
if body.get("fiscal_year_id") is not None:
|
||
fiscal_year_id = int(body.get("fiscal_year_id"))
|
||
except Exception:
|
||
pass
|
||
|
||
# استخراج document_type
|
||
document_type = body.get("document_type")
|
||
if isinstance(document_type, str) and document_type in SUPPORTED_INVOICE_TYPES:
|
||
pass # document_type از قبل تنظیم شده
|
||
else:
|
||
document_type = None
|
||
|
||
# استخراج project_id
|
||
try:
|
||
if body.get("project_id") is not None:
|
||
project_id = int(body.get("project_id"))
|
||
except Exception:
|
||
pass
|
||
|
||
# Convert filters to serializable format
|
||
serializable_filters = None
|
||
if query_info.filters:
|
||
serializable_filters = [to_serializable(f) for f in query_info.filters]
|
||
|
||
# Convert body_data to serializable format (might contain non-serializable objects)
|
||
serializable_body = to_serializable(body_data)
|
||
|
||
key_payload = {
|
||
"business_id": business_id,
|
||
"take": query_info.take,
|
||
"skip": query_info.skip,
|
||
"sort_by": query_info.sort_by,
|
||
"sort_desc": query_info.sort_desc,
|
||
"sort": to_serializable(query_info.sort) if getattr(query_info, "sort", None) else None,
|
||
"search": query_info.search,
|
||
"filters": serializable_filters,
|
||
"body": serializable_body, # شامل تمام فیلترها
|
||
}
|
||
key_str = json.dumps(key_payload, sort_keys=True, ensure_ascii=False)
|
||
key_hash = hashlib.sha256(key_str.encode("utf-8")).hexdigest()[:16]
|
||
cache_key = f"invoices_search:{business_id}:{ctx.get_user_id()}:{key_hash}"
|
||
cached = cache.get(cache_key)
|
||
if cached is not None:
|
||
return success_response(data=cached, request=request)
|
||
|
||
requested_doc_type = body.get("document_type")
|
||
allowed_doc_types = allowed_invoice_types_for_business(ctx, db, business_id, "view")
|
||
if not allowed_doc_types:
|
||
raise ApiError("FORBIDDEN", "No invoice type access", http_status=403)
|
||
if requested_doc_type and not has_invoice_type_permission_for_business(ctx, db, business_id, requested_doc_type, "view"):
|
||
raise ApiError("FORBIDDEN", f"Missing invoice type permission for {requested_doc_type}", http_status=403)
|
||
|
||
# Base query
|
||
q = db.query(Document).filter(
|
||
and_(
|
||
Document.business_id == business_id,
|
||
Document.document_type.in_(allowed_doc_types),
|
||
)
|
||
)
|
||
# extra_info نوع JSON عمومی SQLAlchemy است؛ astext فقط روی JSONB است — برای PG ابتدا cast به JSONB
|
||
_extra_info_jb = cast(Document.extra_info, JSONB)
|
||
extra_info_person_id_int = cast(_extra_info_jb["person_id"].astext, Integer)
|
||
|
||
search: Optional[str] = getattr(query_info, 'search', None)
|
||
search_fields: Optional[List[Any]] = getattr(query_info, 'search_fields', None)
|
||
q = _apply_invoice_list_text_search(
|
||
q,
|
||
business_id=business_id,
|
||
search=search,
|
||
search_fields=search_fields,
|
||
extra_info_person_id_expr=extra_info_person_id_int,
|
||
)
|
||
|
||
# Extra filters
|
||
doc_type = body.get("document_type")
|
||
if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES:
|
||
q = q.filter(Document.document_type == doc_type)
|
||
|
||
is_proforma = body.get("is_proforma")
|
||
if isinstance(is_proforma, bool):
|
||
q = q.filter(Document.is_proforma == is_proforma)
|
||
|
||
currency_id = body.get("currency_id")
|
||
try:
|
||
if currency_id is not None:
|
||
q = q.filter(Document.currency_id == int(currency_id))
|
||
except Exception:
|
||
pass
|
||
|
||
# Fiscal year from header or body
|
||
fiscal_year_id = None
|
||
try:
|
||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||
if fy_header:
|
||
fiscal_year_id = int(fy_header)
|
||
except Exception:
|
||
fiscal_year_id = None
|
||
if fiscal_year_id is None:
|
||
try:
|
||
if body.get("fiscal_year_id") is not None:
|
||
fiscal_year_id = int(body.get("fiscal_year_id"))
|
||
except Exception:
|
||
fiscal_year_id = None
|
||
if fiscal_year_id is not None:
|
||
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
|
||
|
||
# Project filter
|
||
project_id = body.get("project_id")
|
||
try:
|
||
if project_id is not None:
|
||
q = q.filter(Document.project_id == int(project_id))
|
||
except Exception:
|
||
pass
|
||
|
||
q = apply_invoice_tag_filters_to_query(q, body)
|
||
|
||
# Date range from filters or flat body
|
||
# 1) From QueryInfo.filters operators
|
||
try:
|
||
filters = getattr(query_info, 'filters', None)
|
||
except Exception:
|
||
filters = None
|
||
if filters and isinstance(filters, (list, tuple)):
|
||
for flt in filters:
|
||
try:
|
||
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
|
||
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
|
||
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
|
||
if prop == 'person_id' and val is not None:
|
||
# فیلتر بر اساس person_id در extra_info
|
||
try:
|
||
person_id_val = int(val)
|
||
# فیلتر person_id از extra_info با ->> (معادل astext روی JSONB)
|
||
q = q.filter(extra_info_person_id_int == person_id_val)
|
||
except (ValueError, TypeError, KeyError):
|
||
pass
|
||
elif prop == 'document_date' and isinstance(val, str) and val:
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
dt = _p(val)
|
||
col = getattr(Document, prop)
|
||
if op == ">=":
|
||
q = q.filter(col >= dt)
|
||
elif op == "<=":
|
||
q = q.filter(col <= dt)
|
||
except Exception:
|
||
pass
|
||
|
||
# 2) From flat body keys
|
||
if isinstance(body.get("from_date"), str):
|
||
try:
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
q = q.filter(Document.document_date >= _p(body.get("from_date")))
|
||
except Exception:
|
||
pass
|
||
if isinstance(body.get("to_date"), str):
|
||
try:
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
q = q.filter(Document.document_date <= _p(body.get("to_date")))
|
||
except Exception:
|
||
pass
|
||
|
||
# پردازش person_id از body (اگر در filters نبود)
|
||
person_id = body.get("person_id")
|
||
if person_id is not None:
|
||
try:
|
||
person_id_val = int(person_id)
|
||
# فیلتر بر اساس person_id در extra_info
|
||
q = q.filter(extra_info_person_id_int == person_id_val)
|
||
except (ValueError, TypeError, KeyError):
|
||
pass
|
||
|
||
# فیلتر فاکتورهای اقساطی (فقط اسنادی که طرح اقساط دارند)
|
||
is_installment_sale = body.get("is_installment_sale")
|
||
if is_installment_sale is True:
|
||
# فقط فاکتورهای فروش اقساطی
|
||
q = q.filter(Document.document_type == "invoice_sales")
|
||
q = q.filter(Document.extra_info["installment_plan"].isnot(None))
|
||
|
||
q = apply_invoice_search_ordering(q, query_info)
|
||
|
||
# Pagination
|
||
take = int(getattr(query_info, 'take', 20) or 20)
|
||
skip = int(getattr(query_info, 'skip', 0) or 0)
|
||
|
||
try:
|
||
total = q.count()
|
||
items = q.offset(skip).limit(take).all()
|
||
except Exception as e:
|
||
db.rollback()
|
||
logging.getLogger(__name__).warning("invoice search query failed: %s", e)
|
||
raise ApiError(
|
||
"INVALID_SEARCH_FILTER",
|
||
"پارامترهای جستجو یا دادههای فاکتورها معتبر نیستند. در صورت استفاده از فیلتر شخص، آن را حذف کنید و دوباره امتحان کنید.",
|
||
http_status=400,
|
||
)
|
||
|
||
# Helpers for display fields
|
||
def _type_name(tp: str) -> str:
|
||
mapping = {
|
||
'invoice_sales': ('فروش' if is_fa else 'Sales'),
|
||
'invoice_sales_return': ('برگشت از فروش' if is_fa else 'Sales return'),
|
||
'invoice_purchase': ('خرید' if is_fa else 'Purchase'),
|
||
'invoice_purchase_return': ('برگشت از خرید' if is_fa else 'Purchase return'),
|
||
'invoice_direct_consumption': ('مصرف مستقیم' if is_fa else 'Direct consumption'),
|
||
'invoice_production': ('تولید' if is_fa else 'Production'),
|
||
'invoice_waste': ('ضایعات' if is_fa else 'Waste'),
|
||
}
|
||
return mapping.get(str(tp), str(tp))
|
||
|
||
data_items: List[Dict[str, Any]] = []
|
||
_log = logging.getLogger(__name__)
|
||
list_dicts = invoice_documents_to_list_dicts(db, items)
|
||
remaining_by_id = batch_calculate_invoices_remaining(db, business_id, items)
|
||
for item in list_dicts:
|
||
d_id = int(item.get("id") or 0)
|
||
# Tax workspace fields from extra_info
|
||
try:
|
||
extra = item.get("extra_info") or {}
|
||
except Exception:
|
||
extra = {}
|
||
tax_workspace = bool(extra.get("tax_workspace"))
|
||
tax_status = (extra.get("tax_status") or "").strip() if isinstance(extra.get("tax_status"), str) else extra.get("tax_status")
|
||
if not tax_status:
|
||
tax_status = "in_workspace" if tax_workspace else "not_in_workspace"
|
||
item["tax_status"] = tax_status
|
||
|
||
# Installment sale flag: اگر طرح اقساط روی سند وجود داشته باشد
|
||
try:
|
||
item["is_installment_sale"] = bool(
|
||
isinstance(extra, dict) and isinstance(extra.get("installment_plan"), dict)
|
||
)
|
||
except Exception:
|
||
item["is_installment_sale"] = False
|
||
|
||
# total_amount = مبلغ قابل پرداخت (شامل اضافات/کسورات)
|
||
total_amount = None
|
||
try:
|
||
payable = payable_total_from_extra_info(item.get("extra_info"))
|
||
if payable is not None:
|
||
total_amount = float(payable)
|
||
except Exception:
|
||
total_amount = None
|
||
if total_amount is None:
|
||
remaining_result = remaining_by_id.get(d_id) or {}
|
||
total_amount = remaining_result.get("total_amount")
|
||
|
||
item['document_type_name'] = _type_name(item.get('document_type'))
|
||
if total_amount is not None:
|
||
item['total_amount'] = total_amount
|
||
|
||
# مبلغ پرداختشده و مبلغ باقیمانده فاکتور
|
||
try:
|
||
remaining_result = remaining_by_id.get(d_id) or {}
|
||
item["paid_amount"] = remaining_result.get("paid_amount")
|
||
item["remaining_amount"] = remaining_result.get("remaining")
|
||
except Exception:
|
||
item["paid_amount"] = None
|
||
item["remaining_amount"] = None
|
||
|
||
# وضعیت اقساط برای فاکتورهای اقساطی: paid | partial | pending | overdue
|
||
if item.get("is_installment_sale"):
|
||
paid = float(item.get("paid_amount") or 0)
|
||
remaining = float(item.get("remaining_amount") or 0)
|
||
total_amt = float(item.get("total_amount") or 0)
|
||
if total_amt <= 0 or remaining <= 0:
|
||
item["installment_status"] = "paid"
|
||
elif paid > 0:
|
||
item["installment_status"] = "partial"
|
||
else:
|
||
item["installment_status"] = "pending"
|
||
# remaining_total برای سازگاری با UI
|
||
item["remaining_total"] = remaining
|
||
else:
|
||
item["installment_status"] = None
|
||
item["remaining_total"] = None
|
||
|
||
data_items.append(item)
|
||
|
||
batch_add_counterparty_to_invoice_items(db, data_items)
|
||
data_items = [format_datetime_fields(item, request) for item in data_items]
|
||
|
||
attach_tags_to_invoice_items(db, business_id, data_items)
|
||
|
||
# مرتبسازی بر اساس مانده: برای فاکتورهای اقساطی اگر sort_by=remaining_amount باشد
|
||
sort_by_val = getattr(query_info, 'sort_by', None) or body.get('sort_by')
|
||
if is_installment_sale is True and sort_by_val == 'remaining_amount':
|
||
data_items.sort(
|
||
key=lambda x: float(x.get('remaining_amount') or 0),
|
||
reverse=bool(getattr(query_info, 'sort_desc', True) or body.get('sort_desc', True)),
|
||
)
|
||
|
||
# Build pagination info
|
||
page = (skip // take) + 1 if take > 0 else 1
|
||
total_pages = (total + take - 1) // take if take > 0 else 1
|
||
|
||
result = {
|
||
"items": data_items,
|
||
"total": total,
|
||
"take": take,
|
||
"skip": skip,
|
||
# Optional standard pagination shape (supported by UI model)
|
||
"pagination": {
|
||
"page": page,
|
||
"per_page": take,
|
||
"total": total,
|
||
"total_pages": total_pages,
|
||
},
|
||
# Flat shape too, for compatibility
|
||
"page": page,
|
||
"limit": take,
|
||
"total_pages": total_pages,
|
||
}
|
||
|
||
# ذخیره در cache با tag-based caching
|
||
if cache.enabled and cache_key:
|
||
cache.set_with_invoices_tag(
|
||
key=cache_key,
|
||
value=result,
|
||
business_id=business_id,
|
||
fiscal_year_id=fiscal_year_id,
|
||
document_type=document_type,
|
||
project_id=project_id,
|
||
ttl=30 # TTL کوتاه برای فاکتورها
|
||
)
|
||
|
||
return success_response(
|
||
data=result,
|
||
request=request,
|
||
message="INVOICE_LIST",
|
||
)
|
||
|
||
def _add_counterparty_to_invoice_item(db: Session, item: Dict[str, Any]) -> None:
|
||
"""افزودن فیلد counterparty به آیتم فاکتور بر اساس person_id در extra_info"""
|
||
try:
|
||
inv_type = str(item.get("document_type") or "")
|
||
extra_info = item.get("extra_info") or {}
|
||
person_id = extra_info.get("person_id")
|
||
person_name = None
|
||
if person_id is not None:
|
||
try:
|
||
p = db.query(Person).filter(Person.id == int(person_id)).first()
|
||
if p is not None:
|
||
# اولویت: display_name > name > alias_name (که اجباری است)
|
||
person_name = getattr(p, "display_name", None) or getattr(p, "name", None)
|
||
if not person_name:
|
||
# اگر display_name و name خالی باشند، از alias_name استفاده میکنیم (که اجباری است)
|
||
person_name = getattr(p, "alias_name", None)
|
||
if not person_name and (getattr(p, "first_name", None) or getattr(p, "last_name", None)):
|
||
# اگر هنوز خالی است، از first_name و last_name استفاده میکنیم
|
||
name_parts = []
|
||
if getattr(p, "first_name", None):
|
||
name_parts.append(p.first_name)
|
||
if getattr(p, "last_name", None):
|
||
name_parts.append(p.last_name)
|
||
person_name = " ".join(name_parts) if name_parts else None
|
||
if not person_name and getattr(p, "company_name", None):
|
||
# اگر هنوز خالی است، از company_name استفاده میکنیم
|
||
person_name = p.company_name
|
||
except Exception:
|
||
person_name = None
|
||
item["counterparty"] = person_name or ""
|
||
except Exception:
|
||
item["counterparty"] = ""
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/search",
|
||
summary="جستجو در کارپوشه مودیان",
|
||
description=(
|
||
"فقط اسنادی که در کارپوشه مالیاتی علامت خوردهاند؛ فیلتر `tax_status` در بدنه اختیاری است. "
|
||
"بدنه ترکیبی از `QueryInfo` و فیلدهای تخت مشابه لیست فاکتور است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def search_tax_workspace_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
query_info: QueryInfo = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("moadian", "view")),
|
||
) -> Dict[str, Any]:
|
||
"""لیست فاکتورهای موجود در کارپوشه مودیان با فیلتر و صفحهبندی."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
from app.core.i18n import negotiate_locale
|
||
|
||
# Base query: all invoice documents for business
|
||
q = db.query(Document).filter(
|
||
and_(
|
||
Document.business_id == business_id,
|
||
Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)),
|
||
)
|
||
)
|
||
|
||
# Merge flat body extras
|
||
body: Dict[str, Any] = {}
|
||
try:
|
||
body_json = await request.json()
|
||
if isinstance(body_json, dict):
|
||
body = body_json
|
||
except Exception:
|
||
body = {}
|
||
|
||
search: Optional[str] = getattr(query_info, "search", None)
|
||
search_fields: Optional[List[Any]] = getattr(query_info, "search_fields", None)
|
||
q = _apply_invoice_list_text_search(
|
||
q,
|
||
business_id=business_id,
|
||
search=search,
|
||
search_fields=search_fields,
|
||
extra_info_person_id_expr=None,
|
||
)
|
||
|
||
# Document type filter
|
||
doc_type = body.get("document_type")
|
||
if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES:
|
||
q = q.filter(Document.document_type == doc_type)
|
||
|
||
# Proforma filter
|
||
is_proforma = body.get("is_proforma")
|
||
if isinstance(is_proforma, bool):
|
||
q = q.filter(Document.is_proforma == is_proforma)
|
||
|
||
# Currency filter
|
||
currency_id = body.get("currency_id")
|
||
try:
|
||
if currency_id is not None:
|
||
q = q.filter(Document.currency_id == int(currency_id))
|
||
except Exception:
|
||
pass
|
||
|
||
# Fiscal year from header or body
|
||
fiscal_year_id = None
|
||
try:
|
||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||
if fy_header:
|
||
fiscal_year_id = int(fy_header)
|
||
except Exception:
|
||
fiscal_year_id = None
|
||
if fiscal_year_id is None:
|
||
try:
|
||
if body.get("fiscal_year_id") is not None:
|
||
fiscal_year_id = int(body.get("fiscal_year_id"))
|
||
except Exception:
|
||
fiscal_year_id = None
|
||
if fiscal_year_id is not None:
|
||
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
|
||
|
||
q = apply_invoice_tag_filters_to_query(q, body)
|
||
|
||
# Date range filters from QueryInfo.filters
|
||
try:
|
||
filters = getattr(query_info, "filters", None)
|
||
except Exception:
|
||
filters = None
|
||
if filters and isinstance(filters, (list, tuple)):
|
||
for flt in filters:
|
||
try:
|
||
prop = getattr(flt, "property", None) if not isinstance(flt, dict) else flt.get("property")
|
||
op = getattr(flt, "operator", None) if not isinstance(flt, dict) else flt.get("operator")
|
||
val = getattr(flt, "value", None) if not isinstance(flt, dict) else flt.get("value")
|
||
if prop == "document_date" and isinstance(val, str) and val:
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
|
||
dt = _p(val)
|
||
col = getattr(Document, prop)
|
||
if op == ">=":
|
||
q = q.filter(col >= dt)
|
||
elif op == "<=":
|
||
q = q.filter(col <= dt)
|
||
except Exception:
|
||
pass
|
||
|
||
# Date range from flat body
|
||
q = apply_invoice_search_ordering(q, query_info)
|
||
|
||
# Fetch all candidates and filter by workspace/tax_status in Python
|
||
all_docs: List[Document] = q.all()
|
||
requested_status = body.get("tax_status")
|
||
requested_status = requested_status.strip() if isinstance(requested_status, str) else None
|
||
|
||
workspace_docs: List[Document] = []
|
||
status_counts: Dict[str, int] = {
|
||
"not_sent": 0,
|
||
"pending": 0,
|
||
"success": 0,
|
||
"failed": 0,
|
||
"cancelled": 0,
|
||
"all": 0,
|
||
}
|
||
for d in all_docs:
|
||
extra = d.extra_info or {}
|
||
in_workspace = bool(extra.get("tax_workspace"))
|
||
if not in_workspace:
|
||
continue
|
||
status = normalize_stored_tax_status(extra)
|
||
status_counts["all"] += 1
|
||
if status in status_counts:
|
||
status_counts[status] += 1
|
||
else:
|
||
status_counts[status] = status_counts.get(status, 0) + 1
|
||
if requested_status and status != requested_status:
|
||
continue
|
||
workspace_docs.append(d)
|
||
|
||
# Pagination (after workspace filter)
|
||
take = int(getattr(query_info, "take", 20) or 20)
|
||
skip = int(getattr(query_info, "skip", 0) or 0)
|
||
total = len(workspace_docs)
|
||
page_docs = workspace_docs[skip : skip + take] if take > 0 else workspace_docs
|
||
|
||
# Locale for type names
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == "fa"
|
||
|
||
def _type_name(tp: str) -> str:
|
||
mapping = {
|
||
"invoice_sales": ("فروش" if is_fa else "Sales"),
|
||
"invoice_sales_return": ("برگشت از فروش" if is_fa else "Sales return"),
|
||
"invoice_purchase": ("خرید" if is_fa else "Purchase"),
|
||
"invoice_purchase_return": ("برگشت از خرید" if is_fa else "Purchase return"),
|
||
"invoice_direct_consumption": ("مصرف مستقیم" if is_fa else "Direct consumption"),
|
||
"invoice_production": ("تولید" if is_fa else "Production"),
|
||
"invoice_waste": ("ضایعات" if is_fa else "Waste"),
|
||
}
|
||
return mapping.get(str(tp), str(tp))
|
||
|
||
data_items: List[Dict[str, Any]] = []
|
||
list_dicts = invoice_documents_to_list_dicts(db, page_docs)
|
||
for item in list_dicts:
|
||
extra = item.get("extra_info") or {}
|
||
tax_status = normalize_stored_tax_status(extra)
|
||
item["tax_status"] = tax_status
|
||
item["tax_tracking_code"] = extra.get("tax_tracking_code")
|
||
item["tax_last_send_at"] = extra.get("tax_last_send_at")
|
||
item.update(build_tax_status_fields_for_api(extra))
|
||
|
||
# total_amount = مبلغ قابل پرداخت (شامل اضافات/کسورات)
|
||
total_amount = None
|
||
try:
|
||
payable = payable_total_from_extra_info(item.get("extra_info"))
|
||
if payable is not None:
|
||
total_amount = float(payable)
|
||
except Exception:
|
||
total_amount = None
|
||
|
||
item["document_type_name"] = _type_name(item.get("document_type"))
|
||
if total_amount is not None:
|
||
item["total_amount"] = total_amount
|
||
|
||
data_items.append(item)
|
||
|
||
batch_add_counterparty_to_invoice_items(db, data_items)
|
||
data_items = [format_datetime_fields(item, request) for item in data_items]
|
||
|
||
attach_tags_to_invoice_items(db, business_id, data_items)
|
||
|
||
page = (skip // take) + 1 if take > 0 else 1
|
||
total_pages = (total + take - 1) // take if take > 0 else 1
|
||
|
||
return success_response(
|
||
data={
|
||
"items": data_items,
|
||
"total": total,
|
||
"take": take,
|
||
"skip": skip,
|
||
"pagination": {
|
||
"page": page,
|
||
"per_page": take,
|
||
"total": total,
|
||
"total_pages": total_pages,
|
||
},
|
||
"page": page,
|
||
"limit": take,
|
||
"total_pages": total_pages,
|
||
"status_counts": status_counts,
|
||
"summary": {"status_counts": status_counts},
|
||
},
|
||
request=request,
|
||
message="INVOICE_TAX_WORKSPACE_LIST",
|
||
)
|
||
|
||
def _get_invoice_for_business(
|
||
db: Session,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
) -> Document:
|
||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||
return doc
|
||
|
||
|
||
def _ensure_sales_or_return(doc: Document) -> None:
|
||
if doc.document_type not in ("invoice_sales", "invoice_sales_return"):
|
||
raise ApiError(
|
||
"TAX_WORKSPACE_NOT_ALLOWED",
|
||
"Only sales and sales-return invoices can be added to tax workspace",
|
||
http_status=400,
|
||
)
|
||
if doc.is_proforma:
|
||
raise ApiError(
|
||
"TAX_WORKSPACE_NOT_ALLOWED",
|
||
"Proforma invoices cannot be added to tax workspace",
|
||
http_status=400,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/add",
|
||
summary="افزودن فاکتور به کارپوشه مودیان",
|
||
description=(
|
||
"فقط فاکتور فروش یا برگشت از فروش. برای برگشت، `reference_invoice_id` شناسه فاکتور فروش مرجع است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def add_invoice_to_tax_workspace(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
body: Dict[str, Any] = Body(default={}),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""افزودن فاکتور به کارپوشه مودیان."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
|
||
extra = dict(doc.extra_info or {})
|
||
extra["tax_workspace"] = True
|
||
status = extra.get("tax_status")
|
||
if not isinstance(status, str) or not status.strip():
|
||
extra["tax_status"] = "not_sent"
|
||
|
||
ref_id = body.get("reference_invoice_id")
|
||
if ref_id is not None:
|
||
try:
|
||
ref_id_int = int(ref_id)
|
||
except (TypeError, ValueError):
|
||
raise ApiError("INVALID_REQUEST", "reference_invoice_id نامعتبر است.", http_status=400)
|
||
link_reference_invoice(db, business_id, doc, ref_id_int)
|
||
extra = dict(doc.extra_info or {})
|
||
elif doc.document_type == "invoice_sales_return":
|
||
raise ApiError(
|
||
"TAX_REFERENCE_REQUIRED",
|
||
"برای برگشت از فروش، شناسه فاکتور فروش مرجع (reference_invoice_id) الزامی است.",
|
||
http_status=400,
|
||
)
|
||
|
||
doc.extra_info = extra
|
||
db.commit()
|
||
db.refresh(doc)
|
||
|
||
return success_response(
|
||
data={
|
||
"id": doc.id,
|
||
"tax_status": extra.get("tax_status"),
|
||
"reference_invoice_id": extra.get("reference_invoice_id"),
|
||
},
|
||
request=request,
|
||
message="INVOICE_ADDED_TO_TAX_WORKSPACE",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/link-reference",
|
||
summary="اتصال فاکتور مرجع برای برگشت/اصلاح",
|
||
description='بدنه: `{"reference_invoice_id": 123}`',
|
||
)
|
||
@require_business_access("business_id")
|
||
def link_tax_workspace_reference(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
ref_id = body.get("reference_invoice_id")
|
||
if ref_id is None:
|
||
raise ApiError("INVALID_REQUEST", "reference_invoice_id الزامی است.", http_status=400)
|
||
try:
|
||
ref_id_int = int(ref_id)
|
||
except (TypeError, ValueError):
|
||
raise ApiError("INVALID_REQUEST", "reference_invoice_id نامعتبر است.", http_status=400)
|
||
|
||
ref_doc = link_reference_invoice(db, business_id, doc, ref_id_int)
|
||
db.commit()
|
||
db.refresh(doc)
|
||
|
||
return success_response(
|
||
data={
|
||
"id": doc.id,
|
||
"reference_invoice_id": ref_id_int,
|
||
"reference_invoice_code": ref_doc.code,
|
||
"reference_tax_status": get_document_extra(ref_doc).get("tax_status"),
|
||
},
|
||
request=request,
|
||
message="TAX_REFERENCE_LINKED",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/tax-workspace/reference-candidates",
|
||
summary="فاکتورهای مرجع قابل انتخاب برای برگشت",
|
||
description="فاکتورهای فروش ارسالشده به مودیان برای اتصال به برگشت از فروش.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def list_tax_reference_candidates(
|
||
request: Request,
|
||
business_id: int,
|
||
q: str | None = None,
|
||
limit: int = 50,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
from sqlalchemy import cast, Boolean
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
|
||
_extra_info_jb = cast(Document.extra_info, JSONB)
|
||
query = db.query(Document).filter(
|
||
Document.business_id == business_id,
|
||
Document.document_type == "invoice_sales",
|
||
cast(_extra_info_jb["tax_workspace"], Boolean) == True,
|
||
_extra_info_jb["tax_status"].astext.in_(["sent", "finalized"]),
|
||
)
|
||
if q and q.strip():
|
||
query = query.filter(Document.code.ilike(f"%{q.strip()}%"))
|
||
|
||
from adapters.db.models.tax_setting import TaxSetting
|
||
|
||
tax_setting = db.query(TaxSetting).filter(TaxSetting.business_id == business_id).first()
|
||
docs = query.order_by(Document.document_date.desc()).limit(min(limit, 200)).all()
|
||
items = []
|
||
for d in docs:
|
||
ex = get_document_extra(d)
|
||
taxid = ex.get("tax_moadian_taxid")
|
||
if not taxid and tax_setting:
|
||
taxid = compute_taxid_for_document(d, tax_setting)
|
||
items.append({
|
||
"id": d.id,
|
||
"code": d.code,
|
||
"document_date": d.document_date.isoformat() if d.document_date else None,
|
||
"tax_status": ex.get("tax_status"),
|
||
"tax_tracking_code": ex.get("tax_tracking_code"),
|
||
"tax_moadian_taxid": taxid,
|
||
})
|
||
|
||
return success_response(data={"items": items}, request=request, message="TAX_REFERENCE_CANDIDATES")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/remove",
|
||
summary="حذف فاکتور از کارپوشه مودیان",
|
||
description="فقط در صورتی مجاز است که فاکتور هنوز بهصورت نهایی به سامانه ارسال نشده باشد (`sent`/`finalized` ممنوع).",
|
||
)
|
||
@require_business_access("business_id")
|
||
def remove_invoice_from_tax_workspace(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""حذف فاکتور از کارپوشه مودیان (فقط اگر قطعی نشده باشد)."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
extra = dict(doc.extra_info or {})
|
||
status = (extra.get("tax_status") or "").strip() if isinstance(extra.get("tax_status"), str) else extra.get("tax_status")
|
||
if status in ("sent", "finalized"):
|
||
raise ApiError(
|
||
"TAX_WORKSPACE_REMOVE_NOT_ALLOWED",
|
||
"Cannot remove invoice that has been sent to tax system from tax workspace",
|
||
http_status=409,
|
||
)
|
||
|
||
extra["tax_workspace"] = False
|
||
# Optional: mark as not in workspace
|
||
extra["tax_status"] = status or "not_in_workspace"
|
||
doc.extra_info = extra
|
||
db.commit()
|
||
db.refresh(doc)
|
||
|
||
return success_response(
|
||
data={"id": doc.id, "tax_status": extra.get("tax_status")},
|
||
request=request,
|
||
message="INVOICE_REMOVED_FROM_TAX_WORKSPACE",
|
||
)
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/send-to-system",
|
||
summary="ارسال تکی فاکتور به سامانه مودیان",
|
||
description="فاکتور باید در کارپوشه باشد و قبلاً قطعی ارسال نشده باشد؛ پس از فراخوانی، وضعیت و کدهای رهگیری در سند بهروز میشوند.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def send_invoice_to_tax_system(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""ارسال تکی فاکتور موجود در کارپوشه به سامانه مودیان."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
|
||
extra = dict(doc.extra_info or {})
|
||
if not bool(extra.get("tax_workspace")):
|
||
raise ApiError(
|
||
"TAX_WORKSPACE_NOT_SET",
|
||
"Invoice is not in tax workspace",
|
||
http_status=400,
|
||
)
|
||
status = (extra.get("tax_status") or "").strip() if isinstance(extra.get("tax_status"), str) else extra.get("tax_status")
|
||
if status in ("sent", "finalized") or (
|
||
normalize_stored_tax_status(extra) in ("sent", "finalized", "pending")
|
||
and extra.get("tax_tracking_code")
|
||
):
|
||
raise ApiError(
|
||
"TAX_ALREADY_SENT",
|
||
"Invoice has already been sent to tax system",
|
||
http_status=409,
|
||
)
|
||
if extra.get("tax_cancelled_in_modian"):
|
||
raise ApiError(
|
||
"TAX_CANCELLED_IN_MODIAN",
|
||
"این فاکتور در سامانه مودیان ابطال شده و ارسال مجدد مجاز نیست.",
|
||
http_status=409,
|
||
)
|
||
|
||
submission = send_document_to_tax_system(db, doc)
|
||
db.commit()
|
||
db.refresh(doc)
|
||
|
||
extra_after = doc.extra_info or {}
|
||
tax_status = extra_after.get("tax_status")
|
||
if tax_status == "failed" or submission.get("status") == "failed":
|
||
failure_details = build_tax_failure_details(extra_after, submission)
|
||
failure_details["id"] = doc.id
|
||
raise ApiError(
|
||
"TAX_SUBMISSION_REJECTED",
|
||
submission.get("error_message")
|
||
or extra_after.get("tax_error_message")
|
||
or "فاکتور توسط سامانه مودیان رد شد.",
|
||
http_status=400,
|
||
details=failure_details,
|
||
)
|
||
|
||
return success_response(
|
||
data={
|
||
"id": doc.id,
|
||
"tax_status": tax_status,
|
||
"tax_tracking_code": extra_after.get("tax_tracking_code"),
|
||
},
|
||
request=request,
|
||
message="INVOICE_SENT_TO_TAX_SYSTEM",
|
||
)
|
||
|
||
|
||
def _finalize_tax_workspace_submission(
|
||
doc: Document,
|
||
submission: Dict[str, Any],
|
||
db: Session,
|
||
*,
|
||
success_message: str,
|
||
) -> Dict[str, Any]:
|
||
db.commit()
|
||
db.refresh(doc)
|
||
extra_after = doc.extra_info or {}
|
||
tax_status = extra_after.get("tax_status")
|
||
if tax_status == "failed" or submission.get("status") == "failed":
|
||
failure_details = build_tax_failure_details(extra_after, submission)
|
||
failure_details["id"] = doc.id
|
||
raise ApiError(
|
||
"TAX_SUBMISSION_REJECTED",
|
||
submission.get("error_message")
|
||
or extra_after.get("tax_error_message")
|
||
or "عملیات توسط سامانه مودیان رد شد.",
|
||
http_status=400,
|
||
details=failure_details,
|
||
)
|
||
return {
|
||
"id": doc.id,
|
||
"tax_status": tax_status,
|
||
"tax_tracking_code": extra_after.get("tax_tracking_code"),
|
||
"tax_cancelled_in_modian": bool(extra_after.get("tax_cancelled_in_modian")),
|
||
"tax_corrective_sent_at": extra_after.get("tax_corrective_sent_at"),
|
||
**build_tax_status_fields_for_api(extra_after),
|
||
}
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/cancel-in-system",
|
||
summary="ابطال فاکتور در سامانه مودیان",
|
||
description="ارسال صورتحساب ابطالی (inp=3) برای فاکتور قبلاً ارسالشده.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def cancel_invoice_in_tax_system(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
ok, reason = can_cancel_in_modian(doc)
|
||
if not ok:
|
||
raise ApiError("TAX_CANCEL_NOT_ALLOWED", reason, http_status=409)
|
||
|
||
submission = cancel_document_in_tax_system(db, doc)
|
||
data = _finalize_tax_workspace_submission(
|
||
doc, submission, db, success_message="INVOICE_CANCELLED_IN_TAX_SYSTEM"
|
||
)
|
||
return success_response(data=data, request=request, message="INVOICE_CANCELLED_IN_TAX_SYSTEM")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/{invoice_id}/tax-workspace/send-corrective",
|
||
summary="ارسال صورتحساب اصلاحی به مودیان",
|
||
description="ارسال صورتحساب اصلاحی (inp=4) برای فاکتور ارسالشده.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def send_corrective_invoice_to_tax_system(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
ok, reason = can_send_corrective(doc)
|
||
if not ok:
|
||
raise ApiError("TAX_CORRECTIVE_NOT_ALLOWED", reason, http_status=409)
|
||
|
||
submission = send_corrective_to_tax_system(db, doc)
|
||
data = _finalize_tax_workspace_submission(
|
||
doc, submission, db, success_message="INVOICE_CORRECTIVE_SENT_TO_TAX_SYSTEM"
|
||
)
|
||
return success_response(data=data, request=request, message="INVOICE_CORRECTIVE_SENT_TO_TAX_SYSTEM")
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/send-to-system-batch",
|
||
summary="ارسال گروهی به سامانه مودیان",
|
||
description='بدنه: `{"invoice_ids": [1,2,...]}` — برای هر شناسه نتیجه در `succeeded` یا جزئیات خطا در `failed`.',
|
||
)
|
||
@require_business_access("business_id")
|
||
def send_invoices_to_tax_system_batch(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""ارسال گروهی فاکتورهای موجود در کارپوشه به سامانه مودیان."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
ids = body.get("invoice_ids") or []
|
||
if not isinstance(ids, list) or not ids:
|
||
raise ApiError("INVALID_REQUEST", "invoice_ids must be a non-empty list", http_status=400)
|
||
|
||
succeeded: List[int] = []
|
||
failed: List[Dict[str, Any]] = []
|
||
|
||
for raw_id in ids:
|
||
try:
|
||
invoice_id = int(raw_id)
|
||
except Exception:
|
||
failed.append({"id": raw_id, "error": "INVALID_ID"})
|
||
continue
|
||
try:
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
extra = dict(doc.extra_info or {})
|
||
if not bool(extra.get("tax_workspace")):
|
||
raise ApiError("TAX_WORKSPACE_NOT_SET", "Invoice is not in tax workspace", http_status=400)
|
||
status = (extra.get("tax_status") or "").strip() if isinstance(extra.get("tax_status"), str) else extra.get("tax_status")
|
||
if status in ("sent", "finalized") or (
|
||
normalize_stored_tax_status(extra) in ("sent", "finalized", "pending")
|
||
and extra.get("tax_tracking_code")
|
||
):
|
||
raise ApiError("TAX_ALREADY_SENT", "Invoice has already been sent to tax system", http_status=409)
|
||
|
||
submission = send_document_to_tax_system(db, doc)
|
||
extra_after = doc.extra_info or {}
|
||
if extra_after.get("tax_status") == "failed" or submission.get("status") == "failed":
|
||
raise ApiError(
|
||
"TAX_SUBMISSION_REJECTED",
|
||
submission.get("error_message")
|
||
or extra_after.get("tax_error_message")
|
||
or "فاکتور توسط سامانه مودیان رد شد.",
|
||
http_status=400,
|
||
details=build_tax_failure_details(extra_after, submission),
|
||
)
|
||
succeeded.append(invoice_id)
|
||
except ApiError as e:
|
||
error_detail = getattr(e, "detail", {}) or {}
|
||
error_info = error_detail.get("error") if isinstance(error_detail, dict) else {}
|
||
err_details = (error_info or {}).get("details") if isinstance(error_info, dict) else {}
|
||
if not isinstance(err_details, dict):
|
||
err_details = {}
|
||
failed.append(
|
||
{
|
||
"id": invoice_id,
|
||
"error": (error_info or {}).get("code") or str(e),
|
||
"message": (error_info or {}).get("message"),
|
||
"issues": err_details.get("issues") if isinstance(err_details.get("issues"), list) else None,
|
||
"tax_error_message": err_details.get("tax_error_message"),
|
||
"tax_tracking_code": err_details.get("tax_tracking_code") or err_details.get("tracking_code"),
|
||
"moadian_errors": err_details.get("moadian_errors"),
|
||
"details": err_details,
|
||
}
|
||
)
|
||
except Exception as e:
|
||
failed.append({"id": invoice_id, "error": str(e)})
|
||
|
||
db.commit()
|
||
|
||
return success_response(
|
||
data={"succeeded": succeeded, "failed": failed},
|
||
request=request,
|
||
message="INVOICE_BATCH_SENT_TO_TAX_SYSTEM",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/remove-batch",
|
||
summary="حذف گروهی از کارپوشه مودیان",
|
||
description='بدنه: `{"invoice_ids": [...]}` — اسناد ارسالشده به سامانه از کارپوشه حذف نمیشوند.',
|
||
)
|
||
@require_business_access("business_id")
|
||
def remove_invoices_from_tax_workspace_batch(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""حذف گروهی فاکتورها از کارپوشه مودیان (صرفاً غیرقطعیها)."""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
ids = body.get("invoice_ids") or []
|
||
if not isinstance(ids, list) or not ids:
|
||
raise ApiError("INVALID_REQUEST", "invoice_ids must be a non-empty list", http_status=400)
|
||
|
||
removed: List[int] = []
|
||
failed: List[Dict[str, Any]] = []
|
||
|
||
for raw_id in ids:
|
||
try:
|
||
invoice_id = int(raw_id)
|
||
except Exception:
|
||
failed.append({"id": raw_id, "error": "INVALID_ID"})
|
||
continue
|
||
try:
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
extra = dict(doc.extra_info or {})
|
||
status = (extra.get("tax_status") or "").strip() if isinstance(extra.get("tax_status"), str) else extra.get("tax_status")
|
||
if status in ("sent", "finalized"):
|
||
raise ApiError("TAX_WORKSPACE_REMOVE_NOT_ALLOWED", "Cannot remove invoice that has been sent to tax system", http_status=409)
|
||
extra["tax_workspace"] = False
|
||
extra["tax_status"] = status or "not_in_workspace"
|
||
doc.extra_info = extra
|
||
db.add(doc)
|
||
removed.append(invoice_id)
|
||
except ApiError as e:
|
||
failed.append({"id": invoice_id, "error": e.detail.get("error", {}).get("code")})
|
||
except Exception as e:
|
||
failed.append({"id": invoice_id, "error": str(e)})
|
||
|
||
db.commit()
|
||
|
||
return success_response(
|
||
data={"removed": removed, "failed": failed},
|
||
request=request,
|
||
message="INVOICE_BATCH_REMOVED_FROM_TAX_WORKSPACE",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/tax-workspace/health",
|
||
summary="سلامت اتصال سامانه مالیاتی",
|
||
description="وضعیت پیکربندی و اتصال (مثلاً سامانه مودیان) برای این کسبوکار؛ مناسب داشبورد و عیبیابی.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_tax_system_health(
|
||
request: Request,
|
||
business_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""بررسی سلامت سامانه مالیاتی"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
from app.services.tax_health_check import check_tax_system_health
|
||
|
||
health = check_tax_system_health(db, business_id)
|
||
|
||
return success_response(
|
||
data=health,
|
||
request=request,
|
||
message="TAX_SYSTEM_HEALTH_CHECK",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/tax-workspace/failed-invoices",
|
||
summary="صف ارسال ناموفق (Dead Letter)",
|
||
description="ردیفهای صف خطای ارسال مالیاتی با امکان فیلتر و صفحهبندی.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_failed_invoices(
|
||
request: Request,
|
||
business_id: int,
|
||
status: Optional[str] = Query(default=None, description="فیلتر وضعیت رکورد در صف (در صورت پشتیبانی سرویس)"),
|
||
limit: int = Query(default=100, ge=1, le=1000, description="حداکثر تعداد رکورد"),
|
||
offset: int = Query(default=0, ge=0, description="جابجایی صفحه"),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""دریافت لیست فاکتورهای failed از Dead Letter Queue"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
from app.services.tax_dead_letter_queue import get_failed_invoices
|
||
|
||
failed = get_failed_invoices(db, business_id, status, limit, offset)
|
||
|
||
return success_response(
|
||
data={
|
||
"items": [
|
||
{
|
||
"id": item.id,
|
||
"invoice_id": item.invoice_id,
|
||
"tracking_code": item.tracking_code,
|
||
"error_code": item.error_code,
|
||
"error_message": item.error_message,
|
||
"attempt_count": item.attempt_count,
|
||
"first_failed_at": item.first_failed_at.isoformat() if item.first_failed_at else None,
|
||
"last_attempt_at": item.last_attempt_at.isoformat() if item.last_attempt_at else None,
|
||
"status": item.status,
|
||
}
|
||
for item in failed
|
||
],
|
||
"total": len(failed),
|
||
},
|
||
request=request,
|
||
message="TAX_FAILED_INVOICES_LIST",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/failed-invoices/{failed_id}/retry",
|
||
summary="تلاش مجدد ارسال رکورد ناموفق",
|
||
description="یک رکورد از صف Dead Letter را دوباره برای ارسال به سامانه مالیاتی تلاش میکند.",
|
||
)
|
||
@require_business_access("business_id")
|
||
def retry_failed_invoice_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
failed_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""تلاش مجدد برای ارسال فاکتور failed"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
from app.services.tax_dead_letter_queue import retry_failed_invoice
|
||
|
||
result = retry_failed_invoice(db, failed_id)
|
||
db.commit()
|
||
|
||
return success_response(
|
||
data=result,
|
||
request=request,
|
||
message="TAX_FAILED_INVOICE_RETRY_COMPLETED",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/business/{business_id}/{invoice_id}/tax-timeline",
|
||
summary="تایملاین مالیاتی فاکتور",
|
||
description="رویدادهای ارسال، استعلام و خطا مرتبط با وضعیت مالیاتی سند (شامل تلاقی با صف ناموفق).",
|
||
)
|
||
@require_business_access("business_id")
|
||
def get_invoice_tax_timeline(
|
||
request: Request,
|
||
business_id: int,
|
||
invoice_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""دریافت Timeline تغییرات وضعیت مالیاتی فاکتور"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
extra = dict(doc.extra_info or {})
|
||
|
||
timeline = []
|
||
|
||
# تاریخچه از extra_info
|
||
if extra.get("tax_last_send_at"):
|
||
timeline.append(
|
||
enrich_tax_timeline_event(
|
||
{
|
||
"event": "send_attempt",
|
||
"timestamp": extra.get("tax_last_send_at"),
|
||
"status": extra.get("tax_status"),
|
||
"tracking_code": extra.get("tax_tracking_code"),
|
||
"error_message": extra.get("tax_error_message"),
|
||
},
|
||
extra,
|
||
)
|
||
)
|
||
|
||
if extra.get("tax_last_inquiry_at"):
|
||
timeline.append(
|
||
enrich_tax_timeline_event(
|
||
{
|
||
"event": "status_inquiry",
|
||
"timestamp": extra.get("tax_last_inquiry_at"),
|
||
"status": extra.get("tax_status"),
|
||
"error_message": extra.get("tax_error_message"),
|
||
},
|
||
extra,
|
||
)
|
||
)
|
||
|
||
# بررسی Dead Letter Queue
|
||
from app.services.tax_dead_letter_queue import get_failed_invoices
|
||
failed_items = get_failed_invoices(db, business_id, status=None, limit=1000, offset=0)
|
||
for failed in failed_items:
|
||
if failed.invoice_id == invoice_id:
|
||
timeline.append({
|
||
"event": "failed",
|
||
"timestamp": failed.first_failed_at.isoformat() if failed.first_failed_at else None,
|
||
"error_code": failed.error_code,
|
||
"error_message": failed.error_message,
|
||
"attempt_count": failed.attempt_count,
|
||
})
|
||
|
||
# مرتبسازی بر اساس زمان
|
||
timeline.sort(key=lambda x: x.get("timestamp") or "", reverse=True)
|
||
|
||
return success_response(
|
||
data={
|
||
"invoice_id": invoice_id,
|
||
"current_status": extra.get("tax_status"),
|
||
"tax_error_message": extra.get("tax_error_message"),
|
||
"tax_tracking_code": extra.get("tax_tracking_code"),
|
||
"moadian_errors": build_tax_status_fields_for_api(extra).get("tax_moadian_errors"),
|
||
"timeline": timeline,
|
||
},
|
||
request=request,
|
||
message="TAX_TIMELINE_RETRIEVED",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/inquire-status",
|
||
summary="استعلام وضعیت از سامانه مالیاتی",
|
||
description=(
|
||
"حداقل یکی از `invoice_ids` یا `tracking_codes` در بدنه لازم است. "
|
||
"نتیجهٔ استعلام برای اسناد بهروزرسانی و در پاسخ برمیگردد."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def inquire_tax_status_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("moadian", "operate")),
|
||
) -> Dict[str, Any]:
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
invoice_ids = body.get("invoice_ids")
|
||
tracking_codes = body.get("tracking_codes")
|
||
if not isinstance(invoice_ids, list):
|
||
invoice_ids = []
|
||
if not isinstance(tracking_codes, list):
|
||
tracking_codes = []
|
||
if not invoice_ids and not tracking_codes:
|
||
raise ApiError("INVALID_REQUEST", "لیست فاکتور یا کد رهگیری لازم است.", http_status=400)
|
||
|
||
result = inquire_tax_status(
|
||
db,
|
||
business_id,
|
||
invoice_ids=[
|
||
int(i)
|
||
for i in invoice_ids
|
||
if i is not None and str(i).isdigit()
|
||
],
|
||
tracking_codes=[str(code) for code in tracking_codes if code],
|
||
)
|
||
db.commit()
|
||
return success_response(
|
||
data=result,
|
||
request=request,
|
||
message="TAX_STATUS_INQUIRY_COMPLETED",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/validate-batch",
|
||
summary="اعتبارسنجی گروهی قبل از ارسال مالیاتی",
|
||
description='بدنه: `{"invoice_ids": [...]}` — فهرست `validated` و ردها با `issues` در `invalid`.',
|
||
)
|
||
@require_business_access("business_id")
|
||
def validate_invoices_for_tax_batch(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
) -> Dict[str, Any]:
|
||
"""اعتبارسنجی گروهی فاکتورها قبل از ارسال"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
ids = body.get("invoice_ids") or []
|
||
if not isinstance(ids, list) or not ids:
|
||
raise ApiError("INVALID_REQUEST", "invoice_ids must be a non-empty list", http_status=400)
|
||
|
||
validated: List[int] = []
|
||
invalid: List[Dict[str, Any]] = []
|
||
|
||
for raw_id in ids:
|
||
try:
|
||
invoice_id = int(raw_id)
|
||
except Exception:
|
||
invalid.append({"id": raw_id, "error": "INVALID_ID", "message": "شناسه نامعتبر"})
|
||
continue
|
||
|
||
try:
|
||
doc = _get_invoice_for_business(db, business_id, invoice_id)
|
||
_ensure_sales_or_return(doc)
|
||
|
||
# اعتبارسنجی
|
||
from app.services.tax_validation_service import validate_document_for_tax
|
||
validation = validate_document_for_tax(db, doc)
|
||
|
||
if not validation["valid"]:
|
||
invalid.append({
|
||
"id": invoice_id,
|
||
"error": "VALIDATION_FAILED",
|
||
"message": "فاکتور حداقل الزامات سامانه مودیان را ندارد.",
|
||
"issues": validation["issues"],
|
||
})
|
||
else:
|
||
validated.append(invoice_id)
|
||
|
||
except ApiError as e:
|
||
error_detail = getattr(e, "detail", {}) or {}
|
||
error_info = error_detail.get("error") if isinstance(error_detail, dict) else {}
|
||
invalid.append({
|
||
"id": invoice_id,
|
||
"error": (error_info or {}).get("code") or str(e),
|
||
"message": (error_info or {}).get("message") or str(e),
|
||
})
|
||
except Exception as e:
|
||
invalid.append({
|
||
"id": invoice_id,
|
||
"error": "UNKNOWN_ERROR",
|
||
"message": str(e),
|
||
})
|
||
|
||
return success_response(
|
||
data={
|
||
"validated": validated,
|
||
"invalid": invalid,
|
||
"total": len(ids),
|
||
"valid_count": len(validated),
|
||
"invalid_count": len(invalid),
|
||
},
|
||
request=request,
|
||
message="TAX_VALIDATION_BATCH_COMPLETED",
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/tax-workspace/quick-actions",
|
||
summary="عملیات سریع کارپوشه مالیاتی",
|
||
description=(
|
||
"بدنه باید شامل `action` باشد: `send_all_pending`, `inquire_all_sent`, `retry_all_failed`. "
|
||
"در پسزمینه از همان endpointهای ارسال گروهی یا استعلام استفاده میشود."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
def tax_workspace_quick_actions(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("moadian", "operate")),
|
||
) -> Dict[str, Any]:
|
||
"""عملیات سریع برای کارپوشه مالیاتی"""
|
||
ensure_moadian_plugin_active(db, business_id)
|
||
action = body.get("action")
|
||
if not action or not isinstance(action, str):
|
||
raise ApiError("INVALID_REQUEST", "action is required", http_status=400)
|
||
|
||
action = action.lower()
|
||
|
||
# دریافت فاکتورها بر اساس وضعیت
|
||
from sqlalchemy import cast, Boolean
|
||
|
||
# extra_info نوع JSON عمومی است؛ astext فقط روی JSONB (PostgreSQL)
|
||
_extra_info_jb = cast(Document.extra_info, JSONB)
|
||
base_query = db.query(Document).filter(
|
||
and_(
|
||
Document.business_id == business_id,
|
||
Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)),
|
||
cast(_extra_info_jb['tax_workspace'], Boolean) == True,
|
||
)
|
||
)
|
||
|
||
invoice_ids: List[int] = []
|
||
|
||
if action == "send_all_pending":
|
||
# ارسال همه pending
|
||
docs = base_query.filter(
|
||
_extra_info_jb['tax_status'].astext == 'pending'
|
||
).all()
|
||
invoice_ids = [doc.id for doc in docs]
|
||
|
||
if not invoice_ids:
|
||
return success_response(
|
||
data={"message": "هیچ فاکتور pending یافت نشد", "invoice_ids": []},
|
||
request=request,
|
||
message="NO_PENDING_INVOICES",
|
||
)
|
||
|
||
# استفاده از endpoint ارسال گروهی
|
||
return send_invoices_to_tax_system_batch(request, business_id, {"invoice_ids": invoice_ids}, ctx, db)
|
||
|
||
elif action == "inquire_all_sent":
|
||
# استعلام همه sent
|
||
docs = base_query.filter(
|
||
_extra_info_jb['tax_status'].astext == 'sent'
|
||
).all()
|
||
|
||
tracking_codes: List[str] = []
|
||
for doc in docs:
|
||
code = (doc.extra_info or {}).get("tax_tracking_code")
|
||
if code and isinstance(code, str) and code.strip():
|
||
tracking_codes.append(code.strip())
|
||
|
||
if not tracking_codes:
|
||
return success_response(
|
||
data={"message": "هیچ فاکتور sent با کد رهگیری یافت نشد", "tracking_codes": []},
|
||
request=request,
|
||
message="NO_SENT_INVOICES",
|
||
)
|
||
|
||
# استفاده از endpoint استعلام
|
||
return inquire_tax_status_endpoint(request, business_id, {"tracking_codes": tracking_codes}, ctx, db)
|
||
|
||
elif action == "retry_all_failed":
|
||
# Retry همه failed
|
||
docs = base_query.filter(
|
||
_extra_info_jb['tax_status'].astext == 'failed'
|
||
).all()
|
||
invoice_ids = [doc.id for doc in docs]
|
||
|
||
if not invoice_ids:
|
||
return success_response(
|
||
data={"message": "هیچ فاکتور failed یافت نشد", "invoice_ids": []},
|
||
request=request,
|
||
message="NO_FAILED_INVOICES",
|
||
)
|
||
|
||
# استفاده از endpoint ارسال گروهی
|
||
return send_invoices_to_tax_system_batch(request, business_id, {"invoice_ids": invoice_ids}, ctx, db)
|
||
|
||
else:
|
||
raise ApiError("INVALID_ACTION", f"Unknown action: {action}", http_status=400)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/export/excel",
|
||
summary="خروجی Excel لیست فاکتورها",
|
||
description=(
|
||
"فیلتر مشابه جستجو (متن، نوع سند، پیشفاکتور، ارز، سال مالی، پروژه، بازه تاریخ، برچسب، تیک/اسکیپ). "
|
||
"`export_columns` ستونها و برچسبها را مشخص میکند؛ `selected_only` و `selected_indices` برای خروج فقط سطرهای انتخابشده در گرید."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def export_invoices_excel(
|
||
business_id: int,
|
||
request: Request,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||
from app.core.i18n import negotiate_locale
|
||
|
||
# Build base query similar to search endpoint
|
||
take_value = min(int(body.get("take", 1000)), 10000)
|
||
skip_value = int(body.get("skip", 0))
|
||
|
||
q = db.query(Document).filter(
|
||
and_(
|
||
Document.business_id == business_id,
|
||
Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)),
|
||
)
|
||
)
|
||
|
||
search = body.get("search")
|
||
search_fields = body.get("search_fields")
|
||
sf_list = search_fields if isinstance(search_fields, list) else None
|
||
q = _apply_invoice_list_text_search(
|
||
q,
|
||
business_id=business_id,
|
||
search=search if isinstance(search, str) else None,
|
||
search_fields=sf_list,
|
||
extra_info_person_id_expr=None,
|
||
)
|
||
|
||
# Filters
|
||
doc_type = body.get("document_type")
|
||
if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES:
|
||
q = q.filter(Document.document_type == doc_type)
|
||
|
||
is_proforma = body.get("is_proforma")
|
||
if isinstance(is_proforma, bool):
|
||
q = q.filter(Document.is_proforma == is_proforma)
|
||
|
||
currency_id = body.get("currency_id")
|
||
try:
|
||
if currency_id is not None:
|
||
q = q.filter(Document.currency_id == int(currency_id))
|
||
except Exception:
|
||
pass
|
||
|
||
# Fiscal year
|
||
try:
|
||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||
if fy_header:
|
||
q = q.filter(Document.fiscal_year_id == int(fy_header))
|
||
elif body.get("fiscal_year_id") is not None:
|
||
q = q.filter(Document.fiscal_year_id == int(body.get("fiscal_year_id")))
|
||
except Exception:
|
||
pass
|
||
|
||
# Project filter
|
||
project_id = body.get("project_id")
|
||
try:
|
||
if project_id is not None:
|
||
q = q.filter(Document.project_id == int(project_id))
|
||
except Exception:
|
||
pass
|
||
|
||
q = apply_invoice_tag_filters_to_query(q, body)
|
||
|
||
# Date range
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
if isinstance(body.get("from_date"), str):
|
||
try:
|
||
q = q.filter(Document.document_date >= _p(body.get("from_date")))
|
||
except Exception:
|
||
pass
|
||
if isinstance(body.get("to_date"), str):
|
||
try:
|
||
q = q.filter(Document.document_date <= _p(body.get("to_date")))
|
||
except Exception:
|
||
pass
|
||
|
||
q = apply_invoice_search_ordering_from_body(q, body)
|
||
|
||
total = q.count()
|
||
docs: List[Document] = q.offset(skip_value).limit(take_value).all()
|
||
|
||
# Build items like list endpoint
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == 'fa'
|
||
calendar_type = request.state.calendar_type
|
||
|
||
def format_date_for_export(item_dict: dict, date_key: str) -> str:
|
||
"""Format date based on calendar type (date only, no time)"""
|
||
# First check if there's a _formatted field (from format_datetime_fields)
|
||
formatted_key = f"{date_key}_formatted"
|
||
if formatted_key in item_dict:
|
||
formatted_value = item_dict.get(formatted_key)
|
||
if isinstance(formatted_value, dict):
|
||
date_only = formatted_value.get("date_only")
|
||
if date_only:
|
||
return str(date_only)
|
||
formatted = formatted_value.get("formatted", "")
|
||
if formatted:
|
||
# Extract date part only (remove time)
|
||
date_part = str(formatted).split(' ')[0].split('T')[0]
|
||
return date_part
|
||
|
||
# Get the main field value
|
||
value = item_dict.get(date_key)
|
||
if value is None:
|
||
return ""
|
||
|
||
# If it's a dict (from _formatted field), use date_only
|
||
if isinstance(value, dict):
|
||
date_only = value.get("date_only")
|
||
if date_only:
|
||
return str(date_only)
|
||
formatted = value.get("formatted", "")
|
||
if formatted:
|
||
date_part = str(formatted).split(' ')[0].split('T')[0]
|
||
return date_part
|
||
|
||
# If it's a datetime object, format it based on calendar type
|
||
if isinstance(value, datetime.datetime):
|
||
try:
|
||
formatted = CalendarConverter.format_datetime(value, calendar_type)
|
||
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
|
||
except Exception:
|
||
pass
|
||
|
||
# If it's a date object, format it based on calendar type
|
||
if isinstance(value, datetime.date):
|
||
try:
|
||
dt_value = datetime.datetime.combine(value, datetime.datetime.min.time())
|
||
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
|
||
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
|
||
except Exception:
|
||
pass
|
||
|
||
# If it's a string, check if it's already formatted (contains / separator for Jalali)
|
||
if isinstance(value, str):
|
||
# Check if it looks like a Jalali date (contains / and has YYYY/MM/DD format)
|
||
if '/' in value and (len(value.split('/')) == 3):
|
||
# Might be already formatted, but check if it's ISO format (YYYY-MM-DD) or Jalali (YYYY/MM/DD)
|
||
if '-' in value:
|
||
# ISO format (YYYY-MM-DD), parse and format
|
||
try:
|
||
if 'T' in value:
|
||
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||
else:
|
||
date_value = datetime.date.fromisoformat(value)
|
||
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
|
||
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
|
||
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
|
||
except Exception:
|
||
pass
|
||
else:
|
||
# Might be Jalali format (YYYY/MM/DD), return as is but remove time if exists
|
||
if ' ' in value:
|
||
return value.split(' ')[0]
|
||
return value
|
||
else:
|
||
# Try to parse as ISO format
|
||
try:
|
||
if 'T' in value:
|
||
dt_value = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||
else:
|
||
date_value = datetime.date.fromisoformat(value)
|
||
dt_value = datetime.datetime.combine(date_value, datetime.datetime.min.time())
|
||
formatted = CalendarConverter.format_datetime(dt_value, calendar_type)
|
||
return formatted.get("date_only", "") or formatted.get("formatted", "").split(' ')[0]
|
||
except Exception:
|
||
# If parsing fails, return as is (might already be formatted)
|
||
if ' ' in value or 'T' in value:
|
||
date_part = value.split(' ')[0].split('T')[0]
|
||
return date_part
|
||
return value
|
||
|
||
# Fallback
|
||
return str(value) if value else ""
|
||
|
||
def _type_name(tp: str) -> str:
|
||
mapping = {
|
||
'invoice_sales': ('فروش' if is_fa else 'Sales'),
|
||
'invoice_sales_return': ('برگشت از فروش' if is_fa else 'Sales return'),
|
||
'invoice_purchase': ('خرید' if is_fa else 'Purchase'),
|
||
'invoice_purchase_return': ('برگشت از خرید' if is_fa else 'Purchase return'),
|
||
'invoice_direct_consumption': ('مصرف مستقیم' if is_fa else 'Direct consumption'),
|
||
'invoice_production': ('تولید' if is_fa else 'Production'),
|
||
'invoice_waste': ('ضایعات' if is_fa else 'Waste'),
|
||
}
|
||
return mapping.get(str(tp), str(tp))
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
list_dicts = invoice_documents_to_list_dicts(db, docs)
|
||
for item in list_dicts:
|
||
# total_amount = مبلغ قابل پرداخت (شامل اضافات/کسورات)
|
||
total_amount = None
|
||
try:
|
||
payable = payable_total_from_extra_info(item.get("extra_info"))
|
||
if payable is not None:
|
||
total_amount = float(payable)
|
||
except Exception:
|
||
total_amount = None
|
||
|
||
item['document_type_name'] = _type_name(item.get('document_type'))
|
||
if total_amount is not None:
|
||
item['total_amount'] = total_amount
|
||
|
||
items.append(item)
|
||
|
||
batch_add_counterparty_to_invoice_items(db, items)
|
||
items = [format_datetime_fields(item, request) for item in items]
|
||
|
||
attach_tags_to_invoice_items(db, business_id, items)
|
||
|
||
# Handle selected rows
|
||
selected_only = bool(body.get('selected_only', False))
|
||
selected_indices = body.get('selected_indices')
|
||
if selected_only and selected_indices is not None:
|
||
indices = None
|
||
if isinstance(selected_indices, str):
|
||
try:
|
||
indices = json.loads(selected_indices)
|
||
except (json.JSONDecodeError, TypeError):
|
||
indices = None
|
||
elif isinstance(selected_indices, list):
|
||
indices = selected_indices
|
||
if isinstance(indices, list):
|
||
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||
|
||
# Prepare columns
|
||
headers: List[str] = []
|
||
keys: List[str] = []
|
||
export_columns = body.get('export_columns')
|
||
if export_columns:
|
||
# Check if document_type_name exists in export_columns
|
||
has_document_type_name = any(col.get('key') == 'document_type_name' for col in export_columns)
|
||
|
||
for col in export_columns:
|
||
key = col.get('key')
|
||
label = col.get('label', key)
|
||
if key:
|
||
keys.append(str(key))
|
||
headers.append(str(label))
|
||
|
||
# If document_type_name is missing, add it at the beginning (after code if exists)
|
||
if not has_document_type_name:
|
||
# Find position after 'code' if it exists
|
||
code_index = keys.index('code') if 'code' in keys else -1
|
||
insert_pos = code_index + 1 if code_index >= 0 else 0
|
||
keys.insert(insert_pos, 'document_type_name')
|
||
headers.insert(insert_pos, 'نوع فاکتور' if is_fa else 'Invoice type')
|
||
else:
|
||
default_columns = [
|
||
('code', 'کد سند' if is_fa else 'Code'),
|
||
('document_type_name', 'نوع فاکتور' if is_fa else 'Invoice type'),
|
||
('counterparty', 'طرف حساب' if is_fa else 'Counterparty'),
|
||
('tags_display', 'برچسبها' if is_fa else 'Tags'),
|
||
('document_date', 'تاریخ سند' if is_fa else 'Document date'),
|
||
('total_amount', 'مبلغ کل' if is_fa else 'Total amount'),
|
||
('currency_code', 'ارز' if is_fa else 'Currency'),
|
||
('created_by_name', 'ایجادکننده' if is_fa else 'Created by'),
|
||
('is_proforma', 'پیشفاکتور' if is_fa else 'Proforma'),
|
||
('registered_at', 'تاریخ ثبت' if is_fa else 'Registered at'),
|
||
]
|
||
for key, label in default_columns:
|
||
if items and key in items[0]:
|
||
keys.append(key)
|
||
headers.append(label)
|
||
|
||
# Create workbook
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Invoices"
|
||
|
||
header_font = Font(bold=True, color="FFFFFF")
|
||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||
|
||
# Header row
|
||
for col_idx, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col_idx, value=header)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = header_alignment
|
||
cell.border = border
|
||
|
||
# Data rows
|
||
for row_idx, item in enumerate(items, 2):
|
||
for col_idx, key in enumerate(keys, 1):
|
||
value = item.get(key, "")
|
||
|
||
# Format date fields based on calendar type
|
||
if key in ('document_date', 'registered_at', 'created_at', 'due_date'):
|
||
value = format_date_for_export(item, key)
|
||
elif isinstance(value, list):
|
||
value = ", ".join(str(v) for v in value)
|
||
elif isinstance(value, dict):
|
||
value = str(value)
|
||
|
||
ws.cell(row=row_idx, column=col_idx, value=value).border = border
|
||
|
||
# Auto-width
|
||
for column in ws.columns:
|
||
max_length = 0
|
||
column_letter = column[0].column_letter
|
||
for cell in column:
|
||
try:
|
||
if len(str(cell.value)) > max_length:
|
||
max_length = len(str(cell.value))
|
||
except Exception:
|
||
pass
|
||
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||
|
||
# Save to bytes
|
||
buffer = io.BytesIO()
|
||
wb.save(buffer)
|
||
buffer.seek(0)
|
||
|
||
# Filename
|
||
biz_name = ""
|
||
try:
|
||
b = db.query(Business).filter(Business.id == business_id).first()
|
||
if b is not None:
|
||
biz_name = b.name or ""
|
||
except Exception:
|
||
biz_name = ""
|
||
|
||
def slugify(text: str) -> str:
|
||
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||
|
||
base = "invoices"
|
||
if biz_name:
|
||
base += f"_{slugify(biz_name)}"
|
||
if selected_only:
|
||
base += "_selected"
|
||
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||
content = buffer.getvalue()
|
||
|
||
return Response(
|
||
content=content,
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename={filename}",
|
||
"Content-Length": str(len(content)),
|
||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/export/pdf",
|
||
summary="خروجی PDF لیست فاکتورها",
|
||
description=(
|
||
"همان منطق فیلتر و ستونهای Excel؛ علاوه بر آن `template_id` برای قالب سفارشی ماژول invoices/list، "
|
||
"`disposition` برای attachment/inline، `paper_size` و `orientation` برای چاپ."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def export_invoices_pdf(
|
||
business_id: int,
|
||
request: Request,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
from weasyprint import HTML
|
||
from weasyprint.text.fonts import FontConfiguration
|
||
from app.core.i18n import negotiate_locale
|
||
from html import escape
|
||
|
||
# Build same list as excel
|
||
take_value = min(int(body.get("take", 1000)), 10000)
|
||
skip_value = int(body.get("skip", 0))
|
||
|
||
q = db.query(Document).filter(
|
||
and_(
|
||
Document.business_id == business_id,
|
||
Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)),
|
||
)
|
||
)
|
||
|
||
search = body.get("search")
|
||
search_fields = body.get("search_fields")
|
||
sf_list = search_fields if isinstance(search_fields, list) else None
|
||
q = _apply_invoice_list_text_search(
|
||
q,
|
||
business_id=business_id,
|
||
search=search if isinstance(search, str) else None,
|
||
search_fields=sf_list,
|
||
extra_info_person_id_expr=None,
|
||
)
|
||
|
||
doc_type = body.get("document_type")
|
||
if isinstance(doc_type, str) and doc_type in SUPPORTED_INVOICE_TYPES:
|
||
q = q.filter(Document.document_type == doc_type)
|
||
|
||
is_proforma = body.get("is_proforma")
|
||
if isinstance(is_proforma, bool):
|
||
q = q.filter(Document.is_proforma == is_proforma)
|
||
|
||
currency_id = body.get("currency_id")
|
||
try:
|
||
if currency_id is not None:
|
||
q = q.filter(Document.currency_id == int(currency_id))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||
if fy_header:
|
||
q = q.filter(Document.fiscal_year_id == int(fy_header))
|
||
elif body.get("fiscal_year_id") is not None:
|
||
q = q.filter(Document.fiscal_year_id == int(body.get("fiscal_year_id")))
|
||
except Exception:
|
||
pass
|
||
|
||
# Project filter
|
||
project_id = body.get("project_id")
|
||
try:
|
||
if project_id is not None:
|
||
q = q.filter(Document.project_id == int(project_id))
|
||
except Exception:
|
||
pass
|
||
|
||
q = apply_invoice_tag_filters_to_query(q, body)
|
||
|
||
from app.services.transfer_service import _parse_iso_date as _p
|
||
if isinstance(body.get("from_date"), str):
|
||
try:
|
||
q = q.filter(Document.document_date >= _p(body.get("from_date")))
|
||
except Exception:
|
||
pass
|
||
if isinstance(body.get("to_date"), str):
|
||
try:
|
||
q = q.filter(Document.document_date <= _p(body.get("to_date")))
|
||
except Exception:
|
||
pass
|
||
|
||
q = apply_invoice_search_ordering_from_body(q, body)
|
||
|
||
docs: List[Document] = q.offset(skip_value).limit(take_value).all()
|
||
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == 'fa'
|
||
|
||
def _type_name(tp: str) -> str:
|
||
mapping = {
|
||
'invoice_sales': ('فروش' if is_fa else 'Sales'),
|
||
'invoice_sales_return': ('برگشت از فروش' if is_fa else 'Sales return'),
|
||
'invoice_purchase': ('خرید' if is_fa else 'Purchase'),
|
||
'invoice_purchase_return': ('برگشت از خرید' if is_fa else 'Purchase return'),
|
||
'invoice_direct_consumption': ('مصرف مستقیم' if is_fa else 'Direct consumption'),
|
||
'invoice_production': ('تولید' if is_fa else 'Production'),
|
||
'invoice_waste': ('ضایعات' if is_fa else 'Waste'),
|
||
}
|
||
return mapping.get(str(tp), str(tp))
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
# Helper to resolve person display name
|
||
def _get_person_display_name(person_id: int | None) -> str | None:
|
||
if person_id is None:
|
||
return None
|
||
try:
|
||
p = db.query(Person).filter(Person.id == int(person_id)).first()
|
||
if p is None:
|
||
return None
|
||
# اولویت: display_name > name > alias_name (که اجباری است)
|
||
person_name = getattr(p, "display_name", None) or getattr(p, "name", None)
|
||
if not person_name:
|
||
# اگر display_name و name خالی باشند، از alias_name استفاده میکنیم (که اجباری است)
|
||
person_name = getattr(p, "alias_name", None)
|
||
if not person_name and (getattr(p, "first_name", None) or getattr(p, "last_name", None)):
|
||
# اگر هنوز خالی است، از first_name و last_name استفاده میکنیم
|
||
name_parts = []
|
||
if getattr(p, "first_name", None):
|
||
name_parts.append(p.first_name)
|
||
if getattr(p, "last_name", None):
|
||
name_parts.append(p.last_name)
|
||
person_name = " ".join(name_parts) if name_parts else None
|
||
if not person_name and getattr(p, "company_name", None):
|
||
# اگر هنوز خالی است، از company_name استفاده میکنیم
|
||
person_name = p.company_name
|
||
return person_name
|
||
except Exception:
|
||
return None
|
||
for d in docs:
|
||
item = invoice_document_to_dict(db, d, include_tags=False)
|
||
total_amount = None
|
||
try:
|
||
payable = payable_total_from_extra_info(item.get("extra_info"))
|
||
if payable is not None:
|
||
total_amount = float(payable)
|
||
except Exception:
|
||
total_amount = None
|
||
if total_amount is None:
|
||
try:
|
||
net_sum = 0.0
|
||
for pl in item.get('product_lines', []) or []:
|
||
info = pl.get('extra_info') or {}
|
||
qty = float(pl.get('quantity') or 0)
|
||
unit_price = float(info.get('unit_price') or 0)
|
||
line_discount = float(info.get('line_discount') or 0)
|
||
tax_amount = float(info.get('tax_amount') or 0)
|
||
line_total = info.get('line_total')
|
||
if line_total is None:
|
||
line_total = (qty * unit_price) - line_discount + tax_amount
|
||
net_sum += float(line_total)
|
||
total_amount = float(net_sum)
|
||
except Exception:
|
||
total_amount = None
|
||
item['document_type_name'] = _type_name(item.get('document_type'))
|
||
if total_amount is not None:
|
||
item['total_amount'] = total_amount
|
||
# Counterparty based on type: sales -> buyer (person), purchase -> seller (person)
|
||
try:
|
||
inv_type = str(item.get("document_type") or "")
|
||
extra = item.get("extra_info") or {}
|
||
person_id = extra.get("person_id")
|
||
person_name = _get_person_display_name(person_id)
|
||
counterparty = ""
|
||
if inv_type in ("invoice_sales", "invoice_sales_return"):
|
||
counterparty = person_name or ""
|
||
elif inv_type in ("invoice_purchase", "invoice_purchase_return"):
|
||
counterparty = person_name or ""
|
||
else:
|
||
counterparty = person_name or ""
|
||
item["counterparty"] = counterparty
|
||
except Exception:
|
||
item["counterparty"] = ""
|
||
items.append(format_datetime_fields(item, request))
|
||
|
||
attach_tags_to_invoice_items(db, business_id, items)
|
||
|
||
# Handle selected rows
|
||
selected_only = bool(body.get('selected_only', False))
|
||
selected_indices = body.get('selected_indices')
|
||
if selected_only and selected_indices is not None:
|
||
indices = None
|
||
if isinstance(selected_indices, str):
|
||
try:
|
||
indices = json.loads(selected_indices)
|
||
except (json.JSONDecodeError, TypeError):
|
||
indices = None
|
||
elif isinstance(selected_indices, list):
|
||
indices = selected_indices
|
||
if isinstance(indices, list):
|
||
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||
|
||
# Prepare columns
|
||
headers: List[str] = []
|
||
keys: List[str] = []
|
||
export_columns = body.get('export_columns')
|
||
if export_columns:
|
||
# Check if document_type_name exists in export_columns
|
||
has_document_type_name = any(col.get('key') == 'document_type_name' for col in export_columns)
|
||
|
||
for col in export_columns:
|
||
key = col.get('key')
|
||
label = col.get('label', key)
|
||
if key:
|
||
keys.append(str(key))
|
||
headers.append(str(label))
|
||
|
||
# If document_type_name is missing, add it at the beginning (after code if exists)
|
||
if not has_document_type_name:
|
||
# Find position after 'code' if it exists
|
||
code_index = keys.index('code') if 'code' in keys else -1
|
||
insert_pos = code_index + 1 if code_index >= 0 else 0
|
||
keys.insert(insert_pos, 'document_type_name')
|
||
headers.insert(insert_pos, 'نوع فاکتور' if is_fa else 'Invoice type')
|
||
else:
|
||
default_columns = [
|
||
('code', 'کد سند' if is_fa else 'Code'),
|
||
('document_type_name', 'نوع فاکتور' if is_fa else 'Invoice type'),
|
||
('counterparty', 'طرف حساب' if is_fa else 'Counterparty'),
|
||
('tags_display', 'برچسبها' if is_fa else 'Tags'),
|
||
('document_date', 'تاریخ سند' if is_fa else 'Document date'),
|
||
('total_amount', 'مبلغ کل' if is_fa else 'Total amount'),
|
||
('currency_code', 'ارز' if is_fa else 'Currency'),
|
||
('created_by_name', 'ایجادکننده' if is_fa else 'Created by'),
|
||
('is_proforma', 'پیشفاکتور' if is_fa else 'Proforma'),
|
||
('registered_at', 'تاریخ ثبت' if is_fa else 'Registered at'),
|
||
]
|
||
for key, label in default_columns:
|
||
if items and key in items[0]:
|
||
keys.append(key)
|
||
headers.append(label)
|
||
|
||
# Business name & locale
|
||
business_name = ""
|
||
try:
|
||
b = db.query(Business).filter(Business.id == business_id).first()
|
||
if b is not None:
|
||
business_name = b.name or ""
|
||
except Exception:
|
||
business_name = ""
|
||
|
||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||
is_fa = locale == 'fa'
|
||
# respect user's calendar for generated_at
|
||
try:
|
||
cal_header = (request.headers.get("X-Calendar-Type") or "").strip().lower()
|
||
cal_type = cal_header or ("jalali" if is_fa else "gregorian")
|
||
except Exception:
|
||
cal_type = "jalali" if is_fa else "gregorian"
|
||
try:
|
||
from app.core.datetime_utils import format_generated_at_for_pdf
|
||
|
||
now = format_generated_at_for_pdf(business_id, cal_type)
|
||
except Exception:
|
||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||
title_text = "لیست فاکتورها" if is_fa else "Invoices List"
|
||
label_biz = "کسب و کار" if is_fa else "Business"
|
||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||
try:
|
||
from app.services.print_footer_settings import build_generated_at_pdf_footer
|
||
|
||
pn = ""
|
||
try:
|
||
pn = ctx.get_user_name() or ""
|
||
except Exception:
|
||
pn = ""
|
||
footer_text = build_generated_at_pdf_footer(
|
||
db,
|
||
business_id,
|
||
formatted_generated_at=now,
|
||
preparer_name=pn or None,
|
||
is_fa=is_fa,
|
||
)
|
||
except Exception:
|
||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||
|
||
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
||
|
||
# Determine calendar type for date formatting in table rows
|
||
try:
|
||
cal_header = (request.headers.get("X-Calendar-Type") or "").strip().lower()
|
||
cal_type_for_rows = cal_header or ("jalali" if is_fa else "gregorian")
|
||
except Exception:
|
||
cal_type_for_rows = "jalali" if is_fa else "gregorian"
|
||
|
||
# Helper to format date string using CalendarConverter
|
||
def _format_date_for_calendar(value: str) -> str:
|
||
try:
|
||
dt = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
fd = CalendarConverter.format_datetime(dt, cal_type_for_rows)
|
||
return fd.get("date_only") or fd.get("formatted") or str(value)
|
||
except Exception:
|
||
return str(value)
|
||
|
||
# Helpers for numeric formatting with thousands separator and trimming .00
|
||
def _format_number_for_display(value: object) -> str:
|
||
try:
|
||
if value is None:
|
||
return ""
|
||
v = float(value)
|
||
s = f"{v:,.2f}"
|
||
# Trim trailing .00 or trailing zeros
|
||
if "." in s:
|
||
s = s.rstrip("0").rstrip(".")
|
||
return s
|
||
except Exception:
|
||
return str(value)
|
||
|
||
# Build rows with numeric alignment and calendar-aware dates
|
||
amount_keys = {"total_amount", "subtotal", "discount_total", "tax_total", "payable_total"}
|
||
date_keys = {"document_date", "registered_at", "created_at"}
|
||
rows_html = []
|
||
total_sum = 0.0
|
||
discount_sum = 0.0
|
||
tax_sum = 0.0
|
||
for item in items:
|
||
row_cells = []
|
||
for key in keys:
|
||
value = item.get(key, "")
|
||
# Normalize list/dict to string
|
||
if isinstance(value, list):
|
||
value = ", ".join(str(v) for v in value)
|
||
elif isinstance(value, dict):
|
||
value = str(value)
|
||
# Calendar-aware date formatting
|
||
if key in date_keys and value:
|
||
value = _format_date_for_calendar(value)
|
||
# Amount cells: format and accumulate totals
|
||
if key in amount_keys:
|
||
try:
|
||
vnum = float(item.get(key)) if item.get(key) is not None else None
|
||
if key == "total_amount" and vnum is not None:
|
||
total_sum += vnum
|
||
if key == "discount_total" and vnum is not None:
|
||
discount_sum += vnum
|
||
if key == "tax_total" and vnum is not None:
|
||
tax_sum += vnum
|
||
except Exception:
|
||
pass
|
||
disp = _format_number_for_display(value)
|
||
row_cells.append(f'<td class="amount">{escape(disp)}</td>')
|
||
# Proforma: show checkmark for true, empty otherwise
|
||
elif key == "is_proforma":
|
||
checked = (str(value).lower() in ("true", "1"))
|
||
cell = "✓" if checked else ""
|
||
row_cells.append(f'<td style="text-align:center">{cell}</td>')
|
||
else:
|
||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||
|
||
# Summary block (only total amount and count for list)
|
||
total_count = len(items)
|
||
label_rows = 'تعداد ردیف' if is_fa else 'Rows'
|
||
label_total = 'جمع مبلغ کل' if is_fa else 'Total of amounts'
|
||
label_discount = 'جمع تخفیف' if is_fa else 'Total discount'
|
||
label_tax = 'جمع مالیات' if is_fa else 'Total tax'
|
||
summary_parts = [
|
||
f'<div><strong>{label_rows}:</strong> {total_count}</div>',
|
||
f'<div><strong>{label_total}:</strong> <span class="amount">{total_sum:.2f}</span></div>',
|
||
]
|
||
# Only render discount/tax if present in any row (non-zero)
|
||
if discount_sum != 0.0:
|
||
summary_parts.append(f'<div><strong>{label_discount}:</strong> <span class="amount">{discount_sum:.2f}</span></div>')
|
||
if tax_sum != 0.0:
|
||
summary_parts.append(f'<div><strong>{label_tax}:</strong> <span class="amount">{tax_sum:.2f}</span></div>')
|
||
summary_html = f'<div class="summary">{"".join(summary_parts)}</div>'
|
||
|
||
# کانتکست مشترک برای قالبهای سفارشی
|
||
template_context: Dict[str, Any] = {
|
||
"title_text": title_text,
|
||
"business_name": business_name,
|
||
"generated_at": now,
|
||
"is_fa": is_fa,
|
||
"fa_font_url_regular": None,
|
||
"fa_font_url_bold": None,
|
||
"headers": headers,
|
||
"keys": keys,
|
||
"items": items,
|
||
# خروجیهای HTML آماده برای استفاده سریع در قالب
|
||
"table_headers_html": headers_html,
|
||
"table_rows_html": "".join(rows_html),
|
||
"table_summary_html": summary_html,
|
||
}
|
||
|
||
# Embed Farsi fonts like single-invoice PDF (if available)
|
||
try:
|
||
if is_fa:
|
||
project_root = Path(__file__).resolve().parents[4]
|
||
fonts_dir = project_root / "hesabixUI" / "hesabix_ui" / "assets" / "fonts"
|
||
regular_path = fonts_dir / "YekanBakhFaNum-Regular.ttf"
|
||
bold_path = fonts_dir / "YekanBakhFaNum-Bold.ttf"
|
||
if regular_path.is_file():
|
||
import base64 as _b64
|
||
_data = regular_path.read_bytes()
|
||
_b64_data = _b64.b64encode(_data).decode("ascii")
|
||
template_context["fa_font_url_regular"] = f"data:font/ttf;base64,{_b64_data}"
|
||
if bold_path.is_file():
|
||
import base64 as _b64b
|
||
_data_b = bold_path.read_bytes()
|
||
_b64_data_b = _b64b.b64encode(_data_b).decode("ascii")
|
||
template_context["fa_font_url_bold"] = f"data:font/ttf;base64,{_b64_data_b}"
|
||
except Exception:
|
||
pass
|
||
|
||
# تلاش برای رندر با قالب سفارشی (explicit یا پیشفرض)
|
||
try:
|
||
from app.services.report_template_service import ReportTemplateService
|
||
explicit_template_id = None
|
||
try:
|
||
if "template_id" in body and body.get("template_id") is not None:
|
||
explicit_template_id = int(body.get("template_id"))
|
||
except Exception:
|
||
explicit_template_id = None
|
||
resolved_html = ReportTemplateService.try_render_resolved(
|
||
db=db,
|
||
business_id=business_id,
|
||
module_key="invoices",
|
||
subtype="list",
|
||
context=template_context,
|
||
explicit_template_id=explicit_template_id,
|
||
)
|
||
except Exception:
|
||
resolved_html = None
|
||
|
||
# HTML پیشفرض در نبود قالب: استفاده از فایل قالب
|
||
disposition = "attachment"
|
||
try:
|
||
disposition = str(body.get("disposition") or "attachment")
|
||
except Exception:
|
||
disposition = "attachment"
|
||
paper_size = None
|
||
orientation = None
|
||
try:
|
||
paper_size = body.get("paper_size")
|
||
orientation = body.get("orientation")
|
||
except Exception:
|
||
pass
|
||
html_content = resolved_html or render_template(
|
||
"pdf/invoices/list.html",
|
||
{
|
||
**template_context,
|
||
"title_text": title_text,
|
||
"paper_size": paper_size,
|
||
"orientation": orientation,
|
||
"footer_text": footer_text,
|
||
},
|
||
)
|
||
|
||
font_config = FontConfiguration()
|
||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
|
||
|
||
def slugify(text: str) -> str:
|
||
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||
|
||
base = "invoices"
|
||
if business_name:
|
||
base += f"_{slugify(business_name)}"
|
||
if selected_only:
|
||
base += "_selected"
|
||
# Add filters to filename when available
|
||
try:
|
||
doc_type = body.get("document_type")
|
||
if isinstance(doc_type, str) and doc_type:
|
||
base += f"_{slugify(doc_type)}"
|
||
except Exception:
|
||
pass
|
||
try:
|
||
fd = body.get("from_date")
|
||
td = body.get("to_date")
|
||
if isinstance(fd, str) and fd:
|
||
base += f"_from_{slugify(fd[:10])}"
|
||
if isinstance(td, str) and td:
|
||
base += f"_to_{slugify(td[:10])}"
|
||
except Exception:
|
||
pass
|
||
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={
|
||
"Content-Disposition": f"{disposition}; filename={filename}",
|
||
"Content-Length": str(len(pdf_bytes)),
|
||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/import/template",
|
||
summary="دانلود تمپلیت ایمپورت فاکتورها",
|
||
description=(
|
||
"فایل XLSX با هدر ستونها و چند ردیف نمونه؛ ستون `invoice_number` ردیفهای یک فاکتور را گروهبندی میکند. "
|
||
"انواع مجاز در ستون invoice_type در توضیحات هدر فایل آمده است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def download_invoices_import_template(
|
||
request: Request,
|
||
business_id: int,
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "add")),
|
||
):
|
||
import datetime
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, Alignment, PatternFill
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Invoices"
|
||
|
||
# Header row
|
||
headers = [
|
||
"invoice_number", # شناسه یکتا برای گروهبندی ردیفهای یک فاکتور
|
||
"invoice_type", # نوع فاکتور: sales, purchase, sales_return, purchase_return, direct_consumption, production, waste
|
||
"document_date", # تاریخ فاکتور: YYYY-MM-DD
|
||
"currency_code", # کد ارز: IRR, USD, etc.
|
||
"is_proforma", # پیشفاکتور: TRUE/FALSE
|
||
"description", # توضیحات فاکتور
|
||
"person_code", # کد مشتری/تامینکننده (برای sales/purchase)
|
||
"seller_code", # کد فروشنده/بازاریاب (اختیاری)
|
||
"due_date", # تاریخ سررسید (اختیاری)
|
||
"post_inventory", # ثبت انبار: TRUE/FALSE
|
||
"product_code", # کد کالا/خدمت
|
||
"quantity", # تعداد
|
||
"unit", # واحد: main/secondary (پیشفرض: main)
|
||
"unit_price", # قیمت واحد
|
||
"discount_type", # نوع تخفیف: percent/amount (پیشفرض: amount)
|
||
"discount_value", # مقدار تخفیف
|
||
"tax_rate", # نرخ مالیات (درصد)
|
||
"line_description", # توضیحات ردیف
|
||
"movement", # جهت حرکت: in/out (برای فاکتور تولید)
|
||
"warehouse_code", # کد انبار (اختیاری)
|
||
]
|
||
|
||
# Header styling
|
||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||
header_font = Font(bold=True, color="FFFFFF")
|
||
|
||
for col, header in enumerate(headers, 1):
|
||
cell = ws.cell(row=1, column=col, value=header)
|
||
cell.font = header_font
|
||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||
cell.fill = header_fill
|
||
|
||
# Sample data rows
|
||
# نکته: در ردیفهای بعدی هر فاکتور، ستونهای هدر (invoice_type, document_date, person_code, ...) خالی میمانند
|
||
# فقط invoice_number و اطلاعات ردیف (product_code, quantity, ...) در همه ردیفها وارد میشوند
|
||
samples = [
|
||
[
|
||
"INV-001", "sales", "2024-01-15", "IRR", "FALSE", "فاکتور فروش نمونه",
|
||
"CUST-001", "", "2024-02-15", "TRUE",
|
||
"P1001", "10", "main", "100000", "amount", "5000", "9", "ردیف اول", "", "WH-001"
|
||
],
|
||
[
|
||
"INV-001", "", "", "", "", "", "", "", "", "",
|
||
"P1002", "5", "main", "200000", "percent", "10", "9", "ردیف دوم", "", "WH-001"
|
||
],
|
||
[
|
||
"INV-002", "purchase", "2024-01-16", "IRR", "FALSE", "فاکتور خرید نمونه",
|
||
"SUPP-001", "", "", "TRUE",
|
||
"P1003", "20", "main", "50000", "amount", "0", "9", "", "", "WH-001"
|
||
],
|
||
]
|
||
|
||
for row_idx, sample in enumerate(samples, start=2):
|
||
for col, val in enumerate(sample, 1):
|
||
ws.cell(row=row_idx, column=col, value=val)
|
||
|
||
# Auto width
|
||
for column in ws.columns:
|
||
try:
|
||
letter = column[0].column_letter
|
||
max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column)
|
||
ws.column_dimensions[letter].width = min(max_len + 2, 50)
|
||
except Exception:
|
||
pass
|
||
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
buf.seek(0)
|
||
|
||
filename = f"invoices_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||
return Response(
|
||
content=buf.getvalue(),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename={filename}",
|
||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||
},
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/import/excel",
|
||
summary="ایمپورت فاکتورها از فایل Excel",
|
||
description=(
|
||
"فرم چندبخشی: فیلد `file` (xlsx)، فرم `dry_run` (true/false) — در حالت dry_run فقط اعتبارسنجی و خلاصه بدون ثبت سند. "
|
||
"خروجی شامل `summary` (تعداد، معتبر، نامعتبر، ایجادشده) و آرایه `errors` به تفکیک شماره فاکتور است."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def import_invoices_excel(
|
||
request: Request,
|
||
business_id: int,
|
||
file: UploadFile = File(..., description="فایل Excel (.xlsx) مطابق تمپلیت ایمپورت"),
|
||
dry_run: str = Form(
|
||
default="true",
|
||
description="true: فقط اعتبارسنجی و گزارش؛ false: ثبت فاکتورهای معتبر",
|
||
),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "add")),
|
||
):
|
||
import zipfile
|
||
from decimal import Decimal
|
||
from collections import defaultdict
|
||
from datetime import datetime as dt
|
||
from openpyxl import load_workbook
|
||
from app.services.invoice_service import (
|
||
INVOICE_SALES, INVOICE_PURCHASE, INVOICE_SALES_RETURN, INVOICE_PURCHASE_RETURN,
|
||
INVOICE_DIRECT_CONSUMPTION, INVOICE_PRODUCTION, INVOICE_WASTE,
|
||
)
|
||
|
||
def _validate_excel_signature(content: bytes) -> bool:
|
||
try:
|
||
if not content.startswith(b'PK'):
|
||
return False
|
||
with zipfile.ZipFile(io.BytesIO(content), 'r') as zf:
|
||
return any(n.startswith('xl/') for n in zf.namelist())
|
||
except Exception:
|
||
return False
|
||
|
||
def _parse_bool(v: object) -> Optional[bool]:
|
||
if v is None: return None
|
||
s = str(v).strip().lower()
|
||
if s in ("true", "1", "yes", "on", "بله", "هست"):
|
||
return True
|
||
if s in ("false", "0", "no", "off", "خیر", "نیست"):
|
||
return False
|
||
return None
|
||
|
||
def _parse_decimal(v: object) -> Optional[Decimal]:
|
||
if v is None or str(v).strip() == "":
|
||
return None
|
||
try:
|
||
return Decimal(str(v).replace(",", ""))
|
||
except Exception:
|
||
return None
|
||
|
||
def _parse_date(v: object) -> Optional[date]:
|
||
if v is None or str(v).strip() == "":
|
||
return None
|
||
try:
|
||
if isinstance(v, dt):
|
||
return v.date()
|
||
s = str(v).strip()
|
||
# Try different date formats
|
||
for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y", "%d-%m-%Y"]:
|
||
try:
|
||
return dt.strptime(s, fmt).date()
|
||
except Exception:
|
||
continue
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
def _normalize_invoice_type(v: object) -> Optional[str]:
|
||
if v is None: return None
|
||
s = str(v).strip().lower()
|
||
mapping = {
|
||
"sales": INVOICE_SALES,
|
||
"purchase": INVOICE_PURCHASE,
|
||
"sales_return": INVOICE_SALES_RETURN,
|
||
"purchase_return": INVOICE_PURCHASE_RETURN,
|
||
"direct_consumption": INVOICE_DIRECT_CONSUMPTION,
|
||
"production": INVOICE_PRODUCTION,
|
||
"waste": INVOICE_WASTE,
|
||
}
|
||
if s in mapping:
|
||
return mapping[s]
|
||
# Check if already in correct format
|
||
if s.startswith("invoice_"):
|
||
return s
|
||
return None
|
||
|
||
try:
|
||
is_dry_run = str(dry_run).lower() in ("true", "1", "yes", "on")
|
||
|
||
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
||
raise ApiError("INVALID_FILE", "فرمت فایل معتبر نیست. تنها xlsx پشتیبانی میشود", http_status=400)
|
||
|
||
content = await file.read()
|
||
if len(content) < 100 or not _validate_excel_signature(content):
|
||
raise ApiError("INVALID_FILE", "فایل Excel معتبر نیست یا خالی است", http_status=400)
|
||
|
||
try:
|
||
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
|
||
except zipfile.BadZipFile:
|
||
raise ApiError("INVALID_FILE", "فایل Excel خراب است یا فرمت آن معتبر نیست", http_status=400)
|
||
|
||
ws = wb.active
|
||
rows = list(ws.iter_rows(values_only=True))
|
||
if not rows:
|
||
return success_response(data={"summary": {"total": 0}}, request=request, message="EMPTY_FILE")
|
||
|
||
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
|
||
data_rows = rows[1:]
|
||
|
||
# Find column indices
|
||
col_map = {h.lower(): i for i, h in enumerate(headers)}
|
||
|
||
# Required columns
|
||
required_cols = ["invoice_number", "invoice_type", "document_date", "currency_code", "product_code", "quantity", "unit_price"]
|
||
missing_cols = [c for c in required_cols if c.lower() not in col_map]
|
||
if missing_cols:
|
||
raise ApiError("MISSING_COLUMNS", f"ستونهای الزامی یافت نشد: {', '.join(missing_cols)}", http_status=400)
|
||
|
||
# Group rows by invoice_number
|
||
invoices_data: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||
|
||
for row_idx, row in enumerate(data_rows, start=2):
|
||
invoice_number = str(row[col_map["invoice_number"]]).strip() if col_map["invoice_number"] < len(row) else None
|
||
if not invoice_number:
|
||
continue
|
||
|
||
row_data = {}
|
||
for col_name, col_idx in col_map.items():
|
||
if col_idx < len(row):
|
||
row_data[col_name] = row[col_idx]
|
||
|
||
invoices_data[invoice_number].append({
|
||
"row": row_idx,
|
||
"data": row_data
|
||
})
|
||
|
||
# Cache lookups
|
||
product_cache: Dict[str, Optional[Product]] = {}
|
||
person_cache: Dict[str, Optional[Person]] = {}
|
||
currency_cache: Dict[str, Optional[Currency]] = {}
|
||
warehouse_cache: Dict[str, Optional[Warehouse]] = {}
|
||
|
||
def _get_product(code: str) -> Optional[Product]:
|
||
if code in product_cache:
|
||
return product_cache[code]
|
||
product = db.query(Product).filter(
|
||
and_(Product.business_id == business_id, Product.code == code)
|
||
).first()
|
||
product_cache[code] = product
|
||
return product
|
||
|
||
def _get_person(code: str) -> Optional[Person]:
|
||
if code in person_cache:
|
||
return person_cache[code]
|
||
try:
|
||
code_int = int(code)
|
||
person = db.query(Person).filter(
|
||
and_(Person.business_id == business_id, Person.code == code_int)
|
||
).first()
|
||
except Exception:
|
||
person = None
|
||
person_cache[code] = person
|
||
return person
|
||
|
||
def _get_currency(code: str) -> Optional[Currency]:
|
||
if code in currency_cache:
|
||
return currency_cache[code]
|
||
currency = db.query(Currency).filter(Currency.code == code.upper()).first()
|
||
currency_cache[code] = currency
|
||
return currency
|
||
|
||
def _get_warehouse(code: str) -> Optional[Warehouse]:
|
||
if code in warehouse_cache:
|
||
return warehouse_cache[code]
|
||
warehouse = db.query(Warehouse).filter(
|
||
and_(Warehouse.business_id == business_id, Warehouse.code == code)
|
||
).first()
|
||
warehouse_cache[code] = warehouse
|
||
return warehouse
|
||
|
||
errors: List[Dict[str, Any]] = []
|
||
valid_invoices: List[Dict[str, Any]] = []
|
||
created_count = 0
|
||
|
||
# Process each invoice group
|
||
for invoice_number, rows_list in invoices_data.items():
|
||
if not rows_list:
|
||
continue
|
||
|
||
# Extract header data from first row (ردیف اول هر فاکتور باید اطلاعات هدر را داشته باشد)
|
||
# در ردیفهای بعدی، اگر ستونهای هدر خالی باشند، از ردیف اول استفاده میشود
|
||
first_row = rows_list[0]["data"]
|
||
invoice_errors: List[str] = []
|
||
|
||
# Helper function to get header value (from first row if empty in current row)
|
||
def _get_header_value(key: str, row_data: Dict[str, Any]) -> Any:
|
||
val = row_data.get(key)
|
||
# اگر خالی است، از ردیف اول استفاده کن
|
||
if val is None or (isinstance(val, str) and str(val).strip() == ""):
|
||
return first_row.get(key)
|
||
return val
|
||
|
||
# Parse invoice type
|
||
invoice_type_raw = str(_get_header_value("invoice_type", first_row) or "").strip()
|
||
invoice_type = _normalize_invoice_type(invoice_type_raw)
|
||
if not invoice_type:
|
||
invoice_errors.append(f"نوع فاکتور نامعتبر: {invoice_type_raw}")
|
||
|
||
# Parse document date
|
||
doc_date = _parse_date(_get_header_value("document_date", first_row))
|
||
if not doc_date:
|
||
invoice_errors.append("تاریخ فاکتور الزامی است")
|
||
|
||
# Parse currency
|
||
currency_code = str(_get_header_value("currency_code", first_row) or "").strip()
|
||
currency = _get_currency(currency_code) if currency_code else None
|
||
if not currency:
|
||
invoice_errors.append(f"ارز یافت نشد: {currency_code}")
|
||
|
||
# Parse person (for sales/purchase)
|
||
person_id = None
|
||
person_code = str(_get_header_value("person_code", first_row) or "").strip()
|
||
if person_code:
|
||
person = _get_person(person_code)
|
||
if person:
|
||
person_id = person.id
|
||
else:
|
||
invoice_errors.append(f"شخص یافت نشد: {person_code}")
|
||
elif invoice_type in {INVOICE_SALES, INVOICE_SALES_RETURN, INVOICE_PURCHASE, INVOICE_PURCHASE_RETURN}:
|
||
invoice_errors.append("کد مشتری/تامینکننده الزامی است")
|
||
|
||
# Parse other header fields
|
||
is_proforma = _parse_bool(_get_header_value("is_proforma", first_row)) or False
|
||
description = str(_get_header_value("description", first_row) or "").strip() or None
|
||
seller_code = str(_get_header_value("seller_code", first_row) or "").strip()
|
||
seller_id = None
|
||
if seller_code:
|
||
seller = _get_person(seller_code)
|
||
if seller:
|
||
seller_id = seller.id
|
||
else:
|
||
invoice_errors.append(f"فروشنده یافت نشد: {seller_code}")
|
||
|
||
due_date = _parse_date(_get_header_value("due_date", first_row))
|
||
post_inventory = _parse_bool(_get_header_value("post_inventory", first_row))
|
||
if post_inventory is None:
|
||
post_inventory = True
|
||
|
||
# Parse line items
|
||
lines: List[Dict[str, Any]] = []
|
||
for row_info in rows_list:
|
||
row_data = row_info["data"]
|
||
row_num = row_info["row"]
|
||
line_errors: List[str] = []
|
||
|
||
# Product
|
||
product_code = str(row_data.get("product_code", "")).strip()
|
||
if not product_code:
|
||
line_errors.append("کد محصول الزامی است")
|
||
continue
|
||
|
||
product = _get_product(product_code)
|
||
if not product:
|
||
line_errors.append(f"محصول یافت نشد: {product_code}")
|
||
continue
|
||
|
||
# Quantity
|
||
quantity = _parse_decimal(row_data.get("quantity"))
|
||
if not quantity or quantity <= 0:
|
||
line_errors.append("تعداد باید بزرگتر از صفر باشد")
|
||
continue
|
||
|
||
# Unit price
|
||
unit_price = _parse_decimal(row_data.get("unit_price"))
|
||
if not unit_price or unit_price < 0:
|
||
line_errors.append("قیمت واحد الزامی است")
|
||
continue
|
||
|
||
# Unit
|
||
unit = str(row_data.get("unit", "main")).strip().lower()
|
||
if unit not in ("main", "secondary"):
|
||
unit = "main"
|
||
|
||
# Discount
|
||
discount_type = str(row_data.get("discount_type", "amount")).strip().lower()
|
||
if discount_type not in ("percent", "amount"):
|
||
discount_type = "amount"
|
||
discount_value = _parse_decimal(row_data.get("discount_value")) or Decimal(0)
|
||
|
||
# Tax rate
|
||
tax_rate = _parse_decimal(row_data.get("tax_rate")) or Decimal(0)
|
||
|
||
# Line description
|
||
line_description = str(row_data.get("line_description", "")).strip() or None
|
||
|
||
# Movement (for production invoices)
|
||
movement = str(row_data.get("movement", "")).strip().lower()
|
||
if movement not in ("in", "out"):
|
||
movement = None
|
||
|
||
# Warehouse
|
||
warehouse_id = None
|
||
warehouse_code = row_data.get("warehouse_code", "")
|
||
if warehouse_code:
|
||
warehouse = _get_warehouse(str(warehouse_code))
|
||
if warehouse:
|
||
warehouse_id = warehouse.id
|
||
else:
|
||
line_errors.append(f"انبار یافت نشد: {warehouse_code}")
|
||
|
||
if line_errors:
|
||
invoice_errors.extend([f"ردیف {row_num}: {e}" for e in line_errors])
|
||
continue
|
||
|
||
# Build line extra_info
|
||
line_extra_info: Dict[str, Any] = {
|
||
"unit_price": float(unit_price),
|
||
"discount_type": discount_type,
|
||
"discount_value": float(discount_value),
|
||
"tax_rate": float(tax_rate),
|
||
}
|
||
if movement:
|
||
line_extra_info["movement"] = movement
|
||
if warehouse_id:
|
||
line_extra_info["warehouse_id"] = warehouse_id
|
||
|
||
lines.append({
|
||
"product_id": product.id,
|
||
"quantity": float(quantity),
|
||
"description": line_description,
|
||
"extra_info": line_extra_info,
|
||
})
|
||
|
||
if not lines:
|
||
invoice_errors.append("حداقل یک ردیف معتبر الزامی است")
|
||
|
||
if invoice_errors:
|
||
errors.append({
|
||
"invoice_number": invoice_number,
|
||
"row": rows_list[0]["row"],
|
||
"errors": invoice_errors
|
||
})
|
||
continue
|
||
|
||
# Build invoice payload
|
||
extra_info: Dict[str, Any] = {
|
||
"post_inventory": post_inventory,
|
||
}
|
||
if person_id:
|
||
extra_info["person_id"] = person_id
|
||
if seller_id:
|
||
extra_info["seller_id"] = seller_id
|
||
|
||
invoice_payload = {
|
||
"invoice_type": invoice_type,
|
||
"document_date": doc_date.isoformat(),
|
||
"currency_id": currency.id,
|
||
"is_proforma": is_proforma,
|
||
"description": description,
|
||
"extra_info": extra_info,
|
||
"lines": lines,
|
||
}
|
||
|
||
if due_date:
|
||
invoice_payload["due_date"] = due_date.isoformat()
|
||
|
||
valid_invoices.append({
|
||
"invoice_number": invoice_number,
|
||
"payload": invoice_payload,
|
||
})
|
||
|
||
# Create invoices if not dry run
|
||
if not is_dry_run and valid_invoices:
|
||
user_id = ctx.get_user_id()
|
||
can_change_unit = user_can_change_invoice_unit_price(ctx, db, business_id)
|
||
for inv_info in valid_invoices:
|
||
try:
|
||
create_invoice(
|
||
db=db,
|
||
business_id=business_id,
|
||
user_id=user_id,
|
||
data=inv_info["payload"],
|
||
user_can_change_invoice_unit_price=can_change_unit,
|
||
)
|
||
created_count += 1
|
||
except Exception as e:
|
||
logger.error(f"Failed to create invoice {inv_info['invoice_number']}: {e}", exc_info=True)
|
||
errors.append({
|
||
"invoice_number": inv_info["invoice_number"],
|
||
"row": 0,
|
||
"errors": [f"خطا در ایجاد فاکتور: {str(e)}"]
|
||
})
|
||
|
||
summary = {
|
||
"total": len(invoices_data),
|
||
"valid": len(valid_invoices),
|
||
"invalid": len(errors),
|
||
"created": created_count,
|
||
"dry_run": is_dry_run,
|
||
}
|
||
|
||
return success_response(
|
||
data={"summary": summary, "errors": errors},
|
||
request=request,
|
||
message="INVOICES_IMPORT_RESULT",
|
||
)
|
||
except ApiError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Import error: {e}", exc_info=True)
|
||
raise ApiError("IMPORT_ERROR", f"خطا در پردازش فایل: {e}", http_status=500)
|
||
|
||
|
||
@router.post(
|
||
"/business/{business_id}/invoices/calculate-remaining",
|
||
summary="مانده چند فاکتور (پرداختشده و باقیمانده)",
|
||
description=(
|
||
"بدنه: `{\"invoice_ids\": [int, ...]}` — برای هر شناسه مبلغ کل، پرداختشده، مانده و وضعیت تسویه برمیگردد. "
|
||
"برای نمایش در لیست یا فاکتور اقساطی کاربرد دارد."
|
||
),
|
||
)
|
||
@require_business_access("business_id")
|
||
async def calculate_invoices_remaining_endpoint(
|
||
request: Request,
|
||
business_id: int,
|
||
body: Dict[str, Any] = Body(...),
|
||
ctx: AuthContext = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
_: None = Depends(require_business_permission_dep("invoices", "view")),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
محاسبه مانده چند فاکتور در یک درخواست
|
||
|
||
Body:
|
||
{
|
||
"invoice_ids": [123, 456, 789] # لیست شناسه فاکتورها
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"results": {
|
||
123: {
|
||
"invoice_id": 123,
|
||
"total_amount": 1000000.0,
|
||
"paid_amount": 500000.0,
|
||
"remaining": 500000.0,
|
||
"is_settled": false
|
||
},
|
||
456: {
|
||
"invoice_id": 456,
|
||
"total_amount": 2000000.0,
|
||
"paid_amount": 2000000.0,
|
||
"remaining": 0.0,
|
||
"is_settled": true
|
||
}
|
||
},
|
||
"errors": {
|
||
789: "فاکتور یافت نشد"
|
||
}
|
||
}
|
||
"""
|
||
from app.services.invoice_service import calculate_invoice_remaining
|
||
|
||
invoice_ids = body.get("invoice_ids", [])
|
||
|
||
if not isinstance(invoice_ids, list):
|
||
raise ApiError("INVALID_INPUT", "invoice_ids باید یک لیست باشد", http_status=400)
|
||
|
||
if len(invoice_ids) > 100: # محدودیت برای جلوگیری از overload
|
||
raise ApiError("TOO_MANY_INVOICES", "حداکثر 100 فاکتور در یک درخواست", http_status=400)
|
||
|
||
results = {}
|
||
errors = {}
|
||
|
||
logger.info(f"محاسبه مانده برای {len(invoice_ids)} فاکتور - invoice_ids: {invoice_ids}")
|
||
|
||
for invoice_id in invoice_ids:
|
||
try:
|
||
invoice_id_int = int(invoice_id)
|
||
logger.info(f"محاسبه مانده فاکتور {invoice_id_int}")
|
||
result = calculate_invoice_remaining(db, business_id, invoice_id_int)
|
||
# تبدیل کلید به string برای JSON serialization
|
||
results[str(invoice_id_int)] = result
|
||
logger.info(f"نتیجه برای فاکتور {invoice_id_int}: {result}")
|
||
except ApiError as e:
|
||
logger.warning(f"ApiError برای فاکتور {invoice_id}: {e.message}")
|
||
errors[str(invoice_id_int)] = e.message
|
||
except Exception as e:
|
||
logger.exception(f"خطا در محاسبه مانده فاکتور {invoice_id}")
|
||
errors[str(invoice_id_int)] = "خطا در محاسبه مانده"
|
||
|
||
logger.info(f"نتایج: {len(results)} موفق، {len(errors)} خطا")
|
||
|
||
return success_response(
|
||
data={
|
||
"results": results,
|
||
"errors": errors,
|
||
},
|
||
request=request,
|
||
message="INVOICE_REMAINING_CALCULATED",
|
||
)
|
||
|