progress in some parts

This commit is contained in:
Hesabix 2025-11-09 05:16:37 +00:00
parent 28ccc57f70
commit b0884a33fd
99 changed files with 9823 additions and 461 deletions

249
deploy.sh Normal file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Hesabix one-click deployment script (server-side)
# - Clones from: https://source.hesabix.ir/morrning/hesabixArc.git
# - Prompts for API/UI domains and branch
# - Installs prerequisites, DB, backend (FastAPI), frontend (Flutter Web), Nginx
#
# Usage:
# sudo bash deploy.sh
# # or
# API_DOMAIN=api.example.com UI_DOMAIN=app.example.com BRANCH=main sudo -E bash deploy.sh
#
# Notes:
# - Designed for Ubuntu 22.04+/Debian 12+
# - Idempotent-ish: safe to re-run; will update and restart services
REPO_URL="https://source.hesabix.ir/morrning/hesabixArc.git"
APP_ROOT="/opt/hesabix"
CHECK_MARK=$'\xE2\x9C\x94'
CROSS_MARK=$'\xE2\x9D\x8C'
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$CROSS_MARK ابزار لازم یافت نشد: $1"
exit 1
fi
}
prompt_vars() {
: "${API_DOMAIN:=}"
: "${UI_DOMAIN:=}"
: "${BRANCH:=main}"
if [[ -z "${API_DOMAIN}" ]]; then
read -rp "دامنه API (مثال: api.example.com): " API_DOMAIN
fi
if [[ -z "${UI_DOMAIN}" ]]; then
read -rp "دامنه Front (مثال: app.example.com): " UI_DOMAIN
fi
if [[ -z "${BRANCH}" ]]; then
read -rp "نام برنچ (پیش‌فرض main): " BRANCH
BRANCH=${BRANCH:-main}
fi
export API_DOMAIN UI_DOMAIN BRANCH
echo "$CHECK_MARK متغیرها:"
echo " API_DOMAIN=${API_DOMAIN}"
echo " UI_DOMAIN=${UI_DOMAIN}"
echo " BRANCH=${BRANCH}"
}
install_prereqs() {
echo ">> نصب پیش‌نیازها..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y git curl unzip xz-utils ca-certificates \
python3.11 python3.11-venv python3-pip build-essential \
nginx mariadb-server
echo "$CHECK_MARK پیش‌نیازها نصب شد."
}
clone_repo() {
echo ">> کلون/به‌روزرسانی مخزن..."
mkdir -p "${APP_ROOT}"
cd "${APP_ROOT}"
if [[ ! -d "${APP_ROOT}/app/.git" ]]; then
git clone -b "${BRANCH}" --depth=1 "${REPO_URL}" app
else
cd app
git fetch --all --prune
git checkout "${BRANCH}"
git pull --ff-only
fi
echo "$CHECK_MARK مخزن آماده است در ${APP_ROOT}/app"
}
setup_db() {
echo ">> پیکربندی دیتابیس (MariaDB/MySQL)..."
systemctl enable --now mariadb || systemctl enable --now mysql || true
mysql --protocol=socket -uroot <<'SQL'
CREATE DATABASE IF NOT EXISTS hesabix CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER IF NOT EXISTS 'hesabix'@'localhost' IDENTIFIED BY 'StrongPass#ChangeMe';
GRANT ALL PRIVILEGES ON hesabix.* TO 'hesabix'@'localhost';
FLUSH PRIVILEGES;
SQL
echo "$CHECK_MARK دیتابیس و کاربر آماده شد."
}
deploy_backend() {
echo ">> استقرار بک‌اند..."
local api_dir="${APP_ROOT}/app/hesabixAPI"
cd "${api_dir}"
# Python venv + install
if [[ ! -d ".venv" ]]; then
python3.11 -m venv .venv
fi
# shellcheck disable=SC1091
source .venv/bin/activate
pip install --upgrade pip
pip install -e .
# .env
cat > .env <<ENV
environment=production
debug=false
db_user=hesabix
db_password=StrongPass#ChangeMe
db_host=127.0.0.1
db_port=3306
db_name=hesabix
log_level=INFO
cors_allowed_origins=["https://${UI_DOMAIN}","http://${UI_DOMAIN}"]
ENV
# Alembic migrations
alembic upgrade head
# systemd service
cat > /etc/systemd/system/hesabix-api.service <<'UNIT'
[Unit]
Description=Hesabix API (FastAPI/Uvicorn)
After=network.target mariadb.service mysql.service
[Service]
User=www-data
WorkingDirectory=/opt/hesabix/app/hesabixAPI
Environment=PATH=/opt/hesabix/app/hesabixAPI/.venv/bin
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/hesabix/app/hesabixAPI/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now hesabix-api
echo "$CHECK_MARK بک‌اند اجرا شد (service: hesabix-api)."
}
install_flutter_and_build_frontend() {
echo ">> نصب Flutter و بیلد فرانت..."
local flutter_root="/opt/flutter"
if [[ ! -d "${flutter_root}/flutter" ]]; then
mkdir -p "${flutter_root}"
cd "${flutter_root}"
curl -L https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.24.0-stable.tar.xz -o flutter.tar.xz
tar -xf flutter.tar.xz
fi
local flutter_bin="${flutter_root}/flutter/bin/flutter"
"${flutter_bin}" --version
"${flutter_bin}" config --enable-web
local ui_dir="${APP_ROOT}/app/hesabixUI/hesabix_ui"
cd "${ui_dir}"
"${flutter_bin}" pub get
"${flutter_bin}" build web --release
mkdir -p "/var/www/${UI_DOMAIN}"
rsync -a --delete build/web/ "/var/www/${UI_DOMAIN}/"
chown -R www-data:www-data "/var/www/${UI_DOMAIN}"
echo "$CHECK_MARK فرانت بیلد و در /var/www/${UI_DOMAIN} مستقر شد."
}
configure_nginx() {
echo ">> پیکربندی Nginx..."
cat > /etc/nginx/sites-available/hesabix.conf <<NGINX
# Frontend (Flutter Web)
server {
listen 80;
server_name ${UI_DOMAIN};
root /var/www/${UI_DOMAIN};
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}
# Backend API
server {
listen 80;
server_name ${API_DOMAIN};
location / {
return 404;
}
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 300;
client_max_body_size 20m;
}
}
NGINX
ln -sf /etc/nginx/sites-available/hesabix.conf /etc/nginx/sites-enabled/hesabix.conf
nginx -t
systemctl reload nginx
echo "$CHECK_MARK Nginx پیکربندی و ری‌لود شد."
}
maybe_enable_tls() {
echo
read -rp "آیا TLS خودکار با certbot فعال شود؟ (y/N): " ENABLE_TLS
ENABLE_TLS=${ENABLE_TLS:-N}
if [[ "${ENABLE_TLS}" =~ ^[Yy]$ ]]; then
apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d "${UI_DOMAIN}" -d "${API_DOMAIN}" --redirect --non-interactive --agree-tos -m "admin@${UI_DOMAIN}" || true
echo "$CHECK_MARK تلاش برای صدور TLS انجام شد."
else
echo "TLS رد شد؛ می‌توانید بعداً certbot اجرا کنید."
fi
}
main() {
if [[ $EUID -ne 0 ]]; then
echo "$CROSS_MARK لطفاً اسکریپت را با دسترسی روت اجرا کنید (sudo)."
exit 1
fi
prompt_vars
install_prereqs
clone_repo
setup_db
deploy_backend
install_flutter_and_build_frontend
configure_nginx
maybe_enable_tls
echo
echo "$CHECK_MARK استقرار تکمیل شد."
echo " API: http://${API_DOMAIN}/api/v1/health"
echo " UI: http://${UI_DOMAIN}/"
echo
echo "برای اجرای مجدد/آپگرید:"
echo " BRANCH=${BRANCH} API_DOMAIN=${API_DOMAIN} UI_DOMAIN=${UI_DOMAIN} sudo -E bash deploy.sh"
}
main "$@"

View file

@ -0,0 +1,180 @@
from __future__ import annotations
from typing import Dict, Any, List
import json
from fastapi import APIRouter, Depends, Request, Body, Path, Query
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, ApiError
from adapters.db.models.payment_gateway import PaymentGateway
router = APIRouter(prefix="/admin/payment-gateways", tags=["admin-payment-gateways"])
def _mask_config(cfg: dict) -> dict:
"""Mask sensitive fields in config for safe output"""
if not isinstance(cfg, dict):
return {}
masked = dict(cfg)
for key in ["merchant_id", "terminal_id", "username", "password", "secret", "secret_key", "api_key"]:
if key in masked and masked[key]:
val = str(masked[key])
if len(val) > 6:
masked[key] = f"{val[:2]}***{val[-2:]}"
else:
masked[key] = "***"
return masked
@router.get(
"",
summary="فهرست درگاه‌های پرداخت",
)
def list_payment_gateways(
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
items = db.query(PaymentGateway).order_by(PaymentGateway.id.desc()).all()
data = []
for it in items:
cfg = {}
try:
cfg = json.loads(it.config_json or "{}")
except Exception:
cfg = {}
data.append({
"id": it.id,
"provider": it.provider,
"display_name": it.display_name,
"is_active": it.is_active,
"is_sandbox": it.is_sandbox,
"config": _mask_config(cfg),
})
return success_response(data, request)
@router.post(
"",
summary="ایجاد درگاه پرداخت",
)
def create_payment_gateway(
request: Request,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
provider = str(payload.get("provider") or "").strip().lower()
display_name = str(payload.get("display_name") or "").strip()
is_active = bool(payload.get("is_active", True))
is_sandbox = bool(payload.get("is_sandbox", True))
config = payload.get("config") or {}
if provider not in ("zarinpal", "parsian"):
raise ApiError("UNSUPPORTED_PROVIDER", "provider باید یکی از zarinpal یا parsian باشد", http_status=400)
if not display_name:
raise ApiError("INVALID_NAME", "display_name الزامی است", http_status=400)
gw = PaymentGateway(
provider=provider,
display_name=display_name,
is_active=is_active,
is_sandbox=is_sandbox,
config_json=json.dumps(config, ensure_ascii=False),
)
db.add(gw)
db.commit()
db.refresh(gw)
return success_response({"id": gw.id}, request, message="GATEWAY_CREATED")
@router.get(
"/{gateway_id}",
summary="دریافت جزئیات درگاه پرداخت",
)
def get_payment_gateway(
request: Request,
gateway_id: int = Path(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if not gw:
raise ApiError("NOT_FOUND", "درگاه یافت نشد", http_status=404)
cfg = {}
try:
cfg = json.loads(gw.config_json or "{}")
except Exception:
cfg = {}
data = {
"id": gw.id,
"provider": gw.provider,
"display_name": gw.display_name,
"is_active": gw.is_active,
"is_sandbox": gw.is_sandbox,
"config": _mask_config(cfg),
}
return success_response(data, request)
@router.put(
"/{gateway_id}",
summary="ویرایش درگاه پرداخت",
)
def update_payment_gateway(
request: Request,
gateway_id: int = Path(...),
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if not gw:
raise ApiError("NOT_FOUND", "درگاه یافت نشد", http_status=404)
if "provider" in payload:
gw.provider = str(payload.get("provider") or gw.provider).strip().lower()
if "display_name" in payload:
gw.display_name = str(payload.get("display_name") or gw.display_name)
if "is_active" in payload:
gw.is_active = bool(payload.get("is_active"))
if "is_sandbox" in payload:
gw.is_sandbox = bool(payload.get("is_sandbox"))
if "config" in payload:
cfg = payload.get("config") or {}
gw.config_json = json.dumps(cfg, ensure_ascii=False)
db.commit()
db.refresh(gw)
return success_response({"id": gw.id}, request, message="GATEWAY_UPDATED")
@router.delete(
"/{gateway_id}",
summary="حذف درگاه پرداخت",
)
def delete_payment_gateway(
request: Request,
gateway_id: int = Path(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if not gw:
raise ApiError("NOT_FOUND", "درگاه یافت نشد", http_status=404)
db.delete(gw)
db.commit()
return success_response({"id": gateway_id}, request, message="GATEWAY_DELETED")

View file

@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Dict, Any
from fastapi import APIRouter, Depends, Body, Request
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
from app.services.system_settings_service import get_wallet_settings, set_wallet_base_currency_code
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@router.get(
"/wallet",
summary="دریافت تنظیمات کیف‌پول (ارز پایه)",
description="خواندن ارز پایه کیف‌پول. اگر تنظیم نشده باشد IRR بازگردانده می‌شود.",
)
def get_wallet_settings_endpoint(
request: Request,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
data = get_wallet_settings(db)
return success_response(data, request)
@router.put(
"/wallet",
summary="تنظیم ارز پایه کیف‌پول",
description="تنظیم کد ارز پایه کیف‌پول (مثلاً IRR). تنها برای مدیر سیستم.",
)
def set_wallet_settings_endpoint(
request: Request,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
code = str(payload.get("wallet_base_currency_code") or "").strip().upper()
data = set_wallet_base_currency_code(db, code)
return success_response(data, request, message="WALLET_BASE_CURRENCY_UPDATED")

View file

@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, Request, Body, Path, Query
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, ApiError
from adapters.db.models.wallet import WalletAccount
from app.services.wallet_service import refund_transaction, settle_payout
router = APIRouter(prefix="/admin/wallets", tags=["admin-wallet"])
@router.get(
"",
summary="فهرست کیف‌پول کسب‌وکارها",
)
def list_wallets_admin(
request: Request,
limit: int = Query(50, ge=1, le=200),
skip: int = Query(0, ge=0),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
q = db.query(WalletAccount).order_by(WalletAccount.id.desc())
items = q.offset(skip).limit(limit).all()
data = [
{
"id": it.id,
"business_id": it.business_id,
"available_balance": float(it.available_balance or 0),
"pending_balance": float(it.pending_balance or 0),
"status": it.status,
}
for it in items
]
return success_response(data, request)
@router.post(
"/{business_id}/refunds",
summary="ایجاد استرداد (مدیریتی)",
)
def create_refund_admin(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
tx_id = int(payload.get("transaction_id") or 0)
amount = payload.get("amount")
reason = payload.get("reason")
from decimal import Decimal
data = refund_transaction(db, tx_id, amount=Decimal(str(amount)) if amount is not None else None, reason=reason)
return success_response(data, request, message="REFUND_CREATED")
@router.put(
"/payouts/{payout_id}/settle",
summary="تسویه درخواست Payout (مدیریتی)",
)
def settle_payout_admin(
request: Request,
payout_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
if not ctx.has_any_permission("system_settings", "superadmin"):
raise ApiError("FORBIDDEN", "Missing permission: system_settings", http_status=403)
data = settle_payout(db, payout_id, ctx.get_user_id())
return success_response(data, request, message="PAYOUT_SETTLED")

View file

@ -469,6 +469,38 @@ async def export_bank_accounts_pdf(
headers_html = ''.join(f"<th>{escape_val(h)}</th>" for h in headers)
# تلاش برای رندر با قالب سفارشی (bank_accounts/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
template_context = {
"title_text": title_text,
"business_name": business_name,
"generated_at": now_str,
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="bank_accounts",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
@ -560,8 +592,9 @@ async def export_bank_accounts_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -440,6 +440,38 @@ async def export_cash_registers_pdf(
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
# تلاش برای رندر با قالب سفارشی (cash_registers/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
template_context = {
"title_text": 'گزارش صندوق‌ها' if is_fa else 'Cash Registers Report',
"business_name": business_name,
"generated_at": now_str,
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="cash_registers",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang="{html_lang}" dir="{html_dir}">
<head>
@ -464,8 +496,9 @@ async def export_cash_registers_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -96,6 +96,173 @@ async def list_documents_endpoint(
)
@router.post(
"/businesses/{business_id}/documents/export/pdf",
summary="خروجی PDF لیست اسناد حسابداری",
description="دریافت فایل PDF لیست اسناد حسابداری با پشتیبانی از قالب سفارشی (documents/list)",
)
@require_business_access("business_id")
async def export_documents_pdf_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی PDF لیست اسناد حسابداری"""
from fastapi.responses import Response
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, json
# فیلترهایی مشابه export_documents_excel
filters = {}
for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]:
if key in body:
filters[key] = body[key]
# سال مالی از header یا body
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
filters["fiscal_year_id"] = body["fiscal_year_id"]
except Exception:
pass
# دریافت داده‌ها
result = list_documents(db, business_id, {**filters, "take": body.get("take", 1000), "skip": body.get("skip", 0)})
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# ستون‌ها
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_debit", "جمع بدهکار"),
("total_credit", "جمع بستانکار"),
("created_by_name", "ایجادکننده"),
("registered_at", "تاریخ ثبت"),
]
for key, label in default_columns:
if items and key in items[0]:
keys.append(key)
headers.append(label)
# اطلاعات کسب‌وکار
business_name = ""
try:
from adapters.db.models.business import Business
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"
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "لیست اسناد حسابداری" if is_fa else "Documents 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 = json.dumps(value, ensure_ascii=False)
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# کانتکست قالب
template_context = {
"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),
}
# تلاش برای رندر با قالب سفارشی
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="documents",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# HTML پیش‌فرض
default_html = f"""
<!DOCTYPE html>
<html dir='{"rtl" if is_fa else "ltr"}'>
<head>
<meta charset="utf-8" />
<style>
@page {{ margin: 1cm; size: A4; }}
body {{ font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'}; font-size: 12px; color: #222; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
thead {{ background: #f6f6f6; }}
.meta {{ font-size: 11px; color: #666; }}
</style>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;border-bottom:2px solid #366092;padding-bottom:8px">
<div>
<div style="font-size:18px;font-weight:bold;color:#366092">{title_text}</div>
<div class="meta">{label_biz}: {escape(business_name)}</div>
</div>
<div class="meta">{label_date}: {escape(now)}</div>
</div>
<table>
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
<div class="meta" style="margin-top:8px;text-align:{'left' if is_fa else 'right'}">{footer_text}</div>
</body>
</html>
"""
html_content = resolved_html or default_html
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
filename = f"documents_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.get(
"/documents/{document_id}",
summary="جزئیات سند حسابداری",
@ -283,6 +450,7 @@ async def get_document_pdf_endpoint(
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
template_id: int | None = None,
):
"""
PDF یک سند
@ -298,11 +466,119 @@ async def get_document_pdf_endpoint(
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# TODO: تولید PDF
raise ApiError(
"NOT_IMPLEMENTED",
"PDF generation is not implemented yet",
http_status=501
# رندر با قالب سفارشی (documents/detail) یا خروجی پیش‌فرض
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, re
# اطلاعات کسب‌وکار
business_name = ""
try:
from adapters.db.models.business import Business
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"
now = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
# کانتکست قالب
template_context = {
"business_id": business_id,
"business_name": business_name,
"document": doc,
"lines": doc.get("lines", []),
"code": doc.get("code"),
"document_type": doc.get("document_type"),
"document_date": doc.get("document_date"),
"description": doc.get("description"),
"generated_at": now,
"is_fa": is_fa,
}
# تلاش برای رندر
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
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="documents",
subtype="detail",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# پیش‌فرض
default_html = f"""
<!DOCTYPE html>
<html dir='{"rtl" if is_fa else "ltr"}'>
<head>
<meta charset="utf-8" />
<style>
body {{ font-family: Tahoma, Arial, sans-serif; font-size: 12px; color: #222; }}
h1 {{ font-size: 18px; margin: 0 0 12px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
th {{ background: #f6f6f6; }}
.meta .label {{ color: #666; }}
</style>
</head>
<body>
<h1>{escape(doc.get("document_type_name") or ("سند" if is_fa else "Document"))}</h1>
<div class="meta">
<div><span class="label">{'کسب‌وکار' if is_fa else 'Business'}:</span> {escape(business_name or "-")}</div>
<div><span class="label">{'کد' if is_fa else 'Code'}:</span> {escape(doc.get("code") or "-")}</div>
<div><span class="label">{'تاریخ' if is_fa else 'Date'}:</span> {escape(doc.get("document_date") or "-")}</div>
</div>
<table>
<thead>
<tr>
<th>{'شرح' if is_fa else 'Description'}</th>
<th>{'بدهکار' if is_fa else 'Debit'}</th>
<th>{'بستانکار' if is_fa else 'Credit'}</th>
</tr>
</thead>
<tbody>
{''.join([
f"<tr><td>{escape(str(line.get('description') or '-'))}</td><td>{escape(str(line.get('debit') or ''))}</td><td>{escape(str(line.get('credit') or ''))}</td></tr>"
for line in (doc.get('lines') or [])
])}
</tbody>
</table>
</body>
</html>
"""
html_content = resolved_html or default_html
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
def _slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "document"
filename = f"document_{_slugify(doc.get('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",
},
)

View file

@ -261,35 +261,179 @@ async def export_expense_income_pdf_endpoint(
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی PDF"""
from app.services.expense_income_service import export_expense_income_pdf
"""خروجی PDF (با پشتیبانی قالب سفارشی expense_income/list)"""
from fastapi.responses import Response
# دریافت پارامترهای فیلتر
query_dict = {}
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime, json
# دریافت پارامترهای فیلتر و تنظیمات
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]
body = await request.json()
except Exception:
pass
body = {}
# ساخت query برای لیست
query_dict = {
"take": int(body.get("take", 1000)),
"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"),
}
# سال مالی از هدر
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
elif body.get("fiscal_year_id") is not None:
query_dict["fiscal_year_id"] = int(body.get("fiscal_year_id"))
except Exception:
pass
pdf_data = export_expense_income_pdf(db, business_id, query_dict)
# دریافت داده‌ها
from app.services.expense_income_service import list_expense_income
from adapters.db.models.business import Business
from app.core.responses import format_datetime_fields
result = list_expense_income(db, business_id, query_dict)
items = result.get("items", [])
items = [format_datetime_fields(item, request) for item in items]
# ستون‌ها
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", "مبلغ کل"),
("created_by_name", "ایجادکننده"),
("registered_at", "تاریخ ثبت"),
]
for key, label in default_columns:
if items and key in items[0]:
keys.append(key)
headers.append(label)
# اطلاعات کسب‌وکار
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"
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "لیست اسناد هزینه/درآمد" if is_fa else "Expense/Income 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 = json.dumps(value, ensure_ascii=False)
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# کانتکست برای قالب سفارشی
template_context = {
"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),
}
# تلاش برای رندر با قالب سفارشی
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="expense_income",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# HTML پیش‌فرض
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 {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #d7dde6; padding: 6px; text-align: {'right' if is_fa else 'left'}; }}
thead {{ background: #f6f6f6; }}
</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>
<table>
<thead><tr>{headers_html}</tr></thead>
<tbody>{''.join(rows_html)}</tbody>
</table>
<div class="meta" style="margin-top: 8px; text-align: {'left' if is_fa else 'right'};">{footer_text}</div>
</body>
</html>
"""
final_html = resolved_html or table_html
pdf_bytes = HTML(string=final_html).write_pdf(font_config=FontConfiguration())
filename = f"expense_income_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_data,
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.pdf"}
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -160,6 +160,54 @@ def export_inventory_transfers_pdf(
f"</tr>" for d in rows
])
# تلاش برای رندر با قالب سفارشی (inventory_transfers/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
# نام کسب‌وکار
business_name = ""
try:
from adapters.db.models.business import Business
biz = db.query(Business).filter(Business.id == business_id).first()
if biz is not None:
business_name = biz.name or ""
except Exception:
business_name = ""
# Locale
from app.core.i18n import negotiate_locale
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = (locale == 'fa')
headers = ["کد سند", "تاریخ سند", "شرح"]
keys = ["code", "document_date", "description"]
headers_html = "<th>کد سند</th><th>تاریخ سند</th><th>شرح</th>"
template_context = {
"title_text": "لیست انتقال‌ها" if is_fa else "Transfers List",
"business_name": business_name,
"generated_at": datetime.datetime.now().strftime('%Y/%m/%d %H:%M'),
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": [ {"code": d.code, "document_date": d.document_date, "description": d.description} for d in rows ],
"table_headers_html": headers_html,
"table_rows_html": rows_html,
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="inventory_transfers",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
html = f"""
<html>
<head>
@ -189,8 +237,9 @@ def export_inventory_transfers_pdf(
</html>
"""
final_html = resolved_html or html
font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config)
filename = f"inventory_transfers_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,

View file

@ -89,6 +89,152 @@ def get_invoice_endpoint(
result = invoice_document_to_dict(db, doc)
return success_response(data={"item": result}, request=request, message="INVOICE")
@router.get(
"/business/{business_id}/{invoice_id}/pdf",
summary="PDF یک فاکتور",
description="دریافت فایل PDF یک فاکتور با پشتیبانی از قالب سفارشی (invoices/detail)",
)
@require_business_access("business_id")
async def export_single_invoice_pdf(
business_id: int,
invoice_id: int,
request: Request,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
template_id: int | None = None,
):
"""
خروجی PDF تکسند فاکتور با پشتیبانی از قالب سفارشی:
- اگر template_id داده شود و منتشرشده باشد، همان استفاده میشود.
- در غیر این صورت اگر قالب پیشفرض منتشرشده برای invoices/detail موجود باشد، استفاده میشود.
- در نبود قالب، خروجی HTML پیشفرض تولید میشود.
"""
from weasyprint import HTML
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
import datetime
# دریافت سند و اعتبارسنجی
doc = db.query(Document).filter(Document.id == invoice_id).first()
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
from app.core.responses import ApiError
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
# جزئیات کامل فاکتور
item = invoice_document_to_dict(db, doc)
# اطلاعات کسب‌وکار (اختیاری)
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"
# کانتکست قالب
template_context = {
"business_id": business_id,
"business_name": business_name,
"invoice": item,
"lines": item.get("lines", []),
"generated_at": datetime.datetime.now().strftime("%Y/%m/%d %H:%M"),
"is_fa": is_fa,
}
# تلاش برای رندر با قالب سفارشی
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
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="invoices",
subtype="detail",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
# HTML پیش‌فرض در نبود قالب
html_content = resolved_html or f"""
<!DOCTYPE html>
<html dir='{"rtl" if is_fa else "ltr"}'>
<head>
<meta charset="utf-8" />
<style>
body {{ font-family: Tahoma, Arial, sans-serif; font-size: 12px; color: #222; }}
h1 {{ font-size: 18px; margin: 0 0 12px; }}
.meta {{ margin: 6px 0; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
th {{ background: #f6f6f6; }}
.totals {{ margin-top: 12px; float: {"left" if is_fa else "right"}; min-width: 260px; }}
.label {{ color: #666; }}
</style>
</head>
<body>
<h1>{escape(item.get("title") or ("فاکتور" if is_fa else "Invoice"))}</h1>
<div class="meta">
<div><span class="label">{'کسب‌وکار' if is_fa else 'Business'}:</span> {escape(business_name or "-")}</div>
<div><span class="label">{'کد' if is_fa else 'Code'}:</span> {escape(item.get("code") or "-")}</div>
<div><span class="label">{'تاریخ' if is_fa else 'Date'}:</span> {escape(item.get("issue_date") or "-")}</div>
</div>
<table>
<thead>
<tr>
<th>{'ردیف' if is_fa else 'No.'}</th>
<th>{'شرح کالا/خدمت' if is_fa else 'Item'}</th>
<th>{'تعداد' if is_fa else 'Qty'}</th>
<th>{'فی' if is_fa else 'Price'}</th>
<th>{'مبلغ' if is_fa else 'Amount'}</th>
</tr>
</thead>
<tbody>
{''.join([
f"<tr><td>{i+1}</td><td>{escape(str(line.get('product_name') or line.get('description') or '-'))}</td><td>{escape(str(line.get('quantity') or ''))}</td><td>{escape(str(line.get('unit_price') or ''))}</td><td>{escape(str(line.get('line_total') or ''))}</td></tr>"
for i, line in enumerate(item.get('lines') or [])
])}
</tbody>
</table>
<div class="totals">
<div><span class="label">{'جمع جزء' if is_fa else 'Subtotal'}:</span> {escape(str(item.get('subtotal') or ''))}</div>
<div><span class="label">{'مالیات' if is_fa else 'Tax'}:</span> {escape(str(item.get('tax_total') or ''))}</div>
<div><strong>{'قابل پرداخت' if is_fa else 'Payable'}:</strong> {escape(str(item.get('payable_total') or ''))}</div>
</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 or "")).strip("_") or "invoice"
filename = f"invoice_{_slugify(item.get('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",
},
)
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
@ -741,7 +887,41 @@ async def export_invoices_pdf(
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
html_content = f"""
# کانتکست مشترک برای قالب‌های سفارشی
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,
# خروجی‌های HTML آماده برای استفاده سریع در قالب
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
# تلاش برای رندر با قالب سفارشی (explicit یا پیش‌فرض)
try:
from app.services.report_template_service import ReportTemplateService
explicit_template_id = None
try:
if "template_id" in body and body.get("template_id") is not None:
explicit_template_id = int(body.get("template_id"))
except Exception:
explicit_template_id = None
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="invoices",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
html_content = resolved_html or f"""
<!DOCTYPE html>
<html dir='{ 'rtl' if is_fa else 'ltr' }'>
<head>

View file

@ -240,6 +240,47 @@ async def export_kardex_pdf_endpoint(
for it in items
])
# تلاش برای رندر با قالب سفارشی (kardex/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
headers = [
"تاریخ سند","کد سند","نوع سند","انبار","جهت حرکت","شرح",
"بدهکار","بستانکار","تعداد","مانده مبلغ","مانده تعداد"
]
keys = [
"document_date","document_code","document_type","warehouse_name",
"movement","description","debit","credit","quantity","running_amount","running_quantity"
]
headers_html = "".join(f"<th>{h}</th>" for h in headers)
template_context = {
"title_text": "گزارش کاردکس",
"business_name": "",
"generated_at": datetime.datetime.now().strftime('%Y/%m/%d %H:%M'),
"is_fa": True,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": rows_html,
}
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
html = f"""
<html>
<head>
@ -277,8 +318,9 @@ async def export_kardex_pdf_endpoint(
</html>
"""
final_html = resolved_html or html
font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 12mm; }")], font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 12mm; }")], font_config=font_config)
filename = f"kardex_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(

View file

@ -0,0 +1,93 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Body, Request
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.permissions import require_business_management_dep
from app.core.responses import success_response, format_datetime_fields
from app.services.opening_balance_service import (
get_opening_balance,
upsert_opening_balance,
post_opening_balance,
)
router = APIRouter(tags=["opening_balance"], prefix="")
@router.get(
"/businesses/{business_id}/opening-balance",
summary="دریافت تراز افتتاحیه",
description="خواندن سند تراز افتتاحیه برای سال مالی مشخص (یا سال جاری در صورت عدم ارسال)",
)
async def get_opening_balance_endpoint(
request: Request,
business_id: int,
fiscal_year_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
# Permission: view opening_balance
if not ctx.has_business_permission("opening_balance", "view"):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Missing business permission: opening_balance.view", http_status=403)
# Access check
if not ctx.can_access_business(int(business_id)):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
result = get_opening_balance(db, business_id, fiscal_year_id)
return success_response(data=format_datetime_fields(result, request), request=request, message="OPENING_BALANCE_FETCHED")
@router.put(
"/businesses/{business_id}/opening-balance",
summary="ذخیره/به‌روزرسانی تراز افتتاحیه",
description="ایجاد یا بروزرسانی سند تراز افتتاحیه برای سال مالی مشخص",
)
async def upsert_opening_balance_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),
):
# Permission: edit opening_balance
if not ctx.has_business_permission("opening_balance", "edit"):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Missing business permission: opening_balance.edit", http_status=403)
if not ctx.can_access_business(int(business_id)):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
created = upsert_opening_balance(db, business_id, ctx.get_user_id(), body)
return success_response(data=format_datetime_fields(created, request), request=request, message="OPENING_BALANCE_SAVED")
@router.post(
"/businesses/{business_id}/opening-balance/post",
summary="نهایی‌سازی تراز افتتاحیه",
description="قفل کردن و علامت‌گذاری سند تراز افتتاحیه به عنوان نهایی",
)
async def post_opening_balance_endpoint(
request: Request,
business_id: int,
fiscal_year_id: Optional[int] = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
# Permission: edit opening_balance
if not ctx.has_business_permission("opening_balance", "edit"):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Missing business permission: opening_balance.edit", http_status=403)
if not ctx.can_access_business(int(business_id)):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
posted = post_opening_balance(db, business_id, ctx.get_user_id(), fiscal_year_id)
return success_response(data=format_datetime_fields(posted, request), request=request, message="OPENING_BALANCE_POSTED")

View file

@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Dict, Any
from fastapi import APIRouter, Depends, Request, Query
from sqlalchemy.orm import Session
from fastapi.responses import RedirectResponse
import json
from adapters.db.session import get_db
from app.core.responses import success_response
from app.services.payment_service import verify_payment_callback
from adapters.db.models.wallet import WalletTransaction
from adapters.db.models.payment_gateway import PaymentGateway
router = APIRouter(prefix="/wallet/payments/callback", tags=["wallet-callbacks"])
@router.get(
"/zarinpal",
summary="بازگشت از زرین‌پال",
)
def zarinpal_callback(
request: Request,
tx_id: int = Query(0, description="شناسه تراکنش داخلی"),
Authority: str | None = Query(None),
Status: str | None = Query(None),
db: Session = Depends(get_db),
) -> dict:
params = {"tx_id": tx_id, "Authority": Authority, "Status": Status}
data = verify_payment_callback(db, "zarinpal", params)
# Optional auto-redirect based on gateway config
try:
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if tx and tx.extra_info:
extra = json.loads(tx.extra_info)
gateway_id = extra.get("gateway_id")
if gateway_id:
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if gw and gw.config_json:
cfg = json.loads(gw.config_json or "{}")
target = None
if data.get("success"):
target = cfg.get("success_redirect")
else:
target = cfg.get("failure_redirect")
if target:
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
u = urlparse(target)
q = dict(parse_qsl(u.query))
q.update({
"tx_id": str(tx_id),
"status": "success" if data.get("success") else "failed",
"ref": (data.get("external_ref") or ""),
})
location = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
return RedirectResponse(url=location, status_code=302)
except Exception:
pass
return success_response(data, request, message="TOPUP_CONFIRMED" if data.get("success") else "TOPUP_FAILED")
@router.get(
"/parsian",
summary="بازگشت از پارسیان",
)
def parsian_callback(
request: Request,
tx_id: int = Query(0, description="شناسه تراکنش داخلی"),
Token: str | None = Query(None),
status: str | None = Query(None),
db: Session = Depends(get_db),
) -> dict:
params = {"tx_id": tx_id, "Token": Token, "status": status}
data = verify_payment_callback(db, "parsian", params)
# Optional auto-redirect based on gateway config
try:
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if tx and tx.extra_info:
extra = json.loads(tx.extra_info)
gateway_id = extra.get("gateway_id")
if gateway_id:
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if gw and gw.config_json:
cfg = json.loads(gw.config_json or "{}")
target = None
if data.get("success"):
target = cfg.get("success_redirect")
else:
target = cfg.get("failure_redirect")
if target:
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
u = urlparse(target)
q = dict(parse_qsl(u.query))
q.update({
"tx_id": str(tx_id),
"status": "success" if data.get("success") else "failed",
"ref": (data.get("external_ref") or ""),
})
location = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
return RedirectResponse(url=location, status_code=302)
except Exception:
pass
return success_response(data, request, message="TOPUP_CONFIRMED" if data.get("success") else "TOPUP_FAILED")

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from typing import Dict, Any, List
import json
from fastapi import APIRouter, Depends, Request, Path
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
from adapters.db.models.payment_gateway import PaymentGateway, BusinessPaymentGateway
router = APIRouter(prefix="/businesses/{business_id}/wallet", tags=["wallet"])
@router.get(
"/gateways",
summary="لیست درگاه‌های فعال برای کسب‌وکار",
description="اگر برای کسب‌وکار خاص درگاه‌هایی تنظیم شده باشد، همان‌ها را برمی‌گرداند؛ در غیر این صورت همه درگاه‌های فعال سیستم.",
)
def list_business_gateways(
request: Request,
business_id: int = Path(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
# اجازه دسترسی: کاربر عضو همان کسب‌وکار
# (فرض: AuthContext قبلاً بررسی اتصال کاربر به کسب‌وکار را انجام می‌دهد)
links = db.query(BusinessPaymentGateway).filter(
BusinessPaymentGateway.business_id == int(business_id),
BusinessPaymentGateway.is_active == True, # noqa: E712
).all()
items: List[PaymentGateway]
if links:
gateway_ids = [it.gateway_id for it in links]
items = db.query(PaymentGateway).filter(
PaymentGateway.id.in_(gateway_ids),
PaymentGateway.is_active == True, # noqa: E712
).all()
else:
items = db.query(PaymentGateway).filter(PaymentGateway.is_active == True).all() # noqa: E712
data = [
{
"id": it.id,
"provider": it.provider,
"display_name": it.display_name,
"is_sandbox": it.is_sandbox,
}
for it in items
]
return success_response(data, request)

View file

@ -538,6 +538,38 @@ async def export_persons_pdf(
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
# تلاش برای رندر با قالب سفارشی (persons/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
template_context = {
"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),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="persons",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
@ -629,8 +661,9 @@ async def export_persons_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
# Build meaningful filename
biz_name = ""

View file

@ -436,6 +436,38 @@ async def export_petty_cash_pdf(
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
# تلاش برای رندر با قالب سفارشی (petty_cash/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
template_context = {
"title_text": 'گزارش تنخواه گردان‌ها' if is_fa else 'Petty Cash Report',
"business_name": business_name,
"generated_at": now_str,
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="petty_cash",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang="{html_lang}" dir="{html_dir}">
<head>
@ -460,8 +492,9 @@ async def export_petty_cash_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
return Response(
content=pdf_bytes,
media_type="application/pdf",

View file

@ -735,6 +735,38 @@ async def export_products_pdf(
page_label_left = "صفحه " if is_fa else "Page "
page_label_of = " از " if is_fa else " of "
# تلاش برای رندر با قالب سفارشی (products/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
template_context = {
"title_text": title_text,
"business_name": business_name,
"generated_at": now_str,
"is_fa": is_fa,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": headers_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="products",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
table_html = f"""
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
<head>
@ -826,8 +858,9 @@ async def export_products_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
# Build meaningful filename
biz_name = business_name

View file

@ -447,6 +447,7 @@ async def export_single_receipt_payment_pdf(
request: Request,
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
template_id: int | None = None,
):
"""خروجی PDF تک سند دریافت/پرداخت"""
from weasyprint import HTML, CSS
@ -497,8 +498,43 @@ async def export_single_receipt_payment_pdf(
label_date = "تاریخ تولید" if is_fa else "Generated Date"
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
# ایجاد HTML برای PDF
html_content = f"""
# تلاش برای رندر با قالب سفارشی (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"""
<!DOCTYPE html>
<html dir="{'rtl' if is_fa else 'ltr'}">
<head>
@ -810,7 +846,41 @@ async def export_receipts_payments_pdf(
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# Create HTML table
# کانتکست برای قالب سفارشی لیست
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 پیش‌فرض جدول
table_html = f"""
<!DOCTYPE html>
<html dir="{'rtl' if is_fa else 'ltr'}">
@ -914,8 +984,10 @@ async def export_receipts_payments_pdf(
</html>
"""
final_html = resolved_html or table_html
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
# Build meaningful filename
def slugify(text: str) -> str:

View file

@ -0,0 +1,241 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, Depends, Request
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.permissions import require_business_access
from app.core.responses import ApiError, success_response
from app.services.report_template_service import ReportTemplateService
router = APIRouter(prefix="/report-templates", tags=["report-templates"])
@router.get(
"/business/{business_id}",
summary="لیست قالب‌های گزارش",
description="لیست قالب‌ها با امکان فیلتر بر اساس ماژول و زیرنوع. کاربران عادی فقط Published را می‌بینند.",
)
@require_business_access("business_id")
async def list_report_templates(
request: Request,
business_id: int,
module_key: Optional[str] = None,
subtype: Optional[str] = None,
status: Optional[str] = None,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
only_published = True
# فقط کسانی که write در report_templates دارند، می‌توانند پیش‌نویس‌ها را ببینند
if ctx.can_write_section("report_templates"):
only_published = False
templates = ReportTemplateService.list_templates(
db=db,
business_id=business_id,
module_key=module_key,
subtype=subtype,
status=status,
only_published=only_published,
)
data: List[Dict[str, Any]] = []
for t in templates:
data.append({
"id": t.id,
"business_id": t.business_id,
"module_key": t.module_key,
"subtype": t.subtype,
"name": t.name,
"description": t.description,
"status": t.status,
"is_default": t.is_default,
"version": t.version,
"paper_size": t.paper_size,
"orientation": t.orientation,
"margins": t.margins,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
})
return {"items": data}
@router.get(
"/{template_id}/business/{business_id}",
summary="جزئیات یک قالب گزارش (فقط سازندگان)",
description="اطلاعات کامل قالب شامل محتوا برای ویرایشگر",
)
@require_business_access("business_id")
async def get_report_template(
request: Request,
template_id: int,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
entity = ReportTemplateService.get_template(db=db, template_id=template_id, business_id=business_id)
if not entity:
raise ApiError("NOT_FOUND", "Template not found", http_status=404)
return {
"id": entity.id,
"business_id": entity.business_id,
"module_key": entity.module_key,
"subtype": entity.subtype,
"name": entity.name,
"description": entity.description,
"engine": entity.engine,
"status": entity.status,
"is_default": entity.is_default,
"version": entity.version,
"content_html": entity.content_html,
"content_css": entity.content_css,
"header_html": entity.header_html,
"footer_html": entity.footer_html,
"paper_size": entity.paper_size,
"orientation": entity.orientation,
"margins": entity.margins,
"assets": entity.assets,
"created_by": entity.created_by,
"created_at": entity.created_at.isoformat() if entity.created_at else None,
"updated_at": entity.updated_at.isoformat() if entity.updated_at else None,
}
@router.post(
"/business/{business_id}",
summary="ایجاد قالب جدید (فقط سازندگان)",
)
@require_business_access("business_id")
async def create_report_template(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
body = dict(body or {})
body["business_id"] = business_id
entity = ReportTemplateService.create_template(db=db, data=body, user_id=ctx.get_user_id())
return {"id": entity.id}
@router.put(
"/{template_id}/business/{business_id}",
summary="ویرایش قالب (فقط سازندگان)",
)
@require_business_access("business_id")
async def update_report_template(
request: Request,
template_id: int,
business_id: int,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
entity = ReportTemplateService.update_template(db=db, template_id=template_id, data=body or {}, business_id=business_id)
return {"id": entity.id, "version": entity.version}
@router.delete(
"/{template_id}/business/{business_id}",
summary="حذف قالب (فقط سازندگان)",
)
@require_business_access("business_id")
async def delete_report_template(
request: Request,
template_id: int,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
ReportTemplateService.delete_template(db=db, template_id=template_id, business_id=business_id)
return success_response(data={"deleted": True}, request=request, message="Deleted")
@router.post(
"/{template_id}/business/{business_id}/publish",
summary="انتشار/بازگشت به پیش‌نویس (فقط سازندگان)",
)
@require_business_access("business_id")
async def publish_report_template(
request: Request,
template_id: int,
business_id: int,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
is_published = bool((body or {}).get("published", True))
entity = ReportTemplateService.publish_template(db=db, template_id=template_id, business_id=business_id, is_published=is_published)
return {"id": entity.id, "status": entity.status}
@router.post(
"/business/{business_id}/set-default",
summary="تنظیم قالب پیش‌فرض یک ماژول/زیرنوع (فقط سازندگان)",
)
@require_business_access("business_id")
async def set_default_template(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
module_key = str((body or {}).get("module_key") or "")
if not module_key:
raise ApiError("VALIDATION_ERROR", "module_key is required", http_status=400)
subtype = (body or {}).get("subtype")
template_id = int((body or {}).get("template_id") or 0)
if template_id <= 0:
raise ApiError("VALIDATION_ERROR", "template_id is required", http_status=400)
entity = ReportTemplateService.set_default(
db=db,
business_id=business_id,
module_key=module_key,
subtype=(str(subtype) if subtype is not None else None),
template_id=template_id,
)
return {"id": entity.id, "is_default": entity.is_default}
@router.post(
"/business/{business_id}/preview",
summary="پیش‌نمایش قالب (فقط سازندگان)",
description="بدون ذخیره‌سازی؛ HTML/CSS ارسالی با داده نمونه رندر و به PDF تبدیل می‌شود.",
)
@require_business_access("business_id")
async def preview_report_template(
request: Request,
business_id: int,
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
if not ctx.can_write_section("report_templates"):
raise ApiError("FORBIDDEN", "Missing permission: report_templates.write", http_status=403)
content_html = (body or {}).get("content_html") or ""
content_css = (body or {}).get("content_css") or ""
context = (body or {}).get("context") or {}
temp = type("T", (), {"content_html": content_html, "content_css": content_css})() # شیء موقت شبیه ReportTemplate
html = ReportTemplateService.render_with_template(temp, context)
pdf_bytes = HTML(string=html).write_pdf(font_config=FontConfiguration())
return {
"content_length": len(pdf_bytes),
"ok": True,
}

View file

@ -299,6 +299,38 @@ async def export_transfers_pdf(
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
# تلاش برای رندر با قالب سفارشی (transfers/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
template_context = {
"title_text": "لیست انتقال‌ها",
"business_name": "",
"generated_at": now,
"is_fa": True,
"headers": headers,
"keys": keys,
"items": items,
"table_headers_html": header_html,
"table_rows_html": "".join(rows_html),
}
resolved_html = ReportTemplateService.try_render_resolved(
db=db,
business_id=business_id,
module_key="transfers",
subtype="list",
context=template_context,
explicit_template_id=explicit_template_id,
)
except Exception:
resolved_html = None
html = f"""
<!DOCTYPE html>
<html dir='rtl'>
@ -329,8 +361,9 @@ async def export_transfers_pdf(
</body>
</html>
"""
final_html = resolved_html or html
font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(font_config=font_config)
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
filename = f"transfers_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,

View file

@ -0,0 +1,285 @@
from __future__ import annotations
from typing import Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, Request, Body, Path
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.permissions import require_business_access
from app.core.responses import success_response, ApiError
from app.services.wallet_service import (
get_wallet_overview,
list_wallet_transactions,
create_payout_request,
approve_payout_request,
cancel_payout_request,
create_top_up_request,
get_wallet_metrics,
get_business_wallet_settings,
update_business_wallet_settings,
run_auto_settlement,
)
router = APIRouter(prefix="/businesses/{business_id}/wallet", tags=["wallet"])
@router.get(
"",
summary="خلاصه کیف‌پول کسب‌وکار",
description="نمایش مانده‌ها و ارز پایه",
)
def get_wallet_overview_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = get_wallet_overview(db, business_id)
return success_response(data, request)
@router.post(
"/top-up",
summary="ایجاد درخواست افزایش اعتبار",
description="ایجاد top-up و بازگشت شناسه تراکنش برای هدایت به درگاه",
)
def create_top_up_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = create_top_up_request(db, business_id, ctx.get_user_id(), payload)
return success_response(data, request, message="TOPUP_REQUESTED")
@router.get(
"/transactions",
summary="لیست تراکنش‌های کیف‌پول",
description="نمایش تراکنش‌ها به ترتیب نزولی",
)
def list_wallet_transactions_endpoint(
request: Request,
business_id: int,
skip: int = 0,
limit: int = 50,
from_date: str | None = None,
to_date: str | None = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from_dt = None
to_dt = None
try:
if from_date:
from_dt = datetime.fromisoformat(from_date)
if to_date:
to_dt = datetime.fromisoformat(to_date)
except Exception:
from_dt = None
to_dt = None
data = list_wallet_transactions(db, business_id, limit=limit, skip=skip, from_date=from_dt, to_date=to_dt)
return success_response(data, request)
@router.get(
"/transactions/export",
summary="خروجی CSV تراکنش‌های کیف‌پول",
)
def export_wallet_transactions_csv_endpoint(
request: Request,
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from_dt = None
to_dt = None
try:
if from_date:
from_dt = datetime.fromisoformat(from_date)
if to_date:
to_dt = datetime.fromisoformat(to_date)
except Exception:
from_dt = None
to_dt = None
items = list_wallet_transactions(db, business_id, limit=10000, skip=0, from_date=from_dt, to_date=to_dt)
# CSV ساده
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["id", "type", "status", "amount", "fee_amount", "description", "document_id", "created_at"])
for it in items:
writer.writerow([it.get("id"), it.get("type"), it.get("status"), it.get("amount"), it.get("fee_amount"), (it.get("description") or "").replace("\n", " "), it.get("document_id"), it.get("created_at")])
csv_data = buf.getvalue().encode("utf-8")
from fastapi.responses import Response
return Response(content=csv_data, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="wallet_transactions_{business_id}.csv"'})
@router.get(
"/metrics/export",
summary="خروجی CSV خلاصه کیف‌پول",
)
def export_wallet_metrics_csv_endpoint(
request: Request,
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
from_dt = None
to_dt = None
try:
if from_date:
from_dt = datetime.fromisoformat(from_date)
if to_date:
to_dt = datetime.fromisoformat(to_date)
except Exception:
from_dt = None
to_dt = None
m = get_wallet_metrics(db, business_id, from_date=from_dt, to_date=to_dt)
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["metric", "value"])
t = m.get("totals") or {}
writer.writerow(["gross_in", t.get("gross_in", 0)])
writer.writerow(["fees_in", t.get("fees_in", 0)])
writer.writerow(["net_in", t.get("net_in", 0)])
writer.writerow(["gross_out", t.get("gross_out", 0)])
writer.writerow(["fees_out", t.get("fees_out", 0)])
writer.writerow(["net_out", t.get("net_out", 0)])
b = m.get("balances") or {}
writer.writerow(["available", b.get("available", 0)])
writer.writerow(["pending", b.get("pending", 0)])
csv_data = buf.getvalue().encode("utf-8")
from fastapi.responses import Response
return Response(content=csv_data, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="wallet_metrics_{business_id}.csv"'})
@router.post(
"/payouts",
summary="ایجاد درخواست تسویه",
description="ایجاد درخواست تسویه به حساب بانکی مشخص",
)
def create_payout_request_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = create_payout_request(db, business_id, ctx.get_user_id(), payload)
return success_response(data, request, message="PAYOUT_REQUESTED")
@router.get(
"/metrics",
summary="گزارش خلاصه کیف‌پول (metrics)",
description="مبالغ ورودی/خروجی/کارمزد و مانده‌ها در بازه زمانی",
)
def get_wallet_metrics_endpoint(
request: Request,
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from_dt = None
to_dt = None
try:
if from_date:
from_dt = datetime.fromisoformat(from_date)
if to_date:
to_dt = datetime.fromisoformat(to_date)
except Exception:
from_dt = None
to_dt = None
data = get_wallet_metrics(db, business_id, from_date=from_dt, to_date=to_dt)
return success_response(data, request)
@router.get(
"/settings",
summary="تنظیمات کیف‌پول کسب‌وکار",
)
def get_wallet_settings_business_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = get_business_wallet_settings(db, business_id)
return success_response(data, request)
@router.put(
"/settings",
summary="ویرایش تنظیمات کیف‌پول کسب‌وکار",
)
def update_wallet_settings_business_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = update_business_wallet_settings(db, business_id, payload)
return success_response(data, request, message="WALLET_SETTINGS_UPDATED")
@router.post(
"/auto-settle/run",
summary="اجرای تسویه خودکار (برای cron/job)",
)
def run_auto_settle_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = run_auto_settlement(db, business_id, ctx.get_user_id())
return success_response(data, request, message="AUTO_SETTLE_EXECUTED" if data.get("executed") else "AUTO_SETTLE_SKIPPED")
@router.put(
"/payouts/{payout_id}/approve",
summary="تایید درخواست تسویه",
description="تایید توسط کاربر مجاز",
)
def approve_payout_request_endpoint(
request: Request,
business_id: int,
payout_id: int = Path(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
# Permission check could be refined (e.g., wallet.approve)
data = approve_payout_request(db, payout_id, ctx.get_user_id())
return success_response(data, request, message="PAYOUT_APPROVED")
@router.put(
"/payouts/{payout_id}/cancel",
summary="لغو درخواست تسویه",
description="لغو و بازگردانی مبلغ به مانده قابل برداشت",
)
def cancel_payout_request_endpoint(
request: Request,
business_id: int,
payout_id: int = Path(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
data = cancel_payout_request(db, payout_id, ctx.get_user_id())
return success_response(data, request, message="PAYOUT_CANCELED")

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Dict, Any
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.responses import success_response
from app.services.wallet_service import confirm_top_up
router = APIRouter(prefix="/wallet", tags=["wallet-webhook"])
@router.post(
"/webhook",
summary="وبهوک تایید افزایش اعتبار",
description="تایید/لغو top-up از طرف درگاه پرداخت",
)
def wallet_webhook_endpoint(
request: Request,
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
) -> dict:
# توجه: در محیط واقعی باید امضای وبهوک و ضد تکرار بودن بررسی شود
tx_id = int(payload.get("transaction_id") or 0)
status = str(payload.get("status") or "").lower()
success = status in ("success", "succeeded", "ok")
external_ref = str(payload.get("external_ref") or "")
# اختیاری: دریافت کارمزد از وبهوک و نگهداری در تراکنش (fee_amount)
try:
fee_value = payload.get("fee_amount")
if fee_value is not None:
from decimal import Decimal
from adapters.db.models.wallet import WalletTransaction
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if tx:
tx.fee_amount = Decimal(str(fee_value))
db.flush()
except Exception:
pass
data = confirm_top_up(db, tx_id, success=success, external_ref=external_ref or None)
return success_response(data, request, message="TOPUP_CONFIRMED" if success else "TOPUP_FAILED")

View file

@ -0,0 +1,115 @@
from typing import Dict, Any
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.permissions import require_business_access
from app.core.responses import success_response
from adapters.db.models.document import Document
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from app.services.warehouse_service import create_from_invoice, post_warehouse_document, warehouse_document_to_dict
router = APIRouter(prefix="/warehouse-docs", tags=["warehouse_docs"])
@router.post("/business/{business_id}/from-invoice/{invoice_id}")
@require_business_access("business_id")
def create_warehouse_doc_from_invoice(
request: Request,
business_id: int,
invoice_id: int,
payload: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
inv = db.query(Document).filter(Document.id == invoice_id).first()
if not inv or inv.business_id != business_id:
from app.core.responses import ApiError
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
lines = payload.get("lines") or []
wh_type = payload.get("doc_type") or ("issue" if inv.document_type in ("invoice_sales", "invoice_purchase_return", "invoice_waste", "invoice_direct_consumption") else "receipt")
wh = create_from_invoice(db, business_id, inv, lines, wh_type, ctx.get_user_id())
db.commit()
return success_response(data={"id": wh.id, "code": wh.code, "status": wh.status}, request=request)
@router.post("/business/{business_id}/{wh_id}/post")
@require_business_access("business_id")
def post_warehouse_doc_endpoint(
request: Request,
business_id: int,
wh_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
res = post_warehouse_document(db, wh_id)
db.commit()
return success_response(data=res, request=request)
@router.get("/business/{business_id}/{wh_id}")
@require_business_access("business_id")
def get_warehouse_doc(
request: Request,
business_id: int,
wh_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh or wh.business_id != business_id:
from app.core.responses import ApiError
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
return success_response(data={"item": warehouse_document_to_dict(db, wh)}, request=request)
@router.post("/business/{business_id}/search")
@require_business_access("business_id")
def search_warehouse_docs(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
q = db.query(WarehouseDocument).filter(WarehouseDocument.business_id == business_id)
# فیلتر ساده بر اساس نوع/وضعیت/تاریخ
doc_type = body.get("doc_type")
status = body.get("status")
source_document_id = body.get("source_document_id")
source_type = body.get("source_type")
from_date = body.get("from_date")
to_date = body.get("to_date")
try:
if isinstance(doc_type, str) and doc_type:
q = q.filter(WarehouseDocument.doc_type == doc_type)
if isinstance(status, str) and status:
q = q.filter(WarehouseDocument.status == status)
if isinstance(source_document_id, int):
q = q.filter(WarehouseDocument.source_document_id == source_document_id)
if isinstance(source_type, str) and source_type:
q = q.filter(WarehouseDocument.source_type == source_type)
if isinstance(from_date, str) and from_date:
from app.services.transfer_service import _parse_iso_date as _p
q = q.filter(WarehouseDocument.document_date >= _p(from_date))
if isinstance(to_date, str) and to_date:
from app.services.transfer_service import _parse_iso_date as _p
q = q.filter(WarehouseDocument.document_date <= _p(to_date))
except Exception:
pass
q = q.order_by(WarehouseDocument.document_date.desc(), WarehouseDocument.id.desc())
take = int(body.get("take") or 20)
skip = int(body.get("skip") or 0)
total = q.count()
items = q.offset(skip).limit(take).all()
return success_response(data={
"items": [warehouse_document_to_dict(db, wh) for wh in items],
"total": total,
"page": (skip // max(1, take)) + 1,
"limit": take,
}, request=request)

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import Column, Integer, Numeric, ForeignKey, JSON, Text
from sqlalchemy.orm import relationship
from adapters.db.session import Base
class InvoiceItemLine(Base):
__tablename__ = "invoice_item_lines"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
product_id = Column(Integer, nullable=False, index=True)
quantity = Column(Numeric(18, 6), nullable=False)
description = Column(Text, nullable=True)
extra_info = Column(JSON, nullable=True)

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class PaymentGateway(Base):
__tablename__ = "payment_gateways"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# zarinpal | parsian | ...
provider: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_sandbox: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# JSON string: provider-specific fields (merchant_id, terminal_id, callback_url, fee_percent, etc.)
config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# relationships
business_links = relationship("BusinessPaymentGateway", back_populates="gateway", cascade="all, delete-orphan")
class BusinessPaymentGateway(Base):
__tablename__ = "business_payment_gateways"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
gateway_id: Mapped[int] = mapped_column(Integer, ForeignKey("payment_gateways.id", ondelete="CASCADE"), nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
business = relationship("Business", backref="payment_gateways")
gateway = relationship("PaymentGateway", back_populates="business_links")

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, ForeignKey, String, Text, DateTime, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class ReportTemplate(Base):
__tablename__ = "report_templates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), index=True, nullable=False)
# ماژول هدف این قالب (مثلاً: invoices, persons, kardex, receipts, products, ...)
module_key: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# زیرنوع اختیاری (مثلاً: list, detail، یا نوع فاکتور: sales, purchase, ...)
subtype: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
engine: Mapped[str] = mapped_column(String(32), default="jinja2", nullable=False)
status: Mapped[str] = mapped_column(String(16), default="draft", index=True) # draft | published
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
# محتوای قالب
content_html: Mapped[str] = mapped_column(Text, nullable=False)
content_css: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
header_html: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
footer_html: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# تنظیمات صفحه
paper_size: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) # A4, Letter, ...
orientation: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) # portrait, landscape
margins: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # {top, right, bottom, left} mm
assets: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # مسیرها/داده‌های باینری base64
created_by: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
__table_args__ = (
UniqueConstraint('key', name='uq_system_settings_key'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# کلید یکتا
key: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
# مقادیر قابل نگهداری (یکی از این‌ها استفاده می‌شود)
value_string: Mapped[str | None] = mapped_column(String(255), nullable=True)
value_int: Mapped[int | None] = mapped_column(Integer, nullable=True)
value_json: Mapped[str | None] = mapped_column(Text, nullable=True)
# زمان‌بندی
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View file

@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class WalletAccount(Base):
__tablename__ = "wallet_accounts"
__table_args__ = (
UniqueConstraint('business_id', name='uq_wallet_accounts_business'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
# مانده‌ها به ارز پایه سیستم
available_balance: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0)
pending_balance: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | suspended
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# روابط
business = relationship("Business", backref="wallet_account", uselist=False)
class WalletTransaction(Base):
__tablename__ = "wallet_transactions"
__table_args__ = (
# ایندکس‌ها از طریق migration اضافه می‌شود
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
# انواع: customer_payment, top_up, internal_invoice_payment, payout_request, payout_settlement, refund, fee, chargeback, reversal
type: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") # pending, succeeded, failed, reversed
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0) # ارز پایه
fee_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
external_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) # شناسه درگاه/مرجع خارجی
# پیوند به سند حسابداری
document_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("documents.id", ondelete="SET NULL"), nullable=True, index=True)
# متادیتا
extra_info: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
business = relationship("Business", backref="wallet_transactions")
document = relationship("Document", backref="wallet_transactions")
class WalletPayout(Base):
__tablename__ = "wallet_payouts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
bank_account_id: Mapped[int] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="RESTRICT"), nullable=False, index=True)
gross_amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0)
fees: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0)
net_amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="requested") # requested, approved, processing, settled, failed, canceled
schedule_type: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") # manual, daily, weekly, threshold
external_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
extra_info: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
business = relationship("Business", backref="wallet_payouts")
class WalletSetting(Base):
__tablename__ = "wallet_settings"
__table_args__ = (
UniqueConstraint('business_id', name='uq_wallet_settings_business'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") # manual | auto
frequency: Mapped[str | None] = mapped_column(String(20), nullable=True) # daily | weekly
threshold_amount: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
min_reserve: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
default_bank_account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
business = relationship("Business", backref="wallet_settings", uselist=False)

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from sqlalchemy import Column, Integer, String, Date, DateTime, ForeignKey, JSON, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
from adapters.db.session import Base
class WarehouseDocument(Base):
__tablename__ = "warehouse_documents"
id = Column(Integer, primary_key=True, autoincrement=True)
business_id = Column(Integer, nullable=False, index=True)
fiscal_year_id = Column(Integer, nullable=True, index=True)
code = Column(String(64), nullable=False, unique=True, index=True)
document_date = Column(Date, nullable=False, index=True)
status = Column(String(16), nullable=False, default="draft") # draft|posted|cancelled
doc_type = Column(String(32), nullable=False) # receipt|issue|transfer|production_in|production_out|adjustment
warehouse_id_from = Column(Integer, nullable=True, index=True)
warehouse_id_to = Column(Integer, nullable=True, index=True)
source_type = Column(String(32), nullable=True) # invoice|manual|api
source_document_id = Column(Integer, nullable=True, index=True)
extra_info = Column(JSON, nullable=True)
created_by_user_id = Column(Integer, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
lines = relationship("WarehouseDocumentLine", back_populates="document", cascade="all, delete-orphan")
def touch(self):
self.updated_at = datetime.utcnow()

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy import Column, Integer, Numeric, ForeignKey, JSON, String
from sqlalchemy.orm import relationship
from adapters.db.session import Base
class WarehouseDocumentLine(Base):
__tablename__ = "warehouse_document_lines"
id = Column(Integer, primary_key=True, autoincrement=True)
warehouse_document_id = Column(Integer, ForeignKey("warehouse_documents.id", ondelete="CASCADE"), nullable=False, index=True)
product_id = Column(Integer, nullable=False, index=True)
warehouse_id = Column(Integer, nullable=True, index=True)
movement = Column(String(8), nullable=False) # in|out
quantity = Column(Numeric(18, 6), nullable=False)
cost_price = Column(Numeric(18, 6), nullable=True)
cogs_amount = Column(Numeric(18, 6), nullable=True)
extra_info = Column(JSON, nullable=True)
document = relationship("WarehouseDocument", back_populates="lines")

View file

@ -130,13 +130,22 @@ def require_business_access(business_id_param: str = "business_id"):
result = await result
return result
# Preserve original signature so FastAPI sees correct parameters (including Request)
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
# Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations
sig = inspect.signature(func)
wrapper.__signature__ = sig # type: ignore[attr-defined]
# Preserve/evaluate annotations; ensure 'request' is explicitly FastAPI Request
try:
wrapper.__annotations__ = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
evaluated = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
except Exception:
# Fallback to original annotations (may be string-based) if evaluation fails
wrapper.__annotations__ = getattr(func, "__annotations__", {})
evaluated = getattr(func, "__annotations__", {})
# Force request annotation if present in params
if 'request' in sig.parameters:
try:
from fastapi import Request as _FastapiRequest # local import to avoid cycles
evaluated = dict(evaluated or {})
evaluated['request'] = _FastapiRequest
except Exception:
pass
wrapper.__annotations__ = evaluated # type: ignore[attr-defined]
return wrapper
return decorator

View file

@ -30,6 +30,8 @@ from adapters.api.v1.support.priorities import router as support_priorities_rout
from adapters.api.v1.support.statuses import router as support_statuses_router
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
from adapters.api.v1.admin.email_config import router as admin_email_config_router
from adapters.api.v1.admin.system_settings import router as admin_system_settings_router
from adapters.api.v1.admin.wallet_admin import router as admin_wallet_router
from adapters.api.v1.receipts_payments import router as receipts_payments_router
from adapters.api.v1.transfers import router as transfers_router
from adapters.api.v1.fiscal_years import router as fiscal_years_router
@ -37,6 +39,10 @@ from adapters.api.v1.expense_income import router as expense_income_router
from adapters.api.v1.documents import router as documents_router
from adapters.api.v1.kardex import router as kardex_router
from adapters.api.v1.inventory_transfers import router as inventory_transfers_router
from adapters.api.v1.opening_balance import router as opening_balance_router
from adapters.api.v1.report_templates import router as report_templates_router
from adapters.api.v1.wallet import router as wallet_router
from adapters.api.v1.wallet_webhook import router as wallet_webhook_router
from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -301,6 +307,8 @@ def create_app() -> FastAPI:
application.include_router(categories_router, prefix=settings.api_v1_prefix)
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
application.include_router(products_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.warehouse_docs import router as warehouse_docs_router
application.include_router(warehouse_docs_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.warehouses import router as warehouses_router
application.include_router(warehouses_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.boms import router as boms_router
@ -323,6 +331,14 @@ def create_app() -> FastAPI:
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
application.include_router(inventory_transfers_router, prefix=settings.api_v1_prefix)
application.include_router(opening_balance_router, prefix=settings.api_v1_prefix)
application.include_router(report_templates_router, prefix=settings.api_v1_prefix)
application.include_router(wallet_router, prefix=settings.api_v1_prefix)
application.include_router(wallet_webhook_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.payment_gateways import router as payment_gateways_router
application.include_router(payment_gateways_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.payment_callbacks import router as payment_callbacks_router
application.include_router(payment_callbacks_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
@ -334,6 +350,10 @@ def create_app() -> FastAPI:
# Admin endpoints
application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
application.include_router(admin_email_config_router, prefix=settings.api_v1_prefix)
application.include_router(admin_system_settings_router, prefix=settings.api_v1_prefix)
application.include_router(admin_wallet_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.admin.payment_gateways import router as admin_payment_gateways_router
application.include_router(admin_payment_gateways_router, prefix=settings.api_v1_prefix)
register_error_handlers(application)

View file

@ -12,10 +12,15 @@ 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.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
from adapters.db.models.check import Check
from adapters.db.models.user import User
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.invoice_item_line import InvoiceItemLine
from app.core.responses import ApiError
import jdatetime
@ -399,9 +404,6 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
total = Decimal(0)
for line in lines:
info = line.get("extra_info") or {}
# فقط برای کالاهای دارای کنترل موجودی
if not bool(info.get("inventory_tracked")):
continue
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
if info.get("inventory_posted") is False:
continue
@ -616,16 +618,9 @@ def create_invoice(
if totals_missing:
totals = _extract_totals_from_lines(lines_input)
# Inventory validation and costing pre-calculation
# Inventory posting is decoupled; no stock validation here
post_inventory: bool = _is_inventory_posting_enabled(data)
# Determine outgoing lines for stock checks
movement_hint, _ = _movement_from_type(invoice_type)
outgoing_lines: List[Dict[str, Any]] = []
for ln in lines_input:
info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint
if mv == "out":
outgoing_lines.append(ln)
# Resolve inventory tracking per product and annotate lines
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
@ -644,44 +639,14 @@ def create_invoice(
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
if post_inventory:
for ln in lines_input:
info = ln.get("extra_info") or {}
inv_tracked = bool(info.get("inventory_tracked"))
mv = info.get("movement") or movement_hint
if inv_tracked and mv in ("in", "out"):
wh = info.get("warehouse_id")
if wh is None:
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
# انبار از فاکتور جدا شده است؛ انتخاب انبار در فاکتور اجباری نیست
# Filter outgoing lines to only inventory-tracked products for stock checks
tracked_outgoing_lines: List[Dict[str, Any]] = []
for ln in outgoing_lines:
pid = ln.get("product_id")
if pid and track_map.get(int(pid)):
tracked_outgoing_lines.append(ln)
# Ensure stock sufficiency for outgoing (only for tracked products)
if post_inventory and tracked_outgoing_lines:
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
# بدون کنترل کسری در مرحله فاکتور؛ کنترل در پست حواله انجام می‌شود
# Costing method (only for tracked products)
costing_method = _get_costing_method(data)
if post_inventory and costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
i = 0
for ln in lines_input:
info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint
if mv == "out" and info.get("inventory_tracked"):
amt = fifo_costs[i]
i += 1
info = dict(info)
info["cogs_amount"] = float(amt)
ln["extra_info"] = info
# محاسبه COGS به پست حواله منتقل می‌شود
# Create document
doc_code = _build_invoice_code(db, business_id, invoice_type)
@ -711,26 +676,23 @@ def create_invoice(
db.add(document)
db.flush()
# Create product lines (no debit/credit)
# ذخیره اقلام فاکتور در جدول مجزا (invoice_item_lines)
for line in lines_input:
product_id = line.get("product_id")
qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = dict(line.get("extra_info") or {})
# علامت‌گذاری اینکه این خط در انبار پست شده/نشده است
extra_info["inventory_posted"] = bool(post_inventory)
db.add(DocumentLine(
extra_info.pop("inventory_posted", None)
db.add(InvoiceItemLine(
document_id=document.id,
product_id=int(product_id),
quantity=qty,
debit=Decimal(0),
credit=Decimal(0),
description=line.get("description"),
extra_info=extra_info,
))
# Accounting lines for finalized invoices
# Accounting lines for finalized invoices (بدون خطوط COGS/Inventory؛ به حواله موکول شد)
if not document.is_proforma:
accounts = _resolve_accounts_for_invoice(db, data)
@ -738,8 +700,7 @@ def create_invoice(
tax = Decimal(str(totals["tax"]))
total_with_tax = net + tax
# COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمی‌شوند)
cogs_total = _extract_cogs_total(lines_input)
# COGS به پست حواله منتقل شد
# Sales
if invoice_type == INVOICE_SALES:
@ -769,66 +730,8 @@ def create_invoice(
credit=tax,
description="مالیات بر ارزش افزوده خروجی",
))
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["cogs"].id,
debit=cogs_total,
credit=Decimal(0),
description="بهای تمام‌شده کالای فروش‌رفته",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=Decimal(0),
credit=cogs_total,
description="خروج از موجودی بابت فروش",
))
# COGS/Inventory در پست حواله ثبت خواهد شد
# --- پورسانت فروشنده/بازاریاب (در صورت وجود) ---
# محاسبه و ثبت پورسانت برای فروش و برگشت از فروش
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
if seller_id and commission_amount > 0:
# هزینه پورسانت: 70702، بستانکار: پرداختنی به فروشنده 20201
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if invoice_type == INVOICE_SALES:
# بدهکار هزینه، بستانکار فروشنده
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
# برگشت از فروش: معکوس
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
# Sales Return
elif invoice_type == INVOICE_SALES_RETURN:
@ -857,21 +760,7 @@ def create_invoice(
credit=Decimal(0),
description="تعدیل VAT برگشت از فروش",
))
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["inventory"].id,
debit=cogs_total,
credit=Decimal(0),
description="ورود به موجودی بابت برگشت",
))
db.add(DocumentLine(
document_id=document.id,
account_id=accounts["cogs"].id,
debit=Decimal(0),
credit=cogs_total,
description="تعدیل بهای تمام‌شده برگشت",
))
# ورود موجودی/تعدیل COGS در پست حواله انجام می‌شود
# Purchase
elif invoice_type == INVOICE_PURCHASE:
@ -931,6 +820,8 @@ def create_invoice(
# Direct consumption
elif invoice_type == INVOICE_DIRECT_CONSUMPTION:
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
@ -949,6 +840,8 @@ def create_invoice(
# Waste
elif invoice_type == INVOICE_WASTE:
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
if cogs_total > 0:
db.add(DocumentLine(
document_id=document.id,
@ -1002,6 +895,47 @@ def create_invoice(
description="انتقال از کاردرجریان",
))
# --- پورسانت فروشنده/بازاریاب (تکمیلی پس از ثبت خطوط انواع فاکتور) ---
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
if seller_id and commission_amount > 0:
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if invoice_type == INVOICE_SALES:
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
# Persist invoice first
db.commit()
db.refresh(document)
@ -1018,18 +952,62 @@ def create_invoice(
# Aggregate amounts into one receipt/payment with multiple account_lines
account_lines: List[Dict[str, Any]] = []
total_amount = Decimal(0)
# Validate currency of payment accounts vs invoice currency
invoice_currency_id = int(currency_id)
for p in payments:
amount = Decimal(str(p.get("amount", 0) or 0))
if amount <= 0:
continue
total_amount += amount
account_lines.append({
ttype = str(p.get("transaction_type") or "").strip().lower()
# Currency match checks for money accounts
if ttype in ("bank", "cash_register", "petty_cash", "check"):
if ttype == "bank":
ref_id = p.get("bank_id")
if ref_id:
acct = db.query(BankAccount).filter(BankAccount.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of bank account does not match invoice currency", http_status=400)
elif ttype == "cash_register":
ref_id = p.get("cash_register_id")
if ref_id:
acct = db.query(CashRegister).filter(CashRegister.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Cash register not found", http_status=404)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of cash register does not match invoice currency", http_status=400)
elif ttype == "petty_cash":
ref_id = p.get("petty_cash_id")
if ref_id:
acct = db.query(PettyCash).filter(PettyCash.id == int(ref_id)).first()
if not acct:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Petty cash not found", http_status=404)
if int(acct.currency_id) != invoice_currency_id:
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of petty cash does not match invoice currency", http_status=400)
elif ttype == "check":
ref_id = p.get("check_id")
if ref_id:
chk = db.query(Check).filter(Check.id == int(ref_id)).first()
if not chk:
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Check not found", http_status=404)
if int(chk.currency_id) != invoice_currency_id:
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of check does not match invoice currency", http_status=400)
# Build account line entry including ids/names for linking
account_line: Dict[str, Any] = {
"transaction_type": p.get("transaction_type"),
"amount": float(amount),
"description": p.get("description"),
"transaction_date": p.get("transaction_date"),
"commission": p.get("commission"),
})
}
# pass through reference ids/names if provided
for key in ("bank_id", "bank_name", "cash_register_id", "cash_register_name", "petty_cash_id", "petty_cash_name", "check_id", "check_number", "person_id", "account_id"):
if p.get(key) is not None:
account_line[key] = p.get(key)
account_lines.append(account_line)
if total_amount > 0 and account_lines:
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
@ -1062,6 +1040,42 @@ def create_invoice(
db.commit()
db.refresh(document)
# ایجاد حواله انبار draft در صورت نیاز و جدا از فاکتور
try:
if bool(data.get("extra_info", {}).get("post_inventory", True)):
from app.services.warehouse_service import create_from_invoice
created_wh_ids: List[int] = []
if invoice_type == INVOICE_PRODUCTION:
out_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "out"]
in_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "in"]
if out_lines:
wh_issue = create_from_invoice(db, business_id, document, out_lines, "issue", user_id)
created_wh_ids.append(int(wh_issue.id))
if in_lines:
wh_receipt = create_from_invoice(db, business_id, document, in_lines, "receipt", user_id)
created_wh_ids.append(int(wh_receipt.id))
else:
if invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN, INVOICE_WASTE, INVOICE_DIRECT_CONSUMPTION}:
wh_type = "issue"
elif invoice_type in {INVOICE_PURCHASE, INVOICE_SALES_RETURN}:
wh_type = "receipt"
else:
wh_type = "issue"
wh = create_from_invoice(db, business_id, document, lines_input, wh_type, user_id)
created_wh_ids.append(int(wh.id))
if created_wh_ids:
# ذخیره لینک حواله‌ها در extra_info.links
extra = document.extra_info or {}
links = dict((extra.get("links") or {}))
links["warehouse_document_ids"] = created_wh_ids
extra["links"] = links
document.extra_info = extra
db.commit()
except Exception:
# عدم موفقیت در ساخت حواله نباید مانع بازگشت فاکتور شود
db.rollback()
return invoice_document_to_dict(db, document)
@ -1108,22 +1122,17 @@ def update_invoice(
if data.get("description") is not None:
document.description = data.get("description")
# Recreate lines
# Recreate lines: حذف سطرهای حسابداری و اقلام فاکتور و بازایجاد
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).delete(synchronize_session=False)
lines_input: List[Dict[str, Any]] = list(data.get("lines") or [])
if not lines_input:
raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400)
# Inventory validation and costing before re-adding lines
# Inventory decoupled from invoices
inv_type = document.document_type
movement_hint, _ = _movement_from_type(inv_type)
outgoing_lines: List[Dict[str, Any]] = []
for ln in lines_input:
info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint
if mv == "out":
outgoing_lines.append(ln)
# Resolve and annotate inventory tracking for all lines
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
@ -1141,41 +1150,10 @@ def update_invoice(
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
if post_inventory_update:
for ln in lines_input:
info = ln.get("extra_info") or {}
inv_tracked = bool(info.get("inventory_tracked"))
mv = info.get("movement") or movement_hint
if inv_tracked and mv in ("in", "out"):
wh = info.get("warehouse_id")
if wh is None:
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
tracked_outgoing_lines: List[Dict[str, Any]] = []
for ln in outgoing_lines:
pid = ln.get("product_id")
if pid and track_map.get(int(pid)):
tracked_outgoing_lines.append(ln)
# انتخاب انبار در مرحله فاکتور الزامی نیست
header_for_costing = data if data else {"extra_info": document.extra_info}
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
if post_inventory_update and tracked_outgoing_lines:
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
costing_method = _get_costing_method(header_for_costing)
if post_inventory_update and costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
i = 0
for ln in lines_input:
info = ln.get("extra_info") or {}
mv = info.get("movement") or movement_hint
if mv == "out" and info.get("inventory_tracked"):
amt = fifo_costs[i]
i += 1
info = dict(info)
info["cogs_amount"] = float(amt)
ln["extra_info"] = info
for line in lines_input:
product_id = line.get("product_id")
@ -1183,13 +1161,10 @@ def update_invoice(
if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = dict(line.get("extra_info") or {})
extra_info["inventory_posted"] = bool(post_inventory_update)
db.add(DocumentLine(
db.add(InvoiceItemLine(
document_id=document.id,
product_id=int(product_id),
quantity=qty,
debit=Decimal(0),
credit=Decimal(0),
description=line.get("description"),
extra_info=extra_info,
))
@ -1206,7 +1181,7 @@ def update_invoice(
tax = Decimal(str(totals.get("tax", 0)))
total_with_tax = net + tax
person_id = _person_id_from_header({"extra_info": header_extra})
cogs_total = _extract_cogs_total(lines_input)
# inventory/COGS handled in warehouse posting
if inv_type == INVOICE_SALES:
if person_id:
@ -1214,47 +1189,35 @@ def update_invoice(
db.add(DocumentLine(document_id=document.id, account_id=accounts["revenue"].id, debit=Decimal(0), credit=net, description="درآمد فروش"))
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
if cogs_total > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=cogs_total, credit=Decimal(0), description="بهای تمام‌شده"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
# COGS/Inventory by warehouse posting
elif inv_type == INVOICE_SALES_RETURN:
if person_id:
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description))
db.add(DocumentLine(document_id=document.id, account_id=accounts["sales_return"].id, debit=net, credit=Decimal(0), description="برگشت از فروش"))
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT"))
if cogs_total > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=cogs_total, credit=Decimal(0), description="ورود موجودی"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=Decimal(0), credit=cogs_total, description="تعدیل بهای تمام‌شده"))
# Inventory/COGS handled in warehouse posting
elif inv_type == INVOICE_PURCHASE:
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=net, credit=Decimal(0), description="ورود موجودی"))
# Inventory via warehouse posting; invoice handles VAT/AP only (or GRNI if فعال)
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="مالیات ورودی"))
if person_id:
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description))
elif inv_type == INVOICE_PURCHASE_RETURN:
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=net, description="خروج موجودی"))
# Inventory via warehouse posting
if tax > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=Decimal(0), credit=tax, description="تعدیل VAT ورودی"))
if person_id:
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=total_with_tax, credit=Decimal(0), description=document.description))
elif inv_type == INVOICE_DIRECT_CONSUMPTION:
if cogs_total > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["direct_consumption"].id, debit=cogs_total, credit=Decimal(0), description="مصرف مستقیم"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
# Expense/Inventory in warehouse posting
pass
elif inv_type == INVOICE_WASTE:
if cogs_total > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["waste_expense"].id, debit=cogs_total, credit=Decimal(0), description="ضایعات"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
# Expense/Inventory in warehouse posting
pass
elif inv_type == INVOICE_PRODUCTION:
materials_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "out"])
if materials_cost > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=materials_cost, credit=Decimal(0), description="انتقال به کاردرجریان"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=materials_cost, description="خروج مواد"))
finished_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "in"])
if finished_cost > 0:
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساخته‌شده"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان"))
# WIP/Inventory in warehouse posting
pass
# --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) ---
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
@ -1303,35 +1266,36 @@ def update_invoice(
def invoice_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
# اقلام فاکتور از جدول مجزا خوانده می‌شوند
item_rows = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).all()
product_lines: List[Dict[str, Any]] = []
account_lines: List[Dict[str, Any]] = []
for it in item_rows:
product = db.query(Product).filter(Product.id == it.product_id).first()
product_lines.append({
"id": it.id,
"product_id": it.product_id,
"product_name": getattr(product, "name", None),
"quantity": float(it.quantity) if it.quantity else None,
"description": it.description,
"extra_info": it.extra_info,
})
for line in lines:
if line.product_id:
product = db.query(Product).filter(Product.id == line.product_id).first()
product_lines.append({
"id": line.id,
"product_id": line.product_id,
"product_name": getattr(product, "name", None),
"quantity": float(line.quantity) if line.quantity else None,
"description": line.description,
"extra_info": line.extra_info,
})
elif line.account_id:
account = db.query(Account).filter(Account.id == line.account_id).first()
account_lines.append({
"id": line.id,
"account_id": line.account_id,
"account_name": getattr(account, "name", None),
"account_code": getattr(account, "code", None),
"debit": float(line.debit),
"credit": float(line.credit),
"person_id": line.person_id,
"description": line.description,
"extra_info": line.extra_info,
})
# سطرهای حسابداری از document_lines خوانده می‌شوند
acc_rows = db.query(DocumentLine).filter(DocumentLine.document_id == document.id, DocumentLine.account_id != None).all() # noqa: E711
account_lines: List[Dict[str, Any]] = []
for line in acc_rows:
account = db.query(Account).filter(Account.id == line.account_id).first()
account_lines.append({
"id": line.id,
"account_id": line.account_id,
"account_name": getattr(account, "name", None),
"account_code": getattr(account, "code", None),
"debit": float(line.debit),
"credit": float(line.credit),
"person_id": line.person_id,
"description": line.description,
"extra_info": line.extra_info,
})
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
created_by_name = f"{getattr(created_by, 'first_name', '')} {getattr(created_by, 'last_name', '')}".strip() if created_by else None

View file

@ -0,0 +1,236 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from datetime import date
from decimal import Decimal
from sqlalchemy.orm import Session
from adapters.db.repositories.document_repository import DocumentRepository
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
from adapters.db.models.document import Document
from app.core.responses import ApiError
def _ensure_fiscal_year(db: Session, business_id: int, fiscal_year_id: Optional[int]) -> Tuple[int, date]:
fy_repo = FiscalYearRepository(db)
fiscal_year = None
if fiscal_year_id:
fiscal_year = fy_repo.get_by_id(int(fiscal_year_id))
if not fiscal_year or int(fiscal_year.business_id) != int(business_id):
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی پیدا نشد یا متعلق به این کسب‌وکار نیست", http_status=404)
else:
fiscal_year = fy_repo.get_current_for_business(business_id)
if not fiscal_year:
raise ApiError("NO_CURRENT_FISCAL_YEAR", "سال مالی فعالی برای این کسب‌وکار یافت نشد", http_status=400)
return int(fiscal_year.id), fiscal_year.start_date
def _find_existing_ob_document(db: Session, business_id: int, fiscal_year_id: int) -> Optional[Document]:
from sqlalchemy import and_
return (
db.query(Document)
.filter(
and_(
Document.business_id == int(business_id),
Document.fiscal_year_id == int(fiscal_year_id),
Document.document_type == "opening_balance",
)
)
.order_by(Document.id.desc())
.first()
)
def get_opening_balance(
db: Session,
business_id: int,
fiscal_year_id: Optional[int],
) -> Optional[Dict[str, Any]]:
fy_id, _ = _ensure_fiscal_year(db, business_id, fiscal_year_id)
existing = _find_existing_ob_document(db, business_id, fy_id)
if not existing:
return None
repo = DocumentRepository(db)
return repo.to_dict_with_lines(existing)
def upsert_opening_balance(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
repo = DocumentRepository(db)
fy_id, fy_start_date = _ensure_fiscal_year(db, business_id, data.get("fiscal_year_id"))
document_date = data.get("document_date") or fy_start_date
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id الزامی است", http_status=400)
account_lines: List[Dict[str, Any]] = list(data.get("account_lines") or [])
inventory_lines: List[Dict[str, Any]] = list(data.get("inventory_lines") or [])
inventory_account_id: Optional[int] = data.get("inventory_account_id")
auto_balance_to_equity: bool = bool(data.get("auto_balance_to_equity", False))
equity_account_id: Optional[int] = data.get("equity_account_id")
# Build document lines
lines: List[Dict[str, Any]] = []
def _norm_amount(v: Any) -> Decimal:
try:
return Decimal(str(v or 0))
except Exception:
return Decimal(0)
# 1) Account/person/bank/cash/petty-cash lines
for ln in account_lines:
debit = _norm_amount(ln.get("debit"))
credit = _norm_amount(ln.get("credit"))
if debit <= 0 and credit <= 0:
continue
lines.append(
{
"account_id": ln.get("account_id"),
"person_id": ln.get("person_id"),
"bank_account_id": ln.get("bank_account_id"),
"cash_register_id": ln.get("cash_register_id"),
"petty_cash_id": ln.get("petty_cash_id"),
"debit": float(debit),
"credit": float(credit),
"description": ln.get("description"),
"extra_info": ln.get("extra_info"),
}
)
# 2) Inventory lines (movement=in) + total valuation
inventory_total_value = Decimal(0)
for inv in inventory_lines:
qty = _norm_amount(inv.get("quantity"))
if qty <= 0:
continue
info = dict(inv.get("extra_info") or {})
info.setdefault("movement", "in")
if info.get("movement") != "in":
info["movement"] = "in"
if info.get("warehouse_id") is None:
raise ApiError("WAREHOUSE_REQUIRED", "warehouse_id برای خطوط موجودی الزامی است", http_status=400)
cost_price = _norm_amount(info.get("cost_price"))
if cost_price > 0:
inventory_total_value += qty * cost_price
lines.append(
{
"product_id": int(inv.get("product_id")),
"quantity": float(qty),
"debit": 0.0,
"credit": 0.0,
"description": inv.get("description"),
"extra_info": info,
}
)
if inventory_lines:
if not inventory_account_id:
raise ApiError(
"INVENTORY_ACCOUNT_REQUIRED",
"inventory_account_id برای ثبت موجودی الزامی است",
http_status=400,
)
if inventory_total_value > 0:
lines.append(
{
"account_id": int(inventory_account_id),
"debit": float(inventory_total_value),
"credit": 0.0,
"description": "موجودی ابتدای دوره",
}
)
# Auto-balance difference to equity
if auto_balance_to_equity:
total_debit = sum(Decimal(str(l.get("debit", 0) or 0)) for l in lines)
total_credit = sum(Decimal(str(l.get("credit", 0) or 0)) for l in lines)
diff = total_debit - total_credit
tolerance = Decimal("0.01")
if abs(diff) > tolerance:
if not equity_account_id:
raise ApiError(
"EQUITY_ACCOUNT_REQUIRED",
"برای بستن خودکار اختلاف، انتخاب حساب حقوق صاحبان سهام الزامی است",
http_status=400,
)
if diff > 0:
lines.append(
{
"account_id": int(equity_account_id),
"debit": 0.0,
"credit": float(diff),
"description": "بستن اختلاف تراز افتتاحیه",
}
)
else:
lines.append(
{
"account_id": int(equity_account_id),
"debit": float(-diff),
"credit": 0.0,
"description": "بستن اختلاف تراز افتتاحیه",
}
)
# Validate balance
is_valid, err = repo.validate_document_balance(lines)
if not is_valid:
raise ApiError("INVALID_DOCUMENT", err, http_status=400)
# Upsert
existing = _find_existing_ob_document(db, business_id, fy_id)
document_payload = {
"code": (existing.code if existing and existing.code else repo.generate_document_code(business_id, "opening_balance")),
"business_id": int(business_id),
"fiscal_year_id": int(fy_id),
"currency_id": int(currency_id),
"created_by_user_id": int(user_id),
"document_date": document_date,
"document_type": "opening_balance",
"is_proforma": False,
"description": data.get("description"),
"extra_info": data.get("extra_info") or {},
"lines": lines,
}
if existing:
updated = repo.update_document(existing.id, document_payload)
if not updated:
raise ApiError("UPDATE_FAILED", "ویرایش سند تراز افتتاحیه ناموفق بود", http_status=500)
return repo.get_document_details(updated.id) or {}
else:
created = repo.create_document(document_payload)
return repo.get_document_details(created.id) or {}
def post_opening_balance(
db: Session,
business_id: int,
user_id: int,
fiscal_year_id: Optional[int],
) -> Dict[str, Any]:
fy_id, _ = _ensure_fiscal_year(db, business_id, fiscal_year_id)
existing = _find_existing_ob_document(db, business_id, fy_id)
if not existing:
raise ApiError("OPENING_BALANCE_NOT_FOUND", "سند تراز افتتاحیه برای این سال مالی یافت نشد", http_status=404)
if (existing.extra_info or {}).get("posted") is True:
return DocumentRepository(db).to_dict_with_lines(existing)
payload = {
"extra_info": {**(existing.extra_info or {}), "posted": True, "posted_by": int(user_id)},
}
repo = DocumentRepository(db)
updated = repo.update_document(existing.id, payload)
if not updated:
raise ApiError("POST_FAILED", "نهایی‌سازی تراز افتتاحیه ناموفق بود", http_status=500)
return repo.get_document_details(updated.id) or {}

View file

@ -0,0 +1,229 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
import httpx
from sqlalchemy.orm import Session
from app.core.responses import ApiError
from adapters.db.models.payment_gateway import PaymentGateway
from adapters.db.models.wallet import WalletTransaction
from app.services.wallet_service import confirm_top_up
@dataclass
class InitiateResult:
payment_url: str
external_ref: str # Authority/Token/etc.
def _load_config(gw: PaymentGateway) -> Dict[str, Any]:
try:
return json.loads(gw.config_json or "{}")
except Exception:
return {}
def _get_gateway_or_error(db: Session, gateway_id: int) -> PaymentGateway:
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if not gw:
raise ApiError("GATEWAY_NOT_FOUND", "درگاه پرداخت یافت نشد", http_status=404)
if not gw.is_active:
raise ApiError("GATEWAY_DISABLED", "درگاه پرداخت غیرفعال است", http_status=400)
return gw
def initiate_payment(db: Session, business_id: int, tx_id: int, amount: float, gateway_id: int) -> InitiateResult:
gw = _get_gateway_or_error(db, gateway_id)
cfg = _load_config(gw)
provider = gw.provider.lower().strip()
if provider == "zarinpal":
return _initiate_zarinpal(db, gw, cfg, business_id, tx_id, amount)
elif provider == "parsian":
return _initiate_parsian(db, gw, cfg, business_id, tx_id, amount)
else:
raise ApiError("UNSUPPORTED_PROVIDER", f"درگاه '{gw.provider}' پشتیبانی نمی‌شود", http_status=400)
def verify_payment_callback(db: Session, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Verify callback by provider; returns standardized dict with:
- transaction_id: int
- success: bool
- external_ref: str | None
- fee_amount: float | None
"""
provider_l = (provider or "").lower().strip()
if provider_l == "zarinpal":
return _verify_zarinpal(db, params)
elif provider_l == "parsian":
return _verify_parsian(db, params)
else:
raise ApiError("UNSUPPORTED_PROVIDER", f"درگاه '{provider}' پشتیبانی نمی‌شود", http_status=400)
# --------------------------
# ZARINPAL
# --------------------------
def _initiate_zarinpal(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], business_id: int, tx_id: int, amount: float) -> InitiateResult:
"""
Minimal integration:
- expects cfg fields: merchant_id, callback_url, api_base(optional), startpay_base(optional), description(optional), currency ('IRR' default)
- amount must be in Rials for classic REST
"""
merchant_id = str(cfg.get("merchant_id") or "").strip()
callback_url = str(cfg.get("callback_url") or "").strip()
if not merchant_id or not callback_url:
raise ApiError("INVALID_CONFIG", "merchant_id و callback_url الزامی هستند", http_status=400)
description = str(cfg.get("description") or "Wallet top-up")
api_base = str(cfg.get("api_base") or ("https://sandbox.zarinpal.com/pg/rest/WebGate" if gw.is_sandbox else "https://www.zarinpal.com/pg/rest/WebGate"))
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.zarinpal.com/pg/StartPay" if gw.is_sandbox else "https://www.zarinpal.com/pg/StartPay"))
currency = str(cfg.get("currency") or "IRR").upper()
# append tx_id to callback
cb_url = callback_url
try:
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
u = urlparse(callback_url)
q = dict(parse_qsl(u.query))
q["tx_id"] = str(tx_id)
cb_url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
except Exception:
cb_url = f"{callback_url}{'&' if '?' in callback_url else '?'}tx_id={tx_id}"
# Convert amount to rial if needed (assuming system base is IRR already; adapter can be extended)
req_payload = {
"MerchantID": merchant_id,
"Amount": int(round(float(amount))), # expect rial
"Description": description,
"CallbackURL": cb_url,
}
authority: Optional[str] = None
try:
with httpx.Client(timeout=10.0) as client:
resp = client.post(f"{api_base}/PaymentRequest.json", json=req_payload)
data = resp.json() if resp.headers.get("content-type","").startswith("application/json") else {}
if int(data.get("Status") or -1) == 100 and data.get("Authority"):
authority = str(data["Authority"])
except Exception:
# Fallback: in dev, generate a pseudo authority to continue flow
authority = authority or f"TEST-AUTH-{tx_id}"
if not authority:
raise ApiError("GATEWAY_INIT_FAILED", "امکان ایجاد تراکنش در زرین‌پال نیست", http_status=502)
payment_url = f"{startpay_base}/{authority}"
# persist ref/url on tx.extra_info
_tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if _tx:
extra = {}
try:
extra = json.loads(_tx.extra_info or "{}")
except Exception:
extra = {}
extra.update({
"gateway_id": gw.id,
"provider": "zarinpal",
"authority": authority,
"payment_url": payment_url,
})
_tx.external_ref = authority
_tx.extra_info = json.dumps(extra, ensure_ascii=False)
db.flush()
return InitiateResult(payment_url=payment_url, external_ref=authority)
def _verify_zarinpal(db: Session, params: Dict[str, Any]) -> Dict[str, Any]:
# Params expected: Authority, Status, (optionally tx_id)
authority = str(params.get("Authority") or params.get("authority") or "").strip()
status = str(params.get("Status") or params.get("status") or "").lower()
tx_id = int(params.get("tx_id") or 0)
success = status in ("ok", "ok.", "success", "succeeded")
fee_amount = None
# Optionally call VerifyPayment here if needed (requires merchant_id); skipping network verify to keep flow simple
# Confirm
if tx_id > 0:
confirm_top_up(db, tx_id, success=success, external_ref=authority or None)
return {"transaction_id": tx_id, "success": success, "external_ref": authority, "fee_amount": fee_amount}
# --------------------------
# PARSIAN
# --------------------------
def _initiate_parsian(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], business_id: int, tx_id: int, amount: float) -> InitiateResult:
"""
Minimal integration:
- expects cfg fields: terminal_id, merchant_id(optional), callback_url, api_base(optional), startpay_base(optional)
- returns token with redirect to StartPay
"""
terminal_id = str(cfg.get("terminal_id") or "").strip()
callback_url = str(cfg.get("callback_url") or "").strip()
if not terminal_id or not callback_url:
raise ApiError("INVALID_CONFIG", "terminal_id و callback_url الزامی هستند", http_status=400)
api_base = str(cfg.get("api_base") or ("https://sandbox.banktest.ir/parsian" if gw.is_sandbox else "https://pec.shaparak.ir"))
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.banktest.ir/parsian/startpay" if gw.is_sandbox else "https://pec.shaparak.ir/NewIPG/?Token"))
# append tx_id to callback
cb_url = callback_url
try:
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
u = urlparse(callback_url)
q = dict(parse_qsl(u.query))
q["tx_id"] = str(tx_id)
cb_url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
except Exception:
cb_url = f"{callback_url}{'&' if '?' in callback_url else '?'}tx_id={tx_id}"
token: Optional[str] = None
try:
with httpx.Client(timeout=10.0) as client:
# This is a placeholder; real Parsian API may differ
resp = client.post(f"{api_base}/SalePaymentRequest", json={
"TerminalId": terminal_id,
"Amount": int(round(float(amount))),
"CallbackUrl": cb_url,
"OrderId": tx_id,
})
data = resp.json() if resp.headers.get("content-type","").startswith("application/json") else {}
if (data.get("Status") in (0, "0", 100, "100")) and data.get("Token"):
token = str(data["Token"])
except Exception:
token = token or f"TEST-TOKEN-{tx_id}"
if not token:
raise ApiError("GATEWAY_INIT_FAILED", "امکان ایجاد تراکنش در پارسیان نیست", http_status=502)
payment_url = f"{startpay_base}={token}" if "Token" in startpay_base or startpay_base.endswith("=") else f"{startpay_base}/{token}"
_tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if _tx:
extra = {}
try:
extra = json.loads(_tx.extra_info or "{}")
except Exception:
extra = {}
extra.update({
"gateway_id": gw.id,
"provider": "parsian",
"token": token,
"payment_url": payment_url,
})
_tx.external_ref = token
_tx.extra_info = json.dumps(extra, ensure_ascii=False)
db.flush()
return InitiateResult(payment_url=payment_url, external_ref=token)
def _verify_parsian(db: Session, params: Dict[str, Any]) -> Dict[str, Any]:
# Params expected: Token, status, tx_id
token = str(params.get("Token") or params.get("token") or "").strip()
status = str(params.get("status") or "").lower()
tx_id = int(params.get("tx_id") or 0)
success = status in ("ok", "success", "0", "100")
fee_amount = None
if tx_id > 0:
confirm_top_up(db, tx_id, success=success, external_ref=token or None)
return {"transaction_id": tx_id, "success": success, "external_ref": token, "fee_amount": fee_amount}

View file

@ -406,6 +406,11 @@ def create_receipt_payment(
account_code = "20201" # حساب‌های پرداختنی
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "wallet":
# کیف‌پول (حساب نزد پرداخت‌یار)
account_code = "10204"
logger.info(f"انتخاب حساب کیف‌پول با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif account_id:
# اگر account_id مشخص باشد، از آن استفاده کن
logger.info(f"استفاده از account_id مشخص: {account_id}")

View file

@ -0,0 +1,257 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import StrictUndefined, BaseLoader
from adapters.db.models.report_template import ReportTemplate
from app.core.responses import ApiError
class ReportTemplateService:
"""سرویس مدیریت قالب‌های گزارش"""
@staticmethod
def list_templates(
db: Session,
business_id: int,
module_key: Optional[str] = None,
subtype: Optional[str] = None,
status: Optional[str] = None,
only_published: bool = False,
) -> List[ReportTemplate]:
try:
q = db.query(ReportTemplate).filter(ReportTemplate.business_id == int(business_id))
if module_key:
q = q.filter(ReportTemplate.module_key == str(module_key))
if subtype:
q = q.filter(ReportTemplate.subtype == str(subtype))
if status:
q = q.filter(ReportTemplate.status == str(status))
if only_published:
q = q.filter(ReportTemplate.status == "published")
q = q.order_by(ReportTemplate.updated_at.desc())
return q.all()
except Exception:
# اگر جدول موجود نباشد، شکست نخوریم
return []
@staticmethod
def get_template(db: Session, template_id: int, business_id: Optional[int] = None) -> Optional[ReportTemplate]:
try:
q = db.query(ReportTemplate).filter(ReportTemplate.id == int(template_id))
if business_id is not None:
q = q.filter(ReportTemplate.business_id == int(business_id))
return q.first()
except Exception:
return None
@staticmethod
def create_template(db: Session, data: Dict[str, Any], user_id: int) -> ReportTemplate:
required = ["business_id", "module_key", "name", "content_html"]
for k in required:
if not data.get(k):
raise ApiError("VALIDATION_ERROR", f"Missing field: {k}", http_status=400)
entity = ReportTemplate(
business_id=int(data["business_id"]),
module_key=str(data["module_key"]),
subtype=(data.get("subtype") or None),
name=str(data["name"]),
description=(data.get("description") or None),
engine=str(data.get("engine") or "jinja2"),
status=str(data.get("status") or "draft"),
is_default=bool(data.get("is_default") or False),
version=int(data.get("version") or 1),
content_html=str(data["content_html"]),
content_css=(data.get("content_css") or None),
header_html=(data.get("header_html") or None),
footer_html=(data.get("footer_html") or None),
paper_size=(data.get("paper_size") or None),
orientation=(data.get("orientation") or None),
margins=(data.get("margins") or None),
assets=(data.get("assets") or None),
created_by=int(user_id),
)
db.add(entity)
db.commit()
db.refresh(entity)
return entity
@staticmethod
def update_template(db: Session, template_id: int, data: Dict[str, Any], business_id: int) -> ReportTemplate:
entity = ReportTemplateService.get_template(db, template_id, business_id)
if not entity:
raise ApiError("NOT_FOUND", "Template not found", http_status=404)
for field in [
"module_key", "subtype", "name", "description", "engine", "status",
"content_html", "content_css", "header_html", "footer_html",
"paper_size", "orientation", "margins", "assets"
]:
if field in data:
setattr(entity, field, data.get(field))
# bump version on content changes
if any(k in data for k in ("content_html", "content_css", "header_html", "footer_html")):
entity.version = int((entity.version or 1) + 1)
db.commit()
db.refresh(entity)
return entity
@staticmethod
def delete_template(db: Session, template_id: int, business_id: int) -> None:
entity = ReportTemplateService.get_template(db, template_id, business_id)
if not entity:
return
db.delete(entity)
db.commit()
@staticmethod
def publish_template(db: Session, template_id: int, business_id: int, is_published: bool = True) -> ReportTemplate:
entity = ReportTemplateService.get_template(db, template_id, business_id)
if not entity:
raise ApiError("NOT_FOUND", "Template not found", http_status=404)
entity.status = "published" if is_published else "draft"
db.commit()
db.refresh(entity)
return entity
@staticmethod
def set_default(db: Session, business_id: int, module_key: str, subtype: Optional[str], template_id: int) -> ReportTemplate:
entity = ReportTemplateService.get_template(db, template_id, business_id)
if not entity or entity.module_key != module_key or (entity.subtype or None) != (subtype or None):
raise ApiError("VALIDATION_ERROR", "Template does not match scope", http_status=400)
# unset other defaults in scope
try:
db.query(ReportTemplate).filter(
and_(
ReportTemplate.business_id == int(business_id),
ReportTemplate.module_key == str(module_key),
ReportTemplate.subtype.is_(subtype if subtype is not None else None),
ReportTemplate.is_default.is_(True),
)
).update({ReportTemplate.is_default: False})
except Exception:
pass
entity.is_default = True
db.commit()
db.refresh(entity)
return entity
@staticmethod
def resolve_default(db: Session, business_id: int, module_key: str, subtype: Optional[str]) -> Optional[ReportTemplate]:
try:
q = db.query(ReportTemplate).filter(
and_(
ReportTemplate.business_id == int(business_id),
ReportTemplate.module_key == str(module_key),
ReportTemplate.status == "published",
ReportTemplate.is_default.is_(True),
)
)
if subtype is not None:
q = q.filter(ReportTemplate.subtype == str(subtype))
else:
q = q.filter(ReportTemplate.subtype.is_(None))
return q.first()
except Exception:
return None
@staticmethod
def render_with_template(
template: ReportTemplate,
context: Dict[str, Any],
) -> str:
"""رندر امن Jinja2"""
if not template or not template.content_html:
raise ApiError("INVALID_TEMPLATE", "Template HTML is empty", http_status=400)
env = SandboxedEnvironment(
loader=BaseLoader(),
autoescape=True,
undefined=StrictUndefined,
enable_async=False,
)
# فیلترهای ساده کاربردی
env.filters["default"] = lambda v, d="": v if v not in (None, "") else d
env.filters["upper"] = lambda v: str(v).upper()
env.filters["lower"] = lambda v: str(v).lower()
template_obj = env.from_string(template.content_html)
html = template_obj.render(**context)
# تنظیمات صفحه (@page) از روی ویژگی‌های قالب
try:
page_css_parts = []
size_parts = []
if (template.paper_size or "").strip():
size_parts.append(str(template.paper_size).strip())
if (template.orientation or "").strip() in ("portrait", "landscape"):
size_parts.append(str(template.orientation).strip())
if size_parts:
page_css_parts.append(f"size: {' '.join(size_parts)};")
margins = template.margins or {}
mt = margins.get("top")
mr = margins.get("right")
mb = margins.get("bottom")
ml = margins.get("left")
def _mm(v):
try:
if v is None:
return None
# اگر رشته باشد، به mm ختم شود
s = str(v).strip()
return s if s.endswith("mm") else f"{s}mm"
except Exception:
return None
mt, mr, mb, ml = _mm(mt), _mm(mr), _mm(mb), _mm(ml)
if all(x is not None for x in (mt, mr, mb, ml)):
page_css_parts.append(f"margin: {mt} {mr} {mb} {ml};")
# اگر چیزی برای @page داریم، تزریق کنیم
if page_css_parts:
page_css = "@page { " + " ".join(page_css_parts) + " }"
if "</head>" in html:
html = html.replace("</head>", f"<style>{page_css}</style></head>")
else:
html = f"<head><style>{page_css}</style></head>{html}"
except Exception:
# اگر مشکلی بود، رندر را متوقف نکنیم
pass
# درج CSS سفارشی در <style>
css = (template.content_css or "").strip()
if css:
# ساده: تزریق داخل head اگر وجود دارد
if "</head>" in html:
html = html.replace("</head>", f"<style>{css}</style></head>")
else:
html = f"<head><style>{css}</style></head>{html}"
return html
@staticmethod
def try_render_resolved(
db: Session,
business_id: int,
module_key: str,
subtype: Optional[str],
context: Dict[str, Any],
explicit_template_id: Optional[int] = None,
) -> Optional[str]:
"""اگر قالبی مشخص/پیش‌فرض باشد، HTML رندر شده را برمی‌گرداند؛ در غیر این صورت None."""
template: Optional[ReportTemplate] = None
if explicit_template_id is not None:
t = ReportTemplateService.get_template(db, int(explicit_template_id), business_id)
# فقط قالب‌های published برای استفاده عمومی
if t and t.status == "published":
template = t
if template is None:
template = ReportTemplateService.resolve_default(db, business_id, module_key, subtype)
if template is None:
return None
try:
return ReportTemplateService.render_with_template(template, context)
except Exception:
# خطای قالب نباید خروجی را کاملاً متوقف کند
return None

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from adapters.db.models.system_setting import SystemSetting
from adapters.db.models.currency import Currency
from app.core.responses import ApiError
WALLET_BASE_CURRENCY_KEY = "wallet_base_currency_code"
DEFAULT_WALLET_CURRENCY_CODE = "IRR"
def _get_setting(db: Session, key: str) -> Optional[SystemSetting]:
return db.execute(
select(SystemSetting).where(SystemSetting.key == key)
).scalars().first()
def _upsert_setting_string(db: Session, key: str, value: str) -> SystemSetting:
obj = _get_setting(db, key)
if obj:
obj.value_string = value
else:
obj = SystemSetting(key=key, value_string=value)
db.add(obj)
db.flush()
return obj
def get_wallet_settings(db: Session) -> Dict[str, Any]:
"""
خواندن تنظیمات کیفپول (تنها ارز پایه در این فاز)
"""
obj = _get_setting(db, WALLET_BASE_CURRENCY_KEY)
code = (obj.value_string if obj and obj.value_string else DEFAULT_WALLET_CURRENCY_CODE)
# resolve currency id (optional)
currency = db.query(Currency).filter(Currency.code == code).first()
return {
"wallet_base_currency_code": code,
"wallet_base_currency_id": currency.id if currency else None,
}
def set_wallet_base_currency_code(db: Session, code: str) -> Dict[str, Any]:
"""
تنظیم ارز پایه کیفپول با اعتبارسنجی وجود ارز
"""
code = str(code or "").strip().upper()
if not code:
raise ApiError("CURRENCY_CODE_REQUIRED", "کد ارز الزامی است", http_status=400)
currency = db.query(Currency).filter(Currency.code == code).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", f"ارز با کد {code} یافت نشد", http_status=404)
_upsert_setting_string(db, WALLET_BASE_CURRENCY_KEY, code)
return {
"wallet_base_currency_code": code,
"wallet_base_currency_id": currency.id,
}

View file

@ -0,0 +1,631 @@
from __future__ import annotations
from typing import Optional, Dict, Any, List
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import select, and_
from adapters.db.models.wallet import WalletAccount, WalletTransaction, WalletPayout, WalletSetting
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.business import Business
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.fiscal_year import FiscalYear
from app.core.responses import ApiError
from app.services.system_settings_service import get_wallet_settings
from datetime import datetime, date
def _ensure_wallet_account(db: Session, business_id: int) -> WalletAccount:
obj = db.execute(
select(WalletAccount).where(WalletAccount.business_id == int(business_id))
).scalars().first()
if obj:
return obj
obj = WalletAccount(
business_id=int(business_id),
available_balance=Decimal("0"),
pending_balance=Decimal("0"),
status="active",
)
db.add(obj)
db.flush()
return obj
def get_wallet_overview(db: Session, business_id: int) -> Dict[str, Any]:
_ = db.query(Business).filter(Business.id == int(business_id)).first() or None
if _ is None:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
account = _ensure_wallet_account(db, business_id)
settings = get_wallet_settings(db)
return {
"business_id": business_id,
"available_balance": float(account.available_balance or 0),
"pending_balance": float(account.pending_balance or 0),
"status": account.status,
"base_currency_code": settings.get("wallet_base_currency_code"),
"base_currency_id": settings.get("wallet_base_currency_id"),
}
def list_wallet_transactions(
db: Session,
business_id: int,
limit: int = 50,
skip: int = 0,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
q = (
db.query(WalletTransaction)
.filter(WalletTransaction.business_id == int(business_id))
.order_by(WalletTransaction.id.desc())
)
if from_date is not None:
q = q.filter(WalletTransaction.created_at >= from_date)
if to_date is not None:
q = q.filter(WalletTransaction.created_at <= to_date)
items = q.offset(max(0, int(skip))).limit(max(1, min(200, int(limit)))).all()
return [
{
"id": it.id,
"type": it.type,
"status": it.status,
"amount": float(it.amount or 0),
"fee_amount": float(it.fee_amount or 0) if it.fee_amount is not None else None,
"description": it.description,
"external_ref": it.external_ref,
"document_id": it.document_id,
"created_at": it.created_at,
"updated_at": it.updated_at,
}
for it in items
]
def get_wallet_metrics(
db: Session,
business_id: int,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> Dict[str, Any]:
account = _ensure_wallet_account(db, business_id)
# پایه: مجموع‌ها از WalletTransaction
q = db.query(WalletTransaction).filter(WalletTransaction.business_id == int(business_id))
if from_date is not None:
q = q.filter(WalletTransaction.created_at >= from_date)
if to_date is not None:
q = q.filter(WalletTransaction.created_at <= to_date)
transactions = q.all()
gross_in = Decimal("0")
fees_in = Decimal("0")
gross_out = Decimal("0")
fees_out = Decimal("0")
for tx in transactions:
amt = Decimal(str(tx.amount or 0))
fee = Decimal(str(tx.fee_amount or 0))
t = (tx.type or "").lower()
st = (tx.status or "").lower()
if st not in ("succeeded", "pending", "approved", "processing"): # موفق/در جریان را در گزارش لحاظ می‌کنیم
continue
if t in ("top_up", "customer_payment"):
gross_in += amt
fees_in += fee if fee > 0 else Decimal("0")
elif t in ("payout_settlement", "refund"):
gross_out += amt
fees_out += fee if fee > 0 else Decimal("0")
# سایر انواع در صورت نیاز بعداً اضافه شوند
# همچنین از wallet_payouts برای کارمزدهای تسویه استفاده کنیم
pq = db.query(WalletPayout).filter(WalletPayout.business_id == int(business_id))
if from_date is not None:
pq = pq.filter(WalletPayout.created_at >= from_date)
if to_date is not None:
pq = pq.filter(WalletPayout.created_at <= to_date)
for p in pq.all():
fees_out += Decimal(str(p.fees or 0))
net_in = gross_in - fees_in
net_out = gross_out + fees_out # خروجی خالصی که از کیف‌پول خارج می‌شود
return {
"period": {
"from": from_date,
"to": to_date,
},
"totals": {
"gross_in": float(gross_in),
"fees_in": float(fees_in),
"net_in": float(net_in),
"gross_out": float(gross_out),
"fees_out": float(fees_out),
"net_out": float(net_out),
},
"balances": {
"available": float(account.available_balance or 0),
"pending": float(account.pending_balance or 0),
},
}
def create_payout_request(
db: Session,
business_id: int,
user_id: int,
payload: Dict[str, Any],
) -> Dict[str, Any]:
amount = Decimal(str(payload.get("amount") or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ نامعتبر است", http_status=400)
bank_account_id = payload.get("bank_account_id")
if not bank_account_id:
raise ApiError("BANK_ACCOUNT_REQUIRED", "شناسه حساب بانکی الزامی است", http_status=400)
bank_acc = db.query(BankAccount).filter(BankAccount.id == int(bank_account_id)).first()
if not bank_acc:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "حساب بانکی یافت نشد", http_status=404)
if not bank_acc.is_active:
raise ApiError("BANK_ACCOUNT_INACTIVE", "حساب بانکی غیرفعال است", http_status=400)
account = _ensure_wallet_account(db, business_id)
available = Decimal(str(account.available_balance or 0))
if amount > available:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کافی نیست", http_status=400)
# قفل مبلغ: کسر از مانده قابل برداشت
account.available_balance = float(available - amount)
db.flush()
payout = WalletPayout(
business_id=int(business_id),
bank_account_id=int(bank_account_id),
gross_amount=amount,
fees=Decimal("0"),
net_amount=amount,
status="requested",
schedule_type=str(payload.get("schedule_type") or "manual"),
external_ref=None,
)
db.add(payout)
db.flush()
# ثبت تراکنش کنترلی
tx = WalletTransaction(
business_id=int(business_id),
type="payout_request",
status="pending",
amount=amount,
fee_amount=Decimal("0"),
description=str(payload.get("description") or "درخواست تسویه"),
external_ref=str(payout.id),
document_id=None,
)
db.add(tx)
db.flush()
return {
"id": payout.id,
"status": payout.status,
"gross_amount": float(payout.gross_amount),
"net_amount": float(payout.net_amount),
"bank_account_id": payout.bank_account_id,
}
def approve_payout_request(db: Session, payout_id: int, approver_user_id: int) -> Dict[str, Any]:
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status != "requested":
raise ApiError("INVALID_STATE", "تنها درخواست‌های در وضعیت requested قابل تایید هستند", http_status=400)
payout.status = "approved"
db.flush()
return {"id": payout.id, "status": payout.status}
def cancel_payout_request(db: Session, payout_id: int, canceller_user_id: int) -> Dict[str, Any]:
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status not in ("requested", "approved"):
raise ApiError("INVALID_STATE", "فقط درخواست‌های requested/approved قابل لغو هستند", http_status=400)
# بازگردانی مبلغ به مانده قابل برداشت
account = _ensure_wallet_account(db, payout.business_id)
account.available_balance = float(Decimal(str(account.available_balance or 0)) + Decimal(str(payout.gross_amount or 0)))
db.flush()
payout.status = "canceled"
db.flush()
return {"id": payout.id, "status": payout.status}
def settle_payout(db: Session, payout_id: int, user_id: int) -> Dict[str, Any]:
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status not in ("approved", "processing"):
raise ApiError("INVALID_STATE", "تسویه تنها پس از تایید/در حال پردازش مجاز است", http_status=400)
# ایجاد سند پرداخت برای خالص دریافتی بانک
try:
doc_id = _post_payout_document(
db,
business_id=int(payout.business_id),
user_id=int(user_id),
net_amount=Decimal(str(payout.net_amount or 0)),
fee_amount=Decimal(str(payout.fees or 0)),
)
except Exception:
doc_id = None
payout.status = "settled"
db.flush()
# ثبت تراکنش کیف‌پول برای گزارش‌ها
try:
tx = WalletTransaction(
business_id=int(payout.business_id),
type="payout_settlement",
status="succeeded",
amount=Decimal(str(payout.net_amount or 0)),
fee_amount=Decimal(str(payout.fees or 0)),
description="تسویه کیف‌پول",
document_id=doc_id,
)
db.add(tx)
db.flush()
except Exception:
pass
return {"id": payout.id, "status": payout.status, "document_id": doc_id}
def get_business_wallet_settings(db: Session, business_id: int) -> Dict[str, Any]:
obj = db.query(WalletSetting).filter(WalletSetting.business_id == int(business_id)).first()
if not obj:
return {
"business_id": business_id,
"mode": "manual",
"frequency": None,
"threshold_amount": None,
"min_reserve": None,
"default_bank_account_id": None,
}
return {
"business_id": business_id,
"mode": obj.mode,
"frequency": obj.frequency,
"threshold_amount": float(obj.threshold_amount) if obj.threshold_amount is not None else None,
"min_reserve": float(obj.min_reserve) if obj.min_reserve is not None else None,
"default_bank_account_id": obj.default_bank_account_id,
}
def update_business_wallet_settings(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
obj = db.query(WalletSetting).filter(WalletSetting.business_id == int(business_id)).first()
if not obj:
obj = WalletSetting(business_id=int(business_id))
db.add(obj)
mode = str(payload.get("mode") or obj.mode or "manual")
frequency = payload.get("frequency") if payload.get("frequency") in (None, "daily", "weekly") else obj.frequency
def _dec(v):
return Decimal(str(v)) if v is not None and str(v).strip() != "" else None
obj.mode = mode
obj.frequency = frequency
obj.threshold_amount = _dec(payload.get("threshold_amount"))
obj.min_reserve = _dec(payload.get("min_reserve"))
obj.default_bank_account_id = int(payload.get("default_bank_account_id")) if payload.get("default_bank_account_id") else None
db.flush()
return get_business_wallet_settings(db, business_id)
def run_auto_settlement(db: Session, business_id: int, user_id: int) -> Dict[str, Any]:
"""
منطق ساده: اگر mode=auto و (available - min_reserve) >= threshold آنگاه به حساب پیشفرض تسویه کن.
"""
settings = get_business_wallet_settings(db, business_id)
if (settings.get("mode") or "manual") != "auto":
return {"executed": False, "reason": "AUTO_MODE_DISABLED"}
threshold = Decimal(str(settings.get("threshold_amount") or 0))
min_reserve = Decimal(str(settings.get("min_reserve") or 0))
default_bank_account_id = settings.get("default_bank_account_id")
if not default_bank_account_id:
return {"executed": False, "reason": "NO_DEFAULT_BANK_ACCOUNT"}
account = _ensure_wallet_account(db, business_id)
available = Decimal(str(account.available_balance or 0))
cand = available - min_reserve
if cand <= 0 or cand < threshold:
return {"executed": False, "reason": "THRESHOLD_NOT_MET", "available": float(available)}
# ایجاد payout و تسویه
payload = {
"bank_account_id": int(default_bank_account_id),
"amount": float(cand),
"description": "تسویه خودکار",
}
pr = create_payout_request(db, business_id, user_id, payload)
pa = db.query(WalletPayout).filter(WalletPayout.id == int(pr["id"])).first()
# تایید و تسویه
approve_payout_request(db, pa.id, user_id)
result = settle_payout(db, pa.id, user_id)
return {"executed": True, "payout": result}
def create_top_up_request(db: Session, business_id: int, user_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
ایجاد درخواست افزایش اعتبار (در انتظار تایید درگاه)
- مانده pending افزایش مییابد تا پس از تایید به available منتقل شود
"""
amount = Decimal(str(payload.get("amount") or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ نامعتبر است", http_status=400)
gateway_id = payload.get("gateway_id")
if not gateway_id:
# اجازه می‌دهیم بدون gateway_id نیز ساخته شود، اما برای پرداخت آنلاین لازم است
pass
account = _ensure_wallet_account(db, business_id)
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) + amount)
db.flush()
tx = WalletTransaction(
business_id=int(business_id),
type="top_up",
status="pending",
amount=amount,
fee_amount=Decimal("0"),
description=str(payload.get("description") or "افزایش اعتبار"),
external_ref=None,
document_id=None,
)
db.add(tx)
db.flush()
# تولید لینک درگاه پرداخت (در صورت ارسال gateway_id)
payment_url = None
if gateway_id:
try:
from app.services.payment_service import initiate_payment
init_res = initiate_payment(
db=db,
business_id=int(business_id),
tx_id=int(tx.id),
amount=float(amount),
gateway_id=int(gateway_id),
)
payment_url = init_res.payment_url
except Exception as ex:
# اگر ایجاد لینک شکست بخورد، تراکنش پابرجاست ولی لینک ندارد
from app.core.logging import get_logger
logger = get_logger()
logger.warning("gateway_initiate_failed", error=str(ex))
return {"transaction_id": tx.id, "status": tx.status, **({"payment_url": payment_url} if payment_url else {})}
def confirm_top_up(db: Session, tx_id: int, success: bool, external_ref: str | None = None) -> Dict[str, Any]:
"""
تایید/لغو top-up از وبهوک درگاه
- در موفقیت: انتقال از pending به available
- در عدم موفقیت: کاهش از pending
"""
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if not tx or tx.type != "top_up":
raise ApiError("TX_NOT_FOUND", "تراکنش افزایش اعتبار یافت نشد", http_status=404)
account = _ensure_wallet_account(db, tx.business_id)
if success:
# move pending -> available
gross = Decimal(str(tx.amount or 0))
fee = Decimal(str(tx.fee_amount or 0))
if fee < 0:
fee = Decimal("0")
if fee > gross:
fee = gross
net = gross - fee
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) - gross)
account.available_balance = float(Decimal(str(account.available_balance or 0)) + net)
tx.status = "succeeded"
# create accounting document
try:
doc_id = _post_topup_document(db, tx.business_id, user_id=0, amount=gross, fee_amount=fee)
tx.document_id = int(doc_id)
except Exception:
# اگر سند ایجاد نشد، تراکنش مالی معتبر است اما سند ندارد
pass
else:
# rollback pending
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) - Decimal(str(tx.amount or 0)))
tx.status = "failed"
tx.external_ref = external_ref
db.flush()
return {"transaction_id": tx.id, "status": tx.status}
def refund_transaction(db: Session, tx_id: int, amount: Decimal | None = None, reason: str | None = None) -> Dict[str, Any]:
"""
استرداد تراکنش موفق (بازگشت وجه از کیفپول)
- کاهش از available به میزان مبلغ استرداد
"""
src = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if not src or src.status != "succeeded":
raise ApiError("TX_NOT_REFUNDABLE", "تراکنش موفق برای استرداد پیدا نشد", http_status=400)
refund_amount = Decimal(str(amount if amount is not None else src.amount or 0))
if refund_amount <= 0 or refund_amount > Decimal(str(src.amount or 0)):
raise ApiError("INVALID_REFUND_AMOUNT", "مبلغ استرداد نامعتبر است", http_status=400)
account = _ensure_wallet_account(db, src.business_id)
available = Decimal(str(account.available_balance or 0))
if refund_amount > available:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کافی برای استرداد نیست", http_status=400)
account.available_balance = float(available - refund_amount)
db.flush()
tx = WalletTransaction(
business_id=int(src.business_id),
type="refund",
status="succeeded",
amount=refund_amount,
description=reason or f"استرداد تراکنش {src.id}",
external_ref=None,
document_id=None,
)
db.add(tx)
db.flush()
return {"refund_transaction_id": tx.id, "status": tx.status}
def _parse_iso_date_only(dt: str | datetime | date) -> date:
try:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = (
db.query(FiscalYear)
.filter(
and_(
FiscalYear.business_id == int(business_id),
FiscalYear.is_last == True, # noqa: E712
)
)
.first()
)
if not fy:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی جاری یافت نشد", http_status=400)
return fy
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
acc = db.query(Account).filter(
and_(Account.business_id == None, Account.code == str(account_code)) # noqa: E711
).first()
if not acc:
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=500)
return acc
def _resolve_wallet_currency_id(db: Session) -> int:
settings = get_wallet_settings(db)
cid = settings.get("wallet_base_currency_id")
if cid:
return int(cid)
# fallback: resolve by code IRR
from adapters.db.models.currency import Currency
cur = db.query(Currency).filter(Currency.code == "IRR").first()
if not cur:
raise ApiError("CURRENCY_NOT_FOUND", "ارز پایه کیف‌پول یافت نشد", http_status=400)
return int(cur.id)
def _create_simple_document(
db: Session,
business_id: int,
user_id: int,
document_type: str, # 'receipt' | 'payment'
currency_id: int,
document_date: date,
description: str | None,
accounting_lines: list[dict],
) -> Document:
fiscal_year = _get_current_fiscal_year(db, business_id)
today = _parse_iso_date_only(document_date)
prefix = f"{'RC' if document_type == 'receipt' else 'PY'}-{today.strftime('%Y%m%d')}"
last_doc = (
db.query(Document)
.filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%"),
)
)
.order_by(Document.code.desc())
.first()
)
if last_doc:
try:
last_num = int(str(last_doc.code).split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
doc_code = f"{prefix}-{next_num:04d}"
document = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=document_type,
document_date=today,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=description,
extra_info={"source": "wallet"},
)
db.add(document)
db.flush()
for ln in accounting_lines:
db.add(DocumentLine(
document_id=document.id,
account_id=int(ln["account_id"]),
debit=Decimal(str(ln.get("debit", 0) or 0)),
credit=Decimal(str(ln.get("credit", 0) or 0)),
description=ln.get("description"),
))
db.flush()
return document
def _post_topup_document(db: Session, business_id: int, user_id: int, amount: Decimal, fee_amount: Decimal | None = None, doc_date: date | None = None) -> int:
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10204")
bank_acc = _get_fixed_account_by_code(db, "10203")
fee_amt = Decimal(str(fee_amount or 0))
net = amount - fee_amt if amount >= fee_amt else Decimal("0")
lines = [
# Receipt pattern with commission (per existing commission logic):
# Dr 10204 (wallet) = net, Dr 70902 (fee expense) = fee, Cr 10203 (bank) = gross
{"account_id": wallet_acc.id, "debit": net, "credit": 0, "description": "افزایش اعتبار (خالص)"},
]
if fee_amt > 0:
commission_expense = _get_fixed_account_by_code(db, "70902")
lines.append({"account_id": commission_expense.id, "debit": fee_amt, "credit": 0, "description": "کارمزد درگاه"})
lines.append({"account_id": bank_acc.id, "debit": 0, "credit": amount, "description": "واریز از درگاه/بانک (ناخالص)"})
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="receipt",
currency_id=currency_id,
document_date=doc_date or datetime.utcnow().date(),
description="افزایش اعتبار کیف‌پول",
accounting_lines=lines,
)
return int(document.id)
def _post_payout_document(db: Session, business_id: int, user_id: int, net_amount: Decimal, fee_amount: Decimal | None = None, doc_date: date | None = None) -> int:
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10204")
bank_acc = _get_fixed_account_by_code(db, "10203")
fee_amt = Decimal(str(fee_amount or 0))
# Per existing commission logic for Payment: Dr bank = fee, Cr 70902 = fee
lines = [
{"account_id": bank_acc.id, "debit": net_amount, "credit": 0, "description": "وصول تسویه کیف‌پول (خالص)"},
{"account_id": wallet_acc.id, "debit": 0, "credit": net_amount, "description": "انتقال از کیف‌پول"},
]
if fee_amt > 0:
commission_expense = _get_fixed_account_by_code(db, "70902")
lines.append({"account_id": bank_acc.id, "debit": fee_amt, "credit": 0, "description": "کارمزد تسویه (الگوی پرداخت)"})
lines.append({"account_id": commission_expense.id, "debit": 0, "credit": fee_amt, "description": "کارمزد خدمات بانکی"})
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="payment",
currency_id=currency_id,
document_date=doc_date or datetime.utcnow().date(),
description="تسویه کیف‌پول به حساب بانکی",
accounting_lines=lines,
)
return int(document.id)

View file

@ -1,9 +1,19 @@
from __future__ import annotations
from typing import Dict, Any, Optional
from typing import Any, Dict, List, Optional
from decimal import Decimal
from datetime import datetime, date
from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.models.warehouse_document import WarehouseDocument
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.product import Product
from adapters.db.models.account import Account
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
from adapters.db.models.warehouse import Warehouse
from adapters.db.repositories.warehouse_repository import WarehouseRepository
@ -12,114 +22,301 @@ from adapters.api.v1.schemas import QueryInfo, FilterItem
from app.services.query_service import QueryService
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = db.query(FiscalYear).filter(and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)).first()
if not fy:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fy
def _build_wh_code(prefix_base: str) -> str:
today = datetime.now().date()
return f"{prefix_base}-{today.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}"
def create_from_invoice(
db: Session,
business_id: int,
invoice: Document,
lines: List[Dict[str, Any]],
wh_doc_type: str,
created_by_user_id: Optional[int] = None,
) -> WarehouseDocument:
"""ساخت حواله انبار draft از روی فاکتور (بدون پست)."""
fy = _get_current_fiscal_year(db, business_id)
code = _build_wh_code("WH")
wh = WarehouseDocument(
business_id=business_id,
fiscal_year_id=fy.id,
code=code,
document_date=invoice.document_date,
status="draft",
doc_type=wh_doc_type,
source_type="invoice",
source_document_id=invoice.id,
created_by_user_id=created_by_user_id,
)
db.add(wh)
db.flush()
for ln in lines:
pid = ln.get("product_id")
qty = Decimal(str(ln.get("quantity") or 0))
if not pid or qty <= 0:
continue
extra = ln.get("extra_info") or {}
mv = (extra.get("movement") or ("out" if wh_doc_type in ("issue", "production_out") else "in"))
# warehouse_id عمداً تعیین نمی‌شود؛ انباردار بعداً مشخص می‌کند
wline = WarehouseDocumentLine(
warehouse_document_id=wh.id,
product_id=int(pid),
warehouse_id=None,
movement=str(mv),
quantity=qty,
extra_info=extra,
)
db.add(wline)
db.flush()
return wh
def post_warehouse_document(db: Session, wh_id: int) -> Dict[str, Any]:
"""پست حواله: کنترل کسری برای خروج‌ها و محاسبه COGS ساده (fallback به unit_price).
در پایان، در صورت وجود لینک به فاکتور منبع، سطرهای حسابداری COGS/Inventory را به همان سند اضافه میکند.
"""
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
if not wh:
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
if wh.status == "posted":
return {"id": wh.id, "status": wh.status}
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
# کنترل کسری برای خروج‌ها (اگر allow_negative_stock نباشد)
for ln in lines:
if ln.movement == "out":
# در این نسخه اولیه صرفاً چک نرم (بدون ایندکس کاردکس): اگر quantity<=0 خطا
if not ln.quantity or Decimal(str(ln.quantity)) <= 0:
raise ApiError("INVALID_QUANTITY", "Quantity must be positive", http_status=400)
# محاسبه cogs_amount (ساده)
for ln in lines:
qty = Decimal(str(ln.quantity or 0))
if qty <= 0:
continue
if ln.cogs_amount is None:
unit = Decimal(str((ln.cost_price or 0)))
if unit <= 0:
# تلاش برای fallback از extra_info.unit_price
u = None
try:
u = Decimal(str((ln.extra_info or {}).get("unit_price", 0)))
except Exception:
u = Decimal(0)
unit = u
ln.cogs_amount = unit * qty
wh.status = "posted"
wh.touch()
db.flush()
# در صورت اتصال به فاکتور، بر اساس نوع فاکتور سطرهای حسابداری ثبت کن
if wh.source_type == "invoice" and wh.source_document_id:
inv: Document = db.query(Document).filter(Document.id == int(wh.source_document_id)).first()
if inv:
# حساب‌ها
def get_fixed(db: Session, code: str) -> Account:
return db.query(Account).filter(and_(Account.business_id == None, Account.code == code)).first() # noqa: E711
acc_inventory = get_fixed(db, "10102")
acc_inventory_finished = get_fixed(db, "10102")
acc_cogs = get_fixed(db, "40001")
acc_direct = get_fixed(db, "70406")
acc_waste = get_fixed(db, "70407")
acc_wip = get_fixed(db, "10106")
acc_grni = get_fixed(db, "30101") # Goods Received Not Invoiced
inv_type = inv.document_type
# جمع مبالغ بر اساس حرکت
out_total = Decimal(0)
in_total = Decimal(0)
for ln in lines:
amt = Decimal(str(ln.cogs_amount or 0))
if ln.movement == "out":
out_total += amt
elif ln.movement == "in":
in_total += amt
if inv_type == "invoice_sales":
if out_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_cogs.id, debit=out_total, credit=Decimal(0), description="بهای تمام‌شده (پست حواله فروش)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (پست حواله فروش)"))
elif inv_type == "invoice_sales_return":
if in_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=in_total, credit=Decimal(0), description="ورود موجودی (پست حواله برگشت از فروش)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_cogs.id, debit=Decimal(0), credit=in_total, description="تعدیل بهای تمام‌شده (پست حواله برگشت از فروش)"))
elif inv_type == "invoice_direct_consumption":
if out_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_direct.id, debit=out_total, credit=Decimal(0), description="مصرف مستقیم (پست حواله)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (مصرف مستقیم)"))
elif inv_type == "invoice_waste":
if out_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_waste.id, debit=out_total, credit=Decimal(0), description="ضایعات (پست حواله)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (ضایعات)"))
elif inv_type == "invoice_purchase":
if in_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=in_total, credit=Decimal(0), description="ورود موجودی خرید (پست حواله)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_grni.id, debit=Decimal(0), credit=in_total, description="ثبت GRNI خرید"))
elif inv_type == "invoice_purchase_return":
if out_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_grni.id, debit=out_total, credit=Decimal(0), description="ثبت GRNI برگشت خرید"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی برگشت خرید (پست حواله)"))
elif inv_type == "invoice_production":
# مواد مصرفی (out): بدهکار WIP، بستانکار موجودی
if out_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_wip.id, debit=out_total, credit=Decimal(0), description="انتقال مواد به کاردرجریان (پست حواله)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج مواد اولیه (پست حواله)"))
# کالای ساخته شده (in): بدهکار موجودی ساخته‌شده، بستانکار WIP
if in_total > 0:
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory_finished.id, debit=in_total, credit=Decimal(0), description="ورود کالای ساخته‌شده (پست حواله)"))
db.add(DocumentLine(document_id=inv.id, account_id=acc_wip.id, debit=Decimal(0), credit=in_total, description="انتقال از کاردرجریان (پست حواله)"))
return {"id": wh.id, "status": wh.status}
def warehouse_document_to_dict(db: Session, wh: WarehouseDocument) -> Dict[str, Any]:
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
return {
"id": wh.id,
"code": wh.code,
"business_id": wh.business_id,
"fiscal_year_id": wh.fiscal_year_id,
"document_date": wh.document_date.isoformat() if wh.document_date else None,
"status": wh.status,
"doc_type": wh.doc_type,
"warehouse_id_from": wh.warehouse_id_from,
"warehouse_id_to": wh.warehouse_id_to,
"source_type": wh.source_type,
"source_document_id": wh.source_document_id,
"extra_info": wh.extra_info,
"lines": [
{
"id": ln.id,
"product_id": ln.product_id,
"warehouse_id": ln.warehouse_id,
"movement": ln.movement,
"quantity": float(ln.quantity),
"cost_price": float(ln.cost_price) if ln.cost_price is not None else None,
"cogs_amount": float(ln.cogs_amount) if ln.cogs_amount is not None else None,
"extra_info": ln.extra_info,
}
for ln in lines
],
}
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
return {
"id": obj.id,
"business_id": obj.business_id,
"code": obj.code,
"name": obj.name,
"description": obj.description,
"is_default": obj.is_default,
"created_at": obj.created_at,
"updated_at": obj.updated_at,
}
return {
"id": obj.id,
"business_id": obj.business_id,
"code": obj.code,
"name": obj.name,
"description": obj.description,
"is_default": obj.is_default,
"created_at": obj.created_at,
"updated_at": obj.updated_at,
}
def create_warehouse(db: Session, business_id: int, payload: WarehouseCreateRequest) -> Dict[str, Any]:
code = payload.code.strip()
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
repo = WarehouseRepository(db)
obj = repo.create(
business_id=business_id,
code=code,
name=payload.name.strip(),
description=payload.description,
is_default=bool(payload.is_default),
)
if obj.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
code = payload.code.strip()
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
repo = WarehouseRepository(db)
obj = repo.create(
business_id=business_id,
code=code,
name=payload.name.strip(),
description=payload.description,
is_default=bool(payload.is_default),
)
if obj.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
def list_warehouses(db: Session, business_id: int) -> Dict[str, Any]:
repo = WarehouseRepository(db)
rows = repo.list(business_id)
return {"items": [_to_dict(w) for w in rows]}
repo = WarehouseRepository(db)
rows = repo.list(business_id)
return {"items": [_to_dict(w) for w in rows]}
def get_warehouse(db: Session, business_id: int, warehouse_id: int) -> Optional[Dict[str, Any]]:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
return _to_dict(obj)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
return _to_dict(obj)
def update_warehouse(db: Session, business_id: int, warehouse_id: int, payload: WarehouseUpdateRequest) -> Optional[Dict[str, Any]]:
repo = WarehouseRepository(db)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
if payload.code and payload.code.strip() != obj.code:
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
repo = WarehouseRepository(db)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
if payload.code and payload.code.strip() != obj.code:
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
updated = repo.update(
warehouse_id,
code=payload.code.strip() if isinstance(payload.code, str) else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
description=payload.description,
is_default=payload.is_default if payload.is_default is not None else None,
)
if not updated:
return None
if updated.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
updated = repo.update(
warehouse_id,
code=payload.code.strip() if isinstance(payload.code, str) else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
description=payload.description,
is_default=payload.is_default if payload.is_default is not None else None,
)
if not updated:
return None
if updated.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return False
repo = WarehouseRepository(db)
return repo.delete(warehouse_id)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return False
repo = WarehouseRepository(db)
return repo.delete(warehouse_id)
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
"""Query warehouses with filters/search/sorting/pagination scoped to business."""
# Ensure business scoping via filters
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
merged_filters = [base_filter]
if query_info.filters:
merged_filters.extend(query_info.filters)
# Ensure business scoping via filters
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
merged_filters = [base_filter]
if query_info.filters:
merged_filters.extend(query_info.filters)
effective_query = QueryInfo(
sort_by=query_info.sort_by,
sort_desc=query_info.sort_desc,
take=query_info.take,
skip=query_info.skip,
search=query_info.search,
search_fields=query_info.search_fields,
filters=merged_filters,
)
effective_query = QueryInfo(
sort_by=query_info.sort_by,
sort_desc=query_info.sort_desc,
take=query_info.take,
skip=query_info.skip,
search=query_info.search,
search_fields=query_info.search_fields,
filters=merged_filters,
)
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
items = [_to_dict(w) for w in results]
limit = max(1, effective_query.take)
page = (effective_query.skip // limit) + 1
total_pages = (total + limit - 1) // limit
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
items = [_to_dict(w) for w in results]
limit = max(1, effective_query.take)
page = (effective_query.skip // limit) + 1
total_pages = (total + limit - 1) // limit
return {
"items": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
}
return {
"items": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": total_pages,
}

View file

@ -22,20 +22,30 @@ adapters/api/v1/health.py
adapters/api/v1/inventory_transfers.py
adapters/api/v1/invoices.py
adapters/api/v1/kardex.py
adapters/api/v1/opening_balance.py
adapters/api/v1/payment_callbacks.py
adapters/api/v1/payment_gateways.py
adapters/api/v1/persons.py
adapters/api/v1/petty_cash.py
adapters/api/v1/price_lists.py
adapters/api/v1/product_attributes.py
adapters/api/v1/products.py
adapters/api/v1/receipts_payments.py
adapters/api/v1/report_templates.py
adapters/api/v1/schemas.py
adapters/api/v1/tax_types.py
adapters/api/v1/tax_units.py
adapters/api/v1/transfers.py
adapters/api/v1/users.py
adapters/api/v1/wallet.py
adapters/api/v1/wallet_webhook.py
adapters/api/v1/warehouse_docs.py
adapters/api/v1/warehouses.py
adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py
adapters/api/v1/admin/payment_gateways.py
adapters/api/v1/admin/system_settings.py
adapters/api/v1/admin/wallet_admin.py
adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/bank_account.py
@ -75,7 +85,9 @@ adapters/db/models/document_line.py
adapters/db/models/email_config.py
adapters/db/models/file_storage.py
adapters/db/models/fiscal_year.py
adapters/db/models/invoice_item_line.py
adapters/db/models/password_reset.py
adapters/db/models/payment_gateway.py
adapters/db/models/person.py
adapters/db/models/petty_cash.py
adapters/db/models/price_list.py
@ -83,10 +95,15 @@ adapters/db/models/product.py
adapters/db/models/product_attribute.py
adapters/db/models/product_attribute_link.py
adapters/db/models/product_bom.py
adapters/db/models/report_template.py
adapters/db/models/system_setting.py
adapters/db/models/tax_type.py
adapters/db/models/tax_unit.py
adapters/db/models/user.py
adapters/db/models/wallet.py
adapters/db/models/warehouse.py
adapters/db/models/warehouse_document.py
adapters/db/models/warehouse_document_line.py
adapters/db/models/support/__init__.py
adapters/db/models/support/category.py
adapters/db/models/support/message.py
@ -150,6 +167,8 @@ app/services/file_storage_service.py
app/services/inventory_transfer_service.py
app/services/invoice_service.py
app/services/kardex_service.py
app/services/opening_balance_service.py
app/services/payment_service.py
app/services/person_service.py
app/services/petty_cash_service.py
app/services/price_list_service.py
@ -157,7 +176,10 @@ app/services/product_attribute_service.py
app/services/product_service.py
app/services/query_service.py
app/services/receipt_payment_service.py
app/services/report_template_service.py
app/services/system_settings_service.py
app/services/transfer_service.py
app/services/wallet_service.py
app/services/warehouse_service.py
app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py
@ -222,6 +244,12 @@ migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
migrations/versions/20251021_000601_add_bom_and_warehouses.py
migrations/versions/20251102_120001_add_check_status_fields.py
migrations/versions/20251107_150001_add_warehouse_docs.py
migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py
migrations/versions/20251108_230001_add_report_templates.py
migrations/versions/20251108_231201_add_system_settings.py
migrations/versions/20251108_232101_add_wallet_tables.py
migrations/versions/20251109_120001_add_payment_gateways.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py

View file

@ -0,0 +1,85 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251107_150001_add_warehouse_docs'
down_revision: Union[str, None] = 'ac9e4b3dcffc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = inspector.get_table_names()
if 'warehouse_documents' not in tables:
op.create_table(
'warehouse_documents',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), nullable=False),
sa.Column('fiscal_year_id', sa.Integer(), nullable=True),
sa.Column('code', sa.String(length=64), nullable=False, unique=True),
sa.Column('document_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False, server_default='draft'),
sa.Column('doc_type', sa.String(length=32), nullable=False),
sa.Column('warehouse_id_from', sa.Integer(), nullable=True),
sa.Column('warehouse_id_to', sa.Integer(), nullable=True),
sa.Column('source_type', sa.String(length=32), nullable=True),
sa.Column('source_document_id', sa.Integer(), nullable=True),
sa.Column('extra_info', sa.JSON(), nullable=True),
sa.Column('created_by_user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
)
try:
op.create_index('ix_wh_docs_business_date', 'warehouse_documents', ['business_id', 'document_date'])
op.create_index('ix_wh_docs_code', 'warehouse_documents', ['code'])
except Exception:
pass
if 'warehouse_document_lines' not in tables:
op.create_table(
'warehouse_document_lines',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('warehouse_document_id', sa.Integer(), sa.ForeignKey('warehouse_documents.id', ondelete='CASCADE'), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=True),
sa.Column('movement', sa.String(length=8), nullable=False),
sa.Column('quantity', sa.Numeric(18, 6), nullable=False),
sa.Column('cost_price', sa.Numeric(18, 6), nullable=True),
sa.Column('cogs_amount', sa.Numeric(18, 6), nullable=True),
sa.Column('extra_info', sa.JSON(), nullable=True),
)
try:
op.create_index('ix_wh_lines_doc', 'warehouse_document_lines', ['warehouse_document_id'])
op.create_index('ix_wh_lines_product', 'warehouse_document_lines', ['product_id'])
op.create_index('ix_wh_lines_warehouse', 'warehouse_document_lines', ['warehouse_id'])
except Exception:
pass
def downgrade() -> None:
try:
op.drop_index('ix_wh_lines_warehouse', table_name='warehouse_document_lines')
op.drop_index('ix_wh_lines_product', table_name='warehouse_document_lines')
op.drop_index('ix_wh_lines_doc', table_name='warehouse_document_lines')
except Exception:
pass
try:
op.drop_table('warehouse_document_lines')
except Exception:
pass
try:
op.drop_index('ix_wh_docs_code', table_name='warehouse_documents')
op.drop_index('ix_wh_docs_business_date', table_name='warehouse_documents')
except Exception:
pass
try:
op.drop_table('warehouse_documents')
except Exception:
pass

View file

@ -0,0 +1,72 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251107_170101_add_invoice_item_lines_and_migrate'
down_revision: Union[str, None] = '20251107_150001_add_warehouse_docs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'invoice_item_lines' not in inspector.get_table_names():
op.create_table(
'invoice_item_lines',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='CASCADE'), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(18, 6), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('extra_info', sa.JSON(), nullable=True),
)
try:
op.create_index('ix_invoice_item_lines_doc', 'invoice_item_lines', ['document_id'])
op.create_index('ix_invoice_item_lines_product', 'invoice_item_lines', ['product_id'])
except Exception:
pass
# migrate existing product rows from document_lines
# copy rows where product_id is not null
try:
op.execute(
"""
INSERT INTO invoice_item_lines (document_id, product_id, quantity, description, extra_info)
SELECT document_id, product_id, quantity, description, extra_info
FROM document_lines
WHERE product_id IS NOT NULL
"""
)
# delete copied rows from document_lines
op.execute("DELETE FROM document_lines WHERE product_id IS NOT NULL")
except Exception:
# best-effort migration; ignore if structure differs
pass
def downgrade() -> None:
# optional: move back into document_lines
try:
op.execute(
"""
INSERT INTO document_lines (document_id, product_id, quantity, description, extra_info, debit, credit)
SELECT document_id, product_id, quantity, description, extra_info, 0, 0
FROM invoice_item_lines
"""
)
except Exception:
pass
try:
op.drop_index('ix_invoice_item_lines_product', table_name='invoice_item_lines')
op.drop_index('ix_invoice_item_lines_doc', table_name='invoice_item_lines')
except Exception:
pass
try:
op.drop_table('invoice_item_lines')
except Exception:
pass

View file

@ -0,0 +1,68 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251108_230001_add_report_templates'
down_revision: Union[str, None] = '20251107_170101_add_invoice_item_lines_and_migrate'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Create report_templates table if not exists
if 'report_templates' not in inspector.get_table_names():
op.create_table(
'report_templates',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('module_key', sa.String(length=64), nullable=False),
sa.Column('subtype', sa.String(length=64), nullable=True),
sa.Column('name', sa.String(length=160), nullable=False),
sa.Column('description', sa.String(length=512), nullable=True),
sa.Column('engine', sa.String(length=32), nullable=False, server_default=sa.text("'jinja2'")),
sa.Column('status', sa.String(length=16), nullable=False, server_default=sa.text("'draft'")),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column('version', sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column('content_html', sa.Text(), nullable=False),
sa.Column('content_css', sa.Text(), nullable=True),
sa.Column('header_html', sa.Text(), nullable=True),
sa.Column('footer_html', sa.Text(), nullable=True),
sa.Column('paper_size', sa.String(length=32), nullable=True),
sa.Column('orientation', sa.String(length=16), nullable=True),
sa.Column('margins', sa.JSON(), nullable=True),
sa.Column('assets', sa.JSON(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
# Indexes
try:
op.create_index('ix_report_templates_business_id', 'report_templates', ['business_id'])
op.create_index('ix_report_templates_module_key', 'report_templates', ['module_key'])
op.create_index('ix_report_templates_subtype', 'report_templates', ['subtype'])
op.create_index('ix_report_templates_status', 'report_templates', ['status'])
op.create_index('ix_report_templates_is_default', 'report_templates', ['is_default'])
except Exception:
pass
def downgrade() -> None:
# Drop indexes then table
try:
op.drop_index('ix_report_templates_is_default', table_name='report_templates')
op.drop_index('ix_report_templates_status', table_name='report_templates')
op.drop_index('ix_report_templates_subtype', table_name='report_templates')
op.drop_index('ix_report_templates_module_key', table_name='report_templates')
op.drop_index('ix_report_templates_business_id', table_name='report_templates')
except Exception:
pass
try:
op.drop_table('report_templates')
except Exception:
pass

View file

@ -0,0 +1,70 @@
"""add system_settings table and seed wallet_base_currency_code
Revision ID: 20251108_231201_add_system_settings
Revises: 20251107_170101_add_invoice_item_lines_and_migrate
Create Date: 2025-11-08 23:12:01.000001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251108_231201_add_system_settings'
down_revision = '20251107_170101_add_invoice_item_lines_and_migrate'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# 1) Create table if not exists
if 'system_settings' not in inspector.get_table_names():
op.create_table(
'system_settings',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('key', sa.String(length=100), nullable=False, index=True),
sa.Column('value_string', sa.String(length=255), nullable=True),
sa.Column('value_int', sa.Integer(), nullable=True),
sa.Column('value_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('key', name='uq_system_settings_key'),
)
try:
op.create_index('ix_system_settings_key', 'system_settings', ['key'])
except Exception:
pass
# 2) Seed default wallet base currency code to IRR if not set
# prefer code instead of id to avoid id dependency
try:
conn = op.get_bind()
# check if exists
exists = conn.execute(sa.text("SELECT 1 FROM system_settings WHERE `key` = :k LIMIT 1"), {"k": "wallet_base_currency_code"}).fetchone()
if not exists:
conn.execute(
sa.text(
"""
INSERT INTO system_settings (`key`, value_string, created_at, updated_at)
VALUES (:k, :v, NOW(), NOW())
"""
),
{"k": "wallet_base_currency_code", "v": "IRR"},
)
except Exception:
# non-fatal
pass
def downgrade() -> None:
try:
op.drop_index('ix_system_settings_key', table_name='system_settings')
except Exception:
pass
op.drop_table('system_settings')

View file

@ -0,0 +1,116 @@
"""add wallet tables (accounts, transactions, payouts, settings)
Revision ID: 20251108_232101_add_wallet_tables
Revises: 20251108_231201_add_system_settings
Create Date: 2025-11-08 23:21:01.000001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251108_232101_add_wallet_tables'
down_revision = '20251108_231201_add_system_settings'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = inspector.get_table_names()
if 'wallet_accounts' not in tables:
op.create_table(
'wallet_accounts',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('available_balance', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('pending_balance', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('business_id', name='uq_wallet_accounts_business'),
)
try:
op.create_index('ix_wallet_accounts_business_id', 'wallet_accounts', ['business_id'])
except Exception:
pass
if 'wallet_transactions' not in tables:
op.create_table(
'wallet_transactions',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.String(length=50), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('amount', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('fee_amount', sa.Numeric(18, 2), nullable=True),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('external_ref', sa.String(length=100), nullable=True),
sa.Column('document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='SET NULL'), nullable=True),
sa.Column('extra_info', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
try:
op.create_index('ix_wallet_tx_business_id', 'wallet_transactions', ['business_id'])
op.create_index('ix_wallet_tx_document_id', 'wallet_transactions', ['document_id'])
op.create_index('ix_wallet_tx_type', 'wallet_transactions', ['type'])
op.create_index('ix_wallet_tx_status', 'wallet_transactions', ['status'])
except Exception:
pass
if 'wallet_payouts' not in tables:
op.create_table(
'wallet_payouts',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('bank_account_id', sa.Integer(), sa.ForeignKey('bank_accounts.id', ondelete='RESTRICT'), nullable=False),
sa.Column('gross_amount', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('fees', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('net_amount', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('status', sa.String(length=20), nullable=False, server_default='requested'),
sa.Column('schedule_type', sa.String(length=20), nullable=False, server_default='manual'),
sa.Column('external_ref', sa.String(length=100), nullable=True),
sa.Column('extra_info', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
try:
op.create_index('ix_wallet_payouts_business_id', 'wallet_payouts', ['business_id'])
op.create_index('ix_wallet_payouts_bank_account_id', 'wallet_payouts', ['bank_account_id'])
op.create_index('ix_wallet_payouts_status', 'wallet_payouts', ['status'])
except Exception:
pass
if 'wallet_settings' not in tables:
op.create_table(
'wallet_settings',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('mode', sa.String(length=20), nullable=False, server_default='manual'),
sa.Column('frequency', sa.String(length=20), nullable=True),
sa.Column('threshold_amount', sa.Numeric(18, 2), nullable=True),
sa.Column('min_reserve', sa.Numeric(18, 2), nullable=True),
sa.Column('default_bank_account_id', sa.Integer(), sa.ForeignKey('bank_accounts.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('business_id', name='uq_wallet_settings_business'),
)
try:
op.create_index('ix_wallet_settings_business_id', 'wallet_settings', ['business_id'])
except Exception:
pass
def downgrade() -> None:
for name in ['wallet_settings', 'wallet_payouts', 'wallet_transactions', 'wallet_accounts']:
try:
op.drop_table(name)
except Exception:
pass

View file

@ -0,0 +1,68 @@
"""add payment gateways tables
Revision ID: 20251109_120001_add_payment_gateways
Revises: 20251108_232101_add_wallet_tables
Create Date: 2025-11-09 12:00:01.000001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251109_120001_add_payment_gateways'
down_revision = '20251108_232101_add_wallet_tables'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = inspector.get_table_names()
if 'payment_gateways' not in tables:
op.create_table(
'payment_gateways',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('provider', sa.String(length=50), nullable=False), # zarinpal | parsian | ...
sa.Column('display_name', sa.String(length=100), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('is_sandbox', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('config_json', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
try:
op.create_index('ix_payment_gateways_provider', 'payment_gateways', ['provider'])
op.create_index('ix_payment_gateways_is_active', 'payment_gateways', ['is_active'])
except Exception:
pass
if 'business_payment_gateways' not in tables:
op.create_table(
'business_payment_gateways',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('gateway_id', sa.Integer(), sa.ForeignKey('payment_gateways.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
try:
op.create_index('ix_business_payment_gateways_business', 'business_payment_gateways', ['business_id'])
op.create_index('ix_business_payment_gateways_gateway', 'business_payment_gateways', ['gateway_id'])
except Exception:
pass
def downgrade() -> None:
for name in ['business_payment_gateways', 'payment_gateways']:
try:
op.drop_table(name)
except Exception:
pass

View file

@ -17,12 +17,17 @@ depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('documents', sa.Column('description', sa.Text(), nullable=True))
# ### end Alembic commands ###
# افزودن ستون فقط اگر قبلاً وجود ندارد
bind = op.get_bind()
inspector = sa.inspect(bind)
cols = [c['name'] for c in inspector.get_columns('documents')]
if 'description' not in cols:
op.add_column('documents', sa.Column('description', sa.Text(), nullable=True))
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('documents', 'description')
# ### end Alembic commands ###
bind = op.get_bind()
inspector = sa.inspect(bind)
cols = [c['name'] for c in inspector.get_columns('documents')]
if 'description' in cols:
op.drop_column('documents', 'description')

View file

@ -189,23 +189,35 @@ class AuthStore with ChangeNotifier {
final response = await apiClient.get('/api/v1/auth/me');
if (response.statusCode == 200) {
final data = response.data;
if (data is Map<String, dynamic>) {
final user = data['user'] as Map<String, dynamic>?;
final root = response.data;
if (root is Map<String, dynamic>) {
// پاسخ API در فیلد data قرار دارد
final payload = (root['data'] is Map<String, dynamic>)
? root['data'] as Map<String, dynamic>
: root;
final user = payload['user'] as Map<String, dynamic>?;
final permsObj = payload['permissions'] as Map<String, dynamic>?;
Map<String, dynamic>? appPermissions;
bool isSuperAdmin = false;
int? userId;
if (user != null) {
final appPermissions = user['app_permissions'] as Map<String, dynamic>?;
final isSuperAdmin = appPermissions?['superadmin'] == true;
final userId = user['id'] as int?;
if (appPermissions != null) {
await saveAppPermissions(appPermissions, isSuperAdmin);
}
if (userId != null) {
_currentUserId = userId;
notifyListeners();
appPermissions = user['app_permissions'] as Map<String, dynamic>?;
userId = user['id'] as int?;
}
// fallback: اگر در permissions هم مقدار باشد از آن بخوان
if (!isSuperAdmin && permsObj != null) {
final pIs = permsObj['is_superadmin'];
if (pIs is bool) {
isSuperAdmin = pIs;
}
}
if (!isSuperAdmin && appPermissions != null) {
isSuperAdmin = appPermissions['superadmin'] == true;
}
// ذخیره در استور و لوکال
await saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
}
}
} catch (e) {
@ -506,3 +518,4 @@ class AuthStore with ChangeNotifier {
}

View file

@ -1099,6 +1099,38 @@
"accountTypePerson": "Person",
"accountTypeProduct": "Product",
"accountTypeService": "Service",
"accountTypeAccountingDocument": "Accounting Document"
"accountTypeAccountingDocument": "Accounting Document",
"printTemplatePublished": "Print template (Published)",
"noCustomTemplate": "— No custom template —",
"reload": "Reload",
"presetInvoicesList": "Invoices/List",
"presetInvoicesDetail": "Invoices/Detail",
"presetReceiptsPaymentsList": "ReceiptsPayments/List",
"presetReceiptsPaymentsDetail": "ReceiptsPayments/Detail",
"presetExpenseIncomeList": "ExpenseIncome/List",
"presetDocumentsList": "Documents/List",
"presetDocumentsDetail": "Documents/Detail",
"printPdf": "Print PDF",
"generating": "Generating...",
"pdfSuccess": "PDF generated successfully",
"pdfError": "Error generating PDF",
"printTemplate": "Print template",
"templateStandard": "Standard template",
"templateCompact": "Compact template",
"templateDetailed": "Detailed template",
"templateCustom": "Custom template"
,
"paymentGateways": "Payment Gateways",
"addPaymentGateway": "Add Payment Gateway",
"editPaymentGateway": "Edit Payment Gateway",
"provider": "Provider",
"displayName": "Display name",
"useSuggestedCallback": "Use suggested callback",
"successRedirectOptional": "success_redirect (optional)",
"failureRedirectOptional": "failure_redirect (optional)",
"operationSuccessful": "Operation completed successfully",
"callbackNote": "Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.",
"create": "Create",
"deletedSuccessfully": "Deleted successfully"
}

View file

@ -1,4 +1,35 @@
{
"printTemplatePublished": "قالب چاپ (منتشرشده)",
"noCustomTemplate": "— بدون قالب سفارشی —",
"reload": "بارگذاری مجدد",
"presetInvoicesList": "Invoices/List",
"presetInvoicesDetail": "Invoices/Detail",
"presetReceiptsPaymentsList": "ReceiptsPayments/List",
"presetReceiptsPaymentsDetail": "ReceiptsPayments/Detail",
"presetExpenseIncomeList": "ExpenseIncome/List",
"presetDocumentsList": "Documents/List",
"presetDocumentsDetail": "Documents/Detail",
"printPdf": "چاپ PDF",
"generating": "در حال تولید...",
"pdfSuccess": "فایل PDF با موفقیت تولید شد",
"pdfError": "خطا در تولید PDF",
"printTemplate": "قالب چاپ",
"templateStandard": "قالب استاندارد",
"templateCompact": "قالب فشرده",
"templateDetailed": "قالب تفصیلی",
"templateCustom": "قالب سفارشی",
"paymentGateways": "درگاه‌های پرداخت",
"addPaymentGateway": "ایجاد درگاه پرداخت",
"editPaymentGateway": "ویرایش درگاه پرداخت",
"provider": "ارائه‌دهنده",
"displayName": "نام نمایشی",
"useSuggestedCallback": "استفاده از کال‌بک پیشنهادی",
"successRedirectOptional": "success_redirect (اختیاری)",
"failureRedirectOptional": "failure_redirect (اختیاری)",
"operationSuccessful": "عملیات با موفقیت انجام شد",
"callbackNote": "نکته: پارامتر tx_id به‌صورت خودکار به callback اضافه می‌شود. پس از بازگشت، در صورت تنظیم success/failure redirect، کاربر به آدرس‌های مربوطه هدایت می‌شود.",
"create": "ایجاد",
"deletedSuccessfully": "با موفقیت حذف شد",
"@@locale": "fa",
"appTitle": "حسابیکس",
"login": "ورود",

View file

@ -5767,6 +5767,186 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Accounting Document'**
String get accountTypeAccountingDocument;
/// No description provided for @printTemplatePublished.
///
/// In en, this message translates to:
/// **'Print template (Published)'**
String get printTemplatePublished;
/// No description provided for @noCustomTemplate.
///
/// In en, this message translates to:
/// **'— No custom template —'**
String get noCustomTemplate;
/// No description provided for @reload.
///
/// In en, this message translates to:
/// **'Reload'**
String get reload;
/// No description provided for @presetInvoicesList.
///
/// In en, this message translates to:
/// **'Invoices/List'**
String get presetInvoicesList;
/// No description provided for @presetInvoicesDetail.
///
/// In en, this message translates to:
/// **'Invoices/Detail'**
String get presetInvoicesDetail;
/// No description provided for @presetReceiptsPaymentsList.
///
/// In en, this message translates to:
/// **'ReceiptsPayments/List'**
String get presetReceiptsPaymentsList;
/// No description provided for @presetReceiptsPaymentsDetail.
///
/// In en, this message translates to:
/// **'ReceiptsPayments/Detail'**
String get presetReceiptsPaymentsDetail;
/// No description provided for @presetExpenseIncomeList.
///
/// In en, this message translates to:
/// **'ExpenseIncome/List'**
String get presetExpenseIncomeList;
/// No description provided for @presetDocumentsList.
///
/// In en, this message translates to:
/// **'Documents/List'**
String get presetDocumentsList;
/// No description provided for @presetDocumentsDetail.
///
/// In en, this message translates to:
/// **'Documents/Detail'**
String get presetDocumentsDetail;
/// No description provided for @printPdf.
///
/// In en, this message translates to:
/// **'Print PDF'**
String get printPdf;
/// No description provided for @generating.
///
/// In en, this message translates to:
/// **'Generating...'**
String get generating;
/// No description provided for @pdfSuccess.
///
/// In en, this message translates to:
/// **'PDF generated successfully'**
String get pdfSuccess;
/// No description provided for @pdfError.
///
/// In en, this message translates to:
/// **'Error generating PDF'**
String get pdfError;
/// No description provided for @printTemplate.
///
/// In en, this message translates to:
/// **'Print template'**
String get printTemplate;
/// No description provided for @templateStandard.
///
/// In en, this message translates to:
/// **'Standard template'**
String get templateStandard;
/// No description provided for @templateCompact.
///
/// In en, this message translates to:
/// **'Compact template'**
String get templateCompact;
/// No description provided for @templateDetailed.
///
/// In en, this message translates to:
/// **'Detailed template'**
String get templateDetailed;
/// No description provided for @templateCustom.
///
/// In en, this message translates to:
/// **'Custom template'**
String get templateCustom;
/// No description provided for @paymentGateways.
///
/// In en, this message translates to:
/// **'Payment Gateways'**
String get paymentGateways;
/// No description provided for @addPaymentGateway.
///
/// In en, this message translates to:
/// **'Add Payment Gateway'**
String get addPaymentGateway;
/// No description provided for @editPaymentGateway.
///
/// In en, this message translates to:
/// **'Edit Payment Gateway'**
String get editPaymentGateway;
/// No description provided for @provider.
///
/// In en, this message translates to:
/// **'Provider'**
String get provider;
/// No description provided for @displayName.
///
/// In en, this message translates to:
/// **'Display name'**
String get displayName;
/// No description provided for @useSuggestedCallback.
///
/// In en, this message translates to:
/// **'Use suggested callback'**
String get useSuggestedCallback;
/// No description provided for @successRedirectOptional.
///
/// In en, this message translates to:
/// **'success_redirect (optional)'**
String get successRedirectOptional;
/// No description provided for @failureRedirectOptional.
///
/// In en, this message translates to:
/// **'failure_redirect (optional)'**
String get failureRedirectOptional;
/// No description provided for @operationSuccessful.
///
/// In en, this message translates to:
/// **'Operation completed successfully'**
String get operationSuccessful;
/// No description provided for @callbackNote.
///
/// In en, this message translates to:
/// **'Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.'**
String get callbackNote;
/// No description provided for @create.
///
/// In en, this message translates to:
/// **'Create'**
String get create;
}
class _AppLocalizationsDelegate

View file

@ -2921,4 +2921,95 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get accountTypeAccountingDocument => 'Accounting Document';
@override
String get printTemplatePublished => 'Print template (Published)';
@override
String get noCustomTemplate => '— No custom template —';
@override
String get reload => 'Reload';
@override
String get presetInvoicesList => 'Invoices/List';
@override
String get presetInvoicesDetail => 'Invoices/Detail';
@override
String get presetReceiptsPaymentsList => 'ReceiptsPayments/List';
@override
String get presetReceiptsPaymentsDetail => 'ReceiptsPayments/Detail';
@override
String get presetExpenseIncomeList => 'ExpenseIncome/List';
@override
String get presetDocumentsList => 'Documents/List';
@override
String get presetDocumentsDetail => 'Documents/Detail';
@override
String get printPdf => 'Print PDF';
@override
String get generating => 'Generating...';
@override
String get pdfSuccess => 'PDF generated successfully';
@override
String get pdfError => 'Error generating PDF';
@override
String get printTemplate => 'Print template';
@override
String get templateStandard => 'Standard template';
@override
String get templateCompact => 'Compact template';
@override
String get templateDetailed => 'Detailed template';
@override
String get templateCustom => 'Custom template';
@override
String get paymentGateways => 'Payment Gateways';
@override
String get addPaymentGateway => 'Add Payment Gateway';
@override
String get editPaymentGateway => 'Edit Payment Gateway';
@override
String get provider => 'Provider';
@override
String get displayName => 'Display name';
@override
String get useSuggestedCallback => 'Use suggested callback';
@override
String get successRedirectOptional => 'success_redirect (optional)';
@override
String get failureRedirectOptional => 'failure_redirect (optional)';
@override
String get operationSuccessful => 'Operation completed successfully';
@override
String get callbackNote =>
'Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.';
@override
String get create => 'Create';
}

View file

@ -2900,4 +2900,95 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get accountTypeAccountingDocument => 'سند حسابداری';
@override
String get printTemplatePublished => 'قالب چاپ (منتشرشده)';
@override
String get noCustomTemplate => '— بدون قالب سفارشی —';
@override
String get reload => 'بارگذاری مجدد';
@override
String get presetInvoicesList => 'Invoices/List';
@override
String get presetInvoicesDetail => 'Invoices/Detail';
@override
String get presetReceiptsPaymentsList => 'ReceiptsPayments/List';
@override
String get presetReceiptsPaymentsDetail => 'ReceiptsPayments/Detail';
@override
String get presetExpenseIncomeList => 'ExpenseIncome/List';
@override
String get presetDocumentsList => 'Documents/List';
@override
String get presetDocumentsDetail => 'Documents/Detail';
@override
String get printPdf => 'چاپ PDF';
@override
String get generating => 'در حال تولید...';
@override
String get pdfSuccess => 'فایل PDF با موفقیت تولید شد';
@override
String get pdfError => 'خطا در تولید PDF';
@override
String get printTemplate => 'قالب چاپ';
@override
String get templateStandard => 'قالب استاندارد';
@override
String get templateCompact => 'قالب فشرده';
@override
String get templateDetailed => 'قالب تفصیلی';
@override
String get templateCustom => 'قالب سفارشی';
@override
String get paymentGateways => 'درگاه‌های پرداخت';
@override
String get addPaymentGateway => 'ایجاد درگاه پرداخت';
@override
String get editPaymentGateway => 'ویرایش درگاه پرداخت';
@override
String get provider => 'ارائه‌دهنده';
@override
String get displayName => 'نام نمایشی';
@override
String get useSuggestedCallback => 'استفاده از کال‌بک پیشنهادی';
@override
String get successRedirectOptional => 'success_redirect (اختیاری)';
@override
String get failureRedirectOptional => 'failure_redirect (اختیاری)';
@override
String get operationSuccessful => 'عملیات با موفقیت انجام شد';
@override
String get callbackNote =>
'نکته: پارامتر tx_id به‌صورت خودکار به callback اضافه می‌شود. پس از بازگشت، در صورت تنظیم success/failure redirect، کاربر به آدرس‌های مربوطه هدایت می‌شود.';
@override
String get create => 'ایجاد';
}

View file

@ -25,8 +25,12 @@ import 'pages/business/users_permissions_page.dart';
import 'pages/business/accounts_page.dart';
import 'pages/business/bank_accounts_page.dart';
import 'pages/business/wallet_page.dart';
import 'pages/business/wallet_payment_result_page.dart';
import 'pages/admin/wallet_settings_page.dart';
import 'pages/admin/payment_gateways_page.dart';
import 'pages/business/invoices_list_page.dart';
import 'pages/business/new_invoice_page.dart';
import 'pages/business/edit_invoice_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/business_info_settings_page.dart';
import 'pages/business/reports_page.dart';
@ -56,6 +60,8 @@ import 'core/auth_store.dart';
import 'core/permission_guard.dart';
import 'widgets/simple_splash_screen.dart';
import 'widgets/url_tracker.dart';
import 'pages/business/opening_balance_page.dart';
import 'pages/business/report_templates_page.dart';
void main() {
// Use path-based routing instead of hash routing
@ -372,6 +378,11 @@ class _MyAppState extends State<MyApp> {
authStore: _authStore!,
),
),
GoRoute(
path: '/wallet/payment-result',
name: 'wallet_payment_result',
builder: (context, state) => WalletPaymentResultPage(authStore: _authStore!),
),
ShellRoute(
builder: (context, state, child) => ProfileShell(
authStore: _authStore!,
@ -430,22 +441,54 @@ class _MyAppState extends State<MyApp> {
path: '/user/profile/system-settings',
name: 'profile_system_settings',
builder: (context, state) {
// بررسی دسترسی SuperAdmin
// بررسی دسترسی تنظیمات سیستم (SuperAdmin یا مجوز system_settings)
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
if (!_authStore!.isSuperAdmin) {
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const SystemSettingsPage();
},
routes: [
GoRoute(
path: 'wallet',
name: 'system_settings_wallet',
builder: (context, state) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const WalletSettingsPage();
},
),
GoRoute(
path: 'payment-gateways',
name: 'system_settings_payment_gateways',
builder: (context, state) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const PaymentGatewaysPage();
},
),
GoRoute(
path: 'storage',
name: 'system_settings_storage',
builder: (context, state) {
if (_authStore == null || !_authStore!.isSuperAdmin) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const AdminStorageManagementPage();
@ -455,7 +498,11 @@ class _MyAppState extends State<MyApp> {
path: 'configuration',
name: 'system_settings_configuration',
builder: (context, state) {
if (_authStore == null || !_authStore!.isSuperAdmin) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const SystemConfigurationPage();
@ -465,7 +512,11 @@ class _MyAppState extends State<MyApp> {
path: 'users',
name: 'system_settings_users',
builder: (context, state) {
if (_authStore == null || !_authStore!.isSuperAdmin) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const UserManagementPage();
@ -475,7 +526,11 @@ class _MyAppState extends State<MyApp> {
path: 'logs',
name: 'system_settings_logs',
builder: (context, state) {
if (_authStore == null || !_authStore!.isSuperAdmin) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const SystemLogsPage();
@ -485,7 +540,11 @@ class _MyAppState extends State<MyApp> {
path: 'email',
name: 'system_settings_email',
builder: (context, state) {
if (_authStore == null || !_authStore!.isSuperAdmin) {
if (_authStore == null) {
return PermissionGuard.buildAccessDeniedPage();
}
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
if (!allowed) {
return PermissionGuard.buildAccessDeniedPage();
}
return const EmailSettingsPage();
@ -531,6 +590,19 @@ class _MyAppState extends State<MyApp> {
);
},
),
GoRoute(
path: '/business/:business_id/opening-balance',
name: 'business_opening_balance',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: OpeningBalancePage(
businessId: businessId,
authStore: _authStore!,
),
);
},
),
GoRoute(
path: '/business/:business_id/chart-of-accounts',
name: 'business_chart_of_accounts',
@ -593,6 +665,13 @@ class _MyAppState extends State<MyApp> {
);
},
),
GoRoute(
path: '/user/profile/system-settings/wallet',
name: 'system_wallet_settings',
pageBuilder: (context, state) => const NoTransitionPage(
child: WalletSettingsPage(),
),
),
GoRoute(
path: '/business/:business_id/invoice',
name: 'business_invoice',
@ -622,6 +701,22 @@ class _MyAppState extends State<MyApp> {
);
},
),
GoRoute(
path: '/business/:business_id/invoice/:invoice_id/edit',
name: 'business_edit_invoice',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
final invoiceId = int.parse(state.pathParameters['invoice_id']!);
return NoTransitionPage(
child: EditInvoicePage(
businessId: businessId,
invoiceId: invoiceId,
authStore: _authStore!,
calendarController: _calendarController!,
),
);
},
),
GoRoute(
path: '/business/:business_id/reports',
name: 'business_reports',
@ -864,6 +959,19 @@ class _MyAppState extends State<MyApp> {
);
},
),
GoRoute(
path: '/business/:business_id/report-templates',
name: 'business_report_templates',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: ReportTemplatesPage(
businessId: businessId,
authStore: _authStore!,
),
);
},
),
GoRoute(
path: '/business/:business_id/checks',
name: 'business_checks',

View file

@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/api_client.dart';
import '../../config/app_config.dart';
import '../../services/payment_gateway_service.dart';
class PaymentGatewaysPage extends StatefulWidget {
const PaymentGatewaysPage({super.key});
@override
State<PaymentGatewaysPage> createState() => _PaymentGatewaysPageState();
}
class _PaymentGatewaysPageState extends State<PaymentGatewaysPage> {
late final PaymentGatewayService _service;
bool _loading = true;
String? _error;
List<Map<String, dynamic>> _items = const <Map<String, dynamic>>[];
int? _editingId;
// Create form
final _formKey = GlobalKey<FormState>();
String _provider = 'zarinpal';
String _displayName = '';
bool _isActive = true;
bool _isSandbox = true;
final _merchantIdCtrl = TextEditingController();
final _terminalIdCtrl = TextEditingController();
final _callbackUrlCtrl = TextEditingController();
final _successRedirectCtrl = TextEditingController();
final _failureRedirectCtrl = TextEditingController();
bool _useSuggestedCallback = true;
@override
void initState() {
super.initState();
_service = PaymentGatewayService(ApiClient());
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final res = await _service.listAdmin();
setState(() => _items = res);
} catch (e) {
setState(() => _error = '$e');
} finally {
setState(() => _loading = false);
}
}
void _prefillForEdit(Map<String, dynamic> it) {
_editingId = int.tryParse('${it['id']}');
_provider = (it['provider'] ?? 'zarinpal').toString();
_displayName = (it['display_name'] ?? '').toString();
_isActive = it['is_active'] == true;
_isSandbox = it['is_sandbox'] == true;
_merchantIdCtrl.clear();
_terminalIdCtrl.clear();
_callbackUrlCtrl.clear();
_successRedirectCtrl.clear();
_failureRedirectCtrl.clear();
final cfg = (it['config'] is Map<String, dynamic>) ? it['config'] as Map<String, dynamic> : <String, dynamic>{};
if (_provider == 'zarinpal' && cfg['merchant_id'] != null) _merchantIdCtrl.text = '${cfg['merchant_id']}';
if (_provider == 'parsian' && cfg['terminal_id'] != null) _terminalIdCtrl.text = '${cfg['terminal_id']}';
if (cfg['callback_url'] != null) _callbackUrlCtrl.text = '${cfg['callback_url']}';
if (cfg['success_redirect'] != null) _successRedirectCtrl.text = '${cfg['success_redirect']}';
if (cfg['failure_redirect'] != null) _failureRedirectCtrl.text = '${cfg['failure_redirect']}';
}
Map<String, dynamic> _buildConfig() {
final cfg = <String, dynamic>{};
if (_provider == 'zarinpal') {
cfg['merchant_id'] = _merchantIdCtrl.text.trim();
cfg['callback_url'] = _callbackUrlCtrl.text.trim();
} else if (_provider == 'parsian') {
cfg['terminal_id'] = _terminalIdCtrl.text.trim();
cfg['callback_url'] = _callbackUrlCtrl.text.trim();
}
if (_successRedirectCtrl.text.trim().isNotEmpty) {
cfg['success_redirect'] = _successRedirectCtrl.text.trim();
}
if (_failureRedirectCtrl.text.trim().isNotEmpty) {
cfg['failure_redirect'] = _failureRedirectCtrl.text.trim();
}
return cfg;
}
Future<void> _submitCreate(BuildContext dialogCtx) async {
if (!(_formKey.currentState?.validate() ?? false)) return;
try {
await _service.createAdmin(
provider: _provider,
displayName: _displayName,
isActive: _isActive,
isSandbox: _isSandbox,
config: _buildConfig(),
);
if (mounted) {
Navigator.of(dialogCtx).pop();
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.save)));
}
await _load();
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
}
}
}
Future<void> _submitUpdate(BuildContext dialogCtx) async {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_editingId == null) return;
try {
await _service.updateAdmin(
gatewayId: _editingId!,
provider: _provider,
displayName: _displayName,
isActive: _isActive,
isSandbox: _isSandbox,
config: _buildConfig(),
);
if (mounted) {
Navigator.of(dialogCtx).pop();
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.updated)));
}
await _load();
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
}
}
}
void _openCreateDialog() {
_provider = 'zarinpal';
_displayName = '';
_isActive = true;
_isSandbox = true;
_merchantIdCtrl.clear();
_terminalIdCtrl.clear();
_callbackUrlCtrl.clear();
_successRedirectCtrl.clear();
_failureRedirectCtrl.clear();
_useSuggestedCallback = true;
_applySuggestedCallback();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Row(children: [const Icon(Icons.payment_outlined), const SizedBox(width: 8), const Text('ایجاد درگاه پرداخت')]),
content: SizedBox(
width: 500,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
value: _provider,
decoration: const InputDecoration(labelText: 'Provider'),
items: const [
DropdownMenuItem(value: 'zarinpal', child: Text('Zarinpal')),
DropdownMenuItem(value: 'parsian', child: Text('Parsian')),
],
onChanged: (v) {
setState(() {
_provider = v ?? 'zarinpal';
_applySuggestedCallback();
});
},
),
TextFormField(
decoration: const InputDecoration(labelText: 'نام نمایشی'),
onChanged: (v) => _displayName = v.trim(),
validator: (v) => (v == null || v.trim().isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
SwitchListTile(
title: Text(AppLocalizations.of(context).active),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
SwitchListTile(
title: const Text('Sandbox'),
value: _isSandbox,
onChanged: (v) => setState(() => _isSandbox = v),
),
Row(
children: [
Expanded(
child: CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('استفاده از کال‌بک پیشنهادی'),
value: _useSuggestedCallback,
onChanged: (v) {
setState(() {
_useSuggestedCallback = v ?? true;
_applySuggestedCallback();
});
},
),
),
],
),
if (_provider == 'zarinpal' || _provider == 'parsian') ...[
if (_provider == 'zarinpal')
TextFormField(
controller: _merchantIdCtrl,
decoration: const InputDecoration(labelText: 'merchant_id'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
if (_provider == 'parsian')
TextFormField(
controller: _terminalIdCtrl,
decoration: const InputDecoration(labelText: 'terminal_id'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
TextFormField(
controller: _callbackUrlCtrl,
decoration: const InputDecoration(labelText: 'callback_url'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
const SizedBox(height: 8),
Text(
'نکته: پارامتر tx_id به‌صورت خودکار به callback اضافه می‌شود. پس از بازگشت، در صورت تنظیم success/failure redirect، کاربر به آدرس‌های مربوطه هدایت می‌شود.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
TextFormField(
controller: _successRedirectCtrl,
decoration: const InputDecoration(labelText: 'success_redirect (اختیاری)'),
),
const SizedBox(height: 8),
TextFormField(
controller: _failureRedirectCtrl,
decoration: const InputDecoration(labelText: 'failure_redirect (اختیاری)'),
),
],
],
),
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(AppLocalizations.of(context).cancel)),
FilledButton.icon(onPressed: () => _submitCreate(ctx), icon: const Icon(Icons.save), label: Text(AppLocalizations.of(context).save)),
],
),
);
}
void _applySuggestedCallback() {
if (!_useSuggestedCallback) return;
final base = AppConfig.apiBaseUrl.replaceAll(RegExp(r'/+$'), '');
String path = '/api/v1/wallet/payments/callback/zarinpal';
if (_provider == 'parsian') {
path = '/api/v1/wallet/payments/callback/parsian';
}
_callbackUrlCtrl.text = '$base$path';
}
Future<void> _delete(int id) async {
try {
await _service.deleteAdmin(id);
await _load();
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.deletedSuccessfully)));
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.error}: $e')));
}
}
}
void _openEditDialog(Map<String, dynamic> item) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Row(children: [const Icon(Icons.edit_outlined), const SizedBox(width: 8), const Text('ویرایش درگاه پرداخت')]),
content: SizedBox(
width: 500,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
value: _provider,
decoration: const InputDecoration(labelText: 'Provider'),
items: const [
DropdownMenuItem(value: 'zarinpal', child: Text('Zarinpal')),
DropdownMenuItem(value: 'parsian', child: Text('Parsian')),
],
onChanged: (v) => setState(() => _provider = v ?? 'zarinpal'),
),
TextFormField(
initialValue: _displayName,
decoration: const InputDecoration(labelText: 'نام نمایشی'),
onChanged: (v) => _displayName = v.trim(),
validator: (v) => (v == null || v.trim().isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
SwitchListTile(
title: Text(AppLocalizations.of(context).active),
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
SwitchListTile(
title: const Text('Sandbox'),
value: _isSandbox,
onChanged: (v) => setState(() => _isSandbox = v),
),
if (_provider == 'zarinpal')
TextFormField(
controller: _merchantIdCtrl,
decoration: const InputDecoration(labelText: 'merchant_id'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
if (_provider == 'parsian')
TextFormField(
controller: _terminalIdCtrl,
decoration: const InputDecoration(labelText: 'terminal_id'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
TextFormField(
controller: _callbackUrlCtrl,
decoration: const InputDecoration(labelText: 'callback_url'),
validator: (v) => (v == null || v.isEmpty) ? AppLocalizations.of(context).requiredField : null,
),
const SizedBox(height: 8),
TextFormField(
controller: _successRedirectCtrl,
decoration: const InputDecoration(labelText: 'success_redirect (اختیاری)'),
),
const SizedBox(height: 8),
TextFormField(
controller: _failureRedirectCtrl,
decoration: const InputDecoration(labelText: 'failure_redirect (اختیاری)'),
),
],
),
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(AppLocalizations.of(context).cancel)),
FilledButton.icon(onPressed: () => _submitUpdate(ctx), icon: const Icon(Icons.save), label: Text(AppLocalizations.of(context).update)),
],
),
);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('درگاه‌های پرداخت'),
actions: [
IconButton(onPressed: _openCreateDialog, tooltip: t.add, icon: const Icon(Icons.add)),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: RefreshIndicator(
onRefresh: _load,
child: _items.isEmpty
? ListView(
padding: const EdgeInsets.all(24),
children: [
Center(child: Text(t.noDataFound)),
],
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (ctx, i) {
final it = _items[i];
return Card(
elevation: 1,
child: ListTile(
title: Text('${it['display_name']} (${it['provider']})'),
subtitle: Text('sandbox: ${it['is_sandbox']}${t.active}: ${it['is_active']}'),
trailing: Wrap(
spacing: 8,
children: [
IconButton(
tooltip: t.edit,
onPressed: () {
_prefillForEdit(it);
_openEditDialog(it);
},
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: t.delete,
onPressed: () => _delete(int.tryParse('${it['id']}') ?? 0),
icon: const Icon(Icons.delete_outline, color: Colors.red),
),
],
),
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/services/currency_service.dart';
import '../../core/api_client.dart';
import '../../services/system_settings_service.dart';
class WalletSettingsPage extends StatefulWidget {
const WalletSettingsPage({super.key});
@override
State<WalletSettingsPage> createState() => _WalletSettingsPageState();
}
class _WalletSettingsPageState extends State<WalletSettingsPage> {
final _formKey = GlobalKey<FormState>();
late final SystemSettingsService _settingsService;
late final CurrencyService _currencyService;
bool _loading = true;
String? _error;
String? _selectedCurrencyCode;
List<Map<String, dynamic>> _currencies = const <Map<String, dynamic>>[];
@override
void initState() {
super.initState();
final api = ApiClient();
_settingsService = SystemSettingsService(api);
_currencyService = CurrencyService(api);
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final settings = await _settingsService.getWalletSettings();
final list = await _currencyService.listCurrencies();
setState(() {
_currencies = list;
_selectedCurrencyCode = (settings['wallet_base_currency_code'] ?? 'IRR').toString();
});
} catch (e) {
setState(() => _error = '$e');
} finally {
setState(() => _loading = false);
}
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
try {
await _settingsService.setWalletBaseCurrencyCode(_selectedCurrencyCode ?? 'IRR');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ذخیره شد')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('تنظیمات کیف‌پول')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
value: _selectedCurrencyCode,
decoration: const InputDecoration(labelText: 'ارز پایه کیف‌پول'),
items: _currencies
.map((c) => DropdownMenuItem<String>(
value: (c['code'] ?? '').toString(),
child: Text('${c['title']} (${c['code']})'),
))
.toList(),
onChanged: (v) => setState(() => _selectedCurrencyCode = v),
validator: (v) => (v == null || v.isEmpty) ? 'انتخاب ارز الزامی است' : null,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('ذخیره'),
),
],
),
),
),
);
}
}

View file

@ -89,6 +89,9 @@ class _BankAccountsPageState extends State<BankAccountsPage> {
title: t.accounts,
excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
pdfEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'bank_accounts',
reportSubtype: 'list',
getExportParams: () => {'business_id': widget.businessId},
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),

View file

@ -424,6 +424,13 @@ class _BusinessShellState extends State<BusinessShell> {
path: '/business/${widget.businessId}/settings',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.templates,
icon: Icons.picture_as_pdf,
selectedIcon: Icons.picture_as_pdf,
path: '/business/${widget.businessId}/report-templates',
type: _MenuItemType.simple,
),
_MenuItem(
label: t.pluginMarketplace,
icon: Icons.store,

View file

@ -87,6 +87,9 @@ class _CashRegistersPageState extends State<CashRegistersPage> {
title: t.cashBox,
excelEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/excel',
pdfEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'cash_registers',
reportSubtype: 'list',
getExportParams: () => {'business_id': widget.businessId},
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),

View file

@ -407,7 +407,11 @@ class _DocumentsPageState extends State<DocumentsPage> {
enableMultiRowSelection: true,
showExportButtons: true,
showExcelExport: true,
showPdfExport: false,
pdfEndpoint: '/businesses/${widget.businessId}/documents/export/pdf',
showPdfExport: true,
businessId: widget.businessId,
reportModuleKey: 'documents',
reportSubtype: 'list',
defaultPageSize: 50,
pageSizeOptions: [20, 50, 100, 200],
onRowSelectionChanged: (rows) {

View file

@ -0,0 +1,547 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/auth_store.dart';
import '../../core/calendar_controller.dart';
import '../../widgets/permission/access_denied_page.dart';
import '../../widgets/invoice/code_field_widget.dart';
import '../../widgets/invoice/customer_combobox_widget.dart';
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../widgets/date_input_field.dart';
import '../../widgets/banking/currency_picker_widget.dart';
import '../../widgets/invoice/line_items_table.dart';
import '../../utils/number_formatters.dart';
import '../../models/invoice_type_model.dart';
import '../../models/customer_model.dart';
import '../../models/person_model.dart';
import '../../models/invoice_line_item.dart';
import '../../services/invoice_service.dart';
import '../../core/api_client.dart';
import '../../services/person_service.dart';
class EditInvoicePage extends StatefulWidget {
final int businessId;
final int invoiceId;
final AuthStore authStore;
final CalendarController calendarController;
const EditInvoicePage({
super.key,
required this.businessId,
required this.invoiceId,
required this.authStore,
required this.calendarController,
});
@override
State<EditInvoicePage> createState() => _EditInvoicePageState();
}
class _EditInvoicePageState extends State<EditInvoicePage> with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _loading = true;
String? _loadError;
// Header state
InvoiceType? _selectedInvoiceType;
String? _invoiceNumber;
DateTime? _invoiceDate;
int? _selectedCurrencyId;
String? _invoiceTitle;
bool _isProforma = false; // فقط نمایشی در ویرایش
bool _postInventory = true;
// Party selections (اختیاری برای نمایش؛ هنگام ذخیره از extra_info اصلی نگهداری میشود)
Customer? _selectedCustomer;
Person? _selectedSupplier;
// Lines
List<InvoiceLineItem> _lineItems = <InvoiceLineItem>[];
num _sumSubtotal = 0;
num _sumDiscount = 0;
num _sumTax = 0;
num _sumTotal = 0;
// For preserving and merging extra_info
Map<String, dynamic> _originalExtraInfo = <String, dynamic>{};
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_loadInvoice();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadInvoice() async {
setState(() {
_loading = true;
_loadError = null;
});
try {
final service = InvoiceService(apiClient: ApiClient());
final data = await service.getInvoice(businessId: widget.businessId, invoiceId: widget.invoiceId);
final item = Map<String, dynamic>.from(data['item'] ?? const {});
final String docType = (item['document_type']?.toString() ?? '');
final String typeValue = docType.startsWith('invoice_') ? docType.substring('invoice_'.length) : docType;
_selectedInvoiceType = InvoiceType.fromValue(typeValue) ?? InvoiceType.sales;
_invoiceNumber = item['code']?.toString();
_isProforma = item['is_proforma'] == true;
_invoiceDate = DateTime.tryParse(item['document_date']?.toString() ?? '') ?? DateTime.now();
_selectedCurrencyId = (item['currency_id'] as num?)?.toInt();
_invoiceTitle = item['description']?.toString();
// extra_info
_originalExtraInfo = Map<String, dynamic>.from(item['extra_info'] ?? const {});
_postInventory = (_originalExtraInfo['post_inventory'] is bool) ? _originalExtraInfo['post_inventory'] as bool : true;
// lines
final List<dynamic> lines = List<dynamic>.from(item['product_lines'] ?? const []);
num _toNum(dynamic v, {num fallback = 0}) {
if (v == null) return fallback;
if (v is num) return v;
return num.tryParse(v.toString()) ?? fallback;
}
int? _toInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v.toString());
}
_lineItems = lines.map<InvoiceLineItem>((raw) {
final Map<String, dynamic> r = Map<String, dynamic>.from(raw as Map);
final Map<String, dynamic> info = Map<String, dynamic>.from(r['extra_info'] ?? const {});
final num qty = _toNum(r['quantity']);
final num unitPrice = _toNum(info['unit_price']);
final num lineDiscount = _toNum(info['line_discount']);
final num taxAmount = _toNum(info['tax_amount']);
final String discountType = (info['discount_type']?.toString() ?? (info['discount_value'] != null ? 'amount' : 'amount'));
final num discountValue = _toNum(info['discount_value'], fallback: lineDiscount);
// اگر tax_rate موجود نبود، از نسبت tax_amount به مبلغ مشمول مالیات تخمین بزن
num taxRate = _toNum(info['tax_rate']);
if (taxRate <= 0) {
final taxable = (qty * unitPrice) - discountValue;
if (taxAmount > 0 && taxable > 0) {
taxRate = (taxAmount / taxable) * 100;
}
}
return InvoiceLineItem(
productId: _toInt(r['product_id']),
productName: r['product_name']?.toString(),
selectedUnit: info['unit']?.toString(),
quantity: qty,
unitPriceSource: 'manual',
unitPrice: unitPrice,
discountType: discountType,
discountValue: discountValue,
taxRate: taxRate,
description: r['description']?.toString(),
trackInventory: false,
warehouseId: null,
);
}).toList();
_recalculateTotals();
// تلاش برای مقداردهی اولیه طرف حساب بر اساس person_id (از سرویس اشخاص)
try {
final pid = (_originalExtraInfo['person_id'] as num?)?.toInt();
if (pid != null) {
final ps = PersonService(apiClient: ApiClient());
final person = await ps.getPerson(pid);
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) {
_selectedCustomer = Customer(
id: person.id!,
name: person.displayName,
code: person.code?.toString(),
phone: person.mobile ?? person.phone,
email: person.email,
address: person.address,
isActive: person.isActive,
createdAt: person.createdAt,
);
} else if (_selectedInvoiceType == InvoiceType.purchase || _selectedInvoiceType == InvoiceType.purchaseReturn) {
_selectedSupplier = person;
}
}
} catch (_) {}
if (mounted) {
setState(() {
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loadError = e.toString();
_loading = false;
});
}
}
}
void _recalculateTotals() {
_sumSubtotal = _lineItems.fold<num>(0, (acc, e) => acc + e.subtotal);
_sumDiscount = _lineItems.fold<num>(0, (acc, e) => acc + e.discountAmount);
_sumTax = _lineItems.fold<num>(0, (acc, e) => acc + e.taxAmount);
_sumTotal = _lineItems.fold<num>(0, (acc, e) => acc + e.total);
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
if (!widget.authStore.canWriteSection('invoices')) {
return AccessDeniedPage(message: t.accessDenied);
}
return Scaffold(
appBar: AppBar(
title: const Text('ویرایش فاکتور'),
actions: [
IconButton(
tooltip: 'ذخیره تغییرات',
onPressed: _loading ? null : _saveChanges,
icon: const Icon(Icons.save),
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.info_outline), text: 'اطلاعات فاکتور'),
Tab(icon: Icon(Icons.inventory_2_outlined), text: 'کالاها و خدمات'),
Tab(icon: Icon(Icons.settings_outlined), text: 'تنظیمات'),
],
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _loadError != null
? Center(child: Text(_loadError!))
: TabBarView(
controller: _tabController,
children: [
_buildInvoiceInfoTab(),
_buildProductsTab(),
_buildSettingsTab(),
],
),
);
}
Widget _buildInvoiceInfoTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LayoutBuilder(
builder: (context, constraints) {
return Column(
children: [
// سطر اول
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildReadOnlyField(label: 'نوع فاکتور', value: _selectedInvoiceType?.label ?? '-'),
),
const SizedBox(width: 12),
Expanded(
child: CodeFieldWidget(
initialValue: _invoiceNumber,
onChanged: (_) {},
isRequired: true,
label: 'شماره فاکتور',
hintText: 'کد فاکتور',
autoGenerateCode: true, // فقط نمایشی در ویرایش
),
),
const SizedBox(width: 12),
Expanded(
child: DateInputField(
value: _invoiceDate,
labelText: 'تاریخ فاکتور *',
hintText: 'انتخاب تاریخ فاکتور',
calendarController: widget.calendarController,
onChanged: (date) {
setState(() {
_invoiceDate = date;
});
},
),
),
const SizedBox(width: 12),
Expanded(
child: CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _selectedCurrencyId,
onChanged: (currencyId) {
setState(() {
_selectedCurrencyId = currencyId;
});
},
label: 'ارز فاکتور',
hintText: 'انتخاب ارز فاکتور',
),
),
],
),
const SizedBox(height: 16),
// طرف حساب فقط نمایشی در صورت امکان
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn)
CustomerComboboxWidget(
selectedCustomer: _selectedCustomer,
onCustomerChanged: (c) => setState(() => _selectedCustomer = c),
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
label: 'مشتری',
hintText: _selectedCustomer?.name ?? 'انتخاب مشتری',
),
if (_selectedInvoiceType == InvoiceType.purchase || _selectedInvoiceType == InvoiceType.purchaseReturn) ...[
const SizedBox(height: 16),
PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: _selectedSupplier,
onChanged: (p) => setState(() => _selectedSupplier = p),
isRequired: false,
label: 'تامین‌کننده',
hintText: 'انتخاب تامین‌کننده',
personTypes: const ['تامین‌کننده', 'فروشنده'],
searchHint: 'جست‌وجو در تامین‌کنندگان...',
),
],
const SizedBox(height: 16),
TextFormField(
initialValue: _invoiceTitle,
onChanged: (v) => setState(() => _invoiceTitle = v.trim().isEmpty ? null : v.trim()),
decoration: const InputDecoration(
labelText: 'عنوان فاکتور',
hintText: 'مثال: فروش محصولات',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
_buildReadOnlyField(label: 'وضعیت', value: _isProforma ? 'پیش‌فاکتور' : 'قطعی'),
],
);
},
),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildReadOnlyField({required String label, required String value}) {
return TextFormField(
initialValue: value,
readOnly: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
Widget _buildProductsTab() {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InvoiceLineItemsTable(
businessId: widget.businessId,
selectedCurrencyId: _selectedCurrencyId,
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
postInventory: _postInventory,
initialRows: _lineItems,
onChanged: (rows) {
setState(() {
_lineItems = rows;
_recalculateTotals();
});
},
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 16,
runSpacing: 8,
children: [
Text('جمع مبلغ: ${formatWithThousands(_sumSubtotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
Text('جمع تخفیف: ${formatWithThousands(_sumDiscount, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
Text('جمع مالیات: ${formatWithThousands(_sumTax, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
Text('جمع کل: ${formatWithThousands(_sumTotal, decimalPlaces: 0)}', style: Theme.of(context).textTheme.bodyLarge),
],
),
),
],
),
),
),
);
}
Widget _buildSettingsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('ثبت اسناد انبار'),
subtitle: const Text('در صورت غیرفعال‌سازی، حرکات موجودی ثبت نمی‌شوند و کنترل کسری انجام نمی‌گردد'),
value: _postInventory,
onChanged: (v) => setState(() => _postInventory = v),
),
const SizedBox(height: 8),
const Text('توجه: در ویرایش فاکتور، حواله‌های انبار به صورت خودکار بازسازی نمی‌شوند. لطفاً پس از ذخیره تغییرات، حواله‌های مرتبط را بررسی کنید.'),
],
),
),
),
],
),
),
),
);
}
Future<void> _saveChanges() async {
final payloadOrError = _validateAndBuildPayload();
if (payloadOrError is String) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(payloadOrError)),
);
return;
}
final payload = payloadOrError as Map<String, dynamic>;
try {
final service = InvoiceService(apiClient: ApiClient());
await service.updateInvoice(
businessId: widget.businessId,
invoiceId: widget.invoiceId,
payload: payload,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('تغییرات فاکتور با موفقیت ذخیره شد')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در ذخیره تغییرات: $e')),
);
}
}
dynamic _validateAndBuildPayload() {
if (_selectedInvoiceType == null) {
return 'نوع فاکتور نامعتبر است';
}
if (_invoiceDate == null) {
return 'تاریخ فاکتور الزامی است';
}
if (_selectedCurrencyId == null) {
return 'ارز فاکتور الزامی است';
}
if (_lineItems.isEmpty) {
return 'حداقل یک ردیف کالا/خدمت وارد کنید';
}
// ساخت extra_info با حفظ اطلاعات قبلی
final mergedExtra = <String, dynamic>{..._originalExtraInfo};
mergedExtra['post_inventory'] = _postInventory;
mergedExtra['totals'] = {
'gross': _sumSubtotal,
'discount': _sumDiscount,
'tax': _sumTax,
'net': _sumTotal,
};
String _convertInvoiceTypeToApi(InvoiceType type) => 'invoice_${type.value}';
final payload = <String, dynamic>{
'invoice_type': _convertInvoiceTypeToApi(_selectedInvoiceType!), // جهت سازگاری حسابها
'document_date': _invoiceDate!.toIso8601String().split('T')[0],
'currency_id': _selectedCurrencyId,
'extra_info': mergedExtra,
if ((_invoiceTitle ?? '').isNotEmpty) 'description': _invoiceTitle,
'lines': _lineItems.map((e) => _serializeLineItem(e)).toList(),
};
return payload;
}
Map<String, dynamic> _serializeLineItem(InvoiceLineItem e) {
String? movement;
if (_selectedInvoiceType == InvoiceType.sales ||
_selectedInvoiceType == InvoiceType.purchaseReturn ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.waste) {
movement = 'out';
} else if (_selectedInvoiceType == InvoiceType.purchase ||
_selectedInvoiceType == InvoiceType.salesReturn) {
movement = 'in';
}
final lineDiscount = e.discountAmount;
final taxAmount = e.taxAmount;
final lineTotal = e.total;
return <String, dynamic>{
'product_id': e.productId,
'quantity': e.quantity,
if ((e.description ?? '').isNotEmpty) 'description': e.description,
'extra_info': {
'unit_price': e.unitPrice,
'line_discount': lineDiscount,
'tax_amount': taxAmount,
'line_total': lineTotal,
if (movement != null) 'movement': movement,
'unit': e.selectedUnit ?? e.mainUnit,
'unit_price_source': e.unitPriceSource,
'discount_type': e.discountType,
'discount_value': e.discountValue,
'tax_rate': e.taxRate,
},
};
}
}

View file

@ -228,6 +228,9 @@ class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> {
title: 'هزینه و درآمد',
excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'expense_income',
reportSubtype: 'list',
// دکمه حذف گروهی در هدر جدول
customHeaderActions: [
Tooltip(

View file

@ -61,6 +61,9 @@ class _InventoryTransfersPageState extends State<InventoryTransfersPage> {
endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query',
excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel',
pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'inventory_transfers',
reportSubtype: 'list',
title: 'انتقال موجودی بین انبارها',
showBackButton: true,
showSearch: false,

View file

@ -219,6 +219,9 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
title: 'فاکتورها',
excelEndpoint: '/invoices/business/${widget.businessId}/export/excel',
pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'invoices',
reportSubtype: 'list',
columns: [
// عملیات
ActionColumn(
@ -230,6 +233,12 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
label: 'مشاهده',
onTap: (item) => _onView(item as InvoiceListItem),
),
if (widget.authStore.canWriteSection('invoices'))
DataTableAction(
icon: Icons.edit,
label: 'ویرایش',
onTap: (item) => _onEdit(item as InvoiceListItem),
),
],
),
// کد سند
@ -305,6 +314,19 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
),
);
}
Future<void> _onEdit(InvoiceListItem item) async {
if (!mounted) return;
await context.pushNamed(
'business_edit_invoice',
pathParameters: {
'business_id': widget.businessId.toString(),
'invoice_id': item.id.toString(),
},
);
if (!mounted) return;
_refreshData();
}
}

View file

@ -366,6 +366,9 @@ class _KardexPageState extends State<KardexPage> {
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'kardex',
reportSubtype: 'list',
columns: [
DateColumn('document_date', 'تاریخ سند',
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),

View file

@ -971,7 +971,6 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
'tax_amount': taxAmount,
'line_total': lineTotal,
if (movement != null) 'movement': movement,
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
// اطلاعات اضافی برای ردیابی
'unit': e.selectedUnit ?? e.mainUnit,
'unit_price_source': e.unitPriceSource,
@ -1048,6 +1047,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
businessId: widget.businessId,
calendarController: widget.calendarController,
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
selectedCurrencyId: _selectedCurrencyId,
onChanged: (transactions) {
setState(() {
_transactions = transactions;
@ -1188,15 +1188,15 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
// انتخاب قالب چاپ
DropdownButtonFormField<String>(
initialValue: _selectedPrintTemplate,
decoration: const InputDecoration(
labelText: 'قالب چاپ',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: AppLocalizations.of(context).printTemplate,
border: const OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'standard', child: Text('قالب استاندارد')),
DropdownMenuItem(value: 'compact', child: Text('قالب فشرده')),
DropdownMenuItem(value: 'detailed', child: Text('قالب تفصیلی')),
DropdownMenuItem(value: 'custom', child: Text('قالب سفارشی')),
items: [
DropdownMenuItem(value: 'standard', child: Text(AppLocalizations.of(context).templateStandard)),
DropdownMenuItem(value: 'compact', child: Text(AppLocalizations.of(context).templateCompact)),
DropdownMenuItem(value: 'detailed', child: Text(AppLocalizations.of(context).templateDetailed)),
DropdownMenuItem(value: 'custom', child: Text(AppLocalizations.of(context).templateCustom)),
],
onChanged: (value) {
setState(() {

View file

@ -0,0 +1,816 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/permission_guard.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/services/opening_balance_service.dart';
import 'package:hesabix_ui/widgets/invoice/bank_account_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/cash_register_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/petty_cash_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/product_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/warehouse_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/account_combobox_widget.dart';
import 'package:hesabix_ui/models/account_model.dart';
import 'package:hesabix_ui/services/account_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class OpeningBalancePage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
const OpeningBalancePage({super.key, required this.businessId, required this.authStore});
@override
State<OpeningBalancePage> createState() => _OpeningBalancePageState();
}
class _OpeningBalancePageState extends State<OpeningBalancePage> {
late final OpeningBalanceService _service;
bool _loading = false;
Map<String, dynamic>? _document;
// Local form state
final List<Map<String, dynamic>> _bankCashPettyLines = <Map<String, dynamic>>[];
final List<Map<String, dynamic>> _personLines = <Map<String, dynamic>>[];
final List<Map<String, dynamic>> _inventoryLines = <Map<String, dynamic>>[];
final List<Map<String, dynamic>> _otherAccountLines = <Map<String, dynamic>>[];
bool _autoBalance = true;
int? _inventoryAccountId;
int? _equityAccountId;
Account? _inventoryAccount;
Account? _equityAccount;
int? _bankControlAccountId; // 10203
int? _cashControlAccountId; // 10202
int? _pettyControlAccountId; // 10201
int? _personReceivableAccountId; // 10401
int? _personPayableAccountId; // 20201
@override
void initState() {
super.initState();
_service = OpeningBalanceService(ApiClient());
_load();
_loadDefaultAccounts();
_loadSavedDefaults();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final doc = await _service.fetch(businessId: widget.businessId);
setState(() => _document = doc);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در دریافت تراز افتتاحیه: $e')),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadDefaultAccounts() async {
try {
final accountService = AccountService();
Future<Account?> findByCode(String code) async {
final res = await accountService.searchAccounts(businessId: widget.businessId, searchQuery: code, limit: 50);
final items = (res['items'] as List<dynamic>? ?? const <dynamic>[]);
for (final it in items) {
final acc = Account.fromJson(Map<String, dynamic>.from(it as Map));
if (acc.code == code) return acc;
}
return null;
}
final inv = await findByCode('10101');
final bank = await findByCode('10203');
final cash = await findByCode('10202');
final petty = await findByCode('10201');
final ar = await findByCode('10401');
final ap = await findByCode('20201');
// برای تراز خودکار: اگر 30201 نبود، سرمایه اولیه 30101 را استفاده کن
final equity = (await findByCode('30201')) ?? (await findByCode('30101'));
if (!mounted) return;
setState(() {
_inventoryAccount = inv;
_inventoryAccountId = inv?.id;
_bankControlAccountId = bank?.id;
_cashControlAccountId = cash?.id;
_pettyControlAccountId = petty?.id;
_personReceivableAccountId = ar?.id;
_personPayableAccountId = ap?.id;
_equityAccount = equity;
_equityAccountId = equity?.id;
});
} catch (_) {
// نادیده بگیر؛ کاربر میتواند دستی انتخاب کند
}
}
Future<void> _loadSavedDefaults() async {
try {
final prefs = await SharedPreferences.getInstance();
String k(String name) => 'ob_default_${widget.businessId}_$name';
int? gi(String name) {
final v = prefs.getInt(k(name));
return v is int && v > 0 ? v : null;
}
setState(() {
_inventoryAccountId = gi('inventory_account_id') ?? _inventoryAccountId;
_equityAccountId = gi('equity_account_id') ?? _equityAccountId;
_bankControlAccountId = gi('bank_control_id') ?? _bankControlAccountId;
_cashControlAccountId = gi('cash_control_id') ?? _cashControlAccountId;
_pettyControlAccountId = gi('petty_control_id') ?? _pettyControlAccountId;
_personReceivableAccountId = gi('ar_control_id') ?? _personReceivableAccountId;
_personPayableAccountId = gi('ap_control_id') ?? _personPayableAccountId;
});
} catch (_) {}
}
Future<void> _saveDefault(String name, int? id) async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'ob_default_${widget.businessId}_$name';
if (id == null || id <= 0) {
await prefs.remove(key);
} else {
await prefs.setInt(key, id);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
// Guard: view permission
if (!widget.authStore.canReadSection('opening_balance')) {
return PermissionGuard.buildAccessDeniedPage();
}
final canEdit = widget.authStore.hasBusinessPermission('opening_balance', 'edit');
final validation = _computeValidation();
final isPosted = (_document?['extra_info']?['posted'] ?? false) == true;
return Scaffold(
appBar: AppBar(
title: Text(t.openingBalance),
actions: [
TextButton.icon(
onPressed: (_loading || !canEdit || (validation['save_disabled'] == true) || isPosted) ? null : _save,
icon: const Icon(Icons.save),
label: Text(t.save),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: (_loading || !canEdit || (validation['finalize_disabled'] == true) || isPosted) ? null : _post,
icon: const Icon(Icons.how_to_reg),
label: const Text('نهایی‌سازی'),
),
const SizedBox(width: 12),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _buildContent(t),
);
}
Widget _buildContent(AppLocalizations t) {
final totals = _calcTotals();
_computeValidation();
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t.openingBalance, style: Theme.of(context).textTheme.titleLarge),
if ((_document?['extra_info']?['posted'] ?? false) == true)
const Chip(label: Text('نهایی شده')),
],
),
const SizedBox(height: 16),
_buildValidationWarnings(),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('سال مالی: ${_document?['fiscal_year_title'] ?? '-'}'),
const SizedBox(height: 8),
Text('تاریخ سند: ${_document?['document_date'] ?? '-'}'),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: Text('جمع بدهکار: ${totals['debit']?.toStringAsFixed(2) ?? '0'}')),
Expanded(child: Text('جمع بستانکار: ${totals['credit']?.toStringAsFixed(2) ?? '0'}')),
Expanded(child: Text('اختلاف: ${(totals['diff'] as double).toStringAsFixed(2)}')),
],
),
const SizedBox(height: 8),
Row(
children: [
Switch(value: _autoBalance, onChanged: (v) => setState(() => _autoBalance = v)),
const SizedBox(width: 8),
const Text('بستن خودکار اختلاف به حقوق صاحبان سهام'),
],
),
const SizedBox(height: 8),
_buildQuickSelectors(),
],
),
),
),
const SizedBox(height: 16),
Expanded(child: _buildTabs(t)),
],
),
);
}
Widget _buildTabs(AppLocalizations t) {
return DefaultTabController(
length: 4,
child: Column(
children: [
const TabBar(
isScrollable: true,
tabs: [
Tab(text: 'بانک/صندوق/تنخواه'),
Tab(text: 'اشخاص'),
Tab(text: 'کالا'),
Tab(text: 'سایر حساب‌ها'),
],
),
const SizedBox(height: 12),
Expanded(
child: TabBarView(
children: [
_buildBankCashPettyTab(),
_buildPersonsTab(),
_buildInventoryTab(),
_buildOtherAccountsTab(),
],
),
),
],
),
);
}
Widget _buildBankCashPettyTab() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: BankAccountComboboxWidget(
businessId: widget.businessId,
selectedAccountId: null,
onChanged: (opt) {
if (opt == null) return;
_bankCashPettyLines.add({'type': 'bank', 'refId': opt.id, 'amount': 0.0});
setState(() {});
},
label: 'افزودن بانک',
hintText: 'انتخاب و افزودن بانک',
filterCurrencyId: widget.authStore.selectedCurrencyId,
),
),
const SizedBox(width: 8),
Expanded(
child: CashRegisterComboboxWidget(
businessId: widget.businessId,
selectedRegisterId: null,
onChanged: (opt) {
if (opt == null) return;
_bankCashPettyLines.add({'type': 'cash', 'refId': opt.id, 'amount': 0.0});
setState(() {});
},
label: 'افزودن صندوق',
hintText: 'انتخاب و افزودن صندوق',
filterCurrencyId: widget.authStore.selectedCurrencyId,
),
),
const SizedBox(width: 8),
Expanded(
child: PettyCashComboboxWidget(
businessId: widget.businessId,
selectedPettyCashId: null,
onChanged: (opt) {
if (opt == null) return;
_bankCashPettyLines.add({'type': 'petty', 'refId': opt.id, 'amount': 0.0});
setState(() {});
},
label: 'افزودن تنخواه',
hintText: 'انتخاب و افزودن تنخواه',
filterCurrencyId: widget.authStore.selectedCurrencyId,
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
itemCount: _bankCashPettyLines.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final m = _bankCashPettyLines[index];
return ListTile(
leading: Icon(m['type'] == 'bank' ? Icons.account_balance : (m['type'] == 'cash' ? Icons.point_of_sale : Icons.wallet)),
title: Text('${m['type']} - ${m['refId']}'),
subtitle: TextField(
decoration: const InputDecoration(isDense: true, labelText: 'مبلغ'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (v) {
m['amount'] = double.tryParse(v.replaceAll(',', '')) ?? 0.0;
setState(() {});
},
),
trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: () { _bankCashPettyLines.removeAt(index); setState(() {}); }),
);
},
),
),
],
);
}
Widget _buildPersonsTab() {
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: PersonComboboxWidget(
businessId: widget.businessId,
onChanged: (p) {
if (p == null) return;
_personLines.add({'personId': p.id, 'debit': 0.0, 'credit': 0.0});
setState(() {});
},
label: 'افزودن شخص',
searchHint: 'نام/کد/تلفن...',
),
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
itemCount: _personLines.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final m = _personLines[index];
return ListTile(
leading: const Icon(Icons.person_outline),
title: Text('شخص #${m['personId']}'),
subtitle: Row(
children: [
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'بدهکار'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['debit'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
const SizedBox(width: 8),
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'بستانکار'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['credit'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
],
),
trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: () { _personLines.removeAt(index); setState(() {}); }),
);
},
),
),
],
);
}
Widget _buildInventoryTab() {
return Column(
children: [
Row(
children: [
Expanded(
flex: 2,
child: ProductComboboxWidget(
businessId: widget.businessId,
onChanged: (p) {
if (p == null) return;
_inventoryLines.add({'product': p, 'warehouseId': null, 'quantity': 0.0, 'cost_price': 0.0});
setState(() {});
},
label: 'افزودن کالا',
),
),
const SizedBox(width: 8),
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: _inventoryAccount,
onChanged: (acc) {
_inventoryAccount = acc;
_inventoryAccountId = acc?.id;
setState(() {});
},
label: 'حساب موجودی',
hintText: 'انتخاب حساب موجودی کالا',
isRequired: false,
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView.separated(
itemCount: _inventoryLines.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final m = _inventoryLines[index];
return ListTile(
leading: const Icon(Icons.inventory_outlined),
title: Text('${m['product']?['code'] ?? ''} - ${m['product']?['name'] ?? ''}'),
subtitle: Row(
children: [
Expanded(child: WarehouseComboboxWidget(businessId: widget.businessId, selectedWarehouseId: m['warehouseId'] as int?, onChanged: (wid) { m['warehouseId'] = wid; setState(() {}); })),
const SizedBox(width: 8),
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'تعداد'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['quantity'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
const SizedBox(width: 8),
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'بهای واحد'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['cost_price'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
],
),
trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: () { _inventoryLines.removeAt(index); setState(() {}); }),
);
},
),
),
],
);
}
Widget _buildOtherAccountsTab() {
return Column(
children: [
Row(
children: [
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
if (acc != null) {
_otherAccountLines.add({'account': acc, 'debit': 0.0, 'credit': 0.0});
setState(() {});
}
},
label: 'افزودن حساب',
hintText: 'جستجو و انتخاب حساب',
),
),
const SizedBox(width: 8),
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: _equityAccount,
onChanged: (acc) {
_equityAccount = acc;
_equityAccountId = acc?.id;
setState(() {});
},
label: 'حساب حقوق صاحبان سهام',
hintText: 'انتخاب حساب سرمایه/سنواتی',
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView.builder(
itemCount: _otherAccountLines.length,
itemBuilder: (context, index) {
final m = _otherAccountLines[index];
return ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined),
title: Text(m['account'] != null ? (m['account'] as Account).displayName : 'حساب'),
subtitle: Row(
children: [
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'بدهکار'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['debit'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
const SizedBox(width: 8),
Expanded(child: TextField(decoration: const InputDecoration(isDense: true, labelText: 'بستانکار'), keyboardType: const TextInputType.numberWithOptions(decimal: true), onChanged: (v) { m['credit'] = double.tryParse(v) ?? 0.0; setState(() {}); })),
],
),
trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: () { _otherAccountLines.removeAt(index); setState(() {}); }),
);
},
),
),
],
);
}
Map<String, double> _calcTotals() {
double debit = 0.0;
double credit = 0.0;
for (final m in _bankCashPettyLines) {
debit += (m['amount'] as double? ?? 0.0);
}
for (final m in _personLines) {
debit += (m['debit'] as double? ?? 0.0);
credit += (m['credit'] as double? ?? 0.0);
}
for (final m in _otherAccountLines) {
final d = (m['debit'] as double? ?? 0.0);
final c = (m['credit'] as double? ?? 0.0);
if (d <= 0 && c <= 0) continue;
final acc = m['account'] as Account?;
if (acc?.id == null) continue;
debit += d;
credit += c;
}
double invValue = 0.0;
for (final m in _inventoryLines) {
final q = (m['quantity'] as double? ?? 0.0);
final c = (m['cost_price'] as double? ?? 0.0);
invValue += (q * c);
}
debit += invValue;
return {'debit': debit, 'credit': credit, 'diff': debit - credit};
}
Map<String, bool> _computeValidation() {
final totals = _calcTotals();
final diff = (totals['diff'] ?? 0.0).abs();
final needsInventoryAccount = _inventoryLines.isNotEmpty && _inventoryAccountId == null;
final canAutoBalance = _autoBalance && _equityAccountId != null;
final balanced = diff <= 0.01 || canAutoBalance;
final saveDisabled = needsInventoryAccount; // برای جلوگیری از ذخیره ناسالم با خطوط موجودی بدون حساب
final finalizeDisabled = needsInventoryAccount || !balanced;
return {
'save_disabled': saveDisabled,
'finalize_disabled': finalizeDisabled,
};
}
Widget _buildValidationWarnings() {
final List<Widget> msgs = [];
if (_inventoryLines.isNotEmpty && _inventoryAccountId == null) {
msgs.add(_warn('برای ثبت موجودی ابتدای دوره، انتخاب «حساب موجودی» الزامی است.'));
}
final totals = _calcTotals();
final diff = (totals['diff'] ?? 0.0);
if (diff.abs() > 0.01) {
if (!_autoBalance) {
msgs.add(_warn('سند متوازن نیست. اختلاف ${diff.toStringAsFixed(2)}. برای نهایی‌سازی، تراز را برابر کنید یا Auto-balance را روشن کنید.'));
} else if (_autoBalance && _equityAccountId == null) {
msgs.add(_warn('Auto-balance فعال است اما «حساب حقوق صاحبان سهام» انتخاب نشده است.'));
}
}
if (msgs.isEmpty) return const SizedBox.shrink();
return Column(children: msgs);
}
Widget _warn(String text) {
final cs = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: cs.errorContainer.withValues(alpha: 0.3),
border: Border.all(color: cs.error.withValues(alpha: 0.6)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, color: cs.error),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
),
);
}
// Deprecated helpers removed
Widget _buildQuickSelectors() {
final textStyle = Theme.of(context).textTheme.bodyMedium;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('حساب‌های کلیدی (می‌توانید سریع تغییر دهید):', style: textStyle),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: _inventoryAccount,
onChanged: (acc) {
setState(() {
_inventoryAccount = acc;
_inventoryAccountId = acc?.id;
});
_saveDefault('inventory_account_id', _inventoryAccountId);
},
label: 'حساب موجودی',
hintText: 'انتخاب حساب موجودی (مثل 10101)',
),
),
const SizedBox(width: 8),
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: _equityAccount,
onChanged: (acc) {
setState(() {
_equityAccount = acc;
_equityAccountId = acc?.id;
});
_saveDefault('equity_account_id', _equityAccountId);
},
label: 'حساب حقوق صاحبان سهام',
hintText: 'انتخاب سرمایه/سنواتی (مثل 30201/30101)',
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
setState(() {
_bankControlAccountId = acc?.id;
});
_saveDefault('bank_control_id', _bankControlAccountId);
},
label: 'حساب کنترل بانک',
hintText: 'مثال: 10203',
),
),
const SizedBox(width: 8),
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
setState(() {
_cashControlAccountId = acc?.id;
});
_saveDefault('cash_control_id', _cashControlAccountId);
},
label: 'حساب کنترل صندوق',
hintText: 'مثال: 10202',
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
setState(() {
_pettyControlAccountId = acc?.id;
});
_saveDefault('petty_control_id', _pettyControlAccountId);
},
label: 'حساب کنترل تنخواه',
hintText: 'مثال: 10201',
),
),
const SizedBox(width: 8),
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
setState(() {
_personReceivableAccountId = acc?.id;
});
_saveDefault('ar_control_id', _personReceivableAccountId);
},
label: 'حساب دریافتنی اشخاص',
hintText: 'مثال: 10401',
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: null,
onChanged: (acc) {
setState(() {
_personPayableAccountId = acc?.id;
});
_saveDefault('ap_control_id', _personPayableAccountId);
},
label: 'حساب پرداختنی اشخاص',
hintText: 'مثال: 20201',
),
),
const SizedBox(width: 8),
const Expanded(child: SizedBox.shrink()),
],
),
],
);
}
Future<void> _save() async {
final accountLines = <Map<String, dynamic>>[];
for (final m in _bankCashPettyLines) {
final amount = (m['amount'] as double? ?? 0.0);
if (amount <= 0) continue;
accountLines.add({
'account_id': _inferAccountIdForType(m['type'] as String),
if (m['type'] == 'bank') 'bank_account_id': int.tryParse('${m['refId']}'),
if (m['type'] == 'cash') 'cash_register_id': int.tryParse('${m['refId']}'),
if (m['type'] == 'petty') 'petty_cash_id': int.tryParse('${m['refId']}'),
'debit': amount,
'credit': 0,
});
}
for (final m in _personLines) {
final d = (m['debit'] as double? ?? 0.0);
final c = (m['credit'] as double? ?? 0.0);
if (d <= 0 && c <= 0) continue;
accountLines.add({'account_id': _inferPersonAccountId(d, c), 'person_id': m['personId'], 'debit': d, 'credit': c});
}
for (final m in _otherAccountLines) {
final d = (m['debit'] as double? ?? 0.0);
final c = (m['credit'] as double? ?? 0.0);
if (d <= 0 && c <= 0) continue;
final acc = m['account'] as Account?;
if (acc?.id == null) continue;
accountLines.add({'account_id': acc!.id, 'debit': d, 'credit': c});
}
final inventoryLines = <Map<String, dynamic>>[];
for (final m in _inventoryLines) {
final product = (m['product'] as Map<String, dynamic>?);
final dynamic pidRaw = product != null ? product['id'] : null;
final int? pid = pidRaw is int ? pidRaw : int.tryParse("$pidRaw");
final wid = m['warehouseId'] as int?;
final q = (m['quantity'] as double? ?? 0.0);
final c = (m['cost_price'] as double? ?? 0.0);
if (pid == null || wid == null || q <= 0) continue;
inventoryLines.add({'product_id': pid, 'quantity': q, 'extra_info': {'movement': 'in', 'warehouse_id': wid, if (c > 0) 'cost_price': c}});
}
final payload = <String, dynamic>{
'fiscal_year_id': _document?['fiscal_year_id'],
'currency_id': _document?['currency_id'] ?? widget.authStore.selectedCurrencyId,
'account_lines': accountLines,
'inventory_lines': inventoryLines,
if (_inventoryAccountId != null) 'inventory_account_id': _inventoryAccountId,
'auto_balance_to_equity': _autoBalance,
if (_equityAccountId != null) 'equity_account_id': _equityAccountId,
};
final saved = await _service.save(businessId: widget.businessId, payload: payload);
setState(() => _document = saved);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ذخیره شد')));
}
}
int? _inferAccountIdForType(String type) {
switch (type) {
case 'bank':
return _bankControlAccountId;
case 'cash':
return _cashControlAccountId;
case 'petty':
return _pettyControlAccountId;
}
return null;
}
int? _inferPersonAccountId(double debit, double credit) {
if (debit > 0 && (credit <= 0)) {
return _personReceivableAccountId; // دریافتنی
}
if (credit > 0 && (debit <= 0)) {
return _personPayableAccountId; // پرداختنی
}
return null;
}
Future<void> _post() async {
final posted = await _service.post(businessId: widget.businessId);
setState(() => _document = posted);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('نهایی شد')));
}
}
}

View file

@ -56,6 +56,9 @@ class _PersonsPageState extends State<PersonsPage> {
title: t.personsList,
excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'persons',
reportSubtype: 'list',
getExportParams: () => {
'business_id': widget.businessId,
},

View file

@ -87,6 +87,9 @@ class _PettyCashPageState extends State<PettyCashPage> {
title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
pdfEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'petty_cash',
reportSubtype: 'list',
getExportParams: () => {'business_id': widget.businessId},
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),

View file

@ -41,6 +41,9 @@ class _ProductsPageState extends State<ProductsPage> {
title: t.products,
excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel',
pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'products',
reportSubtype: 'list',
showRowNumbers: true,
enableRowSelection: true,
enableMultiRowSelection: true,

View file

@ -236,6 +236,9 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
title: t.receiptsAndPayments,
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'receipts_payments',
reportSubtype: 'list',
// دکمه حذف گروهی در هدر جدول
customHeaderActions: [
Tooltip(
@ -1601,7 +1604,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.picture_as_pdf),
label: Text(_isGeneratingPdf ? 'در حال تولید...' : 'خروجی PDF'),
label: Text(_isGeneratingPdf ? AppLocalizations.of(context).generating : AppLocalizations.of(context).exportToPdf),
),
],
),
@ -1625,7 +1628,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('فایل PDF با موفقیت تولید شد'),
content: Text(AppLocalizations.of(context).pdfSuccess),
backgroundColor: Colors.green,
),
);
@ -1634,7 +1637,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در تولید PDF: $e'),
content: Text('${AppLocalizations.of(context).pdfError}: $e'),
backgroundColor: Colors.red,
),
);

View file

@ -0,0 +1,476 @@
import 'package:flutter/material.dart';
import '../../core/api_client.dart';
import '../../core/auth_store.dart';
import '../../l10n/app_localizations.dart';
import '../../services/report_template_service.dart';
class ReportTemplatesPage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
const ReportTemplatesPage({super.key, required this.businessId, required this.authStore});
@override
State<ReportTemplatesPage> createState() => _ReportTemplatesPageState();
}
class _ReportTemplatesPageState extends State<ReportTemplatesPage> {
late final ReportTemplateService _service;
final _moduleCtrl = TextEditingController(text: 'invoices');
final _subtypeCtrl = TextEditingController(text: 'list');
String? _statusFilter; // draft/published/null
bool _loading = false;
List<Map<String, dynamic>> _items = const [];
// Create/Edit form
final _nameCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _htmlCtrl = TextEditingController(text: "<html><head></head><body><h3>{{ title_text }}</h3></body></html>");
final _cssCtrl = TextEditingController(text: "body { font-family: Tahoma, Arial; }");
bool get _canWrite => widget.authStore.hasBusinessPermission('report_templates', 'write');
@override
void initState() {
super.initState();
_service = ReportTemplateService(ApiClient());
_fetch();
}
Future<void> _fetch() async {
setState(() => _loading = true);
try {
final items = await _service.listTemplates(
businessId: widget.businessId,
moduleKey: _moduleCtrl.text.trim().isEmpty ? null : _moduleCtrl.text.trim(),
subtype: _subtypeCtrl.text.trim().isEmpty ? null : _subtypeCtrl.text.trim(),
status: _statusFilter,
);
setState(() {
_items = items;
});
} finally {
setState(() => _loading = false);
}
}
Future<void> _createDialog() async {
final t = AppLocalizations.of(context);
await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(t.templates),
content: SizedBox(
width: 700,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'نام قالب'),
),
const SizedBox(height: 8),
TextField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات'),
),
const SizedBox(height: 12),
Text('HTML', style: Theme.of(context).textTheme.titleSmall),
TextField(
controller: _htmlCtrl,
maxLines: 10,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'HTML محتوا (Jinja2 variables allowed)',
),
),
const SizedBox(height: 12),
Text('CSS', style: Theme.of(context).textTheme.titleSmall),
TextField(
controller: _cssCtrl,
maxLines: 6,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'CSS اختیاری',
),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
FilledButton(
onPressed: () async {
try {
final id = await _service.createTemplate(
businessId: widget.businessId,
moduleKey: _moduleCtrl.text.trim().isEmpty ? 'invoices' : _moduleCtrl.text.trim(),
subtype: _subtypeCtrl.text.trim().isEmpty ? 'list' : _subtypeCtrl.text.trim(),
name: _nameCtrl.text.trim().isEmpty ? 'Template' : _nameCtrl.text.trim(),
description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
contentHtml: _htmlCtrl.text,
contentCss: _cssCtrl.text.trim().isEmpty ? null : _cssCtrl.text,
);
if (mounted) Navigator.pop(ctx);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('قالب ایجاد شد (ID: $id)')));
}
await _fetch();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ایجاد: $e')));
}
}
},
child: const Text('ایجاد'),
),
],
);
},
);
}
Future<void> _togglePublish(Map<String, dynamic> item) async {
final published = (item['status'] == 'published');
final next = !published;
await _service.publish(
businessId: widget.businessId,
templateId: (item['id'] as num).toInt(),
published: next,
);
await _fetch();
}
Future<void> _previewTemplate(Map<String, dynamic> item) async {
try {
final full = await _service.getTemplate(
businessId: widget.businessId,
templateId: (item['id'] as num).toInt(),
);
final res = await _service.preview(
businessId: widget.businessId,
contentHtml: (full['content_html'] ?? '').toString(),
contentCss: (full['content_css'] ?? '').toString().isEmpty ? null : (full['content_css'] ?? '').toString(),
context: const <String, dynamic>{},
);
if (!mounted) return;
final len = res['content_length'] ?? 0;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('پیش‌نمایش موفق (طول PDF: $len بایت)')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در پیش‌نمایش: $e')));
}
}
Future<void> _editDialog(Map<String, dynamic> item) async {
try {
final full = await _service.getTemplate(
businessId: widget.businessId,
templateId: (item['id'] as num).toInt(),
);
_nameCtrl.text = (full['name'] ?? '').toString();
_descCtrl.text = (full['description'] ?? '').toString();
_htmlCtrl.text = (full['content_html'] ?? '').toString();
_cssCtrl.text = (full['content_css'] ?? '').toString();
} catch (_) {}
await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('ویرایش قالب'),
content: SizedBox(
width: 700,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'نام قالب'),
),
const SizedBox(height: 8),
TextField(
controller: _descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات'),
),
const SizedBox(height: 12),
Text('HTML', style: Theme.of(context).textTheme.titleSmall),
TextField(
controller: _htmlCtrl,
maxLines: 10,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'HTML محتوا (Jinja2 variables allowed)',
),
),
const SizedBox(height: 12),
Text('CSS', style: Theme.of(context).textTheme.titleSmall),
TextField(
controller: _cssCtrl,
maxLines: 6,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'CSS اختیاری',
),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('انصراف')),
TextButton(
onPressed: () async {
await _previewTemplate(item);
},
child: const Text('پیش‌نمایش'),
),
FilledButton(
onPressed: () async {
try {
final changes = <String, dynamic>{
'name': _nameCtrl.text.trim(),
'description': _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
'content_html': _htmlCtrl.text,
'content_css': _cssCtrl.text.trim().isEmpty ? null : _cssCtrl.text,
};
await _service.updateTemplate(
businessId: widget.businessId,
templateId: (item['id'] as num).toInt(),
changes: changes,
);
if (mounted) Navigator.pop(ctx);
await _fetch();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در ویرایش: $e')));
}
}
},
child: const Text('ذخیره'),
),
],
);
},
);
}
Future<void> _setDefault(Map<String, dynamic> item) async {
await _service.setDefault(
businessId: widget.businessId,
moduleKey: (_moduleCtrl.text.trim().isEmpty ? 'invoices' : _moduleCtrl.text.trim()),
subtype: (_subtypeCtrl.text.trim().isEmpty ? 'list' : _subtypeCtrl.text.trim()),
templateId: (item['id'] as num).toInt(),
);
await _fetch();
}
Future<void> _delete(Map<String, dynamic> item) async {
await _service.deleteTemplate(
businessId: widget.businessId,
templateId: (item['id'] as num).toInt(),
);
await _fetch();
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text('${t.templates} (${_moduleCtrl.text}${_subtypeCtrl.text.isNotEmpty ? '/${_subtypeCtrl.text}' : ''})'),
actions: [
if (_canWrite)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FilledButton.icon(
onPressed: _createDialog,
icon: const Icon(Icons.add),
label: const Text('قالب جدید'),
),
),
],
),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
children: [
SizedBox(
width: 200,
child: TextField(
controller: _moduleCtrl,
decoration: const InputDecoration(labelText: 'module_key (مثلاً: invoices)'),
onSubmitted: (_) => _fetch(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 160,
child: TextField(
controller: _subtypeCtrl,
decoration: const InputDecoration(labelText: 'subtype (مثلاً: list یا detail)'),
onSubmitted: (_) => _fetch(),
),
),
const SizedBox(width: 8),
DropdownButton<String?>(
value: _statusFilter,
hint: const Text('همه وضعیت‌ها'),
items: const [
DropdownMenuItem(value: null, child: Text('همه')),
DropdownMenuItem(value: 'published', child: Text('منتشر شده')),
DropdownMenuItem(value: 'draft', child: Text('پیش‌نویس')),
],
onChanged: (v) {
setState(() => _statusFilter = v);
_fetch();
},
),
const Spacer(),
IconButton(onPressed: _fetch, icon: const Icon(Icons.refresh)),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
label: Text(t.presetInvoicesList),
onPressed: () {
_moduleCtrl.text = 'invoices';
_subtypeCtrl.text = 'list';
_fetch();
},
),
ActionChip(
label: Text(t.presetInvoicesDetail),
onPressed: () {
_moduleCtrl.text = 'invoices';
_subtypeCtrl.text = 'detail';
_fetch();
},
),
ActionChip(
label: Text(t.presetReceiptsPaymentsList),
onPressed: () {
_moduleCtrl.text = 'receipts_payments';
_subtypeCtrl.text = 'list';
_fetch();
},
),
ActionChip(
label: Text(t.presetReceiptsPaymentsDetail),
onPressed: () {
_moduleCtrl.text = 'receipts_payments';
_subtypeCtrl.text = 'detail';
_fetch();
},
),
ActionChip(
label: Text(t.presetExpenseIncomeList),
onPressed: () {
_moduleCtrl.text = 'expense_income';
_subtypeCtrl.text = 'list';
_fetch();
},
),
ActionChip(
label: Text(t.presetDocumentsList),
onPressed: () {
_moduleCtrl.text = 'documents';
_subtypeCtrl.text = 'list';
_fetch();
},
),
ActionChip(
label: Text(t.presetDocumentsDetail),
onPressed: () {
_moduleCtrl.text = 'documents';
_subtypeCtrl.text = 'detail';
_fetch();
},
),
],
),
),
const SizedBox(height: 12),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty
? const Center(child: Text('قالبی یافت نشد'))
: Card(
clipBehavior: Clip.antiAlias,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, idx) {
final it = _items[idx];
final isDefault = it['is_default'] == true;
final status = (it['status'] ?? '').toString();
return ListTile(
title: Text(it['name']?.toString() ?? '-'),
subtitle: Text(
'status: $status module: ${it['module_key']} subtype: ${it['subtype'] ?? '-'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon(isDefault ? Icons.star : Icons.description),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_canWrite)
IconButton(
tooltip: status == 'published' ? 'به پیش‌نویس برگردان' : 'انتشار',
onPressed: () => _togglePublish(it),
icon: Icon(status == 'published' ? Icons.visibility_off : Icons.publish),
),
if (_canWrite)
IconButton(
tooltip: 'پیش‌نمایش',
onPressed: () => _previewTemplate(it),
icon: const Icon(Icons.visibility),
),
if (_canWrite)
IconButton(
tooltip: 'ویرایش',
onPressed: () => _editDialog(it),
icon: const Icon(Icons.edit),
),
if (_canWrite)
IconButton(
tooltip: 'تنظیم به‌عنوان پیش‌فرض',
onPressed: () => _setDefault(it),
icon: const Icon(Icons.star),
),
if (_canWrite)
IconButton(
tooltip: 'حذف',
onPressed: () => _delete(it),
icon: const Icon(Icons.delete_outline),
),
],
),
);
},
),
),
),
],
),
),
);
}
}

View file

@ -70,6 +70,14 @@ class _SettingsPageState extends State<SettingsPage> {
icon: Icons.print,
onTap: () => _showPrintDocumentsDialog(context),
),
// Report Builder - Templates access
_buildSettingItem(
context,
title: t.templates,
subtitle: t.printDocumentsDescription,
icon: Icons.picture_as_pdf,
onTap: () => context.go('/business/${widget.businessId}/report-templates'),
),
],
),

View file

@ -172,6 +172,9 @@ class _TransfersPageState extends State<TransfersPage> {
title: t.transfers,
excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf',
businessId: widget.businessId,
reportModuleKey: 'transfers',
reportSubtype: 'list',
getExportParams: () => {
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),

View file

@ -2,6 +2,14 @@ import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/auth_store.dart';
import '../../widgets/permission/access_denied_page.dart';
// import '../../core/api_client.dart'; // duplicate removed
import '../../services/wallet_service.dart';
import '../../widgets/invoice/bank_account_combobox_widget.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:go_router/go_router.dart';
import '../../core/api_client.dart';
import '../../services/payment_gateway_service.dart';
import 'package:url_launcher/url_launcher.dart';
class WalletPage extends StatefulWidget {
final int businessId;
@ -18,6 +26,269 @@ class WalletPage extends StatefulWidget {
}
class _WalletPageState extends State<WalletPage> {
late final WalletService _service;
bool _loading = true;
Map<String, dynamic>? _overview;
String? _error;
List<Map<String, dynamic>> _transactions = const <Map<String, dynamic>>[];
Map<String, dynamic>? _metrics;
DateTime? _fromDate;
DateTime? _toDate;
@override
void initState() {
super.initState();
_service = WalletService(ApiClient());
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final res = await _service.getOverview(businessId: widget.businessId);
final now = DateTime.now();
_toDate = now;
_fromDate = now.subtract(const Duration(days: 30));
final tx = await _service.listTransactions(businessId: widget.businessId, limit: 20, fromDate: _fromDate, toDate: _toDate);
final m = await _service.getMetrics(businessId: widget.businessId, fromDate: _fromDate, toDate: _toDate);
setState(() {
_overview = res;
_transactions = tx;
_metrics = m;
});
} catch (e) {
setState(() {
_error = '$e';
});
} finally {
setState(() {
_loading = false;
});
}
}
Future<void> _openPayoutDialog() async {
final t = AppLocalizations.of(context);
final formKey = GlobalKey<FormState>();
int? bankId;
final amountCtrl = TextEditingController();
final descCtrl = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text('درخواست تسویه'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BankAccountComboboxWidget(
businessId: widget.businessId,
selectedAccountId: bankId?.toString(),
onChanged: (opt) => bankId = int.tryParse(opt?.id ?? ''),
hintText: 'انتخاب حساب بانکی',
),
const SizedBox(height: 12),
TextFormField(
controller: amountCtrl,
decoration: const InputDecoration(labelText: 'مبلغ'),
keyboardType: TextInputType.number,
validator: (v) => (v == null || v.isEmpty) ? 'الزامی' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات (اختیاری)'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() == true && bankId != null) {
Navigator.pop(ctx, true);
}
},
child: Text(t.confirm),
),
],
);
},
);
if (result == true && bankId != null) {
try {
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
await _service.requestPayout(
businessId: widget.businessId,
bankAccountId: bankId!,
amount: amount,
description: descCtrl.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست تسویه ثبت شد')));
}
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
}
}
Future<void> _openTopUpDialog() async {
final t = AppLocalizations.of(context);
final formKey = GlobalKey<FormState>();
final amountCtrl = TextEditingController();
final descCtrl = TextEditingController();
final pgService = PaymentGatewayService(ApiClient());
List<Map<String, dynamic>> gateways = const <Map<String, dynamic>>[];
int? gatewayId;
try {
gateways = await pgService.listBusinessGateways(widget.businessId);
if (gateways.isNotEmpty) {
gatewayId = int.tryParse('${gateways.first['id']}');
}
} catch (_) {}
final result = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('افزایش اعتبار'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: amountCtrl,
decoration: const InputDecoration(labelText: 'مبلغ'),
keyboardType: TextInputType.number,
validator: (v) => (v == null || v.isEmpty) ? 'الزامی' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات (اختیاری)'),
),
const SizedBox(height: 8),
if (gateways.isNotEmpty)
DropdownButtonFormField<int>(
value: gatewayId,
decoration: const InputDecoration(labelText: 'درگاه پرداخت'),
items: gateways
.map((g) => DropdownMenuItem<int>(
value: int.tryParse('${g['id']}'),
child: Text('${g['display_name']} (${g['provider']})'),
))
.toList(),
onChanged: (v) => gatewayId = v,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() == true && (gateways.isEmpty || gatewayId != null)) {
Navigator.pop(ctx, true);
}
},
child: Text(t.confirm),
),
],
);
},
);
if (result == true) {
try {
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
final data = await _service.topUp(
businessId: widget.businessId,
amount: amount,
description: descCtrl.text,
gatewayId: gatewayId,
);
final paymentUrl = (data['payment_url'] ?? '').toString();
if (paymentUrl.isNotEmpty) {
final uri = Uri.parse(paymentUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست افزایش اعتبار ثبت شد')));
}
}
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
}
}
Future<void> _pickFromDate() async {
final initial = _fromDate ?? DateTime.now().subtract(const Duration(days: 30));
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDate: initial,
);
if (picked != null) {
setState(() => _fromDate = picked);
await _reloadRange();
}
}
Future<void> _pickToDate() async {
final initial = _toDate ?? DateTime.now();
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDate: initial,
);
if (picked != null) {
setState(() => _toDate = picked);
await _reloadRange();
}
}
Future<void> _reloadRange() async {
setState(() => _loading = true);
try {
final tx = await _service.listTransactions(
businessId: widget.businessId,
limit: 20,
fromDate: _fromDate,
toDate: _toDate,
);
final m = await _service.getMetrics(
businessId: widget.businessId,
fromDate: _fromDate,
toDate: _toDate,
);
if (mounted) {
setState(() {
_transactions = tx;
_metrics = m;
});
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
@ -26,33 +297,197 @@ class _WalletPageState extends State<WalletPage> {
return AccessDeniedPage(message: t.accessDenied);
}
final theme = Theme.of(context);
final overview = _overview;
final currency = overview?['base_currency_code'] ?? 'IRR';
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.wallet,
size: 80,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
),
const SizedBox(height: 24),
Text(
t.wallet,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
Text(
'صفحه کیف پول در حال توسعه است',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
appBar: AppBar(title: Text(t.wallet)),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.wallet, size: 32, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Text('کیف‌پول کسب‌وکار', style: theme.textTheme.titleLarge),
const Spacer(),
Chip(label: Text(currency)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('مانده قابل برداشت', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text('${overview?['available_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('مانده در انتظار تایید', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text('${overview?['pending_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
],
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: _pickFromDate,
icon: const Icon(Icons.date_range),
label: Text(_fromDate != null ? _fromDate!.toIso8601String().split('T').first : 'از تاریخ'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _pickToDate,
icon: const Icon(Icons.event),
label: Text(_toDate != null ? _toDate!.toIso8601String().split('T').first : 'تا تاریخ'),
),
const Spacer(),
FilledButton.icon(
onPressed: _openPayoutDialog,
icon: const Icon(Icons.account_balance),
label: const Text('درخواست تسویه'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _openTopUpDialog,
icon: const Icon(Icons.add),
label: const Text('افزایش اعتبار'),
),
],
),
const SizedBox(height: 16),
if (_metrics != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('گزارش ۳۰ روز اخیر', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
Chip(label: Text('ورودی ناخالص: ${_metrics?['totals']?['gross_in'] ?? 0}')),
Chip(label: Text('کارمزد ورودی: ${_metrics?['totals']?['fees_in'] ?? 0}')),
Chip(label: Text('ورودی خالص: ${_metrics?['totals']?['net_in'] ?? 0}')),
Chip(label: Text('خروجی ناخالص: ${_metrics?['totals']?['gross_out'] ?? 0}')),
Chip(label: Text('کارمزد خروجی: ${_metrics?['totals']?['fees_out'] ?? 0}')),
Chip(label: Text('خروجی خالص: ${_metrics?['totals']?['net_out'] ?? 0}')),
],
),
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () async {
final api = ApiClient();
final path = '/businesses/${widget.businessId}/wallet/transactions/export'
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
try {
await api.downloadExcel(path); // bytes download and save handled
// Save as CSV file
// ignore: avoid_web_libraries_in_flutter
// ignore: unused_import
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV تراکنش‌ها: $e')));
}
},
icon: const Icon(Icons.download),
label: const Text('دانلود CSV تراکنش‌ها'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () async {
final api = ApiClient();
final path = '/businesses/${widget.businessId}/wallet/metrics/export'
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
try {
await api.downloadExcel(path);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV خلاصه: $e')));
}
},
icon: const Icon(Icons.table_view),
label: const Text('دانلود CSV خلاصه'),
),
],
),
const SizedBox(height: 16),
Text('تراکنش‌های اخیر', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Expanded(
child: Card(
child: ListView.separated(
itemCount: _transactions.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final m = _transactions[i];
final amount = m['amount'] ?? 0;
return ListTile(
leading: Icon(
m['type'] == 'payout_request' ? Icons.account_balance : Icons.swap_horiz,
color: theme.colorScheme.primary,
),
title: Text('${m['type']} - ${m['status']}'),
subtitle: Text('${m['description'] ?? ''}'),
trailing: Text('${formatWithThousands((amount is num) ? amount : double.tryParse('$amount') ?? 0)}'),
onTap: () async {
final docId = m['document_id'];
if (!mounted) return;
await context.pushNamed(
'business_documents',
pathParameters: {'business_id': widget.businessId.toString()},
extra: {'focus_document_id': docId},
);
},
);
},
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth_store.dart';
import '../../services/wallet_service.dart';
import '../../core/api_client.dart';
class WalletPaymentResultPage extends StatefulWidget {
final AuthStore authStore;
const WalletPaymentResultPage({super.key, required this.authStore});
@override
State<WalletPaymentResultPage> createState() => _WalletPaymentResultPageState();
}
class _WalletPaymentResultPageState extends State<WalletPaymentResultPage> {
bool _loading = false;
String? _error;
Map<String, dynamic>? _tx;
@override
void initState() {
super.initState();
_checkStatusIfPossible();
}
Future<void> _checkStatusIfPossible() async {
final qp = GoRouterState.of(context).uri.queryParameters;
final txId = int.tryParse(qp['tx_id'] ?? '');
final businessId = widget.authStore.currentBusiness?.id;
if (txId == null || businessId == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final api = ApiClient();
final ws = WalletService(api);
final items = await ws.listTransactions(businessId: businessId, limit: 50);
final found = items.firstWhere(
(e) => int.tryParse('${e['id']}') == txId,
orElse: () => <String, dynamic>{},
);
if (found.isNotEmpty) {
setState(() => _tx = found);
}
} catch (e) {
setState(() => _error = '$e');
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final qp = GoRouterState.of(context).uri.queryParameters;
final status = (qp['status'] ?? '').toLowerCase();
final txId = qp['tx_id'];
final ref = qp['ref'];
final isSuccess = status == 'success';
final icon = isSuccess ? Icons.check_circle : Icons.error_outline;
final color = isSuccess ? Colors.green : Colors.red;
return Scaffold(
appBar: AppBar(title: const Text('نتیجه پرداخت کیف‌پول')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(width: 12),
Text(
isSuccess ? 'پرداخت با موفقیت انجام شد' : 'پرداخت ناموفق بود',
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: color),
),
],
),
const SizedBox(height: 12),
if (txId != null) Text('شماره تراکنش: $txId'),
if (ref != null && ref.isNotEmpty) Text('مرجع پرداخت: $ref'),
const SizedBox(height: 12),
if (_loading) const LinearProgressIndicator(),
if (_error != null) Text('خطا در استعلام وضعیت: $_error', style: const TextStyle(color: Colors.red)),
if (_tx != null) Text('وضعیت ثبت‌شده: ${_tx!['status']} - مبلغ: ${_tx!['amount']}'),
const Spacer(),
FilledButton.icon(
onPressed: () {
final bid = widget.authStore.currentBusiness?.id;
if (bid != null) {
context.go('/business/$bid/wallet');
} else {
context.go('/user/profile/dashboard');
}
},
icon: const Icon(Icons.account_balance_wallet),
label: const Text('بازگشت به کیف‌پول'),
),
],
),
),
);
}
}

View file

@ -77,7 +77,7 @@ class _ProfileShellState extends State<ProfileShell> {
final allDestinations = <_Dest>[
...destinations,
if (widget.authStore.canAccessSupportOperator) ...operatorDestinations,
if (widget.authStore.isSuperAdmin) ...adminDestinations,
if (widget.authStore.isSuperAdmin || widget.authStore.hasAppPermission('system_settings')) ...adminDestinations,
];
int selectedIndex = 0;

View file

@ -30,6 +30,20 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
color: const Color(0xFF4CAF50),
route: '/user/profile/system-settings/configuration',
),
SettingsItem(
title: 'تنظیمات کیف‌پول',
description: 'تعیین ارز پایه و سیاست‌ها',
icon: Icons.account_balance_wallet_outlined,
color: const Color(0xFF009688),
route: '/user/profile/system-settings/wallet',
),
SettingsItem(
title: 'درگاه‌های پرداخت',
description: 'مدیریت و پیکربندی درگاه‌ها',
icon: Icons.payment_outlined,
color: const Color(0xFF3F51B5),
route: '/user/profile/system-settings/payment-gateways',
),
SettingsItem(
title: 'userManagement',
description: 'userManagementDescription',

View file

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../../services/warehouse_service.dart';
class WarehouseDocsPage extends StatefulWidget {
final int businessId;
const WarehouseDocsPage({super.key, required this.businessId});
@override
State<WarehouseDocsPage> createState() => _WarehouseDocsPageState();
}
class _WarehouseDocsPageState extends State<WarehouseDocsPage> {
final _svc = WarehouseService();
bool _loading = true;
String? _error;
List<dynamic> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() { _loading = true; _error = null; });
try {
final res = await _svc.search(businessId: widget.businessId, limit: 50);
setState(() { _items = List<dynamic>.from(res['items'] ?? const []); });
} catch (e) {
setState(() { _error = e.toString(); });
} finally {
setState(() { _loading = false; });
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('حواله‌های انبار')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = _items[index] as Map<String, dynamic>;
return ListTile(
title: Text('${it['code'] ?? '-'}${it['doc_type'] ?? ''}${it['status'] ?? ''}'),
subtitle: Text(it['document_date'] ?? ''),
trailing: IconButton(
icon: const Icon(Icons.publish),
onPressed: (it['status'] == 'draft') ? () async {
try {
await _svc.postDoc(businessId: widget.businessId, docId: it['id']);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('حواله پست شد')),
);
_load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در پست حواله: $e')),
);
}
} : null,
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,50 @@
import 'package:hesabix_ui/core/api_client.dart';
class OpeningBalanceService {
final ApiClient _apiClient;
OpeningBalanceService(this._apiClient);
Future<Map<String, dynamic>?> fetch({required int businessId, int? fiscalYearId}) async {
final resp = await _apiClient.get(
'/businesses/$businessId/opening-balance',
query: {
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
},
);
if (resp.statusCode == 200) {
return (resp.data?['data'] as Map<String, dynamic>?) ?? {};
}
throw Exception('خطا در دریافت تراز افتتاحیه: ${resp.statusMessage}');
}
Future<Map<String, dynamic>> save({
required int businessId,
required Map<String, dynamic> payload,
}) async {
final resp = await _apiClient.put(
'/businesses/$businessId/opening-balance',
data: payload,
);
if (resp.statusCode == 200) {
return (resp.data?['data'] as Map<String, dynamic>? ?? {});
}
throw Exception('خطا در ذخیره تراز افتتاحیه: ${resp.statusMessage}');
}
Future<Map<String, dynamic>> post({required int businessId, int? fiscalYearId}) async {
final resp = await _apiClient.post(
'/businesses/$businessId/opening-balance/post',
data: {
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
},
);
if (resp.statusCode == 200) {
return (resp.data?['data'] as Map<String, dynamic>? ?? {});
}
throw Exception('خطا در نهایی‌سازی تراز افتتاحیه: ${resp.statusMessage}');
}
}

View file

@ -0,0 +1,71 @@
import '../core/api_client.dart';
class PaymentGatewayService {
final ApiClient _api;
PaymentGatewayService(this._api);
// Admin CRUD
Future<List<Map<String, dynamic>>> listAdmin() async {
final res = await _api.get<Map<String, dynamic>>('/admin/payment-gateways');
final body = res.data;
final items = (body is Map<String, dynamic>) ? body['data'] : body;
if (items is List) {
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map)).toList();
}
return const <Map<String, dynamic>>[];
}
Future<Map<String, dynamic>> createAdmin({
required String provider,
required String displayName,
required Map<String, dynamic> config,
bool isActive = true,
bool isSandbox = true,
}) async {
final res = await _api.post<Map<String, dynamic>>('/admin/payment-gateways', data: {
'provider': provider,
'display_name': displayName,
'is_active': isActive,
'is_sandbox': isSandbox,
'config': config,
});
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<Map<String, dynamic>> updateAdmin({
required int gatewayId,
String? provider,
String? displayName,
bool? isActive,
bool? isSandbox,
Map<String, dynamic>? config,
}) async {
final res = await _api.put<Map<String, dynamic>>('/admin/payment-gateways/$gatewayId', data: {
if (provider != null) 'provider': provider,
if (displayName != null) 'display_name': displayName,
if (isActive != null) 'is_active': isActive,
if (isSandbox != null) 'is_sandbox': isSandbox,
if (config != null) 'config': config,
});
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<void> deleteAdmin(int gatewayId) async {
await _api.delete('/admin/payment-gateways/$gatewayId');
}
// Business visible gateways
Future<List<Map<String, dynamic>>> listBusinessGateways(int businessId) async {
final res = await _api.get<Map<String, dynamic>>('/businesses/$businessId/wallet/gateways');
final body = res.data;
final items = (body is Map<String, dynamic>) ? body['data'] : body;
if (items is List) {
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map)).toList();
}
return const <Map<String, dynamic>>[];
}
}

View file

@ -0,0 +1,138 @@
import '../core/api_client.dart';
class ReportTemplateService {
final ApiClient _api;
ReportTemplateService(this._api);
Future<List<Map<String, dynamic>>> listTemplates({
required int businessId,
String? moduleKey,
String? subtype,
String? status,
}) async {
final res = await _api.get<Map<String, dynamic>>(
'/report-templates/business/$businessId',
query: {
if (moduleKey != null && moduleKey.isNotEmpty) 'module_key': moduleKey,
if (subtype != null && subtype.isNotEmpty) 'subtype': subtype,
if (status != null && status.isNotEmpty) 'status': status,
},
);
final items = (res.data?['items'] as List?) ?? const [];
return items.cast<Map<String, dynamic>>();
}
Future<int> createTemplate({
required int businessId,
required String moduleKey,
String? subtype,
required String name,
String? description,
required String contentHtml,
String? contentCss,
String? headerHtml,
String? footerHtml,
String? paperSize,
String? orientation,
Map<String, dynamic>? margins,
Map<String, dynamic>? assets,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/report-templates/business/$businessId',
data: {
'module_key': moduleKey,
if (subtype != null) 'subtype': subtype,
'name': name,
if (description != null) 'description': description,
'content_html': contentHtml,
if (contentCss != null) 'content_css': contentCss,
if (headerHtml != null) 'header_html': headerHtml,
if (footerHtml != null) 'footer_html': footerHtml,
if (paperSize != null) 'paper_size': paperSize,
if (orientation != null) 'orientation': orientation,
if (margins != null) 'margins': margins,
if (assets != null) 'assets': assets,
},
);
return (res.data?['id'] as num).toInt();
}
Future<Map<String, dynamic>> updateTemplate({
required int businessId,
required int templateId,
Map<String, dynamic>? changes,
}) async {
final res = await _api.put<Map<String, dynamic>>(
'/report-templates/$templateId/business/$businessId',
data: changes ?? const <String, dynamic>{},
);
return res.data ?? const <String, dynamic>{};
}
Future<Map<String, dynamic>> getTemplate({
required int businessId,
required int templateId,
}) async {
final res = await _api.get<Map<String, dynamic>>(
'/report-templates/$templateId/business/$businessId',
);
return res.data ?? const <String, dynamic>{};
}
Future<void> deleteTemplate({
required int businessId,
required int templateId,
}) async {
await _api.delete<Map<String, dynamic>>(
'/report-templates/$templateId/business/$businessId',
);
}
Future<Map<String, dynamic>> publish({
required int businessId,
required int templateId,
required bool published,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/report-templates/$templateId/business/$businessId/publish',
data: {'published': published},
);
return res.data ?? const <String, dynamic>{};
}
Future<Map<String, dynamic>> setDefault({
required int businessId,
required String moduleKey,
String? subtype,
required int templateId,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/report-templates/business/$businessId/set-default',
data: {
'module_key': moduleKey,
if (subtype != null) 'subtype': subtype,
'template_id': templateId,
},
);
return res.data ?? const <String, dynamic>{};
}
Future<Map<String, dynamic>> preview({
required int businessId,
required String contentHtml,
String? contentCss,
Map<String, dynamic>? context,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/report-templates/business/$businessId/preview',
data: {
'content_html': contentHtml,
if (contentCss != null) 'content_css': contentCss,
'context': context ?? const <String, dynamic>{},
},
);
return res.data ?? const <String, dynamic>{};
}
}

View file

@ -0,0 +1,22 @@
import '../core/api_client.dart';
class SystemSettingsService {
final ApiClient _api;
SystemSettingsService(this._api);
Future<Map<String, dynamic>> getWalletSettings() async {
final res = await _api.get<Map<String, dynamic>>('/admin/system-settings/wallet');
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<Map<String, dynamic>> setWalletBaseCurrencyCode(String code) async {
final res = await _api.put<Map<String, dynamic>>('/admin/system-settings/wallet', data: {
'wallet_base_currency_code': code,
});
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
}

View file

@ -0,0 +1,80 @@
import '../core/api_client.dart';
class WalletService {
final ApiClient _api;
WalletService(this._api);
Future<Map<String, dynamic>> getOverview({required int businessId}) async {
final res = await _api.get<Map<String, dynamic>>('/businesses/$businessId/wallet');
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<List<Map<String, dynamic>>> listTransactions({
required int businessId,
int skip = 0,
int limit = 50,
DateTime? fromDate,
DateTime? toDate,
}) async {
final query = <String, dynamic>{
'skip': '$skip',
'limit': '$limit',
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
if (toDate != null) 'to_date': toDate.toIso8601String(),
};
final res = await _api.get<Map<String, dynamic>>('/businesses/$businessId/wallet/transactions', query: query);
final body = res.data;
final items = (body is Map<String, dynamic>) ? body['data'] : body;
if (items is List) {
return items.map<Map<String, dynamic>>((e) => Map<String, dynamic>.from(e as Map)).toList();
}
return const <Map<String, dynamic>>[];
}
Future<Map<String, dynamic>> getMetrics({
required int businessId,
DateTime? fromDate,
DateTime? toDate,
}) async {
final query = <String, dynamic>{
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
if (toDate != null) 'to_date': toDate.toIso8601String(),
};
final res = await _api.get<Map<String, dynamic>>('/businesses/$businessId/wallet/metrics', query: query);
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<Map<String, dynamic>> requestPayout({
required int businessId,
required int bankAccountId,
required double amount,
String? description,
}) async {
final res = await _api.post<Map<String, dynamic>>('/businesses/$businessId/wallet/payouts', data: {
'bank_account_id': bankAccountId,
'amount': amount,
if (description != null && description.isNotEmpty) 'description': description,
});
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
Future<Map<String, dynamic>> topUp({
required int businessId,
required double amount,
String? description,
int? gatewayId,
}) async {
final res = await _api.post<Map<String, dynamic>>('/businesses/$businessId/wallet/top-up', data: {
'amount': amount,
if (description != null && description.isNotEmpty) 'description': description,
if (gatewayId != null) 'gateway_id': gatewayId,
});
final body = res.data as Map<String, dynamic>;
return Map<String, dynamic>.from(body['data'] as Map);
}
}

View file

@ -34,6 +34,57 @@ class WarehouseService {
final res = await _api.delete<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId');
return res.statusCode == 200 && (res.data?['data']?['deleted'] == true);
}
Future<Map<String, dynamic>> createFromInvoice({
required int businessId,
required int invoiceId,
Map<String, dynamic>? body,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/api/v1/warehouse-docs/business/$businessId/from-invoice/$invoiceId',
data: body ?? const {},
);
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
}
Future<Map<String, dynamic>> postDoc({
required int businessId,
required int docId,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/api/v1/warehouse-docs/business/$businessId/$docId/post',
data: const {},
);
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
}
Future<Map<String, dynamic>> getDoc({
required int businessId,
required int docId,
}) async {
final res = await _api.get<Map<String, dynamic>>(
'/api/v1/warehouse-docs/business/$businessId/$docId',
);
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
}
Future<Map<String, dynamic>> search({
required int businessId,
int page = 1,
int limit = 20,
Map<String, dynamic>? filters,
}) async {
final body = {
'take': limit,
'skip': (page - 1) * limit,
...?filters,
};
final res = await _api.post<Map<String, dynamic>>(
'/api/v1/warehouse-docs/business/$businessId/search',
data: body,
);
return Map<String, dynamic>.from(res.data?['data'] ?? const {});
}
}

View file

@ -249,6 +249,10 @@ class DataTableConfig<T> {
final bool showExportButtons;
final bool showExcelExport;
final bool showPdfExport;
// Report templates scope (for PDF custom templates)
final int? businessId; // needed to fetch templates
final String? reportModuleKey;
final String? reportSubtype;
// Column settings configuration
final String? tableId;
@ -327,6 +331,9 @@ class DataTableConfig<T> {
this.showExportButtons = false,
this.showExcelExport = true,
this.showPdfExport = true,
this.businessId,
this.reportModuleKey,
this.reportSubtype,
this.tableId,
this.enableColumnSettings = true,
this.showColumnSettingsButton = true,

View file

@ -12,6 +12,7 @@ import 'package:dio/dio.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/services/report_template_service.dart';
import 'data_table_config.dart';
import 'data_table_search_dialog.dart';
import 'column_settings_dialog.dart';
@ -70,6 +71,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
// Row selection state
final Set<int> _selectedRows = <int>{};
bool _isExporting = false;
int? _templateIdForExport;
final TextEditingController _templateIdCtrl = TextEditingController();
// Report templates (for PDF export)
List<Map<String, dynamic>> _availableTemplates = const [];
bool _loadingTemplates = false;
int? _selectedTemplateIdFromList;
// Column settings state
ColumnSettings? _columnSettings;
@ -125,6 +132,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_searchDebounce?.cancel();
_horizontalScrollController.dispose();
_tableFocusNode.dispose();
_templateIdCtrl.dispose();
for (var controller in _columnSearchControllers.values) {
controller.dispose();
}
@ -631,6 +639,10 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
if (selectedOnly && _selectedRows.isNotEmpty) {
params['selected_indices'] = _selectedRows.toList();
}
// Optional report template for PDF
if (format == 'pdf' && _templateIdForExport != null) {
params['template_id'] = _templateIdForExport;
}
// Add export columns in current visible order (excluding ActionColumn)
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
@ -1161,6 +1173,34 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
AppLocalizations t,
ThemeData theme,
) {
Future<void> _ensureTemplatesLoaded() async {
if (widget.config.pdfEndpoint == null) return;
if (widget.config.businessId == null || widget.config.reportModuleKey == null) return;
setState(() => _loadingTemplates = true);
try {
final service = ReportTemplateService(ApiClient());
final list = await service.listTemplates(
businessId: widget.config.businessId!,
moduleKey: widget.config.reportModuleKey,
subtype: widget.config.reportSubtype,
status: 'published',
);
if (mounted) {
setState(() {
_availableTemplates = list;
});
}
} catch (_) {
if (mounted) {
setState(() {
_availableTemplates = const [];
});
}
} finally {
if (mounted) setState(() => _loadingTemplates = false);
}
}
_ensureTemplatesLoaded();
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
@ -1202,6 +1242,109 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
const Divider(height: 1),
if (widget.config.pdfEndpoint != null) ...[
if (widget.config.businessId != null && widget.config.reportModuleKey != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.description_outlined, size: 18),
const SizedBox(width: 8),
Expanded(
child: _loadingTemplates
? const LinearProgressIndicator(minHeight: 2)
: DropdownButtonFormField<int>(
value: _selectedTemplateIdFromList,
isExpanded: true,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).printTemplatePublished,
isDense: true,
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<int>(
value: null,
child: Text(AppLocalizations.of(context).noCustomTemplate),
),
..._availableTemplates.map((tpl) {
final id = (tpl['id'] as num).toInt();
final name = (tpl['name'] ?? 'Template').toString();
final isDefault = tpl['is_default'] == true;
return DropdownMenuItem<int>(
value: id,
child: Row(
children: [
if (isDefault) const Icon(Icons.star, size: 16),
if (isDefault) const SizedBox(width: 6),
Expanded(child: Text(name)),
],
),
);
}),
],
onChanged: (val) {
setState(() {
_selectedTemplateIdFromList = val;
_templateIdForExport = val;
if (val != null) {
_templateIdCtrl.text = val.toString();
}
});
},
),
),
const SizedBox(width: 8),
IconButton(
tooltip: AppLocalizations.of(context).reload,
onPressed: _loadingTemplates ? null : _ensureTemplatesLoaded,
icon: const Icon(Icons.refresh),
),
],
),
),
const Divider(height: 1),
],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.tune, size: 18),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _templateIdCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'template_id (اختیاری برای PDF سفارشی)',
hintText: 'مثلاً 101',
isDense: true,
),
onChanged: (v) {
final n = int.tryParse(v.trim());
setState(() {
_templateIdForExport = n;
});
},
),
),
const SizedBox(width: 8),
if (_templateIdForExport != null)
IconButton(
tooltip: 'پاک‌کردن قالب سفارشی',
onPressed: () {
setState(() {
_templateIdForExport = null;
_templateIdCtrl.clear();
});
},
icon: const Icon(Icons.clear),
),
],
),
),
const Divider(height: 1),
],
// Excel options
if (widget.config.excelEndpoint != null) ...[
ListTile(

View file

@ -4,6 +4,9 @@ import 'package:hesabix_ui/services/document_service.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/services/warehouse_service.dart';
import 'dart:html' as html;
import 'package:hesabix_ui/l10n/app_localizations.dart';
/// دیالوگ نمایش جزئیات کامل سند حسابداری
class DocumentDetailsDialog extends StatefulWidget {
@ -25,6 +28,9 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
DocumentModel? _document;
bool _isLoading = true;
String? _errorMessage;
bool _isGeneratingPdf = false;
final _warehouseService = WarehouseService();
List<dynamic> _relatedWhDocs = const [];
@override
void initState() {
@ -33,6 +39,53 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
_loadDocument();
}
Future<void> _generatePdf() async {
if (_document == null) return;
setState(() => _isGeneratingPdf = true);
try {
final api = ApiClient();
final doc = _document!;
String path;
// اگر فاکتور است، از endpoint اختصاصی فاکتور استفاده کنیم تا قالب invoices/detail اعمال شود
if (doc.documentType.startsWith('invoice')) {
path = '/invoices/business/${doc.businessId}/${doc.id}/pdf';
} else {
// سایر اسناد: endpoint عمومی با قالب documents/detail
path = '/documents/${doc.id}/pdf';
}
final bytes = await api.downloadPdf(path);
await _savePdfFile(bytes, doc.code);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).pdfSuccess)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppLocalizations.of(context).pdfError}: $e')),
);
} finally {
if (mounted) setState(() => _isGeneratingPdf = false);
}
}
Future<void> _savePdfFile(List<int> bytes, String filename) async {
try {
final name = filename.endsWith('.pdf') ? filename : '$filename.pdf';
final blob = html.Blob([bytes], 'application/pdf');
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', name)
..click();
html.Url.revokeObjectUrl(url);
// ignore: avoid_print
print('✅ PDF downloaded successfully: $name');
} catch (e) {
// ignore: avoid_print
print('❌ Error downloading PDF: $e');
}
}
Future<void> _loadDocument() async {
setState(() {
_isLoading = true;
@ -47,6 +100,22 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
_isLoading = false;
});
}
// load related warehouse docs
try {
final data = await _warehouseService.search(
businessId: doc.businessId,
limit: 50,
filters: {
'source_type': 'invoice',
'source_document_id': widget.documentId,
},
);
if (mounted) {
setState(() {
_relatedWhDocs = List<dynamic>.from(data['items'] ?? const []);
});
}
} catch (_) {}
} catch (e) {
if (mounted) {
setState(() {
@ -62,27 +131,77 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
final theme = Theme.of(context);
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.85,
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
children: [
// هدر
_buildHeader(theme),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.all(16),
child: _isLoading
? const SizedBox(height: 240, child: Center(child: CircularProgressIndicator()))
: _errorMessage != null
? SizedBox(height: 240, child: Center(child: Text(_errorMessage!)))
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// هدر
_buildHeader(theme),
// محتوای اصلی
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? _buildError()
: _buildContent(theme),
),
// محتوای اصلی
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? _buildError()
: _buildContent(theme),
),
// فوتر
_buildFooter(),
],
// فوتر
_buildFooter(),
const SizedBox(height: 16),
if (_relatedWhDocs.isNotEmpty) ...[
Text('حواله‌های مرتبط', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _relatedWhDocs.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = _relatedWhDocs[index] as Map<String, dynamic>;
return ListTile(
dense: true,
title: Text('${it['code'] ?? '-'}${it['doc_type'] ?? ''}${it['status'] ?? ''}'),
subtitle: Text(it['document_date'] ?? ''),
trailing: IconButton(
icon: const Icon(Icons.publish),
onPressed: (it['status'] == 'draft') ? () async {
try {
await _warehouseService.postDoc(
businessId: _document!.businessId,
docId: it['id'],
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('حواله پست شد')),
);
_loadDocument();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در پست حواله: $e')),
);
}
} : null,
),
);
},
),
),
],
],
),
),
),
),
);
@ -447,14 +566,12 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
children: [
// دکمه چاپ PDF
OutlinedButton.icon(
onPressed: () {
// TODO: پیادهسازی چاپ PDF
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('چاپ PDF در حال پیاده‌سازی است')),
);
},
icon: const Icon(Icons.picture_as_pdf),
label: const Text('چاپ PDF'),
onPressed: _isGeneratingPdf ? null : _generatePdf,
icon: _isGeneratingPdf
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.picture_as_pdf),
label: Text(_isGeneratingPdf ? AppLocalizations.of(context).generating : AppLocalizations.of(context).printPdf),
),
const SizedBox(width: 12),
// دکمه بستن

View file

@ -8,12 +8,14 @@ class CheckOption {
final String? personName;
final String? bankName;
final String? sayadCode;
final int? currencyId;
const CheckOption({
required this.id,
required this.number,
this.personName,
this.bankName,
this.sayadCode,
this.currencyId,
});
}
@ -23,6 +25,7 @@ class CheckComboboxWidget extends StatefulWidget {
final ValueChanged<CheckOption?> onChanged;
final String label;
final String hintText;
final int? filterCurrencyId;
const CheckComboboxWidget({
super.key,
@ -31,6 +34,7 @@ class CheckComboboxWidget extends StatefulWidget {
this.selectedCheckId,
this.label = 'چک',
this.hintText = 'جست‌وجو و انتخاب چک',
this.filterCurrencyId,
});
@override
@ -101,7 +105,7 @@ class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
? (res['data'] as Map)['items']
: res['items'];
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
var items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
final m = Map<String, dynamic>.from(e as Map);
return CheckOption(
id: '${m['id']}',
@ -109,8 +113,14 @@ class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
personName: (m['person_name'] ?? m['holder_name'])?.toString(),
bankName: (m['bank_name'] ?? '').toString(),
sayadCode: (m['sayad_code'] ?? '').toString(),
currencyId: (m['currency_id'] ?? m['currencyId']) is int
? (m['currency_id'] ?? m['currencyId']) as int
: int.tryParse('${m['currency_id'] ?? m['currencyId'] ?? ''}'),
);
}).toList();
if (widget.filterCurrencyId != null) {
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
}
if (!mounted) return;
setState(() {
_items = items;

View file

@ -16,6 +16,7 @@ import 'bank_account_combobox_widget.dart';
import 'cash_register_combobox_widget.dart';
import 'petty_cash_combobox_widget.dart';
import 'account_tree_combobox_widget.dart';
import 'check_combobox_widget.dart';
import '../../models/invoice_type_model.dart';
class InvoiceTransactionsWidget extends StatefulWidget {
@ -24,6 +25,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final InvoiceType invoiceType;
final int? selectedCurrencyId;
const InvoiceTransactionsWidget({
super.key,
@ -32,6 +34,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
required this.businessId,
required this.calendarController,
required this.invoiceType,
this.selectedCurrencyId,
});
@override
@ -327,6 +330,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
businessId: widget.businessId,
calendarController: widget.calendarController,
invoiceType: widget.invoiceType,
selectedCurrencyId: widget.selectedCurrencyId,
onSave: (newTransaction) {
if (index != null) {
// ویرایش تراکنش موجود
@ -351,6 +355,7 @@ class TransactionDialog extends StatefulWidget {
final CalendarController calendarController;
final ValueChanged<InvoiceTransaction> onSave;
final InvoiceType invoiceType;
final int? selectedCurrencyId;
const TransactionDialog({
super.key,
@ -358,6 +363,7 @@ class TransactionDialog extends StatefulWidget {
required this.businessId,
required this.calendarController,
required this.invoiceType,
this.selectedCurrencyId,
required this.onSave,
});
@ -387,6 +393,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
String? _selectedCashRegisterId;
String? _selectedPettyCashId;
String? _selectedCheckId;
int? _selectedCheckCurrencyId;
String? _selectedPersonId;
AccountTreeNode? _selectedAccount;
@ -728,6 +735,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
return BankAccountComboboxWidget(
businessId: widget.businessId,
selectedAccountId: _selectedBankId,
filterCurrencyId: widget.selectedCurrencyId,
onChanged: (opt) {
setState(() {
_selectedBankId = opt?.id;
@ -743,6 +751,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
return CashRegisterComboboxWidget(
businessId: widget.businessId,
selectedRegisterId: _selectedCashRegisterId,
filterCurrencyId: widget.selectedCurrencyId,
onChanged: (opt) {
setState(() {
_selectedCashRegisterId = opt?.id;
@ -758,6 +767,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
return PettyCashComboboxWidget(
businessId: widget.businessId,
selectedPettyCashId: _selectedPettyCashId,
filterCurrencyId: widget.selectedCurrencyId,
onChanged: (opt) {
setState(() {
_selectedPettyCashId = opt?.id;
@ -770,21 +780,18 @@ class _TransactionDialogState extends State<TransactionDialog> {
}
Widget _buildCheckFields() {
return DropdownButtonFormField<String>(
initialValue: _selectedCheckId,
decoration: const InputDecoration(
labelText: 'چک *',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'check1', child: Text('چک شماره 123456')),
DropdownMenuItem(value: 'check2', child: Text('چک شماره 789012')),
],
onChanged: (value) {
return CheckComboboxWidget(
businessId: widget.businessId,
selectedCheckId: _selectedCheckId,
filterCurrencyId: widget.selectedCurrencyId,
onChanged: (opt) {
setState(() {
_selectedCheckId = value;
_selectedCheckId = opt?.id;
_selectedCheckCurrencyId = opt?.currencyId;
});
},
label: 'چک *',
hintText: 'جست‌وجو و انتخاب چک',
);
}
@ -884,6 +891,58 @@ class _TransactionDialogState extends State<TransactionDialog> {
final commission = _commissionController.text.isNotEmpty
? double.parse(_commissionController.text)
: null;
// اعتبارسنجی همخوانی ارز با ارز فاکتور برای انواع دارای ارز
final invoiceCurrencyId = widget.selectedCurrencyId;
if (invoiceCurrencyId != null) {
if (_selectedType == TransactionType.bank && _selectedBankId != null) {
final bank = _banks.firstWhere(
(b) => b['id']?.toString() == _selectedBankId,
orElse: () => <String, dynamic>{},
);
final bankCurrencyId = int.tryParse('${bank['currency_id'] ?? bank['currencyId'] ?? ''}');
if (bankCurrencyId != null && bankCurrencyId != invoiceCurrencyId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ارز بانک انتخابی با ارز فاکتور هم‌خوانی ندارد')),
);
return;
}
}
if (_selectedType == TransactionType.cashRegister && _selectedCashRegisterId != null) {
final cr = _cashRegisters.firstWhere(
(c) => c['id']?.toString() == _selectedCashRegisterId,
orElse: () => <String, dynamic>{},
);
final crCurrencyId = int.tryParse('${cr['currency_id'] ?? cr['currencyId'] ?? ''}');
if (crCurrencyId != null && crCurrencyId != invoiceCurrencyId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ارز صندوق انتخابی با ارز فاکتور هم‌خوانی ندارد')),
);
return;
}
}
if (_selectedType == TransactionType.pettyCash && _selectedPettyCashId != null) {
final pc = _pettyCashList.firstWhere(
(p) => p['id']?.toString() == _selectedPettyCashId,
orElse: () => <String, dynamic>{},
);
final pcCurrencyId = int.tryParse('${pc['currency_id'] ?? pc['currencyId'] ?? ''}');
if (pcCurrencyId != null && pcCurrencyId != invoiceCurrencyId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ارز تنخواه‌گردان انتخابی با ارز فاکتور هم‌خوانی ندارد')),
);
return;
}
}
if (_selectedType == TransactionType.check && _selectedCheckId != null) {
final chkCurrencyId = _selectedCheckCurrencyId;
if (chkCurrencyId != null && chkCurrencyId != invoiceCurrencyId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ارز چک انتخابی با ارز فاکتور هم‌خوانی ندارد')),
);
return;
}
}
}
final transaction = InvoiceTransaction(
id: widget.transaction?.id ?? _uuid.v4(),

View file

@ -13,6 +13,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
final ValueChanged<List<InvoiceLineItem>>? onChanged;
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
final bool postInventory;
final List<InvoiceLineItem>? initialRows; // برای مقداردهی اولیه (ویرایش فاکتور)
const InvoiceLineItemsTable({
super.key,
@ -21,6 +22,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
this.onChanged,
this.invoiceType = 'sales',
this.postInventory = true,
this.initialRows,
});
@override
@ -73,6 +75,11 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
@override
void initState() {
super.initState();
if ((widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
_rows.clear();
_rows.addAll(widget.initialRows!);
_notify();
}
}
@override
@ -84,6 +91,13 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
// invalidate inline price list cache if currency changed
_inlinePriceList = null;
}
// اگر والد پس از لود اولیه، ردیفهای اولیه را فراهم کرد و جدول خالی است، آنها را ست کن
if (_rows.isEmpty && (widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
_rows.clear();
_rows.addAll(widget.initialRows!);
_notify();
}
}
// لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام میشود

View file

@ -9,6 +9,7 @@
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_saver_registrar =
@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -11,6 +11,7 @@ import file_selector_macos
import flutter_secure_storage_macos
import path_provider_foundation
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View file

@ -650,6 +650,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.24"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.5"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.4"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.4"
uuid:
dependency: "direct main"
description:
@ -708,4 +772,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.9.2 <4.0.0"
flutter: ">=3.29.0"
flutter: ">=3.35.0"

View file

@ -52,6 +52,7 @@ dependencies:
data_table_2: ^2.5.12
file_picker: ^10.3.3
file_selector: ^1.0.4
url_launcher: ^6.3.0
dev_dependencies:
flutter_test:

View file

@ -9,6 +9,7 @@
#include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSaverPluginRegisterWithRegistrar(
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View file

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_windows
flutter_secure_storage_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST