Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/kardex.py

977 lines
42 KiB
Python
Executable file

from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
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
from app.core.permissions import require_business_access
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.list_query_common import KardexListQueryBody, kardex_list_query_to_dict
from app.services.kardex_service import list_kardex_lines
from app.services.pdf.template_renderer import render_template
from app.core.i18n import negotiate_locale
from adapters.db.models.business import Business
from app.core.cache import get_cache
router = APIRouter(prefix="/kardex", tags=["گزارش‌ها", "انبارداری"])
@router.post(
"/businesses/{business_id}/lines",
summary="لیست کاردکس (خطوط اسناد)",
description="دریافت خطوط اسناد مرتبط با انتخاب‌های چندگانه موجودیت‌ها با فیلتر تاریخ",
)
@require_business_access("business_id")
async def list_kardex_lines_endpoint(
request: Request,
business_id: int,
body: KardexListQueryBody,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
query_dict = kardex_list_query_to_dict(body, request=request)
if not query_dict.get("sort_by"):
query_dict["sort_by"] = "document_date"
# کش نتایج لیست کاردکس (خطوط اسناد)
cache = get_cache()
cache_key = None
if cache.enabled:
import json, hashlib
key_payload = {
"business_id": business_id,
"query": query_dict,
# نتیجه شامل تاریخ‌های فرمت‌شده است؛ تقویم درخواست باید در کلید کش باشد
"calendar_type": getattr(getattr(request, "state", None), "calendar_type", None),
}
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"kardex_lines:{key_hash}"
cached = cache.get(cache_key)
if cached is not None:
return success_response(data=cached, request=request, message="KARDEX_LINES")
result = list_kardex_lines(db, business_id, query_dict)
# Format date fields in response items (document_date)
try:
items = result.get("items", [])
for item in items:
# Use format_datetime_fields for consistency
item.update(format_datetime_fields({"document_date": item.get("document_date")}, request))
except Exception:
pass
if cache.enabled and cache_key:
cache.set(cache_key, result, ttl=60)
return success_response(data=result, request=request, message="KARDEX_LINES")
@router.post(
"/businesses/{business_id}/lines/export/excel",
summary="خروجی Excel کاردکس",
description="خروجی اکسل از لیست خطوط کاردکس با فیلترهای اعمال‌شده",
)
@require_business_access("business_id")
async def export_kardex_excel_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from fastapi.responses import Response
import datetime
try:
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
except Exception:
take_value = 1000
query_dict: Dict[str, Any] = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by") or "document_date",
"sort_desc": bool(body.get("sort_desc", True)),
"sort": body.get("sort") if isinstance(body.get("sort"), list) else None,
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
"person_ids": body.get("person_ids"),
"product_ids": body.get("product_ids"),
"bank_account_ids": body.get("bank_account_ids"),
"cash_register_ids": body.get("cash_register_ids"),
"petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"),
"warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)),
}
result = list_kardex_lines(db, business_id, query_dict)
items = result.get("items", [])
items = [format_datetime_fields(it, request) for it in items]
# Build Excel using openpyxl (RTL, thousand separators, safe strings, configurable columns)
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
import re
from io import BytesIO
import datetime as _dt
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == "fa")
wb = Workbook()
ws = wb.active
ws.title = "Kardex"
if is_fa:
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
# Helpers
def _slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
def _safe_text(v: Any) -> str:
s = "" if v is None else str(v)
# Prevent Excel formula injection
if s.startswith(("=", "+", "-", "@")):
return "'" + s
return s
def _num(v: Any) -> float | None:
if v is None:
return None
try:
return float(v)
except Exception:
return None
# Apply selected-only filtering with stable keys (line_id) if provided
selected_only = bool(body.get("selected_only", False))
selected_row_keys = body.get("selected_row_keys")
if selected_only and isinstance(selected_row_keys, list):
try:
wanted_ids = set()
for k in selected_row_keys:
if isinstance(k, dict) and k.get("line_id") is not None:
try:
wanted_ids.add(int(k.get("line_id")))
except Exception:
pass
if wanted_ids:
items = [it for it in items if int(it.get("line_id") or -1) in wanted_ids]
except Exception:
pass
# Backward compatible: selected_indices (indexes) - best-effort
if selected_only and (not selected_row_keys) and body.get("selected_indices") is not None:
try:
selected_indices = body.get("selected_indices")
indices = None
if isinstance(selected_indices, str):
import json as _json
indices = _json.loads(selected_indices)
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)]
except Exception:
pass
# Determine export columns
export_columns = body.get("export_columns")
if export_columns and isinstance(export_columns, list):
keys = [c.get("key") for c in export_columns if c.get("key")]
headers = [c.get("label") or c.get("key") for c in export_columns if c.get("key")]
else:
# Default columns (labels localized)
headers = [
("document_date", "تاریخ سند" if is_fa else "Date"),
("document_code", "کد سند" if is_fa else "Code"),
("document_type_name", "نوع سند" if is_fa else "Type"),
("warehouse_name", "انبار" if is_fa else "Warehouse"),
("movement", "جهت" if is_fa else "Movement"),
("description", "شرح" if is_fa else "Description"),
("debit", "بدهکار" if is_fa else "Debit"),
("credit", "بستانکار" if is_fa else "Credit"),
("quantity", "تعداد" if is_fa else "Qty"),
]
if bool(query_dict.get("include_running_balance", False)):
headers += [
("running_amount", "مانده مبلغ" if is_fa else "Running Amount"),
("running_quantity", "مانده تعداد" if is_fa else "Running Qty"),
]
keys = [k for k, _ in headers]
headers = [h for _, h in headers]
ws.append(headers)
header_font = Font(bold=True)
header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid")
thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
# Freeze header + enable filter row
try:
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
except Exception:
pass
# Column kind for number formatting
money_keys = {"debit", "credit", "running_amount"}
qty_keys = {"quantity", "running_quantity"}
for it in items:
row_vals = []
for k in keys:
# map friendly values
if k == "document_type_name":
v = it.get("document_type_name") or it.get("document_type")
elif k == "warehouse_name":
v = it.get("warehouse_name") or it.get("warehouse_id")
else:
v = it.get(k)
if k in money_keys:
row_vals.append(_num(v))
elif k in qty_keys:
row_vals.append(_num(v))
else:
row_vals.append(_safe_text(v))
ws.append(row_vals)
# style row
for j, cell in enumerate(ws[ws.max_row], start=1):
cell.border = thin_border
if is_fa:
cell.alignment = Alignment(horizontal="right")
# Apply number formats for the row cells
try:
for idx, k in enumerate(keys, start=1):
c = ws.cell(row=ws.max_row, column=idx)
if k in money_keys:
c.number_format = '#,##0'
c.alignment = Alignment(horizontal="right")
elif k in qty_keys:
c.number_format = '#,##0.00'
c.alignment = Alignment(horizontal="right")
except Exception:
pass
# Auto width columns (cap)
try:
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if cell.value is not None:
max_length = max(max_length, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
except Exception:
pass
buf = BytesIO()
wb.save(buf)
content = buf.getvalue()
filename = f"kardex_{business_id}_{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
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(
"/businesses/{business_id}/lines/export/pdf",
summary="خروجی PDF کاردکس",
description="خروجی PDF از لیست خطوط کاردکس با فیلترهای اعمال‌شده",
)
@require_business_access("business_id")
async def export_kardex_pdf_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from fastapi.responses import Response
import datetime
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from html import escape
try:
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
except Exception:
take_value = 1000
query_dict: Dict[str, Any] = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by") or "document_date",
"sort_desc": bool(body.get("sort_desc", True)),
"sort": body.get("sort") if isinstance(body.get("sort"), list) else None,
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
"person_ids": body.get("person_ids"),
"product_ids": body.get("product_ids"),
"bank_account_ids": body.get("bank_account_ids"),
"cash_register_ids": body.get("cash_register_ids"),
"petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"),
"warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)),
}
result = list_kardex_lines(db, business_id, query_dict)
items = result.get("items", [])
items = [format_datetime_fields(it, request) for it in items]
all_totals = result.get("totals") or {}
all_count = (result.get("pagination") or {}).get("total")
# Apply selected-only filtering (prefer stable keys, fallback to indices)
selected_only = bool(body.get("selected_only", False))
selected_row_keys = body.get("selected_row_keys")
if selected_only and isinstance(selected_row_keys, list):
try:
wanted_ids = set()
for k in selected_row_keys:
if isinstance(k, dict) and k.get("line_id") is not None:
try:
wanted_ids.add(int(k.get("line_id")))
except Exception:
pass
if wanted_ids:
items = [it for it in items if int(it.get("line_id") or -1) in wanted_ids]
except Exception:
pass
if selected_only and (not selected_row_keys) and body.get("selected_indices") is not None:
try:
selected_indices = body.get("selected_indices")
indices = None
if isinstance(selected_indices, str):
import json as _json
indices = _json.loads(selected_indices)
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)]
except Exception:
pass
# Build a human-readable filters summary for PDF header (used in templates and fallback HTML)
filters_summary = []
try:
# locale/is_fa are set later too; compute early for summary
_locale0 = negotiate_locale(request.headers.get("Accept-Language"))
_is_fa0 = (_locale0 == "fa")
def _add(label_fa: str, label_en: str, value: Any):
if value is None:
return
if isinstance(value, str) and not value.strip():
return
if isinstance(value, (list, tuple)) and len(value) == 0:
return
label = label_fa if _is_fa0 else label_en
if isinstance(value, (list, tuple)):
vals = [str(v) for v in value if v is not None and str(v).strip()]
if not vals:
return
if len(vals) <= 3:
vtxt = "، ".join(vals) if _is_fa0 else ", ".join(vals)
else:
vtxt = (f"{'، '.join(vals[:3])} (+{len(vals)-3})" if _is_fa0 else f"{', '.join(vals[:3])} (+{len(vals)-3})")
else:
vtxt = str(value)
filters_summary.append({"label": label, "value": vtxt})
_add("از تاریخ", "From", query_dict.get("from_date"))
_add("تا تاریخ", "To", query_dict.get("to_date"))
_add("سال مالی", "Fiscal year", query_dict.get("fiscal_year_id"))
def _as_int_list(v: Any) -> list[int]:
if not isinstance(v, (list, tuple)):
return []
out: list[int] = []
for x in v:
try:
if x is None:
continue
out.append(int(x))
except Exception:
continue
# unique preserve order
seen = set()
uniq: list[int] = []
for i in out:
if i in seen:
continue
seen.add(i)
uniq.append(i)
return uniq
def _names_or_count(label_fa: str, label_en: str, ids_any: Any, rows: list[Any], pick_name):
ids = _as_int_list(ids_any)
if not ids:
return
# show names for small lists; otherwise first 3 + (+N)
try:
if len(ids) <= 3:
names = [pick_name(r) for r in rows if r is not None]
names = [n for n in names if n]
if names:
_add(label_fa, label_en, names)
return
# fallback to count
_add(label_fa, label_en, [f"{len(ids)} مورد" if _is_fa0 else f"{len(ids)} items"])
except Exception:
_add(label_fa, label_en, [f"{len(ids)} مورد" if _is_fa0 else f"{len(ids)} items"])
# persons (show alias_name)
person_ids = _as_int_list(query_dict.get("person_ids"))
if person_ids:
try:
from adapters.db.models.person import Person
people = (
db.query(Person)
.filter(Person.business_id == business_id, Person.id.in_(person_ids[:3]))
.all()
)
_names_or_count("اشخاص", "Persons", person_ids, people, lambda p: getattr(p, "alias_name", None) or "")
except Exception:
_add("اشخاص", "Persons", [f"{len(person_ids)} مورد" if _is_fa0 else f"{len(person_ids)} items"])
# products
product_ids = _as_int_list(query_dict.get("product_ids"))
if product_ids:
try:
from adapters.db.models.product import Product
prows = (
db.query(Product)
.filter(Product.business_id == business_id, Product.id.in_(product_ids[:3]))
.all()
)
def _pname(p):
code = getattr(p, "code", None)
name = getattr(p, "name", None)
return f"{code} - {name}" if code and name else (name or code or "")
_names_or_count("کالاها", "Products", product_ids, prows, _pname)
except Exception:
_add("کالاها", "Products", [f"{len(product_ids)} مورد" if _is_fa0 else f"{len(product_ids)} items"])
# bank accounts
bank_ids = _as_int_list(query_dict.get("bank_account_ids"))
if bank_ids:
try:
from adapters.db.models.bank_account import BankAccount
brows = (
db.query(BankAccount)
.filter(BankAccount.business_id == business_id, BankAccount.id.in_(bank_ids[:3]))
.all()
)
def _bname(b):
code = getattr(b, "code", None)
name = getattr(b, "name", None)
return f"{code} - {name}" if code and name else (name or code or "")
_names_or_count("حساب‌های بانکی", "Bank accounts", bank_ids, brows, _bname)
except Exception:
_add("حساب‌های بانکی", "Bank accounts", [f"{len(bank_ids)} مورد" if _is_fa0 else f"{len(bank_ids)} items"])
# cash registers
cash_ids = _as_int_list(query_dict.get("cash_register_ids"))
if cash_ids:
try:
from adapters.db.models.cash_register import CashRegister
crows = (
db.query(CashRegister)
.filter(CashRegister.business_id == business_id, CashRegister.id.in_(cash_ids[:3]))
.all()
)
_names_or_count("صندوق‌ها", "Cash registers", cash_ids, crows, lambda c: getattr(c, "name", None) or "")
except Exception:
_add("صندوق‌ها", "Cash registers", [f"{len(cash_ids)} مورد" if _is_fa0 else f"{len(cash_ids)} items"])
# petty cash
petty_ids = _as_int_list(query_dict.get("petty_cash_ids"))
if petty_ids:
try:
from adapters.db.models.petty_cash import PettyCash
prows = (
db.query(PettyCash)
.filter(PettyCash.business_id == business_id, PettyCash.id.in_(petty_ids[:3]))
.all()
)
_names_or_count("تنخواه‌ها", "Petty cash", petty_ids, prows, lambda p: getattr(p, "name", None) or "")
except Exception:
_add("تنخواه‌ها", "Petty cash", [f"{len(petty_ids)} مورد" if _is_fa0 else f"{len(petty_ids)} items"])
# accounts
acc_ids = _as_int_list(query_dict.get("account_ids"))
if acc_ids:
try:
from adapters.db.models.account import Account
arows = (
db.query(Account)
.filter(Account.business_id == business_id, Account.id.in_(acc_ids[:3]))
.all()
)
def _aname(a):
code = getattr(a, "code", None)
name = getattr(a, "name", None)
return f"{code} - {name}" if code and name else (name or code or "")
_names_or_count("حساب‌ها", "Accounts", acc_ids, arows, _aname)
except Exception:
_add("حساب‌ها", "Accounts", [f"{len(acc_ids)} مورد" if _is_fa0 else f"{len(acc_ids)} items"])
# checks
check_ids = _as_int_list(query_dict.get("check_ids"))
if check_ids:
try:
from adapters.db.models.check import Check
chrows = (
db.query(Check)
.filter(Check.business_id == business_id, Check.id.in_(check_ids[:3]))
.all()
)
def _cname(ch):
num = getattr(ch, "check_number", None)
sayad = getattr(ch, "sayad_code", None)
bank = getattr(ch, "bank_name", None)
if num and bank:
return f"{bank} #{num}"
if num:
return f"#{num}"
if sayad:
return f"صیاد {sayad}" if _is_fa0 else f"Sayad {sayad}"
return ""
_names_or_count("چک‌ها", "Checks", check_ids, chrows, _cname)
except Exception:
_add("چک‌ها", "Checks", [f"{len(check_ids)} مورد" if _is_fa0 else f"{len(check_ids)} items"])
# warehouses
wh_ids = _as_int_list(query_dict.get("warehouse_ids"))
if wh_ids:
try:
from adapters.db.models.warehouse import Warehouse
wrows = (
db.query(Warehouse)
.filter(Warehouse.business_id == business_id, Warehouse.id.in_(wh_ids[:3]))
.all()
)
_names_or_count("انبارها", "Warehouses", wh_ids, wrows, lambda w: getattr(w, "name", None) or "")
except Exception:
_add("انبارها", "Warehouses", [f"{len(wh_ids)} مورد" if _is_fa0 else f"{len(wh_ids)} items"])
mm = str(query_dict.get("match_mode") or "").lower()
if mm:
mm_map_fa = {"any": "هرکدام", "same_line": "همان سطر", "document_and": "همان سند (AND)"}
mm_map_en = {"any": "Any", "same_line": "Same line", "document_and": "Same document (AND)"}
_add("حالت تطبیق", "Match mode", mm_map_fa.get(mm, mm) if _is_fa0 else mm_map_en.get(mm, mm))
rs = str(query_dict.get("result_scope") or "").lower()
if rs:
rs_map_fa = {"lines_matching": "فقط سطرهای منطبق", "lines_of_document": "تمام سطرهای سند"}
rs_map_en = {"lines_matching": "Matching lines", "lines_of_document": "All lines of document"}
_add("دامنه نتایج", "Result scope", rs_map_fa.get(rs, rs) if _is_fa0 else rs_map_en.get(rs, rs))
if query_dict.get("search"):
_add("جستجو", "Search", query_dict.get("search"))
flt = query_dict.get("filters")
if isinstance(flt, list) and flt:
_add("فیلترهای ستونی", "Column filters", f"{len(flt)} مورد" if _is_fa0 else f"{len(flt)} items")
except Exception:
filters_summary = []
# Page totals (for exported rows after selected-only filtering)
try:
page_debit = 0.0
page_credit = 0.0
page_qty_sum = 0.0
qty_seen = False
for it in items or []:
try:
page_debit += float(it.get("debit") or 0)
except Exception:
pass
try:
page_credit += float(it.get("credit") or 0)
except Exception:
pass
qv = it.get("quantity")
if qv is not None:
try:
page_qty_sum += float(qv or 0)
qty_seen = True
except Exception:
pass
page_totals = {
"debit": page_debit,
"credit": page_credit,
"quantity": page_qty_sum if qty_seen else None,
"count": len(items or []),
}
except Exception:
page_totals = {"debit": 0.0, "credit": 0.0, "quantity": None, "count": len(items or [])}
# Build simple HTML table
def cell(val: Any) -> str:
# Never show literal "None"
if val is None:
return ""
s = str(val)
if s.strip().lower() == "none":
return ""
return escape(s)
def cell_num(val: Any, decimals: int = 2) -> str:
if val is None:
return ""
try:
n = float(val)
if abs(n - round(n)) < 1e-9:
return escape(f"{int(round(n)):,}")
s = f"{n:,.{decimals}f}".rstrip("0").rstrip(".")
return escape(s)
except Exception:
return cell(val)
rows_html = "".join([
f"<tr>"
f"<td style='text-align:right'>{cell_num(i+1, 0)}</td>"
f"<td>{cell(it.get('document_date'))}</td>"
f"<td>{cell(it.get('document_code'))}</td>"
f"<td>{cell(it.get('document_type_name') or it.get('document_type'))}</td>"
f"<td>{cell(it.get('warehouse_name') or it.get('warehouse_id'))}</td>"
f"<td>{cell(it.get('movement'))}</td>"
f"<td>{cell(it.get('description'))}</td>"
f"<td style='text-align:right'>{cell_num(it.get('debit'), 0)}</td>"
f"<td style='text-align:right'>{cell_num(it.get('credit'), 0)}</td>"
f"<td style='text-align:right'>{cell_num(it.get('quantity'), 2)}</td>"
f"<td style='text-align:right'>{cell_num(it.get('running_amount'), 0)}</td>"
f"<td style='text-align:right'>{cell_num(it.get('running_quantity'), 2)}</td>"
f"</tr>"
for i, it in enumerate(items)
])
# Prepare font URIs for base.html / templates
fa_font_url_regular = ""
fa_font_url_bold = ""
# تلاش برای رندر با قالب سفارشی (kardex/list) و سپس قالب پیش‌فرض فایل
resolved_html = None
kardex_lines_footer = None
is_fa = negotiate_locale(request.headers.get("Accept-Language")) == "fa"
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
# اطلاعات کسب‌وکار
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 و جهت
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == "fa")
calendar_type = getattr(request.state, "calendar_type", "jalali")
# Load font data URIs early so templates (base.html) can use them
try:
if is_fa:
from app.services.pdf.template_renderer import load_farsi_font_data_uris
fa_reg, fa_bold = load_farsi_font_data_uris()
fa_font_url_regular = fa_reg or ""
fa_font_url_bold = fa_bold or ""
except Exception:
fa_font_url_regular = ""
fa_font_url_bold = ""
# زمان تولید گزارش بر اساس تقویم انتخابی
try:
from app.core.calendar import CalendarConverter
_now = datetime.datetime.now()
_gen = CalendarConverter.format_datetime(_now, calendar_type).get("formatted") or ""
# Trim seconds for nicer display
generated_at = " ".join(_gen.split(" ")[:2])
if generated_at.count(":") >= 2:
generated_at = generated_at.rsplit(":", 1)[0]
except Exception:
generated_at = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
try:
from app.services.print_footer_settings import build_report_title_and_time_footer
_kt = "گزارش کاردکس" if is_fa else "Kardex Report"
_pn = ""
try:
_pn = ctx.get_user_name() or ""
except Exception:
_pn = ""
kardex_lines_footer = build_report_title_and_time_footer(
db,
business_id,
title=_kt,
time_part=generated_at,
preparer_name=_pn or None,
is_fa=is_fa,
)
except Exception:
kardex_lines_footer = f"{'گزارش کاردکس' if is_fa else 'Kardex Report'} • {generated_at}"
# پارامترهای صفحه از کوئری (اختیاری)
try:
qp = request.query_params
paper_size = qp.get("paper_size")
orientation = qp.get("orientation") or "landscape"
disposition = qp.get("disposition") or "attachment"
except Exception:
paper_size = None
orientation = "landscape"
disposition = "attachment"
# کانتکست مشترک
template_context = {
"title_text": "گزارش کاردکس" if is_fa else "Kardex Report",
"business_name": business_name,
"generated_at": generated_at,
"is_fa": is_fa,
"locale": locale,
"paper_size": paper_size,
"orientation": orientation,
"show_running": bool(query_dict.get("include_running_balance", False)),
"filters_summary": filters_summary,
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"footer_text": kardex_lines_footer,
"page_totals": page_totals,
"all_totals": all_totals,
"all_count": all_count,
"items": items,
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="kardex",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
if kardex_lines_footer is None:
gen_at = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
try:
from app.core.calendar import CalendarConverter
_ct = getattr(request.state, "calendar_type", "jalali")
_n = datetime.datetime.now()
_g = CalendarConverter.format_datetime(_n, _ct).get("formatted") or ""
gen_at = " ".join(_g.split(" ")[:2])
if gen_at.count(":") >= 2:
gen_at = gen_at.rsplit(":", 1)[0]
except Exception:
pass
try:
from app.services.print_footer_settings import build_report_title_and_time_footer
kardex_lines_footer = build_report_title_and_time_footer(
db,
business_id,
title="گزارش کاردکس" if is_fa else "Kardex Report",
time_part=gen_at,
preparer_name=ctx.get_user_name() or None,
is_fa=is_fa,
)
except Exception:
kardex_lines_footer = f"{'گزارش کاردکس' if is_fa else 'Kardex Report'} • {gen_at}"
# Inject Persian fonts (YekanBakhFaNum/Vazirmatn) for PDF rendering (CSS fallback)
font_face_css = ""
if is_fa and fa_font_url_regular:
font_face_css += f"@font-face {{ font-family: 'YekanBakhFaNum'; src: url('{fa_font_url_regular}') format('truetype'); font-weight: 400; font-style: normal; }}\n"
if is_fa and fa_font_url_bold:
font_face_css += f"@font-face {{ font-family: 'YekanBakhFaNum'; src: url('{fa_font_url_bold}') format('truetype'); font-weight: 700; font-style: normal; }}\n"
body_font_family = "YekanBakhFaNum, Vazirmatn, Tahoma, Arial, sans-serif" if is_fa else "Arial, sans-serif"
html = f"""
<html>
<head>
<meta charset='utf-8'/>
<style>
{font_face_css}
body {{ font-family: {body_font_family}; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ border: 1px solid #ddd; padding: 6px; font-size: 12px; }}
th {{ background: #f5f5f5; text-align: right; }}
</style>
</head>
<body>
<h3>{'گزارش کاردکس' if is_fa else 'Kardex Report'}</h3>
{(
"<div style='margin:6px 0 10px 0; font-size:11px; color:#555'>" +
"".join([f'''<span style="display:inline-block;border:1px solid #e5e7eb;border-radius:999px;padding:3px 8px;margin:2px 4px;background:#fff"><b>{escape(str(f.get('label')))}:</b> {escape(str(f.get('value')))}</span>''' for f in (filters_summary or [])]) +
"</div>"
) if filters_summary else ""}
<table>
<thead>
<tr>
<th>#</th>
<th>تاریخ سند</th>
<th>کد سند</th>
<th>نوع سند</th>
<th>انبار</th>
<th>جهت حرکت</th>
<th>شرح</th>
<th>بدهکار</th>
<th>بستانکار</th>
<th>تعداد</th>
<th>مانده مبلغ</th>
<th>مانده تعداد</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</body>
</html>
"""
# در صورت نبود قالب سفارشی، از قالب فایل استفاده کن
if not resolved_html:
try:
final_html = render_template("pdf/kardex/list.html", {
"title_text": "گزارش کاردکس" if is_fa else "Kardex Report",
"business_name": business_name,
"generated_at": generated_at if 'generated_at' in locals() else datetime.datetime.now().strftime("%Y/%m/%d %H:%M"),
"is_fa": is_fa,
"locale": locale,
"paper_size": paper_size or "A4",
"orientation": orientation or "landscape",
"show_running": bool(query_dict.get("include_running_balance", False)),
"filters_summary": filters_summary,
"fa_font_url_regular": fa_font_url_regular,
"fa_font_url_bold": fa_font_url_bold,
"footer_text": kardex_lines_footer,
"page_totals": page_totals,
"all_totals": all_totals,
"all_count": all_count,
"items": items,
})
except Exception:
final_html = html
else:
final_html = resolved_html
# If a custom template is used (DB), it may not include totals section yet.
# Ensure totals are still visible by injecting a compact B/W totals block near the top of <body>.
try:
if "data-kardex-totals=\"1\"" not in final_html:
def _fmt_num(v: Any, decimals: int = 0) -> str:
if v is None:
return "—"
try:
n = float(v)
if abs(n - round(n)) < 1e-9:
return f"{int(round(n)):,}"
s = f"{n:,.{decimals}f}".rstrip("0").rstrip(".")
return s
except Exception:
return "—"
_is_fa1 = bool(is_fa)
pt = page_totals or {}
at = all_totals or {}
totals_html = f"""
<div data-kardex-totals="1" style="margin:10px 0 10px 0; padding:8px 10px; border:1px solid #000; border-radius:6px; background:#fff; font-size:10.5px; color:#111; page-break-inside:avoid;">
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-start;">
<div style="flex:1; min-width:240px; border:1px solid #000; border-radius:6px; padding:8px 10px;">
<div style="font-weight:700; margin-bottom:6px;">{('جمع صفحه' if _is_fa1 else 'Page totals')}</div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('تعداد سطر' if _is_fa1 else 'Rows')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(pt.get('count', 0), 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('جمع بدهکار' if _is_fa1 else 'Total debit')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(pt.get('debit', 0), 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('جمع بستانکار' if _is_fa1 else 'Total credit')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(pt.get('credit', 0), 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0;"><span>{('جمع تعداد' if _is_fa1 else 'Total qty')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(pt.get('quantity', None), 2))}</span></div>
</div>
<div style="flex:1; min-width:240px; border:1px solid #000; border-radius:6px; padding:8px 10px;">
<div style="font-weight:700; margin-bottom:6px;">{('جمع کل' if _is_fa1 else 'Overall totals')}</div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('تعداد کل' if _is_fa1 else 'Total rows')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(all_count, 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('جمع بدهکار' if _is_fa1 else 'Total debit')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(at.get('debit', 0), 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0; border-bottom:1px dashed #999;"><span>{('جمع بستانکار' if _is_fa1 else 'Total credit')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(at.get('credit', 0), 0))}</span></div>
<div style="display:flex; justify-content:space-between; gap:8px; padding:2px 0;"><span>{('جمع تعداد' if _is_fa1 else 'Total qty')}</span><span style="font-weight:700; direction:ltr; unicode-bidi:plaintext;">{escape(_fmt_num(at.get('quantity', None), 2))}</span></div>
</div>
</div>
</div>
"""
idx = final_html.lower().find("<body")
if idx != -1:
gt = final_html.find(">", idx)
if gt != -1:
final_html = final_html[: gt + 1] + totals_html + final_html[gt + 1 :]
else:
final_html = totals_html + final_html
else:
final_html = totals_html + final_html
except Exception:
pass
# Respect paper size/orientation params (fallback to A4 landscape)
effective_paper = paper_size or "A4"
effective_orientation = orientation or "landscape"
effective_disposition = disposition or "attachment"
font_config = FontConfiguration()
pdf_css = f"""
{font_face_css}
@page {{ size: {effective_paper} {effective_orientation}; margin: 12mm; }}
body {{ font-family: {body_font_family}; }}
"""
pdf_bytes = HTML(string=final_html).write_pdf(stylesheets=[CSS(string=pdf_css)], font_config=font_config)
filename = f"kardex_{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"{effective_disposition}; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)