progress in some parts
This commit is contained in:
parent
28ccc57f70
commit
b0884a33fd
249
deploy.sh
Normal file
249
deploy.sh
Normal 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 "$@"
|
||||||
|
|
||||||
|
|
||||||
180
hesabixAPI/adapters/api/v1/admin/payment_gateways.py
Normal file
180
hesabixAPI/adapters/api/v1/admin/payment_gateways.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
52
hesabixAPI/adapters/api/v1/admin/system_settings.py
Normal file
52
hesabixAPI/adapters/api/v1/admin/system_settings.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
81
hesabixAPI/adapters/api/v1/admin/wallet_admin.py
Normal file
81
hesabixAPI/adapters/api/v1/admin/wallet_admin.py
Normal 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")
|
||||||
|
|
||||||
|
|
@ -469,6 +469,38 @@ async def export_bank_accounts_pdf(
|
||||||
|
|
||||||
headers_html = ''.join(f"<th>{escape_val(h)}</th>" for h in headers)
|
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"""
|
table_html = f"""
|
||||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -560,8 +592,9 @@ async def export_bank_accounts_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
font_config = FontConfiguration()
|
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(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,38 @@ async def export_cash_registers_pdf(
|
||||||
|
|
||||||
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
|
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"""
|
table_html = f"""
|
||||||
<html lang="{html_lang}" dir="{html_dir}">
|
<html lang="{html_lang}" dir="{html_dir}">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -464,8 +496,9 @@ async def export_cash_registers_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
font_config = FontConfiguration()
|
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(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
@router.get(
|
||||||
"/documents/{document_id}",
|
"/documents/{document_id}",
|
||||||
summary="جزئیات سند حسابداری",
|
summary="جزئیات سند حسابداری",
|
||||||
|
|
@ -283,6 +450,7 @@ async def get_document_pdf_endpoint(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
ctx: AuthContext = Depends(get_current_user),
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
template_id: int | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PDF یک سند
|
PDF یک سند
|
||||||
|
|
@ -298,11 +466,119 @@ async def get_document_pdf_endpoint(
|
||||||
if business_id and not ctx.can_access_business(business_id):
|
if business_id and not ctx.can_access_business(business_id):
|
||||||
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
|
||||||
# TODO: تولید PDF
|
# رندر با قالب سفارشی (documents/detail) یا خروجی پیشفرض
|
||||||
raise ApiError(
|
from weasyprint import HTML
|
||||||
"NOT_IMPLEMENTED",
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
"PDF generation is not implemented yet",
|
from app.core.i18n import negotiate_locale
|
||||||
http_status=501
|
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",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,35 +261,179 @@ async def export_expense_income_pdf_endpoint(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
ctx: AuthContext = Depends(get_current_user),
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""خروجی PDF"""
|
"""خروجی PDF (با پشتیبانی قالب سفارشی expense_income/list)"""
|
||||||
from app.services.expense_income_service import export_expense_income_pdf
|
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from weasyprint import HTML
|
||||||
# دریافت پارامترهای فیلتر
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
query_dict = {}
|
from app.core.i18n import negotiate_locale
|
||||||
|
from html import escape
|
||||||
|
import datetime, json
|
||||||
|
# دریافت پارامترهای فیلتر و تنظیمات
|
||||||
try:
|
try:
|
||||||
body_json = await request.json()
|
body = 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]
|
|
||||||
except Exception:
|
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:
|
try:
|
||||||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||||||
if fy_header:
|
if fy_header:
|
||||||
query_dict["fiscal_year_id"] = int(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:
|
except Exception:
|
||||||
pass
|
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(
|
return Response(
|
||||||
content=pdf_data,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
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",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,54 @@ def export_inventory_transfers_pdf(
|
||||||
f"</tr>" for d in rows
|
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 = f"""
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -189,8 +237,9 @@ def export_inventory_transfers_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or html
|
||||||
font_config = FontConfiguration()
|
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"
|
filename = f"inventory_transfers_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,152 @@ def get_invoice_endpoint(
|
||||||
result = invoice_document_to_dict(db, doc)
|
result = invoice_document_to_dict(db, doc)
|
||||||
return success_response(data={"item": result}, request=request, message="INVOICE")
|
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")
|
@router.post("/business/{business_id}/search")
|
||||||
@require_business_access("business_id")
|
@require_business_access("business_id")
|
||||||
|
|
@ -741,7 +887,41 @@ async def export_invoices_pdf(
|
||||||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
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>
|
<!DOCTYPE html>
|
||||||
<html dir='{ 'rtl' if is_fa else 'ltr' }'>
|
<html dir='{ 'rtl' if is_fa else 'ltr' }'>
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,47 @@ async def export_kardex_pdf_endpoint(
|
||||||
for it in items
|
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 = f"""
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -277,8 +318,9 @@ async def export_kardex_pdf_endpoint(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or html
|
||||||
font_config = FontConfiguration()
|
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"
|
filename = f"kardex_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||||
return Response(
|
return Response(
|
||||||
|
|
|
||||||
93
hesabixAPI/adapters/api/v1/opening_balance.py
Normal file
93
hesabixAPI/adapters/api/v1/opening_balance.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
108
hesabixAPI/adapters/api/v1/payment_callbacks.py
Normal file
108
hesabixAPI/adapters/api/v1/payment_callbacks.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
56
hesabixAPI/adapters/api/v1/payment_gateways.py
Normal file
56
hesabixAPI/adapters/api/v1/payment_gateways.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -538,6 +538,38 @@ async def export_persons_pdf(
|
||||||
page_label_left = "صفحه " if is_fa else "Page "
|
page_label_left = "صفحه " if is_fa else "Page "
|
||||||
page_label_of = " از " if is_fa else " of "
|
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"""
|
table_html = f"""
|
||||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -629,8 +661,9 @@ async def export_persons_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
font_config = FontConfiguration()
|
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
|
# Build meaningful filename
|
||||||
biz_name = ""
|
biz_name = ""
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,38 @@ async def export_petty_cash_pdf(
|
||||||
|
|
||||||
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
|
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"""
|
table_html = f"""
|
||||||
<html lang="{html_lang}" dir="{html_dir}">
|
<html lang="{html_lang}" dir="{html_dir}">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -460,8 +492,9 @@ async def export_petty_cash_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
font_config = FontConfiguration()
|
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(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -735,6 +735,38 @@ async def export_products_pdf(
|
||||||
page_label_left = "صفحه " if is_fa else "Page "
|
page_label_left = "صفحه " if is_fa else "Page "
|
||||||
page_label_of = " از " if is_fa else " of "
|
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"""
|
table_html = f"""
|
||||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -826,8 +858,9 @@ async def export_products_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
font_config = FontConfiguration()
|
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
|
# Build meaningful filename
|
||||||
biz_name = business_name
|
biz_name = business_name
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,7 @@ async def export_single_receipt_payment_pdf(
|
||||||
request: Request,
|
request: Request,
|
||||||
auth_context: AuthContext = Depends(get_current_user),
|
auth_context: AuthContext = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
template_id: int | None = None,
|
||||||
):
|
):
|
||||||
"""خروجی PDF تک سند دریافت/پرداخت"""
|
"""خروجی PDF تک سند دریافت/پرداخت"""
|
||||||
from weasyprint import HTML, CSS
|
from weasyprint import HTML, CSS
|
||||||
|
|
@ -497,8 +498,43 @@ async def export_single_receipt_payment_pdf(
|
||||||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||||
|
|
||||||
# ایجاد HTML برای PDF
|
# تلاش برای رندر با قالب سفارشی (receipts_payments/detail)
|
||||||
html_content = f"""
|
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>
|
<!DOCTYPE html>
|
||||||
<html dir="{'rtl' if is_fa else 'ltr'}">
|
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -810,7 +846,41 @@ async def export_receipts_payments_pdf(
|
||||||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
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"""
|
table_html = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html dir="{'rtl' if is_fa else 'ltr'}">
|
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||||
|
|
@ -914,8 +984,10 @@ async def export_receipts_payments_pdf(
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
final_html = resolved_html or table_html
|
||||||
|
|
||||||
font_config = FontConfiguration()
|
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
|
# Build meaningful filename
|
||||||
def slugify(text: str) -> str:
|
def slugify(text: str) -> str:
|
||||||
|
|
|
||||||
241
hesabixAPI/adapters/api/v1/report_templates.py
Normal file
241
hesabixAPI/adapters/api/v1/report_templates.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -299,6 +299,38 @@ async def export_transfers_pdf(
|
||||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||||
|
|
||||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
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"""
|
html = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html dir='rtl'>
|
<html dir='rtl'>
|
||||||
|
|
@ -329,8 +361,9 @@ async def export_transfers_pdf(
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
final_html = resolved_html or html
|
||||||
font_config = FontConfiguration()
|
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"
|
filename = f"transfers_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
|
|
|
||||||
285
hesabixAPI/adapters/api/v1/wallet.py
Normal file
285
hesabixAPI/adapters/api/v1/wallet.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
46
hesabixAPI/adapters/api/v1/wallet_webhook.py
Normal file
46
hesabixAPI/adapters/api/v1/wallet_webhook.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
115
hesabixAPI/adapters/api/v1/warehouse_docs.py
Normal file
115
hesabixAPI/adapters/api/v1/warehouse_docs.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
19
hesabixAPI/adapters/db/models/invoice_item_line.py
Normal file
19
hesabixAPI/adapters/db/models/invoice_item_line.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
48
hesabixAPI/adapters/db/models/payment_gateway.py
Normal file
48
hesabixAPI/adapters/db/models/payment_gateway.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
46
hesabixAPI/adapters/db/models/report_template.py
Normal file
46
hesabixAPI/adapters/db/models/report_template.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
31
hesabixAPI/adapters/db/models/system_setting.py
Normal file
31
hesabixAPI/adapters/db/models/system_setting.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
109
hesabixAPI/adapters/db/models/wallet.py
Normal file
109
hesabixAPI/adapters/db/models/wallet.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
34
hesabixAPI/adapters/db/models/warehouse_document.py
Normal file
34
hesabixAPI/adapters/db/models/warehouse_document.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
24
hesabixAPI/adapters/db/models/warehouse_document_line.py
Normal file
24
hesabixAPI/adapters/db/models/warehouse_document_line.py
Normal 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")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,13 +130,22 @@ def require_business_access(business_id_param: str = "business_id"):
|
||||||
result = await result
|
result = await result
|
||||||
return result
|
return result
|
||||||
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
||||||
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
|
sig = inspect.signature(func)
|
||||||
# Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations
|
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||||
|
# Preserve/evaluate annotations; ensure 'request' is explicitly FastAPI Request
|
||||||
try:
|
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:
|
except Exception:
|
||||||
# Fallback to original annotations (may be string-based) if evaluation fails
|
evaluated = getattr(func, "__annotations__", {})
|
||||||
wrapper.__annotations__ = 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 wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.receipts_payments import router as receipts_payments_router
|
||||||
from adapters.api.v1.transfers import router as transfers_router
|
from adapters.api.v1.transfers import router as transfers_router
|
||||||
from adapters.api.v1.fiscal_years import router as fiscal_years_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.documents import router as documents_router
|
||||||
from adapters.api.v1.kardex import router as kardex_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.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.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
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(categories_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(product_attributes_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)
|
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
|
from adapters.api.v1.warehouses import router as warehouses_router
|
||||||
application.include_router(warehouses_router, prefix=settings.api_v1_prefix)
|
application.include_router(warehouses_router, prefix=settings.api_v1_prefix)
|
||||||
from adapters.api.v1.boms import router as boms_router
|
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(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(kardex_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(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
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
@ -334,6 +350,10 @@ def create_app() -> FastAPI:
|
||||||
# Admin endpoints
|
# Admin endpoints
|
||||||
application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
|
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_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)
|
register_error_handlers(application)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,15 @@ from adapters.db.models.document import Document
|
||||||
from adapters.db.models.document_line import DocumentLine
|
from adapters.db.models.document_line import DocumentLine
|
||||||
from adapters.db.models.account import Account
|
from adapters.db.models.account import Account
|
||||||
from adapters.db.models.currency import Currency
|
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.user import User
|
||||||
from adapters.db.models.fiscal_year import FiscalYear
|
from adapters.db.models.fiscal_year import FiscalYear
|
||||||
from adapters.db.models.person import Person
|
from adapters.db.models.person import Person
|
||||||
from adapters.db.models.product import Product
|
from adapters.db.models.product import Product
|
||||||
|
from adapters.db.models.invoice_item_line import InvoiceItemLine
|
||||||
from app.core.responses import ApiError
|
from app.core.responses import ApiError
|
||||||
import jdatetime
|
import jdatetime
|
||||||
|
|
||||||
|
|
@ -399,9 +404,6 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
|
||||||
total = Decimal(0)
|
total = Decimal(0)
|
||||||
for line in lines:
|
for line in lines:
|
||||||
info = line.get("extra_info") or {}
|
info = line.get("extra_info") or {}
|
||||||
# فقط برای کالاهای دارای کنترل موجودی
|
|
||||||
if not bool(info.get("inventory_tracked")):
|
|
||||||
continue
|
|
||||||
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
|
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
|
||||||
if info.get("inventory_posted") is False:
|
if info.get("inventory_posted") is False:
|
||||||
continue
|
continue
|
||||||
|
|
@ -616,16 +618,9 @@ def create_invoice(
|
||||||
if totals_missing:
|
if totals_missing:
|
||||||
totals = _extract_totals_from_lines(lines_input)
|
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)
|
post_inventory: bool = _is_inventory_posting_enabled(data)
|
||||||
# Determine outgoing lines for stock checks
|
|
||||||
movement_hint, _ = _movement_from_type(invoice_type)
|
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
|
# 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")]
|
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 = dict(ln.get("extra_info") or {})
|
||||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||||
ln["extra_info"] = info
|
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 (only for tracked products)
|
||||||
costing_method = _get_costing_method(data)
|
costing_method = _get_costing_method(data)
|
||||||
if post_inventory and costing_method == "fifo" and tracked_outgoing_lines:
|
# محاسبه COGS به پست حواله منتقل میشود
|
||||||
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
|
|
||||||
|
|
||||||
# Create document
|
# Create document
|
||||||
doc_code = _build_invoice_code(db, business_id, invoice_type)
|
doc_code = _build_invoice_code(db, business_id, invoice_type)
|
||||||
|
|
@ -711,26 +676,23 @@ def create_invoice(
|
||||||
db.add(document)
|
db.add(document)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Create product lines (no debit/credit)
|
# ذخیره اقلام فاکتور در جدول مجزا (invoice_item_lines)
|
||||||
for line in lines_input:
|
for line in lines_input:
|
||||||
product_id = line.get("product_id")
|
product_id = line.get("product_id")
|
||||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||||
if not product_id or qty <= 0:
|
if not product_id or qty <= 0:
|
||||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
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 = dict(line.get("extra_info") or {})
|
||||||
# علامتگذاری اینکه این خط در انبار پست شده/نشده است
|
extra_info.pop("inventory_posted", None)
|
||||||
extra_info["inventory_posted"] = bool(post_inventory)
|
db.add(InvoiceItemLine(
|
||||||
db.add(DocumentLine(
|
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
product_id=int(product_id),
|
product_id=int(product_id),
|
||||||
quantity=qty,
|
quantity=qty,
|
||||||
debit=Decimal(0),
|
|
||||||
credit=Decimal(0),
|
|
||||||
description=line.get("description"),
|
description=line.get("description"),
|
||||||
extra_info=extra_info,
|
extra_info=extra_info,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Accounting lines for finalized invoices
|
# Accounting lines for finalized invoices (بدون خطوط COGS/Inventory؛ به حواله موکول شد)
|
||||||
if not document.is_proforma:
|
if not document.is_proforma:
|
||||||
accounts = _resolve_accounts_for_invoice(db, data)
|
accounts = _resolve_accounts_for_invoice(db, data)
|
||||||
|
|
||||||
|
|
@ -738,8 +700,7 @@ def create_invoice(
|
||||||
tax = Decimal(str(totals["tax"]))
|
tax = Decimal(str(totals["tax"]))
|
||||||
total_with_tax = net + tax
|
total_with_tax = net + tax
|
||||||
|
|
||||||
# COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمیشوند)
|
# COGS به پست حواله منتقل شد
|
||||||
cogs_total = _extract_cogs_total(lines_input)
|
|
||||||
|
|
||||||
# Sales
|
# Sales
|
||||||
if invoice_type == INVOICE_SALES:
|
if invoice_type == INVOICE_SALES:
|
||||||
|
|
@ -769,66 +730,8 @@ def create_invoice(
|
||||||
credit=tax,
|
credit=tax,
|
||||||
description="مالیات بر ارزش افزوده خروجی",
|
description="مالیات بر ارزش افزوده خروجی",
|
||||||
))
|
))
|
||||||
if cogs_total > 0:
|
# COGS/Inventory در پست حواله ثبت خواهد شد
|
||||||
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="خروج از موجودی بابت فروش",
|
|
||||||
))
|
|
||||||
|
|
||||||
# --- پورسانت فروشنده/بازاریاب (در صورت وجود) ---
|
|
||||||
# محاسبه و ثبت پورسانت برای فروش و برگشت از فروش
|
|
||||||
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
|
# Sales Return
|
||||||
elif invoice_type == INVOICE_SALES_RETURN:
|
elif invoice_type == INVOICE_SALES_RETURN:
|
||||||
|
|
@ -857,21 +760,7 @@ def create_invoice(
|
||||||
credit=Decimal(0),
|
credit=Decimal(0),
|
||||||
description="تعدیل VAT برگشت از فروش",
|
description="تعدیل VAT برگشت از فروش",
|
||||||
))
|
))
|
||||||
if cogs_total > 0:
|
# ورود موجودی/تعدیل COGS در پست حواله انجام میشود
|
||||||
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="تعدیل بهای تمامشده برگشت",
|
|
||||||
))
|
|
||||||
|
|
||||||
# Purchase
|
# Purchase
|
||||||
elif invoice_type == INVOICE_PURCHASE:
|
elif invoice_type == INVOICE_PURCHASE:
|
||||||
|
|
@ -931,6 +820,8 @@ def create_invoice(
|
||||||
|
|
||||||
# Direct consumption
|
# Direct consumption
|
||||||
elif invoice_type == 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:
|
if cogs_total > 0:
|
||||||
db.add(DocumentLine(
|
db.add(DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
|
|
@ -949,6 +840,8 @@ def create_invoice(
|
||||||
|
|
||||||
# Waste
|
# Waste
|
||||||
elif invoice_type == 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:
|
if cogs_total > 0:
|
||||||
db.add(DocumentLine(
|
db.add(DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
|
|
@ -1002,6 +895,47 @@ def create_invoice(
|
||||||
description="انتقال از کاردرجریان",
|
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
|
# Persist invoice first
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(document)
|
db.refresh(document)
|
||||||
|
|
@ -1018,18 +952,62 @@ def create_invoice(
|
||||||
# Aggregate amounts into one receipt/payment with multiple account_lines
|
# Aggregate amounts into one receipt/payment with multiple account_lines
|
||||||
account_lines: List[Dict[str, Any]] = []
|
account_lines: List[Dict[str, Any]] = []
|
||||||
total_amount = Decimal(0)
|
total_amount = Decimal(0)
|
||||||
|
# Validate currency of payment accounts vs invoice currency
|
||||||
|
invoice_currency_id = int(currency_id)
|
||||||
for p in payments:
|
for p in payments:
|
||||||
amount = Decimal(str(p.get("amount", 0) or 0))
|
amount = Decimal(str(p.get("amount", 0) or 0))
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
continue
|
continue
|
||||||
total_amount += amount
|
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"),
|
"transaction_type": p.get("transaction_type"),
|
||||||
"amount": float(amount),
|
"amount": float(amount),
|
||||||
"description": p.get("description"),
|
"description": p.get("description"),
|
||||||
"transaction_date": p.get("transaction_date"),
|
"transaction_date": p.get("transaction_date"),
|
||||||
"commission": p.get("commission"),
|
"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:
|
if total_amount > 0 and account_lines:
|
||||||
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
|
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
|
||||||
|
|
@ -1062,6 +1040,42 @@ def create_invoice(
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(document)
|
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)
|
return invoice_document_to_dict(db, document)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1108,22 +1122,17 @@ def update_invoice(
|
||||||
if data.get("description") is not None:
|
if data.get("description") is not None:
|
||||||
document.description = data.get("description")
|
document.description = data.get("description")
|
||||||
|
|
||||||
# Recreate lines
|
# Recreate lines: حذف سطرهای حسابداری و اقلام فاکتور و بازایجاد
|
||||||
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
|
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 [])
|
lines_input: List[Dict[str, Any]] = list(data.get("lines") or [])
|
||||||
if not lines_input:
|
if not lines_input:
|
||||||
raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400)
|
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
|
inv_type = document.document_type
|
||||||
movement_hint, _ = _movement_from_type(inv_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
|
# 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")]
|
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 = dict(ln.get("extra_info") or {})
|
||||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||||
ln["extra_info"] = info
|
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}
|
header_for_costing = data if data else {"extra_info": document.extra_info}
|
||||||
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
|
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:
|
for line in lines_input:
|
||||||
product_id = line.get("product_id")
|
product_id = line.get("product_id")
|
||||||
|
|
@ -1183,13 +1161,10 @@ def update_invoice(
|
||||||
if not product_id or qty <= 0:
|
if not product_id or qty <= 0:
|
||||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
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 = dict(line.get("extra_info") or {})
|
||||||
extra_info["inventory_posted"] = bool(post_inventory_update)
|
db.add(InvoiceItemLine(
|
||||||
db.add(DocumentLine(
|
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
product_id=int(product_id),
|
product_id=int(product_id),
|
||||||
quantity=qty,
|
quantity=qty,
|
||||||
debit=Decimal(0),
|
|
||||||
credit=Decimal(0),
|
|
||||||
description=line.get("description"),
|
description=line.get("description"),
|
||||||
extra_info=extra_info,
|
extra_info=extra_info,
|
||||||
))
|
))
|
||||||
|
|
@ -1206,7 +1181,7 @@ def update_invoice(
|
||||||
tax = Decimal(str(totals.get("tax", 0)))
|
tax = Decimal(str(totals.get("tax", 0)))
|
||||||
total_with_tax = net + tax
|
total_with_tax = net + tax
|
||||||
person_id = _person_id_from_header({"extra_info": header_extra})
|
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 inv_type == INVOICE_SALES:
|
||||||
if person_id:
|
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="درآمد فروش"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["revenue"].id, debit=Decimal(0), credit=net, description="درآمد فروش"))
|
||||||
if tax > 0:
|
if tax > 0:
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
|
||||||
if cogs_total > 0:
|
# COGS/Inventory by warehouse posting
|
||||||
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="خروج موجودی"))
|
|
||||||
elif inv_type == INVOICE_SALES_RETURN:
|
elif inv_type == INVOICE_SALES_RETURN:
|
||||||
if person_id:
|
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["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="برگشت از فروش"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["sales_return"].id, debit=net, credit=Decimal(0), description="برگشت از فروش"))
|
||||||
if tax > 0:
|
if tax > 0:
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT"))
|
||||||
if cogs_total > 0:
|
# Inventory/COGS handled in warehouse posting
|
||||||
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="تعدیل بهای تمامشده"))
|
|
||||||
elif inv_type == INVOICE_PURCHASE:
|
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:
|
if tax > 0:
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="مالیات ورودی"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="مالیات ورودی"))
|
||||||
if person_id:
|
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["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description))
|
||||||
elif inv_type == INVOICE_PURCHASE_RETURN:
|
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:
|
if tax > 0:
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=Decimal(0), credit=tax, description="تعدیل VAT ورودی"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=Decimal(0), credit=tax, description="تعدیل VAT ورودی"))
|
||||||
if person_id:
|
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))
|
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:
|
elif inv_type == INVOICE_DIRECT_CONSUMPTION:
|
||||||
if cogs_total > 0:
|
# Expense/Inventory in warehouse posting
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["direct_consumption"].id, debit=cogs_total, credit=Decimal(0), description="مصرف مستقیم"))
|
pass
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
|
|
||||||
elif inv_type == INVOICE_WASTE:
|
elif inv_type == INVOICE_WASTE:
|
||||||
if cogs_total > 0:
|
# Expense/Inventory in warehouse posting
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["waste_expense"].id, debit=cogs_total, credit=Decimal(0), description="ضایعات"))
|
pass
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
|
|
||||||
elif inv_type == INVOICE_PRODUCTION:
|
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"])
|
# WIP/Inventory in warehouse posting
|
||||||
if materials_cost > 0:
|
pass
|
||||||
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="انتقال از کاردرجریان"))
|
|
||||||
|
|
||||||
# --- پورسانت فروشنده/بازاریاب (بهصورت تکمیلی) ---
|
# --- پورسانت فروشنده/بازاریاب (بهصورت تکمیلی) ---
|
||||||
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||||
|
|
@ -1303,23 +1266,24 @@ def update_invoice(
|
||||||
|
|
||||||
|
|
||||||
def invoice_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
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]] = []
|
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()
|
||||||
for line in lines:
|
|
||||||
if line.product_id:
|
|
||||||
product = db.query(Product).filter(Product.id == line.product_id).first()
|
|
||||||
product_lines.append({
|
product_lines.append({
|
||||||
"id": line.id,
|
"id": it.id,
|
||||||
"product_id": line.product_id,
|
"product_id": it.product_id,
|
||||||
"product_name": getattr(product, "name", None),
|
"product_name": getattr(product, "name", None),
|
||||||
"quantity": float(line.quantity) if line.quantity else None,
|
"quantity": float(it.quantity) if it.quantity else None,
|
||||||
"description": line.description,
|
"description": it.description,
|
||||||
"extra_info": line.extra_info,
|
"extra_info": it.extra_info,
|
||||||
})
|
})
|
||||||
elif line.account_id:
|
|
||||||
|
# سطرهای حسابداری از 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 = db.query(Account).filter(Account.id == line.account_id).first()
|
||||||
account_lines.append({
|
account_lines.append({
|
||||||
"id": line.id,
|
"id": line.id,
|
||||||
|
|
|
||||||
236
hesabixAPI/app/services/opening_balance_service.py
Normal file
236
hesabixAPI/app/services/opening_balance_service.py
Normal 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 {}
|
||||||
|
|
||||||
|
|
||||||
229
hesabixAPI/app/services/payment_service.py
Normal file
229
hesabixAPI/app/services/payment_service.py
Normal 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}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -406,6 +406,11 @@ def create_receipt_payment(
|
||||||
account_code = "20201" # حسابهای پرداختنی
|
account_code = "20201" # حسابهای پرداختنی
|
||||||
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
||||||
account = _get_fixed_account_by_code(db, 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:
|
elif account_id:
|
||||||
# اگر account_id مشخص باشد، از آن استفاده کن
|
# اگر account_id مشخص باشد، از آن استفاده کن
|
||||||
logger.info(f"استفاده از account_id مشخص: {account_id}")
|
logger.info(f"استفاده از account_id مشخص: {account_id}")
|
||||||
|
|
|
||||||
257
hesabixAPI/app/services/report_template_service.py
Normal file
257
hesabixAPI/app/services/report_template_service.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
64
hesabixAPI/app/services/system_settings_service.py
Normal file
64
hesabixAPI/app/services/system_settings_service.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
631
hesabixAPI/app/services/wallet_service.py
Normal file
631
hesabixAPI/app/services/wallet_service.py
Normal 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)
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
from __future__ import annotations
|
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.orm import Session
|
||||||
from sqlalchemy import and_
|
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 app.core.responses import ApiError
|
||||||
from adapters.db.models.warehouse import Warehouse
|
from adapters.db.models.warehouse import Warehouse
|
||||||
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
||||||
|
|
@ -12,6 +22,195 @@ from adapters.api.v1.schemas import QueryInfo, FilterItem
|
||||||
from app.services.query_service import QueryService
|
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]:
|
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": obj.id,
|
"id": obj.id,
|
||||||
|
|
@ -90,9 +289,7 @@ def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
|
||||||
return repo.delete(warehouse_id)
|
return repo.delete(warehouse_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
|
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
|
# Ensure business scoping via filters
|
||||||
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
|
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
|
||||||
merged_filters = [base_filter]
|
merged_filters = [base_filter]
|
||||||
|
|
|
||||||
|
|
@ -22,20 +22,30 @@ adapters/api/v1/health.py
|
||||||
adapters/api/v1/inventory_transfers.py
|
adapters/api/v1/inventory_transfers.py
|
||||||
adapters/api/v1/invoices.py
|
adapters/api/v1/invoices.py
|
||||||
adapters/api/v1/kardex.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/persons.py
|
||||||
adapters/api/v1/petty_cash.py
|
adapters/api/v1/petty_cash.py
|
||||||
adapters/api/v1/price_lists.py
|
adapters/api/v1/price_lists.py
|
||||||
adapters/api/v1/product_attributes.py
|
adapters/api/v1/product_attributes.py
|
||||||
adapters/api/v1/products.py
|
adapters/api/v1/products.py
|
||||||
adapters/api/v1/receipts_payments.py
|
adapters/api/v1/receipts_payments.py
|
||||||
|
adapters/api/v1/report_templates.py
|
||||||
adapters/api/v1/schemas.py
|
adapters/api/v1/schemas.py
|
||||||
adapters/api/v1/tax_types.py
|
adapters/api/v1/tax_types.py
|
||||||
adapters/api/v1/tax_units.py
|
adapters/api/v1/tax_units.py
|
||||||
adapters/api/v1/transfers.py
|
adapters/api/v1/transfers.py
|
||||||
adapters/api/v1/users.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/warehouses.py
|
||||||
adapters/api/v1/admin/email_config.py
|
adapters/api/v1/admin/email_config.py
|
||||||
adapters/api/v1/admin/file_storage.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/__init__.py
|
||||||
adapters/api/v1/schema_models/account.py
|
adapters/api/v1/schema_models/account.py
|
||||||
adapters/api/v1/schema_models/bank_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/email_config.py
|
||||||
adapters/db/models/file_storage.py
|
adapters/db/models/file_storage.py
|
||||||
adapters/db/models/fiscal_year.py
|
adapters/db/models/fiscal_year.py
|
||||||
|
adapters/db/models/invoice_item_line.py
|
||||||
adapters/db/models/password_reset.py
|
adapters/db/models/password_reset.py
|
||||||
|
adapters/db/models/payment_gateway.py
|
||||||
adapters/db/models/person.py
|
adapters/db/models/person.py
|
||||||
adapters/db/models/petty_cash.py
|
adapters/db/models/petty_cash.py
|
||||||
adapters/db/models/price_list.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.py
|
||||||
adapters/db/models/product_attribute_link.py
|
adapters/db/models/product_attribute_link.py
|
||||||
adapters/db/models/product_bom.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_type.py
|
||||||
adapters/db/models/tax_unit.py
|
adapters/db/models/tax_unit.py
|
||||||
adapters/db/models/user.py
|
adapters/db/models/user.py
|
||||||
|
adapters/db/models/wallet.py
|
||||||
adapters/db/models/warehouse.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/__init__.py
|
||||||
adapters/db/models/support/category.py
|
adapters/db/models/support/category.py
|
||||||
adapters/db/models/support/message.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/inventory_transfer_service.py
|
||||||
app/services/invoice_service.py
|
app/services/invoice_service.py
|
||||||
app/services/kardex_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/person_service.py
|
||||||
app/services/petty_cash_service.py
|
app/services/petty_cash_service.py
|
||||||
app/services/price_list_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/product_service.py
|
||||||
app/services/query_service.py
|
app/services/query_service.py
|
||||||
app/services/receipt_payment_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/transfer_service.py
|
||||||
|
app/services/wallet_service.py
|
||||||
app/services/warehouse_service.py
|
app/services/warehouse_service.py
|
||||||
app/services/pdf/__init__.py
|
app/services/pdf/__init__.py
|
||||||
app/services/pdf/base_pdf_service.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/20251014_000501_add_quantity_to_document_lines.py
|
||||||
migrations/versions/20251021_000601_add_bom_and_warehouses.py
|
migrations/versions/20251021_000601_add_bom_and_warehouses.py
|
||||||
migrations/versions/20251102_120001_add_check_status_fields.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/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,12 +17,17 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# افزودن ستون فقط اگر قبلاً وجود ندارد
|
||||||
|
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))
|
op.add_column('documents', sa.Column('description', sa.Text(), nullable=True))
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
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')
|
op.drop_column('documents', 'description')
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
|
||||||
|
|
@ -189,23 +189,35 @@ class AuthStore with ChangeNotifier {
|
||||||
final response = await apiClient.get('/api/v1/auth/me');
|
final response = await apiClient.get('/api/v1/auth/me');
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = response.data;
|
final root = response.data;
|
||||||
if (data is Map<String, dynamic>) {
|
if (root is Map<String, dynamic>) {
|
||||||
final user = data['user'] as 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) {
|
if (user != null) {
|
||||||
final appPermissions = user['app_permissions'] as Map<String, dynamic>?;
|
appPermissions = user['app_permissions'] as Map<String, dynamic>?;
|
||||||
final isSuperAdmin = appPermissions?['superadmin'] == true;
|
userId = user['id'] as int?;
|
||||||
final userId = user['id'] as int?;
|
}
|
||||||
|
// fallback: اگر در permissions هم مقدار باشد از آن بخوان
|
||||||
if (appPermissions != null) {
|
if (!isSuperAdmin && permsObj != null) {
|
||||||
await saveAppPermissions(appPermissions, isSuperAdmin);
|
final pIs = permsObj['is_superadmin'];
|
||||||
|
if (pIs is bool) {
|
||||||
|
isSuperAdmin = pIs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin && appPermissions != null) {
|
||||||
|
isSuperAdmin = appPermissions['superadmin'] == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId != null) {
|
// ذخیره در استور و لوکال
|
||||||
_currentUserId = userId;
|
await saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -506,3 +518,4 @@ class AuthStore with ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1099,6 +1099,38 @@
|
||||||
"accountTypePerson": "Person",
|
"accountTypePerson": "Person",
|
||||||
"accountTypeProduct": "Product",
|
"accountTypeProduct": "Product",
|
||||||
"accountTypeService": "Service",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"@@locale": "fa",
|
||||||
"appTitle": "حسابیکس",
|
"appTitle": "حسابیکس",
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
|
|
|
||||||
|
|
@ -5767,6 +5767,186 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Accounting Document'**
|
/// **'Accounting Document'**
|
||||||
String get accountTypeAccountingDocument;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2921,4 +2921,95 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountTypeAccountingDocument => 'Accounting Document';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2900,4 +2900,95 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get accountTypeAccountingDocument => 'سند حسابداری';
|
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 => 'ایجاد';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,12 @@ import 'pages/business/users_permissions_page.dart';
|
||||||
import 'pages/business/accounts_page.dart';
|
import 'pages/business/accounts_page.dart';
|
||||||
import 'pages/business/bank_accounts_page.dart';
|
import 'pages/business/bank_accounts_page.dart';
|
||||||
import 'pages/business/wallet_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/invoices_list_page.dart';
|
||||||
import 'pages/business/new_invoice_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/settings_page.dart';
|
||||||
import 'pages/business/business_info_settings_page.dart';
|
import 'pages/business/business_info_settings_page.dart';
|
||||||
import 'pages/business/reports_page.dart';
|
import 'pages/business/reports_page.dart';
|
||||||
|
|
@ -56,6 +60,8 @@ import 'core/auth_store.dart';
|
||||||
import 'core/permission_guard.dart';
|
import 'core/permission_guard.dart';
|
||||||
import 'widgets/simple_splash_screen.dart';
|
import 'widgets/simple_splash_screen.dart';
|
||||||
import 'widgets/url_tracker.dart';
|
import 'widgets/url_tracker.dart';
|
||||||
|
import 'pages/business/opening_balance_page.dart';
|
||||||
|
import 'pages/business/report_templates_page.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Use path-based routing instead of hash routing
|
// Use path-based routing instead of hash routing
|
||||||
|
|
@ -372,6 +378,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/wallet/payment-result',
|
||||||
|
name: 'wallet_payment_result',
|
||||||
|
builder: (context, state) => WalletPaymentResultPage(authStore: _authStore!),
|
||||||
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => ProfileShell(
|
builder: (context, state, child) => ProfileShell(
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
|
|
@ -430,22 +441,54 @@ class _MyAppState extends State<MyApp> {
|
||||||
path: '/user/profile/system-settings',
|
path: '/user/profile/system-settings',
|
||||||
name: 'profile_system_settings',
|
name: 'profile_system_settings',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
// بررسی دسترسی SuperAdmin
|
// بررسی دسترسی تنظیمات سیستم (SuperAdmin یا مجوز system_settings)
|
||||||
if (_authStore == null) {
|
if (_authStore == null) {
|
||||||
return PermissionGuard.buildAccessDeniedPage();
|
return PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
|
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||||
if (!_authStore!.isSuperAdmin) {
|
if (!allowed) {
|
||||||
return PermissionGuard.buildAccessDeniedPage();
|
return PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const SystemSettingsPage();
|
return const SystemSettingsPage();
|
||||||
},
|
},
|
||||||
routes: [
|
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(
|
GoRoute(
|
||||||
path: 'storage',
|
path: 'storage',
|
||||||
name: 'system_settings_storage',
|
name: 'system_settings_storage',
|
||||||
builder: (context, state) {
|
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 PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const AdminStorageManagementPage();
|
return const AdminStorageManagementPage();
|
||||||
|
|
@ -455,7 +498,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
path: 'configuration',
|
path: 'configuration',
|
||||||
name: 'system_settings_configuration',
|
name: 'system_settings_configuration',
|
||||||
builder: (context, state) {
|
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 PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const SystemConfigurationPage();
|
return const SystemConfigurationPage();
|
||||||
|
|
@ -465,7 +512,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
path: 'users',
|
path: 'users',
|
||||||
name: 'system_settings_users',
|
name: 'system_settings_users',
|
||||||
builder: (context, state) {
|
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 PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const UserManagementPage();
|
return const UserManagementPage();
|
||||||
|
|
@ -475,7 +526,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
name: 'system_settings_logs',
|
name: 'system_settings_logs',
|
||||||
builder: (context, state) {
|
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 PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const SystemLogsPage();
|
return const SystemLogsPage();
|
||||||
|
|
@ -485,7 +540,11 @@ class _MyAppState extends State<MyApp> {
|
||||||
path: 'email',
|
path: 'email',
|
||||||
name: 'system_settings_email',
|
name: 'system_settings_email',
|
||||||
builder: (context, state) {
|
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 PermissionGuard.buildAccessDeniedPage();
|
||||||
}
|
}
|
||||||
return const EmailSettingsPage();
|
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(
|
GoRoute(
|
||||||
path: '/business/:business_id/chart-of-accounts',
|
path: '/business/:business_id/chart-of-accounts',
|
||||||
name: 'business_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(
|
GoRoute(
|
||||||
path: '/business/:business_id/invoice',
|
path: '/business/:business_id/invoice',
|
||||||
name: 'business_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(
|
GoRoute(
|
||||||
path: '/business/:business_id/reports',
|
path: '/business/:business_id/reports',
|
||||||
name: 'business_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(
|
GoRoute(
|
||||||
path: '/business/:business_id/checks',
|
path: '/business/:business_id/checks',
|
||||||
name: 'business_checks',
|
name: 'business_checks',
|
||||||
|
|
|
||||||
425
hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart
Normal file
425
hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
106
hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart
Normal file
106
hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart
Normal 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('ذخیره'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -89,6 +89,9 @@ class _BankAccountsPageState extends State<BankAccountsPage> {
|
||||||
title: t.accounts,
|
title: t.accounts,
|
||||||
excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
|
excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
|
||||||
pdfEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/pdf',
|
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},
|
getExportParams: () => {'business_id': widget.businessId},
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onBack: () => Navigator.of(context).maybePop(),
|
onBack: () => Navigator.of(context).maybePop(),
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,13 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
path: '/business/${widget.businessId}/settings',
|
path: '/business/${widget.businessId}/settings',
|
||||||
type: _MenuItemType.simple,
|
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(
|
_MenuItem(
|
||||||
label: t.pluginMarketplace,
|
label: t.pluginMarketplace,
|
||||||
icon: Icons.store,
|
icon: Icons.store,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ class _CashRegistersPageState extends State<CashRegistersPage> {
|
||||||
title: t.cashBox,
|
title: t.cashBox,
|
||||||
excelEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/excel',
|
excelEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/excel',
|
||||||
pdfEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/pdf',
|
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},
|
getExportParams: () => {'business_id': widget.businessId},
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onBack: () => Navigator.of(context).maybePop(),
|
onBack: () => Navigator.of(context).maybePop(),
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,11 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
||||||
enableMultiRowSelection: true,
|
enableMultiRowSelection: true,
|
||||||
showExportButtons: true,
|
showExportButtons: true,
|
||||||
showExcelExport: true,
|
showExcelExport: true,
|
||||||
showPdfExport: false,
|
pdfEndpoint: '/businesses/${widget.businessId}/documents/export/pdf',
|
||||||
|
showPdfExport: true,
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'documents',
|
||||||
|
reportSubtype: 'list',
|
||||||
defaultPageSize: 50,
|
defaultPageSize: 50,
|
||||||
pageSizeOptions: [20, 50, 100, 200],
|
pageSizeOptions: [20, 50, 100, 200],
|
||||||
onRowSelectionChanged: (rows) {
|
onRowSelectionChanged: (rows) {
|
||||||
|
|
|
||||||
547
hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart
Normal file
547
hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -228,6 +228,9 @@ class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> {
|
||||||
title: 'هزینه و درآمد',
|
title: 'هزینه و درآمد',
|
||||||
excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
|
excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
|
||||||
pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf',
|
pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'expense_income',
|
||||||
|
reportSubtype: 'list',
|
||||||
// دکمه حذف گروهی در هدر جدول
|
// دکمه حذف گروهی در هدر جدول
|
||||||
customHeaderActions: [
|
customHeaderActions: [
|
||||||
Tooltip(
|
Tooltip(
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ class _InventoryTransfersPageState extends State<InventoryTransfersPage> {
|
||||||
endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query',
|
endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query',
|
||||||
excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel',
|
excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel',
|
||||||
pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf',
|
pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'inventory_transfers',
|
||||||
|
reportSubtype: 'list',
|
||||||
title: 'انتقال موجودی بین انبارها',
|
title: 'انتقال موجودی بین انبارها',
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,9 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
|
||||||
title: 'فاکتورها',
|
title: 'فاکتورها',
|
||||||
excelEndpoint: '/invoices/business/${widget.businessId}/export/excel',
|
excelEndpoint: '/invoices/business/${widget.businessId}/export/excel',
|
||||||
pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf',
|
pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'invoices',
|
||||||
|
reportSubtype: 'list',
|
||||||
columns: [
|
columns: [
|
||||||
// عملیات
|
// عملیات
|
||||||
ActionColumn(
|
ActionColumn(
|
||||||
|
|
@ -230,6 +233,12 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
|
||||||
label: 'مشاهده',
|
label: 'مشاهده',
|
||||||
onTap: (item) => _onView(item as InvoiceListItem),
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,9 @@ class _KardexPageState extends State<KardexPage> {
|
||||||
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
|
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
|
||||||
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
|
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
|
||||||
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
|
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'kardex',
|
||||||
|
reportSubtype: 'list',
|
||||||
columns: [
|
columns: [
|
||||||
DateColumn('document_date', 'تاریخ سند',
|
DateColumn('document_date', 'تاریخ سند',
|
||||||
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),
|
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),
|
||||||
|
|
|
||||||
|
|
@ -971,7 +971,6 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
'tax_amount': taxAmount,
|
'tax_amount': taxAmount,
|
||||||
'line_total': lineTotal,
|
'line_total': lineTotal,
|
||||||
if (movement != null) 'movement': movement,
|
if (movement != null) 'movement': movement,
|
||||||
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
|
|
||||||
// اطلاعات اضافی برای ردیابی
|
// اطلاعات اضافی برای ردیابی
|
||||||
'unit': e.selectedUnit ?? e.mainUnit,
|
'unit': e.selectedUnit ?? e.mainUnit,
|
||||||
'unit_price_source': e.unitPriceSource,
|
'unit_price_source': e.unitPriceSource,
|
||||||
|
|
@ -1048,6 +1047,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
|
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
onChanged: (transactions) {
|
onChanged: (transactions) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_transactions = transactions;
|
_transactions = transactions;
|
||||||
|
|
@ -1188,15 +1188,15 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
// انتخاب قالب چاپ
|
// انتخاب قالب چاپ
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedPrintTemplate,
|
initialValue: _selectedPrintTemplate,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'قالب چاپ',
|
labelText: AppLocalizations.of(context).printTemplate,
|
||||||
border: OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(value: 'standard', child: Text('قالب استاندارد')),
|
DropdownMenuItem(value: 'standard', child: Text(AppLocalizations.of(context).templateStandard)),
|
||||||
DropdownMenuItem(value: 'compact', child: Text('قالب فشرده')),
|
DropdownMenuItem(value: 'compact', child: Text(AppLocalizations.of(context).templateCompact)),
|
||||||
DropdownMenuItem(value: 'detailed', child: Text('قالب تفصیلی')),
|
DropdownMenuItem(value: 'detailed', child: Text(AppLocalizations.of(context).templateDetailed)),
|
||||||
DropdownMenuItem(value: 'custom', child: Text('قالب سفارشی')),
|
DropdownMenuItem(value: 'custom', child: Text(AppLocalizations.of(context).templateCustom)),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -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('نهایی شد')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -56,6 +56,9 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
title: t.personsList,
|
title: t.personsList,
|
||||||
excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
|
excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
|
||||||
pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
|
pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'persons',
|
||||||
|
reportSubtype: 'list',
|
||||||
getExportParams: () => {
|
getExportParams: () => {
|
||||||
'business_id': widget.businessId,
|
'business_id': widget.businessId,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ class _PettyCashPageState extends State<PettyCashPage> {
|
||||||
title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
|
title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
|
||||||
excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
|
excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
|
||||||
pdfEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/pdf',
|
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},
|
getExportParams: () => {'business_id': widget.businessId},
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onBack: () => Navigator.of(context).maybePop(),
|
onBack: () => Navigator.of(context).maybePop(),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,9 @@ class _ProductsPageState extends State<ProductsPage> {
|
||||||
title: t.products,
|
title: t.products,
|
||||||
excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel',
|
excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel',
|
||||||
pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf',
|
pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'products',
|
||||||
|
reportSubtype: 'list',
|
||||||
showRowNumbers: true,
|
showRowNumbers: true,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
enableMultiRowSelection: true,
|
enableMultiRowSelection: true,
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,9 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
title: t.receiptsAndPayments,
|
title: t.receiptsAndPayments,
|
||||||
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
||||||
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
|
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'receipts_payments',
|
||||||
|
reportSubtype: 'list',
|
||||||
// دکمه حذف گروهی در هدر جدول
|
// دکمه حذف گروهی در هدر جدول
|
||||||
customHeaderActions: [
|
customHeaderActions: [
|
||||||
Tooltip(
|
Tooltip(
|
||||||
|
|
@ -1601,7 +1604,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.picture_as_pdf),
|
: 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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('فایل PDF با موفقیت تولید شد'),
|
content: Text(AppLocalizations.of(context).pdfSuccess),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1634,7 +1637,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('خطا در تولید PDF: $e'),
|
content: Text('${AppLocalizations.of(context).pdfError}: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,6 +70,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||||
icon: Icons.print,
|
icon: Icons.print,
|
||||||
onTap: () => _showPrintDocumentsDialog(context),
|
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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ class _TransfersPageState extends State<TransfersPage> {
|
||||||
title: t.transfers,
|
title: t.transfers,
|
||||||
excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel',
|
excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel',
|
||||||
pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf',
|
pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf',
|
||||||
|
businessId: widget.businessId,
|
||||||
|
reportModuleKey: 'transfers',
|
||||||
|
reportSubtype: 'list',
|
||||||
getExportParams: () => {
|
getExportParams: () => {
|
||||||
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
|
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
|
||||||
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
|
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import '../../core/auth_store.dart';
|
import '../../core/auth_store.dart';
|
||||||
import '../../widgets/permission/access_denied_page.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 {
|
class WalletPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -18,6 +26,269 @@ class WalletPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WalletPageState extends State<WalletPage> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
|
|
@ -26,28 +297,192 @@ class _WalletPageState extends State<WalletPage> {
|
||||||
return AccessDeniedPage(message: t.accessDenied);
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final overview = _overview;
|
||||||
|
final currency = overview?['base_currency_code'] ?? 'IRR';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Row(
|
||||||
Icons.wallet,
|
children: [
|
||||||
size: 80,
|
Icon(Icons.wallet, size: 32, color: theme.colorScheme.primary),
|
||||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
|
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: 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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Row(
|
||||||
'صفحه کیف پول در حال توسعه است',
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
OutlinedButton.icon(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
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},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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('بازگشت به کیفپول'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ class _ProfileShellState extends State<ProfileShell> {
|
||||||
final allDestinations = <_Dest>[
|
final allDestinations = <_Dest>[
|
||||||
...destinations,
|
...destinations,
|
||||||
if (widget.authStore.canAccessSupportOperator) ...operatorDestinations,
|
if (widget.authStore.canAccessSupportOperator) ...operatorDestinations,
|
||||||
if (widget.authStore.isSuperAdmin) ...adminDestinations,
|
if (widget.authStore.isSuperAdmin || widget.authStore.hasAppPermission('system_settings')) ...adminDestinations,
|
||||||
];
|
];
|
||||||
|
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,20 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||||
color: const Color(0xFF4CAF50),
|
color: const Color(0xFF4CAF50),
|
||||||
route: '/user/profile/system-settings/configuration',
|
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(
|
SettingsItem(
|
||||||
title: 'userManagement',
|
title: 'userManagement',
|
||||||
description: 'userManagementDescription',
|
description: 'userManagementDescription',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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>>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
138
hesabixUI/hesabix_ui/lib/services/report_template_service.dart
Normal file
138
hesabixUI/hesabix_ui/lib/services/report_template_service.dart
Normal 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>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
80
hesabixUI/hesabix_ui/lib/services/wallet_service.dart
Normal file
80
hesabixUI/hesabix_ui/lib/services/wallet_service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,6 +34,57 @@ class WarehouseService {
|
||||||
final res = await _api.delete<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId');
|
final res = await _api.delete<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId');
|
||||||
return res.statusCode == 200 && (res.data?['data']?['deleted'] == true);
|
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 {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,10 @@ class DataTableConfig<T> {
|
||||||
final bool showExportButtons;
|
final bool showExportButtons;
|
||||||
final bool showExcelExport;
|
final bool showExcelExport;
|
||||||
final bool showPdfExport;
|
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
|
// Column settings configuration
|
||||||
final String? tableId;
|
final String? tableId;
|
||||||
|
|
@ -327,6 +331,9 @@ class DataTableConfig<T> {
|
||||||
this.showExportButtons = false,
|
this.showExportButtons = false,
|
||||||
this.showExcelExport = true,
|
this.showExcelExport = true,
|
||||||
this.showPdfExport = true,
|
this.showPdfExport = true,
|
||||||
|
this.businessId,
|
||||||
|
this.reportModuleKey,
|
||||||
|
this.reportSubtype,
|
||||||
this.tableId,
|
this.tableId,
|
||||||
this.enableColumnSettings = true,
|
this.enableColumnSettings = true,
|
||||||
this.showColumnSettingsButton = true,
|
this.showColumnSettingsButton = true,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:dio/dio.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/api_client.dart';
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
import 'package:hesabix_ui/core/calendar_controller.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_config.dart';
|
||||||
import 'data_table_search_dialog.dart';
|
import 'data_table_search_dialog.dart';
|
||||||
import 'column_settings_dialog.dart';
|
import 'column_settings_dialog.dart';
|
||||||
|
|
@ -70,6 +71,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
// Row selection state
|
// Row selection state
|
||||||
final Set<int> _selectedRows = <int>{};
|
final Set<int> _selectedRows = <int>{};
|
||||||
bool _isExporting = false;
|
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
|
// Column settings state
|
||||||
ColumnSettings? _columnSettings;
|
ColumnSettings? _columnSettings;
|
||||||
|
|
@ -125,6 +132,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_horizontalScrollController.dispose();
|
_horizontalScrollController.dispose();
|
||||||
_tableFocusNode.dispose();
|
_tableFocusNode.dispose();
|
||||||
|
_templateIdCtrl.dispose();
|
||||||
for (var controller in _columnSearchControllers.values) {
|
for (var controller in _columnSearchControllers.values) {
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -631,6 +639,10 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
if (selectedOnly && _selectedRows.isNotEmpty) {
|
if (selectedOnly && _selectedRows.isNotEmpty) {
|
||||||
params['selected_indices'] = _selectedRows.toList();
|
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)
|
// Add export columns in current visible order (excluding ActionColumn)
|
||||||
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
||||||
|
|
@ -1161,6 +1173,34 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
AppLocalizations t,
|
AppLocalizations t,
|
||||||
ThemeData theme,
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|
@ -1202,6 +1242,109 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
|
|
||||||
const Divider(height: 1),
|
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
|
// Excel options
|
||||||
if (widget.config.excelEndpoint != null) ...[
|
if (widget.config.excelEndpoint != null) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
|
||||||
|
|
@ -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/api_client.dart';
|
||||||
import 'package:hesabix_ui/core/calendar_controller.dart';
|
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||||
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
|
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 {
|
class DocumentDetailsDialog extends StatefulWidget {
|
||||||
|
|
@ -25,6 +28,9 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
||||||
DocumentModel? _document;
|
DocumentModel? _document;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
bool _isGeneratingPdf = false;
|
||||||
|
final _warehouseService = WarehouseService();
|
||||||
|
List<dynamic> _relatedWhDocs = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -33,6 +39,53 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
||||||
_loadDocument();
|
_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 {
|
Future<void> _loadDocument() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
|
@ -47,6 +100,22 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
||||||
_isLoading = false;
|
_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) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -62,11 +131,17 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: Container(
|
child: ConstrainedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
constraints: const BoxConstraints(maxWidth: 1100),
|
||||||
height: MediaQuery.of(context).size.height * 0.85,
|
child: Padding(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
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(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// هدر
|
// هدر
|
||||||
_buildHeader(theme),
|
_buildHeader(theme),
|
||||||
|
|
@ -82,7 +157,51 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
||||||
|
|
||||||
// فوتر
|
// فوتر
|
||||||
_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: [
|
children: [
|
||||||
// دکمه چاپ PDF
|
// دکمه چاپ PDF
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _isGeneratingPdf ? null : _generatePdf,
|
||||||
// TODO: پیادهسازی چاپ PDF
|
icon: _isGeneratingPdf
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
? const SizedBox(
|
||||||
const SnackBar(content: Text('چاپ PDF در حال پیادهسازی است')),
|
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),
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
label: const Text('چاپ PDF'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// دکمه بستن
|
// دکمه بستن
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ class CheckOption {
|
||||||
final String? personName;
|
final String? personName;
|
||||||
final String? bankName;
|
final String? bankName;
|
||||||
final String? sayadCode;
|
final String? sayadCode;
|
||||||
|
final int? currencyId;
|
||||||
const CheckOption({
|
const CheckOption({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.number,
|
required this.number,
|
||||||
this.personName,
|
this.personName,
|
||||||
this.bankName,
|
this.bankName,
|
||||||
this.sayadCode,
|
this.sayadCode,
|
||||||
|
this.currencyId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ class CheckComboboxWidget extends StatefulWidget {
|
||||||
final ValueChanged<CheckOption?> onChanged;
|
final ValueChanged<CheckOption?> onChanged;
|
||||||
final String label;
|
final String label;
|
||||||
final String hintText;
|
final String hintText;
|
||||||
|
final int? filterCurrencyId;
|
||||||
|
|
||||||
const CheckComboboxWidget({
|
const CheckComboboxWidget({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -31,6 +34,7 @@ class CheckComboboxWidget extends StatefulWidget {
|
||||||
this.selectedCheckId,
|
this.selectedCheckId,
|
||||||
this.label = 'چک',
|
this.label = 'چک',
|
||||||
this.hintText = 'جستوجو و انتخاب چک',
|
this.hintText = 'جستوجو و انتخاب چک',
|
||||||
|
this.filterCurrencyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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)
|
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||||
? (res['data'] as Map)['items']
|
? (res['data'] as Map)['items']
|
||||||
: res['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);
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
return CheckOption(
|
return CheckOption(
|
||||||
id: '${m['id']}',
|
id: '${m['id']}',
|
||||||
|
|
@ -109,8 +113,14 @@ class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
|
||||||
personName: (m['person_name'] ?? m['holder_name'])?.toString(),
|
personName: (m['person_name'] ?? m['holder_name'])?.toString(),
|
||||||
bankName: (m['bank_name'] ?? '').toString(),
|
bankName: (m['bank_name'] ?? '').toString(),
|
||||||
sayadCode: (m['sayad_code'] ?? '').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();
|
}).toList();
|
||||||
|
if (widget.filterCurrencyId != null) {
|
||||||
|
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_items = items;
|
_items = items;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'bank_account_combobox_widget.dart';
|
||||||
import 'cash_register_combobox_widget.dart';
|
import 'cash_register_combobox_widget.dart';
|
||||||
import 'petty_cash_combobox_widget.dart';
|
import 'petty_cash_combobox_widget.dart';
|
||||||
import 'account_tree_combobox_widget.dart';
|
import 'account_tree_combobox_widget.dart';
|
||||||
|
import 'check_combobox_widget.dart';
|
||||||
import '../../models/invoice_type_model.dart';
|
import '../../models/invoice_type_model.dart';
|
||||||
|
|
||||||
class InvoiceTransactionsWidget extends StatefulWidget {
|
class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
|
|
@ -24,6 +25,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final CalendarController calendarController;
|
final CalendarController calendarController;
|
||||||
final InvoiceType invoiceType;
|
final InvoiceType invoiceType;
|
||||||
|
final int? selectedCurrencyId;
|
||||||
|
|
||||||
const InvoiceTransactionsWidget({
|
const InvoiceTransactionsWidget({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -32,6 +34,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.calendarController,
|
required this.calendarController,
|
||||||
required this.invoiceType,
|
required this.invoiceType,
|
||||||
|
this.selectedCurrencyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -327,6 +330,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
invoiceType: widget.invoiceType,
|
invoiceType: widget.invoiceType,
|
||||||
|
selectedCurrencyId: widget.selectedCurrencyId,
|
||||||
onSave: (newTransaction) {
|
onSave: (newTransaction) {
|
||||||
if (index != null) {
|
if (index != null) {
|
||||||
// ویرایش تراکنش موجود
|
// ویرایش تراکنش موجود
|
||||||
|
|
@ -351,6 +355,7 @@ class TransactionDialog extends StatefulWidget {
|
||||||
final CalendarController calendarController;
|
final CalendarController calendarController;
|
||||||
final ValueChanged<InvoiceTransaction> onSave;
|
final ValueChanged<InvoiceTransaction> onSave;
|
||||||
final InvoiceType invoiceType;
|
final InvoiceType invoiceType;
|
||||||
|
final int? selectedCurrencyId;
|
||||||
|
|
||||||
const TransactionDialog({
|
const TransactionDialog({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -358,6 +363,7 @@ class TransactionDialog extends StatefulWidget {
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.calendarController,
|
required this.calendarController,
|
||||||
required this.invoiceType,
|
required this.invoiceType,
|
||||||
|
this.selectedCurrencyId,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -387,6 +393,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
String? _selectedCashRegisterId;
|
String? _selectedCashRegisterId;
|
||||||
String? _selectedPettyCashId;
|
String? _selectedPettyCashId;
|
||||||
String? _selectedCheckId;
|
String? _selectedCheckId;
|
||||||
|
int? _selectedCheckCurrencyId;
|
||||||
String? _selectedPersonId;
|
String? _selectedPersonId;
|
||||||
AccountTreeNode? _selectedAccount;
|
AccountTreeNode? _selectedAccount;
|
||||||
|
|
||||||
|
|
@ -728,6 +735,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
return BankAccountComboboxWidget(
|
return BankAccountComboboxWidget(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
selectedAccountId: _selectedBankId,
|
selectedAccountId: _selectedBankId,
|
||||||
|
filterCurrencyId: widget.selectedCurrencyId,
|
||||||
onChanged: (opt) {
|
onChanged: (opt) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedBankId = opt?.id;
|
_selectedBankId = opt?.id;
|
||||||
|
|
@ -743,6 +751,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
return CashRegisterComboboxWidget(
|
return CashRegisterComboboxWidget(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
selectedRegisterId: _selectedCashRegisterId,
|
selectedRegisterId: _selectedCashRegisterId,
|
||||||
|
filterCurrencyId: widget.selectedCurrencyId,
|
||||||
onChanged: (opt) {
|
onChanged: (opt) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCashRegisterId = opt?.id;
|
_selectedCashRegisterId = opt?.id;
|
||||||
|
|
@ -758,6 +767,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
return PettyCashComboboxWidget(
|
return PettyCashComboboxWidget(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
selectedPettyCashId: _selectedPettyCashId,
|
selectedPettyCashId: _selectedPettyCashId,
|
||||||
|
filterCurrencyId: widget.selectedCurrencyId,
|
||||||
onChanged: (opt) {
|
onChanged: (opt) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedPettyCashId = opt?.id;
|
_selectedPettyCashId = opt?.id;
|
||||||
|
|
@ -770,21 +780,18 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCheckFields() {
|
Widget _buildCheckFields() {
|
||||||
return DropdownButtonFormField<String>(
|
return CheckComboboxWidget(
|
||||||
initialValue: _selectedCheckId,
|
businessId: widget.businessId,
|
||||||
decoration: const InputDecoration(
|
selectedCheckId: _selectedCheckId,
|
||||||
labelText: 'چک *',
|
filterCurrencyId: widget.selectedCurrencyId,
|
||||||
border: OutlineInputBorder(),
|
onChanged: (opt) {
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'check1', child: Text('چک شماره 123456')),
|
|
||||||
DropdownMenuItem(value: 'check2', child: Text('چک شماره 789012')),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
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
|
final commission = _commissionController.text.isNotEmpty
|
||||||
? double.parse(_commissionController.text)
|
? double.parse(_commissionController.text)
|
||||||
: null;
|
: 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(
|
final transaction = InvoiceTransaction(
|
||||||
id: widget.transaction?.id ?? _uuid.v4(),
|
id: widget.transaction?.id ?? _uuid.v4(),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
||||||
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
||||||
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
||||||
final bool postInventory;
|
final bool postInventory;
|
||||||
|
final List<InvoiceLineItem>? initialRows; // برای مقداردهی اولیه (ویرایش فاکتور)
|
||||||
|
|
||||||
const InvoiceLineItemsTable({
|
const InvoiceLineItemsTable({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -21,6 +22,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.invoiceType = 'sales',
|
this.invoiceType = 'sales',
|
||||||
this.postInventory = true,
|
this.postInventory = true,
|
||||||
|
this.initialRows,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -73,6 +75,11 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
if ((widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
|
||||||
|
_rows.clear();
|
||||||
|
_rows.addAll(widget.initialRows!);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -84,6 +91,13 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
// invalidate inline price list cache if currency changed
|
// invalidate inline price list cache if currency changed
|
||||||
_inlinePriceList = null;
|
_inlinePriceList = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// اگر والد پس از لود اولیه، ردیفهای اولیه را فراهم کرد و جدول خالی است، آنها را ست کن
|
||||||
|
if (_rows.isEmpty && (widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
|
||||||
|
_rows.clear();
|
||||||
|
_rows.addAll(widget.initialRows!);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام میشود
|
// لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام میشود
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_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) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
||||||
|
|
@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_saver
|
file_saver
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import file_selector_macos
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
|
|
@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -650,6 +650,70 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -708,4 +772,4 @@ packages:
|
||||||
version: "6.6.1"
|
version: "6.6.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.2 <4.0.0"
|
dart: ">=3.9.2 <4.0.0"
|
||||||
flutter: ">=3.29.0"
|
flutter: ">=3.35.0"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ dependencies:
|
||||||
data_table_2: ^2.5.12
|
data_table_2: ^2.5.12
|
||||||
file_picker: ^10.3.3
|
file_picker: ^10.3.3
|
||||||
file_selector: ^1.0.4
|
file_selector: ^1.0.4
|
||||||
|
url_launcher: ^6.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#include <file_saver/file_saver_plugin.h>
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.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) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FileSaverPluginRegisterWithRegistrar(
|
FileSaverPluginRegisterWithRegistrar(
|
||||||
|
|
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_saver
|
file_saver
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue