2025-10-14 23:16:28 +03:30
|
|
|
|
"""
|
|
|
|
|
|
API endpoints برای دریافت و پرداخت (Receipt & Payment)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2025-10-15 21:21:11 +03:30
|
|
|
|
from typing import Any, Dict, List
|
2025-10-14 23:16:28 +03:30
|
|
|
|
from fastapi import APIRouter, Depends, Request, Body
|
2025-10-15 21:21:11 +03:30
|
|
|
|
from fastapi.responses import Response
|
2025-10-14 23:16:28 +03:30
|
|
|
|
from sqlalchemy.orm import Session
|
2025-10-15 21:21:11 +03:30
|
|
|
|
import io
|
|
|
|
|
|
import json
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
import re
|
2025-10-14 23:16:28 +03:30
|
|
|
|
|
|
|
|
|
|
from adapters.db.session import get_db
|
|
|
|
|
|
from app.core.auth_dependency import get_current_user, AuthContext
|
|
|
|
|
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
|
|
|
|
|
from app.core.permissions import require_business_management_dep, require_business_access
|
|
|
|
|
|
from adapters.api.v1.schemas import QueryInfo
|
|
|
|
|
|
from app.services.receipt_payment_service import (
|
|
|
|
|
|
create_receipt_payment,
|
|
|
|
|
|
get_receipt_payment,
|
|
|
|
|
|
list_receipts_payments,
|
|
|
|
|
|
delete_receipt_payment,
|
2025-10-16 13:02:03 +03:30
|
|
|
|
update_receipt_payment,
|
2025-10-14 23:16:28 +03:30
|
|
|
|
)
|
2025-10-15 21:21:11 +03:30
|
|
|
|
from adapters.db.models.business import Business
|
2025-10-14 23:16:28 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(tags=["receipts-payments"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/businesses/{business_id}/receipts-payments",
|
|
|
|
|
|
summary="لیست اسناد دریافت و پرداخت",
|
|
|
|
|
|
description="دریافت لیست اسناد دریافت و پرداخت با فیلتر و جستجو",
|
|
|
|
|
|
)
|
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
|
async def list_receipts_payments_endpoint(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
business_id: int,
|
|
|
|
|
|
query_info: QueryInfo = Body(...),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
لیست اسناد دریافت و پرداخت
|
|
|
|
|
|
|
|
|
|
|
|
پارامترهای اضافی در body:
|
|
|
|
|
|
- document_type: "receipt" یا "payment" (اختیاری)
|
|
|
|
|
|
- from_date: تاریخ شروع (اختیاری)
|
|
|
|
|
|
- to_date: تاریخ پایان (اختیاری)
|
|
|
|
|
|
"""
|
|
|
|
|
|
query_dict: Dict[str, Any] = {
|
|
|
|
|
|
"take": query_info.take,
|
|
|
|
|
|
"skip": query_info.skip,
|
|
|
|
|
|
"sort_by": query_info.sort_by,
|
|
|
|
|
|
"sort_desc": query_info.sort_desc,
|
|
|
|
|
|
"search": query_info.search,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت پارامترهای اضافی از body
|
|
|
|
|
|
try:
|
|
|
|
|
|
body_json = await request.json()
|
|
|
|
|
|
if isinstance(body_json, dict):
|
|
|
|
|
|
for key in ["document_type", "from_date", "to_date"]:
|
|
|
|
|
|
if key in body_json:
|
|
|
|
|
|
query_dict[key] = body_json[key]
|
2025-10-16 20:52:59 +03:30
|
|
|
|
print(f"API - پارامتر {key}: {body_json[key]}")
|
2025-10-14 23:16:28 +03:30
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2025-10-16 13:02:03 +03:30
|
|
|
|
# دریافت fiscal_year_id از هدر برای اولویت دادن به انتخاب کاربر
|
|
|
|
|
|
try:
|
|
|
|
|
|
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
|
|
|
|
|
if fy_header:
|
|
|
|
|
|
query_dict["fiscal_year_id"] = int(fy_header)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2025-10-14 23:16:28 +03:30
|
|
|
|
result = list_receipts_payments(db, business_id, query_dict)
|
|
|
|
|
|
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data=result,
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
message="RECEIPTS_PAYMENTS_LIST_FETCHED"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/businesses/{business_id}/receipts-payments/create",
|
|
|
|
|
|
summary="ایجاد سند دریافت یا پرداخت",
|
|
|
|
|
|
description="ایجاد سند دریافت یا پرداخت جدید",
|
|
|
|
|
|
)
|
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
|
async def create_receipt_payment_endpoint(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
business_id: int,
|
|
|
|
|
|
body: Dict[str, Any] = Body(...),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
_: None = Depends(require_business_management_dep),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
ایجاد سند دریافت یا پرداخت
|
|
|
|
|
|
|
|
|
|
|
|
Body باید شامل موارد زیر باشد:
|
|
|
|
|
|
{
|
|
|
|
|
|
"document_type": "receipt" | "payment",
|
|
|
|
|
|
"document_date": "2025-01-15T10:30:00",
|
|
|
|
|
|
"currency_id": 1,
|
2025-10-16 20:52:59 +03:30
|
|
|
|
"description": "توضیحات کلی سند (اختیاری)",
|
2025-10-14 23:16:28 +03:30
|
|
|
|
"person_lines": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"person_id": 123,
|
|
|
|
|
|
"person_name": "علی احمدی",
|
|
|
|
|
|
"amount": 1000000,
|
|
|
|
|
|
"description": "توضیحات (اختیاری)"
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"account_lines": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"account_id": 456,
|
|
|
|
|
|
"amount": 1000000,
|
|
|
|
|
|
"transaction_type": "bank" | "cash_register" | "petty_cash" | "check",
|
|
|
|
|
|
"transaction_date": "2025-01-15T10:30:00",
|
|
|
|
|
|
"commission": 5000, // اختیاری
|
|
|
|
|
|
"description": "توضیحات (اختیاری)",
|
|
|
|
|
|
// اطلاعات اضافی بر اساس نوع تراکنش:
|
|
|
|
|
|
"bank_id": "123", // برای نوع bank
|
|
|
|
|
|
"bank_name": "بانک ملی",
|
|
|
|
|
|
"cash_register_id": "456", // برای نوع cash_register
|
|
|
|
|
|
"cash_register_name": "صندوق اصلی",
|
|
|
|
|
|
"petty_cash_id": "789", // برای نوع petty_cash
|
|
|
|
|
|
"petty_cash_name": "تنخواهگردان فروش",
|
|
|
|
|
|
"check_id": "101", // برای نوع check
|
|
|
|
|
|
"check_number": "123456"
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"extra_info": {} // اختیاری
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
created = create_receipt_payment(db, business_id, ctx.get_user_id(), body)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data=format_datetime_fields(created, request),
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
message="RECEIPT_PAYMENT_CREATED"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
|
"/receipts-payments/{document_id}",
|
|
|
|
|
|
summary="جزئیات سند دریافت/پرداخت",
|
|
|
|
|
|
description="دریافت جزئیات یک سند دریافت یا پرداخت",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def get_receipt_payment_endpoint(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
document_id: int,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""دریافت جزئیات سند"""
|
|
|
|
|
|
result = get_receipt_payment(db, document_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
"DOCUMENT_NOT_FOUND",
|
|
|
|
|
|
"Receipt/Payment document not found",
|
|
|
|
|
|
http_status=404
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# بررسی دسترسی
|
|
|
|
|
|
business_id = result.get("business_id")
|
|
|
|
|
|
if business_id and not ctx.can_access_business(business_id):
|
|
|
|
|
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data=format_datetime_fields(result, request),
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
message="RECEIPT_PAYMENT_DETAILS"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
|
|
|
|
"/receipts-payments/{document_id}",
|
|
|
|
|
|
summary="حذف سند دریافت/پرداخت",
|
|
|
|
|
|
description="حذف یک سند دریافت یا پرداخت",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def delete_receipt_payment_endpoint(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
document_id: int,
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
_: None = Depends(require_business_management_dep),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""حذف سند"""
|
|
|
|
|
|
# دریافت سند برای بررسی دسترسی
|
|
|
|
|
|
result = get_receipt_payment(db, document_id)
|
|
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
|
business_id = result.get("business_id")
|
|
|
|
|
|
if business_id and not ctx.can_access_business(business_id):
|
|
|
|
|
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
|
|
|
|
|
|
|
|
|
|
|
ok = delete_receipt_payment(db, document_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
"DOCUMENT_NOT_FOUND",
|
|
|
|
|
|
"Receipt/Payment document not found",
|
|
|
|
|
|
http_status=404
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data=None,
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
message="RECEIPT_PAYMENT_DELETED"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-10-15 21:21:11 +03:30
|
|
|
|
|
2025-10-16 13:02:03 +03:30
|
|
|
|
@router.put(
|
|
|
|
|
|
"/receipts-payments/{document_id}",
|
|
|
|
|
|
summary="ویرایش سند دریافت/پرداخت",
|
|
|
|
|
|
description="بهروزرسانی یک سند دریافت یا پرداخت",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def update_receipt_payment_endpoint(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
document_id: int,
|
|
|
|
|
|
body: Dict[str, Any] = Body(...),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
_: None = Depends(require_business_management_dep),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""ویرایش سند"""
|
|
|
|
|
|
# دریافت سند برای بررسی دسترسی
|
|
|
|
|
|
result = get_receipt_payment(db, document_id)
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise ApiError("DOCUMENT_NOT_FOUND", "Receipt/Payment document not found", http_status=404)
|
|
|
|
|
|
|
|
|
|
|
|
business_id = result.get("business_id")
|
|
|
|
|
|
if business_id and not ctx.can_access_business(business_id):
|
|
|
|
|
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
|
|
|
|
|
|
|
|
|
|
|
updated = update_receipt_payment(db, document_id, ctx.get_user_id(), body)
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data=format_datetime_fields(updated, request),
|
|
|
|
|
|
request=request,
|
|
|
|
|
|
message="RECEIPT_PAYMENT_UPDATED",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-15 21:21:11 +03:30
|
|
|
|
@router.post(
|
|
|
|
|
|
"/businesses/{business_id}/receipts-payments/export/excel",
|
|
|
|
|
|
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
|
|
|
|
|
|
description="خروجی Excel لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
|
|
|
|
|
)
|
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
|
async def export_receipts_payments_excel(
|
|
|
|
|
|
business_id: int,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
body: Dict[str, Any] = Body(...),
|
|
|
|
|
|
auth_context: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""خروجی Excel لیست اسناد دریافت و پرداخت"""
|
|
|
|
|
|
from openpyxl import Workbook
|
|
|
|
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
|
|
|
|
from app.core.i18n import negotiate_locale
|
|
|
|
|
|
|
|
|
|
|
|
# Build query dict from flat body
|
|
|
|
|
|
# For export, we limit to reasonable number to prevent memory issues
|
|
|
|
|
|
max_export_records = 10000
|
|
|
|
|
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
|
|
|
|
|
|
|
|
|
|
|
query_dict = {
|
|
|
|
|
|
"take": take_value,
|
|
|
|
|
|
"skip": int(body.get("skip", 0)),
|
|
|
|
|
|
"sort_by": body.get("sort_by"),
|
|
|
|
|
|
"sort_desc": bool(body.get("sort_desc", False)),
|
|
|
|
|
|
"search": body.get("search"),
|
|
|
|
|
|
"search_fields": body.get("search_fields"),
|
|
|
|
|
|
"filters": body.get("filters"),
|
|
|
|
|
|
"document_type": body.get("document_type"),
|
|
|
|
|
|
"from_date": body.get("from_date"),
|
|
|
|
|
|
"to_date": body.get("to_date"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = list_receipts_payments(db, business_id, query_dict)
|
|
|
|
|
|
items = result.get('items', [])
|
|
|
|
|
|
items = [format_datetime_fields(item, request) for item in items]
|
|
|
|
|
|
|
|
|
|
|
|
# Check if we hit the limit
|
|
|
|
|
|
if len(items) >= max_export_records:
|
|
|
|
|
|
# Add a warning row to indicate data was truncated
|
|
|
|
|
|
warning_item = {
|
|
|
|
|
|
"code": "⚠️ هشدار",
|
|
|
|
|
|
"document_type": "حداکثر ۱۰,۰۰۰ رکورد قابل export است",
|
|
|
|
|
|
"document_date": "",
|
|
|
|
|
|
"total_amount": "",
|
|
|
|
|
|
"person_lines_count": "",
|
|
|
|
|
|
"account_lines_count": "",
|
|
|
|
|
|
"created_by_name": "",
|
|
|
|
|
|
"registered_at": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
items.append(warning_item)
|
|
|
|
|
|
|
|
|
|
|
|
# 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 headers based on export_columns (order + visibility)
|
|
|
|
|
|
headers: List[str] = []
|
|
|
|
|
|
keys: List[str] = []
|
|
|
|
|
|
export_columns = body.get('export_columns')
|
|
|
|
|
|
if 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))
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Default columns for receipts/payments
|
|
|
|
|
|
default_columns = [
|
|
|
|
|
|
('code', 'کد سند'),
|
|
|
|
|
|
('document_type_name', 'نوع سند'),
|
|
|
|
|
|
('document_date', 'تاریخ سند'),
|
|
|
|
|
|
('total_amount', 'مبلغ کل'),
|
|
|
|
|
|
('person_names', 'اشخاص'),
|
|
|
|
|
|
('account_lines_count', 'تعداد حسابها'),
|
|
|
|
|
|
('created_by_name', 'ایجادکننده'),
|
|
|
|
|
|
('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 = "Receipts & Payments"
|
|
|
|
|
|
|
|
|
|
|
|
# Locale and RTL/LTR handling
|
|
|
|
|
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
|
|
|
|
|
if locale == 'fa':
|
|
|
|
|
|
try:
|
|
|
|
|
|
ws.sheet_view.rightToLeft = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
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'))
|
|
|
|
|
|
|
|
|
|
|
|
# Write 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
|
|
|
|
|
|
|
|
|
|
|
|
# Write data rows
|
|
|
|
|
|
for row_idx, item in enumerate(items, 2):
|
|
|
|
|
|
for col_idx, key in enumerate(keys, 1):
|
|
|
|
|
|
value = item.get(key, "")
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
value = ", ".join(str(v) for v in value)
|
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
|
value = str(value)
|
|
|
|
|
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
|
|
|
|
|
cell.border = border
|
|
|
|
|
|
|
|
|
|
|
|
# RTL alignment for Persian text
|
|
|
|
|
|
if locale == 'fa' and isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in value):
|
|
|
|
|
|
cell.alignment = Alignment(horizontal="right")
|
|
|
|
|
|
|
|
|
|
|
|
# Auto-width columns
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
# Build meaningful 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 = "receipts_payments"
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-16 20:52:59 +03:30
|
|
|
|
@router.get(
|
|
|
|
|
|
"/receipts-payments/{document_id}/pdf",
|
|
|
|
|
|
summary="خروجی PDF تک سند دریافت/پرداخت",
|
|
|
|
|
|
description="خروجی PDF یک سند دریافت یا پرداخت",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def export_single_receipt_payment_pdf(
|
|
|
|
|
|
document_id: int,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
auth_context: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
2025-11-09 08:46:37 +03:30
|
|
|
|
template_id: int | None = None,
|
2025-10-16 20:52:59 +03:30
|
|
|
|
):
|
|
|
|
|
|
"""خروجی PDF تک سند دریافت/پرداخت"""
|
|
|
|
|
|
from weasyprint import HTML, CSS
|
|
|
|
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
|
|
|
|
from app.core.i18n import negotiate_locale
|
|
|
|
|
|
from html import escape
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت سند
|
|
|
|
|
|
result = get_receipt_payment(db, document_id)
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise ApiError(
|
|
|
|
|
|
"DOCUMENT_NOT_FOUND",
|
|
|
|
|
|
"Receipt/Payment document not found",
|
|
|
|
|
|
http_status=404
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# بررسی دسترسی
|
|
|
|
|
|
business_id = result.get("business_id")
|
|
|
|
|
|
if business_id and not auth_context.can_access_business(business_id):
|
|
|
|
|
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
|
|
|
|
|
|
|
|
|
|
|
# دریافت اطلاعات کسبوکار
|
|
|
|
|
|
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 handling
|
|
|
|
|
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
|
|
|
|
|
is_fa = locale == 'fa'
|
|
|
|
|
|
|
|
|
|
|
|
# آمادهسازی دادهها
|
|
|
|
|
|
doc_type_name = result.get("document_type_name", "")
|
|
|
|
|
|
doc_code = result.get("code", "")
|
|
|
|
|
|
doc_date = result.get("document_date", "")
|
|
|
|
|
|
total_amount = result.get("total_amount", 0)
|
|
|
|
|
|
description = result.get("description", "")
|
|
|
|
|
|
person_lines = result.get("person_lines", [])
|
|
|
|
|
|
account_lines = result.get("account_lines", [])
|
|
|
|
|
|
|
|
|
|
|
|
# تاریخ تولید
|
|
|
|
|
|
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
|
|
|
|
|
title_text = f"سند {doc_type_name}" if is_fa else f"{doc_type_name} Document"
|
|
|
|
|
|
label_biz = "کسب و کار" if is_fa else "Business"
|
|
|
|
|
|
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
|
|
|
|
|
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
|
|
|
|
|
|
2025-11-09 08:46:37 +03:30
|
|
|
|
# تلاش برای رندر با قالب سفارشی (receipts_payments/detail)
|
|
|
|
|
|
resolved_html = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.report_template_service import ReportTemplateService
|
|
|
|
|
|
explicit_template_id = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
if template_id is not None:
|
|
|
|
|
|
explicit_template_id = int(template_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
explicit_template_id = None
|
|
|
|
|
|
template_context = {
|
|
|
|
|
|
"business_id": business_id,
|
|
|
|
|
|
"business_name": business_name,
|
|
|
|
|
|
"document": result,
|
|
|
|
|
|
"person_lines": person_lines,
|
|
|
|
|
|
"account_lines": account_lines,
|
|
|
|
|
|
"code": doc_code,
|
|
|
|
|
|
"document_date": doc_date,
|
|
|
|
|
|
"total_amount": total_amount,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"title_text": title_text,
|
|
|
|
|
|
"generated_at": now,
|
|
|
|
|
|
"is_fa": is_fa,
|
|
|
|
|
|
}
|
|
|
|
|
|
resolved_html = ReportTemplateService.try_render_resolved(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
business_id=business_id,
|
|
|
|
|
|
module_key="receipts_payments",
|
|
|
|
|
|
subtype="detail",
|
|
|
|
|
|
context=template_context,
|
|
|
|
|
|
explicit_template_id=explicit_template_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
resolved_html = None
|
|
|
|
|
|
|
|
|
|
|
|
# ایجاد HTML پیشفرض در نبود قالب
|
|
|
|
|
|
html_content = resolved_html or f"""
|
2025-10-16 20:52:59 +03:30
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html dir="{'rtl' if is_fa else 'ltr'}">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>{title_text}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
@page {{
|
|
|
|
|
|
margin: 1cm;
|
|
|
|
|
|
size: A4;
|
|
|
|
|
|
}}
|
|
|
|
|
|
body {{
|
|
|
|
|
|
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
direction: {'rtl' if is_fa else 'ltr'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
.header {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
padding-bottom: 10px;
|
|
|
|
|
|
border-bottom: 2px solid #366092;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.title {{
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #366092;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta {{
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.document-info {{
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
padding: 15px;
|
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
|
border-radius: 5px;
|
|
|
|
|
|
background-color: #f9f9f9;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.info-row {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.info-label {{
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
width: 150px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.info-value {{
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.section {{
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.section-title {{
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
background-color: #366092;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.lines-table {{
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
margin: 10px 0;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.lines-table th {{
|
|
|
|
|
|
background-color: #f0f0f0;
|
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
text-align: {'right' if is_fa else 'left'};
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.lines-table td {{
|
|
|
|
|
|
border: 1px solid #ddd;
|
|
|
|
|
|
padding: 6px;
|
|
|
|
|
|
text-align: {'right' if is_fa else 'left'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
.lines-table tr:nth-child(even) {{
|
|
|
|
|
|
background-color: #f9f9f9;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.amount {{
|
|
|
|
|
|
text-align: {'left' if is_fa else 'right'};
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.commission-row {{
|
|
|
|
|
|
background-color: #ffe6e6 !important;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.footer {{
|
|
|
|
|
|
position: running(footer);
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
text-align: {'left' if is_fa else 'right'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="title">{title_text}</div>
|
|
|
|
|
|
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="meta">{label_date}: {escape(now)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="document-info">
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<div class="info-label">کد سند:</div>
|
|
|
|
|
|
<div class="info-value">{escape(doc_code)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<div class="info-label">نوع سند:</div>
|
|
|
|
|
|
<div class="info-value">{escape(doc_type_name)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<div class="info-label">تاریخ سند:</div>
|
|
|
|
|
|
<div class="info-value">{escape(doc_date)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-row">
|
|
|
|
|
|
<div class="info-label">مبلغ کل:</div>
|
|
|
|
|
|
<div class="info-value">{escape(str(total_amount))} ریال</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{f'<div class="info-row"><div class="info-label">توضیحات:</div><div class="info-value">{escape(description or "")}</div></div>' if description else ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<div class="section-title">خطوط اشخاص</div>
|
|
|
|
|
|
<table class="lines-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>نام شخص</th>
|
|
|
|
|
|
<th>مبلغ</th>
|
|
|
|
|
|
<th>توضیحات</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{''.join([f'<tr><td>{escape(line.get("person_name") or "نامشخص")}</td><td class="amount">{escape(str(line.get("amount", 0)))} ریال</td><td>{escape(line.get("description") or "")}</td></tr>' for line in person_lines])}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<div class="section-title">خطوط حسابها</div>
|
|
|
|
|
|
<table class="lines-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>نام حساب</th>
|
|
|
|
|
|
<th>کد حساب</th>
|
|
|
|
|
|
<th>نوع تراکنش</th>
|
|
|
|
|
|
<th>مبلغ</th>
|
|
|
|
|
|
<th>توضیحات</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{''.join([f'<tr class="{"commission-row" if line.get("extra_info", {}).get("is_commission_line") else ""}"><td>{escape(line.get("account_name") or "")}</td><td>{escape(line.get("account_code") or "")}</td><td>{escape(line.get("transaction_type") or "")}</td><td class="amount">{escape(str(line.get("amount", 0)))} ریال</td><td>{escape(line.get("description") or "")}</td></tr>' for line in account_lines])}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">{footer_text}</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
font_config = FontConfiguration()
|
|
|
|
|
|
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
|
|
|
|
|
|
|
|
|
|
|
|
# Build filename
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
|
|
|
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
|
|
|
|
|
|
|
|
|
|
|
filename = f"receipt_payment_{slugify(doc_code)}_{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",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-15 21:21:11 +03:30
|
|
|
|
@router.post(
|
|
|
|
|
|
"/businesses/{business_id}/receipts-payments/export/pdf",
|
|
|
|
|
|
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
|
|
|
|
|
|
description="خروجی PDF لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
|
|
|
|
|
)
|
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
|
async def export_receipts_payments_pdf(
|
|
|
|
|
|
business_id: int,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
body: Dict[str, Any] = Body(...),
|
|
|
|
|
|
auth_context: AuthContext = Depends(get_current_user),
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""خروجی PDF لیست اسناد دریافت و پرداخت"""
|
|
|
|
|
|
from weasyprint import HTML, CSS
|
|
|
|
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
|
|
|
|
from app.core.i18n import negotiate_locale
|
|
|
|
|
|
from html import escape
|
|
|
|
|
|
|
|
|
|
|
|
# Build query dict from flat body
|
|
|
|
|
|
# For export, we limit to reasonable number to prevent memory issues
|
|
|
|
|
|
max_export_records = 10000
|
|
|
|
|
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
|
|
|
|
|
|
|
|
|
|
|
query_dict = {
|
|
|
|
|
|
"take": take_value,
|
|
|
|
|
|
"skip": int(body.get("skip", 0)),
|
|
|
|
|
|
"sort_by": body.get("sort_by"),
|
|
|
|
|
|
"sort_desc": bool(body.get("sort_desc", False)),
|
|
|
|
|
|
"search": body.get("search"),
|
|
|
|
|
|
"search_fields": body.get("search_fields"),
|
|
|
|
|
|
"filters": body.get("filters"),
|
|
|
|
|
|
"document_type": body.get("document_type"),
|
|
|
|
|
|
"from_date": body.get("from_date"),
|
|
|
|
|
|
"to_date": body.get("to_date"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = list_receipts_payments(db, business_id, query_dict)
|
|
|
|
|
|
items = result.get('items', [])
|
|
|
|
|
|
items = [format_datetime_fields(item, request) for item in 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 headers and data
|
|
|
|
|
|
headers: List[str] = []
|
|
|
|
|
|
keys: List[str] = []
|
|
|
|
|
|
export_columns = body.get('export_columns')
|
|
|
|
|
|
if 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))
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Default columns for receipts/payments
|
|
|
|
|
|
default_columns = [
|
|
|
|
|
|
('code', 'کد سند'),
|
|
|
|
|
|
('document_type_name', 'نوع سند'),
|
|
|
|
|
|
('document_date', 'تاریخ سند'),
|
|
|
|
|
|
('total_amount', 'مبلغ کل'),
|
|
|
|
|
|
('person_names', 'اشخاص'),
|
|
|
|
|
|
('account_lines_count', 'تعداد حسابها'),
|
|
|
|
|
|
('created_by_name', 'ایجادکننده'),
|
|
|
|
|
|
('registered_at', 'تاریخ ثبت'),
|
|
|
|
|
|
]
|
|
|
|
|
|
for key, label in default_columns:
|
|
|
|
|
|
if items and key in items[0]:
|
|
|
|
|
|
keys.append(key)
|
|
|
|
|
|
headers.append(label)
|
|
|
|
|
|
|
|
|
|
|
|
# Get business name
|
|
|
|
|
|
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 handling
|
|
|
|
|
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
|
|
|
|
|
is_fa = locale == 'fa'
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare data for HTML
|
|
|
|
|
|
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
|
|
|
|
|
title_text = "لیست اسناد دریافت و پرداخت" if is_fa else "Receipts & Payments List"
|
|
|
|
|
|
label_biz = "کسب و کار" if is_fa else "Business"
|
|
|
|
|
|
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
|
|
|
|
|
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
|
|
|
|
|
|
|
|
|
|
|
# Create headers HTML
|
|
|
|
|
|
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
|
|
|
|
|
|
|
|
|
|
|
# Create rows HTML
|
|
|
|
|
|
rows_html = []
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
row_cells = []
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
value = item.get(key, "")
|
|
|
|
|
|
if isinstance(value, list):
|
|
|
|
|
|
value = ", ".join(str(v) for v in value)
|
|
|
|
|
|
elif isinstance(value, dict):
|
|
|
|
|
|
value = str(value)
|
|
|
|
|
|
row_cells.append(f'<td>{escape(str(value))}</td>')
|
|
|
|
|
|
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
|
|
|
|
|
|
2025-11-09 08:46:37 +03:30
|
|
|
|
# کانتکست برای قالب سفارشی لیست
|
|
|
|
|
|
template_context: Dict[str, Any] = {
|
|
|
|
|
|
"title_text": title_text,
|
|
|
|
|
|
"business_name": business_name,
|
|
|
|
|
|
"generated_at": now,
|
|
|
|
|
|
"is_fa": is_fa,
|
|
|
|
|
|
"headers": headers,
|
|
|
|
|
|
"keys": keys,
|
|
|
|
|
|
"items": items,
|
|
|
|
|
|
"table_headers_html": headers_html,
|
|
|
|
|
|
"table_rows_html": "".join(rows_html),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# تلاش برای رندر با قالب سفارشی (receipts_payments/list)
|
|
|
|
|
|
resolved_html = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.report_template_service import ReportTemplateService
|
|
|
|
|
|
explicit_template_id = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
if 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="receipts_payments",
|
|
|
|
|
|
subtype="list",
|
|
|
|
|
|
context=template_context,
|
|
|
|
|
|
explicit_template_id=explicit_template_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
resolved_html = None
|
|
|
|
|
|
|
|
|
|
|
|
# HTML پیشفرض جدول
|
2025-10-15 21:21:11 +03:30
|
|
|
|
table_html = f"""
|
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html dir="{'rtl' if is_fa else 'ltr'}">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<title>{title_text}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
@page {{
|
|
|
|
|
|
margin: 1cm;
|
|
|
|
|
|
size: A4;
|
|
|
|
|
|
}}
|
|
|
|
|
|
body {{
|
|
|
|
|
|
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
color: #333;
|
|
|
|
|
|
direction: {'rtl' if is_fa else 'ltr'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
.header {{
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
padding-bottom: 10px;
|
|
|
|
|
|
border-bottom: 2px solid #366092;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.title {{
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #366092;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.meta {{
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.table-wrapper {{
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.report-table {{
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.report-table thead {{
|
|
|
|
|
|
background-color: #366092;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.report-table th {{
|
|
|
|
|
|
border: 1px solid #d7dde6;
|
|
|
|
|
|
padding: 8px 6px;
|
|
|
|
|
|
text-align: {'right' if is_fa else 'left'};
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.report-table tbody tr:nth-child(even) {{
|
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
|
}}
|
|
|
|
|
|
.report-table tbody tr:hover {{
|
|
|
|
|
|
background-color: #e9ecef;
|
|
|
|
|
|
}}
|
|
|
|
|
|
tbody td {{
|
|
|
|
|
|
border: 1px solid #d7dde6;
|
|
|
|
|
|
padding: 5px 4px;
|
|
|
|
|
|
vertical-align: top;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
white-space: normal;
|
|
|
|
|
|
text-align: {'right' if is_fa else 'left'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
.footer {{
|
|
|
|
|
|
position: running(footer);
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
text-align: {'left' if is_fa else 'right'};
|
|
|
|
|
|
}}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="title">{title_text}</div>
|
|
|
|
|
|
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="meta">{label_date}: {escape(now)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-wrapper">
|
|
|
|
|
|
<table class="report-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>{headers_html}</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{''.join(rows_html)}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="footer">{footer_text}</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2025-11-09 08:46:37 +03:30
|
|
|
|
final_html = resolved_html or table_html
|
|
|
|
|
|
|
2025-10-15 21:21:11 +03:30
|
|
|
|
font_config = FontConfiguration()
|
2025-11-09 08:46:37 +03:30
|
|
|
|
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
2025-10-15 21:21:11 +03:30
|
|
|
|
|
|
|
|
|
|
# Build meaningful filename
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
|
|
|
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
|
|
|
|
|
|
|
|
|
|
|
base = "receipts_payments"
|
|
|
|
|
|
if business_name:
|
|
|
|
|
|
base += f"_{slugify(business_name)}"
|
|
|
|
|
|
if selected_only:
|
|
|
|
|
|
base += "_selected"
|
|
|
|
|
|
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"attachment; filename={filename}",
|
|
|
|
|
|
"Content-Length": str(len(pdf_bytes)),
|
|
|
|
|
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|