2025-10-31 21:32:23 +03:30
|
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
|
from fastapi import APIRouter, Depends, Request, Body
|
|
|
|
|
from fastapi.responses import Response
|
2025-10-11 02:13:18 +03:30
|
|
|
from sqlalchemy.orm import Session
|
2025-10-31 21:32:23 +03:30
|
|
|
from sqlalchemy import and_, or_
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import datetime
|
|
|
|
|
import re
|
2025-10-11 02:13:18 +03:30
|
|
|
|
|
|
|
|
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
|
2025-10-31 21:32:23 +03:30
|
|
|
from app.core.responses import success_response, format_datetime_fields
|
2025-10-11 02:13:18 +03:30
|
|
|
from adapters.api.v1.schemas import QueryInfo
|
2025-10-31 21:32:23 +03:30
|
|
|
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.fiscal_year import FiscalYear
|
|
|
|
|
from adapters.db.models.business import Business
|
|
|
|
|
from app.services.invoice_service import (
|
|
|
|
|
create_invoice,
|
|
|
|
|
update_invoice,
|
|
|
|
|
invoice_document_to_dict,
|
|
|
|
|
SUPPORTED_INVOICE_TYPES,
|
|
|
|
|
)
|
2025-10-11 02:13:18 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/business/{business_id}")
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
def create_invoice_endpoint(
|
|
|
|
|
request: Request,
|
|
|
|
|
business_id: int,
|
2025-10-31 21:32:23 +03:30
|
|
|
payload: Dict[str, Any] = Body(...),
|
2025-10-11 02:13:18 +03:30
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> Dict[str, Any]:
|
2025-10-31 21:32:23 +03:30
|
|
|
result = create_invoice(
|
|
|
|
|
db=db,
|
|
|
|
|
business_id=business_id,
|
|
|
|
|
user_id=ctx.get_user_id(),
|
|
|
|
|
data=payload,
|
|
|
|
|
)
|
|
|
|
|
return success_response(data=result, request=request, message="INVOICE_CREATED")
|
2025-10-11 02:13:18 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/business/{business_id}/{invoice_id}")
|
|
|
|
|
@require_business_access("business_id")
|
|
|
|
|
def update_invoice_endpoint(
|
|
|
|
|
request: Request,
|
|
|
|
|
business_id: int,
|
|
|
|
|
invoice_id: int,
|
2025-10-31 21:32:23 +03:30
|
|
|
payload: Dict[str, Any] = Body(...),
|
2025-10-11 02:13:18 +03:30
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> Dict[str, Any]:
|
2025-10-31 21:32:23 +03:30
|
|
|
# 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)
|
|
|
|
|
result = update_invoice(
|
|
|
|
|
db=db,
|
|
|
|
|
document_id=invoice_id,
|
|
|
|
|
user_id=ctx.get_user_id(),
|
|
|
|
|
data=payload,
|
|
|
|
|
)
|
|
|
|
|
return success_response(data=result, request=request, message="INVOICE_UPDATED")
|
2025-10-11 02:13:18 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/business/{business_id}/{invoice_id}")
|
|
|
|
|
@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),
|
|
|
|
|
) -> Dict[str, Any]:
|
2025-10-31 21:32:23 +03:30
|
|
|
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)
|
|
|
|
|
result = invoice_document_to_dict(db, doc)
|
|
|
|
|
return success_response(data={"item": result}, request=request, message="INVOICE")
|
2025-10-11 02:13:18 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/business/{business_id}/search")
|
|
|
|
|
@require_business_access("business_id")
|
2025-10-31 21:32:23 +03:30
|
|
|
async def search_invoices_endpoint(
|
2025-10-11 02:13:18 +03:30
|
|
|
request: Request,
|
|
|
|
|
business_id: int,
|
2025-10-31 21:32:23 +03:30
|
|
|
query_info: QueryInfo = Body(...),
|
2025-10-11 02:13:18 +03:30
|
|
|
ctx: AuthContext = Depends(get_current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> Dict[str, Any]:
|
2025-10-31 21:32:23 +03:30
|
|
|
"""لیست فاکتورها با فیلتر، جستوجو، مرتبسازی و صفحهبندی استاندارد"""
|
2025-10-11 02:13:18 +03:30
|
|
|
|
2025-10-31 21:32:23 +03:30
|
|
|
# Base query
|
|
|
|
|
q = db.query(Document).filter(
|
|
|
|
|
and_(
|
|
|
|
|
Document.business_id == business_id,
|
|
|
|
|
Document.document_type.in_(list(SUPPORTED_INVOICE_TYPES)),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Merge flat body extras similar to other list endpoints
|
|
|
|
|
body: Dict[str, Any] = {}
|
|
|
|
|
try:
|
|
|
|
|
body_json = await request.json()
|
|
|
|
|
if isinstance(body_json, dict):
|
|
|
|
|
body = body_json
|
|
|
|
|
except Exception:
|
|
|
|
|
body = {}
|
|
|
|
|
|
|
|
|
|
# Simple search on code/description
|
|
|
|
|
search: Optional[str] = getattr(query_info, 'search', None)
|
|
|
|
|
if isinstance(search, str) and search.strip():
|
|
|
|
|
s = f"%{search.strip()}%"
|
|
|
|
|
q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s)))
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
# 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 == '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
|
|
|
|
|
|
|
|
|
|
# Sorting
|
|
|
|
|
sort_desc = bool(getattr(query_info, 'sort_desc', True))
|
|
|
|
|
sort_by = getattr(query_info, 'sort_by', None) or 'document_date'
|
|
|
|
|
sort_col = Document.document_date
|
|
|
|
|
if isinstance(sort_by, str):
|
|
|
|
|
if sort_by == 'code' and hasattr(Document, 'code'):
|
|
|
|
|
sort_col = Document.code
|
|
|
|
|
elif sort_by == 'created_at' and hasattr(Document, 'created_at'):
|
|
|
|
|
sort_col = Document.created_at
|
|
|
|
|
elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'):
|
|
|
|
|
sort_col = Document.registered_at
|
|
|
|
|
else:
|
|
|
|
|
sort_col = Document.document_date
|
|
|
|
|
q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc())
|
|
|
|
|
|
|
|
|
|
# Pagination
|
|
|
|
|
take = int(getattr(query_info, 'take', 20) or 20)
|
|
|
|
|
skip = int(getattr(query_info, 'skip', 0) or 0)
|
|
|
|
|
|
|
|
|
|
total = q.count()
|
|
|
|
|
items: List[Document] = q.offset(skip).limit(take).all()
|
|
|
|
|
|
|
|
|
|
# Helpers for display fields
|
|
|
|
|
def _type_name(tp: str) -> str:
|
|
|
|
|
mapping = {
|
|
|
|
|
'invoice_sales': 'فروش',
|
|
|
|
|
'invoice_sales_return': 'برگشت از فروش',
|
|
|
|
|
'invoice_purchase': 'خرید',
|
|
|
|
|
'invoice_purchase_return': 'برگشت از خرید',
|
|
|
|
|
'invoice_direct_consumption': 'مصرف مستقیم',
|
|
|
|
|
'invoice_production': 'تولید',
|
|
|
|
|
'invoice_waste': 'ضایعات',
|
|
|
|
|
}
|
|
|
|
|
return mapping.get(str(tp), str(tp))
|
|
|
|
|
|
|
|
|
|
data_items: List[Dict[str, Any]] = []
|
|
|
|
|
for d in items:
|
|
|
|
|
item = invoice_document_to_dict(db, d)
|
|
|
|
|
# total_amount from extra_info.totals.net if available
|
|
|
|
|
total_amount = None
|
|
|
|
|
try:
|
|
|
|
|
totals = (item.get('extra_info') or {}).get('totals') or {}
|
|
|
|
|
if isinstance(totals, dict) and 'net' in totals:
|
|
|
|
|
total_amount = totals.get('net')
|
|
|
|
|
except Exception:
|
|
|
|
|
total_amount = None
|
|
|
|
|
# Fallback compute from product lines
|
|
|
|
|
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
|
|
|
|
|
data_items.append(format_datetime_fields(item, request))
|
|
|
|
|
|
|
|
|
|
# Build pagination info
|
|
|
|
|
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,
|
|
|
|
|
# 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,
|
|
|
|
|
},
|
|
|
|
|
request=request,
|
|
|
|
|
message="INVOICE_LIST",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/business/{business_id}/export/excel",
|
|
|
|
|
summary="خروجی Excel لیست فاکتورها",
|
|
|
|
|
description="خروجی Excel لیست فاکتورها با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
|
|
|
|
)
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
search = body.get("search")
|
|
|
|
|
if isinstance(search, str) and search.strip():
|
|
|
|
|
s = f"%{search.strip()}%"
|
|
|
|
|
q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s)))
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# Sorting
|
|
|
|
|
sort_desc = bool(body.get("sort_desc", True))
|
|
|
|
|
sort_by = body.get("sort_by") or "document_date"
|
|
|
|
|
sort_col = Document.document_date
|
|
|
|
|
if sort_by == 'code' and hasattr(Document, 'code'):
|
|
|
|
|
sort_col = Document.code
|
|
|
|
|
elif sort_by == 'created_at' and hasattr(Document, 'created_at'):
|
|
|
|
|
sort_col = Document.created_at
|
|
|
|
|
elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'):
|
|
|
|
|
sort_col = Document.registered_at
|
|
|
|
|
q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc())
|
|
|
|
|
|
|
|
|
|
total = q.count()
|
|
|
|
|
docs: List[Document] = q.offset(skip_value).limit(take_value).all()
|
|
|
|
|
|
|
|
|
|
# Build items like list endpoint
|
|
|
|
|
def _type_name(tp: str) -> str:
|
|
|
|
|
mapping = {
|
|
|
|
|
'invoice_sales': 'فروش',
|
|
|
|
|
'invoice_sales_return': 'برگشت از فروش',
|
|
|
|
|
'invoice_purchase': 'خرید',
|
|
|
|
|
'invoice_purchase_return': 'برگشت از خرید',
|
|
|
|
|
'invoice_direct_consumption': 'مصرف مستقیم',
|
|
|
|
|
'invoice_production': 'تولید',
|
|
|
|
|
'invoice_waste': 'ضایعات',
|
|
|
|
|
}
|
|
|
|
|
return mapping.get(str(tp), str(tp))
|
|
|
|
|
|
|
|
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
|
for d in docs:
|
|
|
|
|
item = invoice_document_to_dict(db, d)
|
|
|
|
|
# total_amount
|
|
|
|
|
total_amount = None
|
|
|
|
|
try:
|
|
|
|
|
totals = (item.get('extra_info') or {}).get('totals') or {}
|
|
|
|
|
if isinstance(totals, dict) and 'net' in totals:
|
|
|
|
|
total_amount = totals.get('net')
|
|
|
|
|
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
|
|
|
|
|
items.append(format_datetime_fields(item, request))
|
|
|
|
|
|
|
|
|
|
# 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:
|
|
|
|
|
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 = [
|
|
|
|
|
('code', 'کد سند'),
|
|
|
|
|
('document_type_name', 'نوع فاکتور'),
|
|
|
|
|
('document_date', 'تاریخ سند'),
|
|
|
|
|
('total_amount', 'مبلغ کل'),
|
|
|
|
|
('currency_code', 'ارز'),
|
|
|
|
|
('created_by_name', 'ایجادکننده'),
|
|
|
|
|
('is_proforma', 'وضعیت'),
|
|
|
|
|
('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, "")
|
|
|
|
|
if 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="خروجی PDF لیست فاکتورها با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
|
|
|
|
)
|
|
|
|
|
@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")
|
|
|
|
|
if isinstance(search, str) and search.strip():
|
|
|
|
|
s = f"%{search.strip()}%"
|
|
|
|
|
q = q.filter(or_(Document.code.ilike(s), Document.description.ilike(s)))
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
sort_desc = bool(body.get("sort_desc", True))
|
|
|
|
|
sort_by = body.get("sort_by") or "document_date"
|
|
|
|
|
sort_col = Document.document_date
|
|
|
|
|
if sort_by == 'code' and hasattr(Document, 'code'):
|
|
|
|
|
sort_col = Document.code
|
|
|
|
|
elif sort_by == 'created_at' and hasattr(Document, 'created_at'):
|
|
|
|
|
sort_col = Document.created_at
|
|
|
|
|
elif sort_by == 'registered_at' and hasattr(Document, 'registered_at'):
|
|
|
|
|
sort_col = Document.registered_at
|
|
|
|
|
q = q.order_by(sort_col.desc() if sort_desc else sort_col.asc())
|
|
|
|
|
|
|
|
|
|
docs: List[Document] = q.offset(skip_value).limit(take_value).all()
|
|
|
|
|
|
|
|
|
|
def _type_name(tp: str) -> str:
|
|
|
|
|
mapping = {
|
|
|
|
|
'invoice_sales': 'فروش',
|
|
|
|
|
'invoice_sales_return': 'برگشت از فروش',
|
|
|
|
|
'invoice_purchase': 'خرید',
|
|
|
|
|
'invoice_purchase_return': 'برگشت از خرید',
|
|
|
|
|
'invoice_direct_consumption': 'مصرف مستقیم',
|
|
|
|
|
'invoice_production': 'تولید',
|
|
|
|
|
'invoice_waste': 'ضایعات',
|
|
|
|
|
}
|
|
|
|
|
return mapping.get(str(tp), str(tp))
|
|
|
|
|
|
|
|
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
|
for d in docs:
|
|
|
|
|
item = invoice_document_to_dict(db, d)
|
|
|
|
|
total_amount = None
|
|
|
|
|
try:
|
|
|
|
|
totals = (item.get('extra_info') or {}).get('totals') or {}
|
|
|
|
|
if isinstance(totals, dict) and 'net' in totals:
|
|
|
|
|
total_amount = totals.get('net')
|
|
|
|
|
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
|
|
|
|
|
items.append(format_datetime_fields(item, request))
|
|
|
|
|
|
|
|
|
|
# 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:
|
|
|
|
|
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 = [
|
|
|
|
|
('code', 'کد سند'),
|
|
|
|
|
('document_type_name', 'نوع فاکتور'),
|
|
|
|
|
('document_date', 'تاریخ سند'),
|
|
|
|
|
('total_amount', 'مبلغ کل'),
|
|
|
|
|
('currency_code', 'ارز'),
|
|
|
|
|
('created_by_name', 'ایجادکننده'),
|
|
|
|
|
('is_proforma', 'وضعیت'),
|
|
|
|
|
('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'
|
|
|
|
|
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"
|
|
|
|
|
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
|
|
|
|
|
|
|
|
|
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
|
|
|
|
|
|
|
|
|
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>')
|
|
|
|
|
|
|
|
|
|
html_content = 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; }}
|
|
|
|
|
table {{ width: 100%; border-collapse: collapse; font-size: 11px; }}
|
|
|
|
|
thead {{ background-color: #366092; color: #fff; }}
|
|
|
|
|
th {{ border: 1px solid #d7dde6; padding: 8px 6px; text-align: {'right' if is_fa else 'left'}; font-weight: bold; white-space: nowrap; }}
|
|
|
|
|
tbody tr:nth-child(even) {{ background-color: #f8f9fa; }}
|
|
|
|
|
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>
|
|
|
|
|
<thead><tr>{headers_html}</tr></thead>
|
|
|
|
|
<tbody>{''.join(rows_html)}</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)
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
)
|
2025-10-11 02:13:18 +03:30
|
|
|
|