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)
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (bank_accounts/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now_str,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="bank_accounts",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
table_html = f"""
|
||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||
<head>
|
||||
|
|
@ -560,8 +592,9 @@ async def export_bank_accounts_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
|
|
|||
|
|
@ -440,6 +440,38 @@ async def export_cash_registers_pdf(
|
|||
|
||||
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (cash_registers/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": 'گزارش صندوقها' if is_fa else 'Cash Registers Report',
|
||||
"business_name": business_name,
|
||||
"generated_at": now_str,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="cash_registers",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
table_html = f"""
|
||||
<html lang="{html_lang}" dir="{html_dir}">
|
||||
<head>
|
||||
|
|
@ -464,8 +496,9 @@ async def export_cash_registers_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,173 @@ async def list_documents_endpoint(
|
|||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/businesses/{business_id}/documents/export/pdf",
|
||||
summary="خروجی PDF لیست اسناد حسابداری",
|
||||
description="دریافت فایل PDF لیست اسناد حسابداری با پشتیبانی از قالب سفارشی (documents/list)",
|
||||
)
|
||||
@require_business_access("business_id")
|
||||
async def export_documents_pdf_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
body: Dict[str, Any] = Body(default={}),
|
||||
db: Session = Depends(get_db),
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
):
|
||||
"""خروجی PDF لیست اسناد حسابداری"""
|
||||
from fastapi.responses import Response
|
||||
from weasyprint import HTML
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from app.core.i18n import negotiate_locale
|
||||
from html import escape
|
||||
import datetime, json
|
||||
# فیلترهایی مشابه export_documents_excel
|
||||
filters = {}
|
||||
for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]:
|
||||
if key in body:
|
||||
filters[key] = body[key]
|
||||
# سال مالی از header یا body
|
||||
try:
|
||||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||||
if fy_header:
|
||||
filters["fiscal_year_id"] = int(fy_header)
|
||||
elif "fiscal_year_id" in body:
|
||||
filters["fiscal_year_id"] = body["fiscal_year_id"]
|
||||
except Exception:
|
||||
pass
|
||||
# دریافت دادهها
|
||||
result = list_documents(db, business_id, {**filters, "take": body.get("take", 1000), "skip": body.get("skip", 0)})
|
||||
items = result.get("items", [])
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
# ستونها
|
||||
headers: list[str] = []
|
||||
keys: list[str] = []
|
||||
export_columns = body.get("export_columns")
|
||||
if export_columns:
|
||||
for col in export_columns:
|
||||
key = col.get("key")
|
||||
label = col.get("label", key)
|
||||
if key:
|
||||
keys.append(str(key))
|
||||
headers.append(str(label))
|
||||
else:
|
||||
default_columns = [
|
||||
("code", "کد سند"),
|
||||
("document_type_name", "نوع سند"),
|
||||
("document_date", "تاریخ سند"),
|
||||
("total_debit", "جمع بدهکار"),
|
||||
("total_credit", "جمع بستانکار"),
|
||||
("created_by_name", "ایجادکننده"),
|
||||
("registered_at", "تاریخ ثبت"),
|
||||
]
|
||||
for key, label in default_columns:
|
||||
if items and key in items[0]:
|
||||
keys.append(key)
|
||||
headers.append(label)
|
||||
# اطلاعات کسبوکار
|
||||
business_name = ""
|
||||
try:
|
||||
from adapters.db.models.business import Business
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
business_name = b.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
# Locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = locale == "fa"
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
title_text = "لیست اسناد حسابداری" if is_fa else "Documents List"
|
||||
label_biz = "کسب و کار" if is_fa else "Business"
|
||||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
||||
rows_html = []
|
||||
for item in items:
|
||||
row_cells = []
|
||||
for key in keys:
|
||||
value = item.get(key, "")
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
value = json.dumps(value, ensure_ascii=False)
|
||||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||
# کانتکست قالب
|
||||
template_context = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
# تلاش برای رندر با قالب سفارشی
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="documents",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
# HTML پیشفرض
|
||||
default_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir='{"rtl" if is_fa else "ltr"}'>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
@page {{ margin: 1cm; size: A4; }}
|
||||
body {{ font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'}; font-size: 12px; color: #222; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
|
||||
thead {{ background: #f6f6f6; }}
|
||||
.meta {{ font-size: 11px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;border-bottom:2px solid #366092;padding-bottom:8px">
|
||||
<div>
|
||||
<div style="font-size:18px;font-weight:bold;color:#366092">{title_text}</div>
|
||||
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
||||
</div>
|
||||
<div class="meta">{label_date}: {escape(now)}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>{headers_html}</tr></thead>
|
||||
<tbody>{''.join(rows_html)}</tbody>
|
||||
</table>
|
||||
<div class="meta" style="margin-top:8px;text-align:{'left' if is_fa else 'right'}">{footer_text}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
html_content = resolved_html or default_html
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
|
||||
filename = f"documents_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
@router.get(
|
||||
"/documents/{document_id}",
|
||||
summary="جزئیات سند حسابداری",
|
||||
|
|
@ -283,6 +450,7 @@ async def get_document_pdf_endpoint(
|
|||
document_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
template_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
PDF یک سند
|
||||
|
|
@ -298,11 +466,119 @@ async def get_document_pdf_endpoint(
|
|||
if business_id and not ctx.can_access_business(business_id):
|
||||
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||
|
||||
# TODO: تولید PDF
|
||||
raise ApiError(
|
||||
"NOT_IMPLEMENTED",
|
||||
"PDF generation is not implemented yet",
|
||||
http_status=501
|
||||
# رندر با قالب سفارشی (documents/detail) یا خروجی پیشفرض
|
||||
from weasyprint import HTML
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from app.core.i18n import negotiate_locale
|
||||
from html import escape
|
||||
import datetime, re
|
||||
|
||||
# اطلاعات کسبوکار
|
||||
business_name = ""
|
||||
try:
|
||||
from adapters.db.models.business import Business
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
business_name = b.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
|
||||
# Locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = locale == "fa"
|
||||
now = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")
|
||||
|
||||
# کانتکست قالب
|
||||
template_context = {
|
||||
"business_id": business_id,
|
||||
"business_name": business_name,
|
||||
"document": doc,
|
||||
"lines": doc.get("lines", []),
|
||||
"code": doc.get("code"),
|
||||
"document_type": doc.get("document_type"),
|
||||
"document_date": doc.get("document_date"),
|
||||
"description": doc.get("description"),
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
}
|
||||
|
||||
# تلاش برای رندر
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if template_id is not None:
|
||||
explicit_template_id = int(template_id)
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="documents",
|
||||
subtype="detail",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
# پیشفرض
|
||||
default_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir='{"rtl" if is_fa else "ltr"}'>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body {{ font-family: Tahoma, Arial, sans-serif; font-size: 12px; color: #222; }}
|
||||
h1 {{ font-size: 18px; margin: 0 0 12px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
|
||||
th {{ background: #f6f6f6; }}
|
||||
.meta .label {{ color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{escape(doc.get("document_type_name") or ("سند" if is_fa else "Document"))}</h1>
|
||||
<div class="meta">
|
||||
<div><span class="label">{'کسبوکار' if is_fa else 'Business'}:</span> {escape(business_name or "-")}</div>
|
||||
<div><span class="label">{'کد' if is_fa else 'Code'}:</span> {escape(doc.get("code") or "-")}</div>
|
||||
<div><span class="label">{'تاریخ' if is_fa else 'Date'}:</span> {escape(doc.get("document_date") or "-")}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{'شرح' if is_fa else 'Description'}</th>
|
||||
<th>{'بدهکار' if is_fa else 'Debit'}</th>
|
||||
<th>{'بستانکار' if is_fa else 'Credit'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join([
|
||||
f"<tr><td>{escape(str(line.get('description') or '-'))}</td><td>{escape(str(line.get('debit') or ''))}</td><td>{escape(str(line.get('credit') or ''))}</td></tr>"
|
||||
for line in (doc.get('lines') or [])
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
html_content = resolved_html or default_html
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=FontConfiguration())
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "document"
|
||||
filename = f"document_{_slugify(doc.get('code'))}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -261,35 +261,179 @@ async def export_expense_income_pdf_endpoint(
|
|||
db: Session = Depends(get_db),
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
):
|
||||
"""خروجی PDF"""
|
||||
from app.services.expense_income_service import export_expense_income_pdf
|
||||
"""خروجی PDF (با پشتیبانی قالب سفارشی expense_income/list)"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
# دریافت پارامترهای فیلتر
|
||||
query_dict = {}
|
||||
from weasyprint import HTML
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from app.core.i18n import negotiate_locale
|
||||
from html import escape
|
||||
import datetime, json
|
||||
# دریافت پارامترهای فیلتر و تنظیمات
|
||||
try:
|
||||
body_json = await request.json()
|
||||
if isinstance(body_json, dict):
|
||||
for key in ["document_type", "from_date", "to_date"]:
|
||||
if key in body_json:
|
||||
query_dict[key] = body_json[key]
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
body = {}
|
||||
# ساخت query برای لیست
|
||||
query_dict = {
|
||||
"take": int(body.get("take", 1000)),
|
||||
"skip": int(body.get("skip", 0)),
|
||||
"sort_by": body.get("sort_by"),
|
||||
"sort_desc": bool(body.get("sort_desc", False)),
|
||||
"search": body.get("search"),
|
||||
"search_fields": body.get("search_fields"),
|
||||
"filters": body.get("filters"),
|
||||
"document_type": body.get("document_type"),
|
||||
"from_date": body.get("from_date"),
|
||||
"to_date": body.get("to_date"),
|
||||
}
|
||||
# سال مالی از هدر
|
||||
try:
|
||||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||||
if fy_header:
|
||||
query_dict["fiscal_year_id"] = int(fy_header)
|
||||
elif body.get("fiscal_year_id") is not None:
|
||||
query_dict["fiscal_year_id"] = int(body.get("fiscal_year_id"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pdf_data = export_expense_income_pdf(db, business_id, query_dict)
|
||||
|
||||
# دریافت دادهها
|
||||
from app.services.expense_income_service import list_expense_income
|
||||
from adapters.db.models.business import Business
|
||||
from app.core.responses import format_datetime_fields
|
||||
result = list_expense_income(db, business_id, query_dict)
|
||||
items = result.get("items", [])
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
# ستونها
|
||||
headers: list[str] = []
|
||||
keys: list[str] = []
|
||||
export_columns = body.get("export_columns")
|
||||
if export_columns:
|
||||
for col in export_columns:
|
||||
key = col.get("key")
|
||||
label = col.get("label", key)
|
||||
if key:
|
||||
keys.append(str(key))
|
||||
headers.append(str(label))
|
||||
else:
|
||||
default_columns = [
|
||||
("code", "کد سند"),
|
||||
("document_type_name", "نوع سند"),
|
||||
("document_date", "تاریخ سند"),
|
||||
("total_amount", "مبلغ کل"),
|
||||
("created_by_name", "ایجادکننده"),
|
||||
("registered_at", "تاریخ ثبت"),
|
||||
]
|
||||
for key, label in default_columns:
|
||||
if items and key in items[0]:
|
||||
keys.append(key)
|
||||
headers.append(label)
|
||||
# اطلاعات کسبوکار
|
||||
business_name = ""
|
||||
try:
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
business_name = b.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
# Locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = locale == "fa"
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
title_text = "لیست اسناد هزینه/درآمد" if is_fa else "Expense/Income List"
|
||||
label_biz = "کسب و کار" if is_fa else "Business"
|
||||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
||||
rows_html = []
|
||||
for item in items:
|
||||
row_cells = []
|
||||
for key in keys:
|
||||
value = item.get(key, "")
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
value = json.dumps(value, ensure_ascii=False)
|
||||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||
# کانتکست برای قالب سفارشی
|
||||
template_context = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
# تلاش برای رندر با قالب سفارشی
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="expense_income",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
# HTML پیشفرض
|
||||
table_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir="{{'rtl' if is_fa else 'ltr'}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{title_text}</title>
|
||||
<style>
|
||||
@page {{ margin: 1cm; size: A4; }}
|
||||
body {{
|
||||
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
|
||||
font-size: 12px; line-height: 1.4; color: #333; direction: {'rtl' if is_fa else 'ltr'};
|
||||
}}
|
||||
.header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #366092; }}
|
||||
.title {{ font-size: 18px; font-weight: bold; color: #366092; }}
|
||||
.meta {{ font-size: 11px; color: #666; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #d7dde6; padding: 6px; text-align: {'right' if is_fa else 'left'}; }}
|
||||
thead {{ background: #f6f6f6; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">{title_text}</div>
|
||||
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
||||
</div>
|
||||
<div class="meta">{label_date}: {escape(now)}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>{headers_html}</tr></thead>
|
||||
<tbody>{''.join(rows_html)}</tbody>
|
||||
</table>
|
||||
<div class="meta" style="margin-top: 8px; text-align: {'left' if is_fa else 'right'};">{footer_text}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
final_html = resolved_html or table_html
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=FontConfiguration())
|
||||
filename = f"expense_income_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
return Response(
|
||||
content=pdf_data,
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.pdf"}
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,54 @@ def export_inventory_transfers_pdf(
|
|||
f"</tr>" for d in rows
|
||||
])
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (inventory_transfers/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
# نام کسبوکار
|
||||
business_name = ""
|
||||
try:
|
||||
from adapters.db.models.business import Business
|
||||
biz = db.query(Business).filter(Business.id == business_id).first()
|
||||
if biz is not None:
|
||||
business_name = biz.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
# Locale
|
||||
from app.core.i18n import negotiate_locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = (locale == 'fa')
|
||||
headers = ["کد سند", "تاریخ سند", "شرح"]
|
||||
keys = ["code", "document_date", "description"]
|
||||
headers_html = "<th>کد سند</th><th>تاریخ سند</th><th>شرح</th>"
|
||||
template_context = {
|
||||
"title_text": "لیست انتقالها" if is_fa else "Transfers List",
|
||||
"business_name": business_name,
|
||||
"generated_at": datetime.datetime.now().strftime('%Y/%m/%d %H:%M'),
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": [ {"code": d.code, "document_date": d.document_date, "description": d.description} for d in rows ],
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": rows_html,
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="inventory_transfers",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
html = f"""
|
||||
<html>
|
||||
<head>
|
||||
|
|
@ -189,8 +237,9 @@ def export_inventory_transfers_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config)
|
||||
filename = f"inventory_transfers_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,152 @@ def get_invoice_endpoint(
|
|||
result = invoice_document_to_dict(db, doc)
|
||||
return success_response(data={"item": result}, request=request, message="INVOICE")
|
||||
|
||||
@router.get(
|
||||
"/business/{business_id}/{invoice_id}/pdf",
|
||||
summary="PDF یک فاکتور",
|
||||
description="دریافت فایل PDF یک فاکتور با پشتیبانی از قالب سفارشی (invoices/detail)",
|
||||
)
|
||||
@require_business_access("business_id")
|
||||
async def export_single_invoice_pdf(
|
||||
business_id: int,
|
||||
invoice_id: int,
|
||||
request: Request,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
template_id: int | None = None,
|
||||
):
|
||||
"""
|
||||
خروجی PDF تکسند فاکتور با پشتیبانی از قالب سفارشی:
|
||||
- اگر template_id داده شود و منتشرشده باشد، همان استفاده میشود.
|
||||
- در غیر این صورت اگر قالب پیشفرض منتشرشده برای invoices/detail موجود باشد، استفاده میشود.
|
||||
- در نبود قالب، خروجی HTML پیشفرض تولید میشود.
|
||||
"""
|
||||
from weasyprint import HTML
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from app.core.i18n import negotiate_locale
|
||||
from html import escape
|
||||
import datetime
|
||||
|
||||
# دریافت سند و اعتبارسنجی
|
||||
doc = db.query(Document).filter(Document.id == invoice_id).first()
|
||||
if not doc or doc.business_id != business_id or doc.document_type not in SUPPORTED_INVOICE_TYPES:
|
||||
from app.core.responses import ApiError
|
||||
raise ApiError("DOCUMENT_NOT_FOUND", "Invoice document not found", http_status=404)
|
||||
|
||||
# جزئیات کامل فاکتور
|
||||
item = invoice_document_to_dict(db, doc)
|
||||
|
||||
# اطلاعات کسبوکار (اختیاری)
|
||||
business_name = ""
|
||||
try:
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
business_name = b.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
|
||||
# Locale
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = locale == "fa"
|
||||
|
||||
# کانتکست قالب
|
||||
template_context = {
|
||||
"business_id": business_id,
|
||||
"business_name": business_name,
|
||||
"invoice": item,
|
||||
"lines": item.get("lines", []),
|
||||
"generated_at": datetime.datetime.now().strftime("%Y/%m/%d %H:%M"),
|
||||
"is_fa": is_fa,
|
||||
}
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if template_id is not None:
|
||||
explicit_template_id = int(template_id)
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="invoices",
|
||||
subtype="detail",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
# HTML پیشفرض در نبود قالب
|
||||
html_content = resolved_html or f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir='{"rtl" if is_fa else "ltr"}'>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body {{ font-family: Tahoma, Arial, sans-serif; font-size: 12px; color: #222; }}
|
||||
h1 {{ font-size: 18px; margin: 0 0 12px; }}
|
||||
.meta {{ margin: 6px 0; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px; text-align: {"right" if is_fa else "left"}; }}
|
||||
th {{ background: #f6f6f6; }}
|
||||
.totals {{ margin-top: 12px; float: {"left" if is_fa else "right"}; min-width: 260px; }}
|
||||
.label {{ color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{escape(item.get("title") or ("فاکتور" if is_fa else "Invoice"))}</h1>
|
||||
<div class="meta">
|
||||
<div><span class="label">{'کسبوکار' if is_fa else 'Business'}:</span> {escape(business_name or "-")}</div>
|
||||
<div><span class="label">{'کد' if is_fa else 'Code'}:</span> {escape(item.get("code") or "-")}</div>
|
||||
<div><span class="label">{'تاریخ' if is_fa else 'Date'}:</span> {escape(item.get("issue_date") or "-")}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{'ردیف' if is_fa else 'No.'}</th>
|
||||
<th>{'شرح کالا/خدمت' if is_fa else 'Item'}</th>
|
||||
<th>{'تعداد' if is_fa else 'Qty'}</th>
|
||||
<th>{'فی' if is_fa else 'Price'}</th>
|
||||
<th>{'مبلغ' if is_fa else 'Amount'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join([
|
||||
f"<tr><td>{i+1}</td><td>{escape(str(line.get('product_name') or line.get('description') or '-'))}</td><td>{escape(str(line.get('quantity') or ''))}</td><td>{escape(str(line.get('unit_price') or ''))}</td><td>{escape(str(line.get('line_total') or ''))}</td></tr>"
|
||||
for i, line in enumerate(item.get('lines') or [])
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div><span class="label">{'جمع جزء' if is_fa else 'Subtotal'}:</span> {escape(str(item.get('subtotal') or ''))}</div>
|
||||
<div><span class="label">{'مالیات' if is_fa else 'Tax'}:</span> {escape(str(item.get('tax_total') or ''))}</div>
|
||||
<div><strong>{'قابل پرداخت' if is_fa else 'Payable'}:</strong> {escape(str(item.get('payable_total') or ''))}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config)
|
||||
|
||||
# نام فایل
|
||||
def _slugify(text: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "")).strip("_") or "invoice"
|
||||
filename = f"invoice_{_slugify(item.get('code'))}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/business/{business_id}/search")
|
||||
@require_business_access("business_id")
|
||||
|
|
@ -741,7 +887,41 @@ async def export_invoices_pdf(
|
|||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||
|
||||
html_content = f"""
|
||||
# کانتکست مشترک برای قالبهای سفارشی
|
||||
template_context: Dict[str, Any] = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
# خروجیهای HTML آماده برای استفاده سریع در قالب
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (explicit یا پیشفرض)
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if "template_id" in body and body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="invoices",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
html_content = resolved_html or f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir='{ 'rtl' if is_fa else 'ltr' }'>
|
||||
<head>
|
||||
|
|
|
|||
|
|
@ -240,6 +240,47 @@ async def export_kardex_pdf_endpoint(
|
|||
for it in items
|
||||
])
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (kardex/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
headers = [
|
||||
"تاریخ سند","کد سند","نوع سند","انبار","جهت حرکت","شرح",
|
||||
"بدهکار","بستانکار","تعداد","مانده مبلغ","مانده تعداد"
|
||||
]
|
||||
keys = [
|
||||
"document_date","document_code","document_type","warehouse_name",
|
||||
"movement","description","debit","credit","quantity","running_amount","running_quantity"
|
||||
]
|
||||
headers_html = "".join(f"<th>{h}</th>" for h in headers)
|
||||
template_context = {
|
||||
"title_text": "گزارش کاردکس",
|
||||
"business_name": "",
|
||||
"generated_at": datetime.datetime.now().strftime('%Y/%m/%d %H:%M'),
|
||||
"is_fa": True,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": rows_html,
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="kardex",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
html = f"""
|
||||
<html>
|
||||
<head>
|
||||
|
|
@ -277,8 +318,9 @@ async def export_kardex_pdf_endpoint(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 12mm; }")], font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 12mm; }")], font_config=font_config)
|
||||
|
||||
filename = f"kardex_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
return Response(
|
||||
|
|
|
|||
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_of = " از " if is_fa else " of "
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (persons/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="persons",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
table_html = f"""
|
||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||
<head>
|
||||
|
|
@ -629,8 +661,9 @@ async def export_persons_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
|
||||
# Build meaningful filename
|
||||
biz_name = ""
|
||||
|
|
|
|||
|
|
@ -436,6 +436,38 @@ async def export_petty_cash_pdf(
|
|||
|
||||
headers_html = ''.join(f"<th>{esc(h)}</th>" for h in headers)
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (petty_cash/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": 'گزارش تنخواه گردانها' if is_fa else 'Petty Cash Report',
|
||||
"business_name": business_name,
|
||||
"generated_at": now_str,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="petty_cash",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
table_html = f"""
|
||||
<html lang="{html_lang}" dir="{html_dir}">
|
||||
<head>
|
||||
|
|
@ -460,8 +492,9 @@ async def export_petty_cash_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
|
|
|||
|
|
@ -735,6 +735,38 @@ async def export_products_pdf(
|
|||
page_label_left = "صفحه " if is_fa else "Page "
|
||||
page_label_of = " از " if is_fa else " of "
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (products/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now_str,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="products",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
table_html = f"""
|
||||
<html lang=\"{html_lang}\" dir=\"{html_dir}\">
|
||||
<head>
|
||||
|
|
@ -826,8 +858,9 @@ async def export_products_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
|
||||
# Build meaningful filename
|
||||
biz_name = business_name
|
||||
|
|
|
|||
|
|
@ -447,6 +447,7 @@ async def export_single_receipt_payment_pdf(
|
|||
request: Request,
|
||||
auth_context: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
template_id: int | None = None,
|
||||
):
|
||||
"""خروجی PDF تک سند دریافت/پرداخت"""
|
||||
from weasyprint import HTML, CSS
|
||||
|
|
@ -497,8 +498,43 @@ async def export_single_receipt_payment_pdf(
|
|||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||
|
||||
# ایجاد HTML برای PDF
|
||||
html_content = f"""
|
||||
# تلاش برای رندر با قالب سفارشی (receipts_payments/detail)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if template_id is not None:
|
||||
explicit_template_id = int(template_id)
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"business_id": business_id,
|
||||
"business_name": business_name,
|
||||
"document": result,
|
||||
"person_lines": person_lines,
|
||||
"account_lines": account_lines,
|
||||
"code": doc_code,
|
||||
"document_date": doc_date,
|
||||
"total_amount": total_amount,
|
||||
"description": description,
|
||||
"title_text": title_text,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="receipts_payments",
|
||||
subtype="detail",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
# ایجاد HTML پیشفرض در نبود قالب
|
||||
html_content = resolved_html or f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||
<head>
|
||||
|
|
@ -810,7 +846,41 @@ async def export_receipts_payments_pdf(
|
|||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||
|
||||
# Create HTML table
|
||||
# کانتکست برای قالب سفارشی لیست
|
||||
template_context: Dict[str, Any] = {
|
||||
"title_text": title_text,
|
||||
"business_name": business_name,
|
||||
"generated_at": now,
|
||||
"is_fa": is_fa,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": headers_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
|
||||
# تلاش برای رندر با قالب سفارشی (receipts_payments/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="receipts_payments",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
# HTML پیشفرض جدول
|
||||
table_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||
|
|
@ -914,8 +984,10 @@ async def export_receipts_payments_pdf(
|
|||
</html>
|
||||
"""
|
||||
|
||||
final_html = resolved_html or table_html
|
||||
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
|
||||
# Build meaningful filename
|
||||
def slugify(text: str) -> str:
|
||||
|
|
|
|||
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>')
|
||||
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
# تلاش برای رندر با قالب سفارشی (transfers/list)
|
||||
resolved_html = None
|
||||
try:
|
||||
from app.services.report_template_service import ReportTemplateService
|
||||
explicit_template_id = None
|
||||
try:
|
||||
if body.get("template_id") is not None:
|
||||
explicit_template_id = int(body.get("template_id"))
|
||||
except Exception:
|
||||
explicit_template_id = None
|
||||
template_context = {
|
||||
"title_text": "لیست انتقالها",
|
||||
"business_name": "",
|
||||
"generated_at": now,
|
||||
"is_fa": True,
|
||||
"headers": headers,
|
||||
"keys": keys,
|
||||
"items": items,
|
||||
"table_headers_html": header_html,
|
||||
"table_rows_html": "".join(rows_html),
|
||||
}
|
||||
resolved_html = ReportTemplateService.try_render_resolved(
|
||||
db=db,
|
||||
business_id=business_id,
|
||||
module_key="transfers",
|
||||
subtype="list",
|
||||
context=template_context,
|
||||
explicit_template_id=explicit_template_id,
|
||||
)
|
||||
except Exception:
|
||||
resolved_html = None
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir='rtl'>
|
||||
|
|
@ -329,8 +361,9 @@ async def export_transfers_pdf(
|
|||
</body>
|
||||
</html>
|
||||
"""
|
||||
final_html = resolved_html or html
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=html).write_pdf(font_config=font_config)
|
||||
pdf_bytes = HTML(string=final_html).write_pdf(font_config=font_config)
|
||||
filename = f"transfers_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
|
|
|
|||
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
|
||||
return result
|
||||
# Preserve original signature so FastAPI sees correct parameters (including Request)
|
||||
wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined]
|
||||
# Also preserve evaluated type annotations to avoid ForwardRef issues under __future__.annotations
|
||||
sig = inspect.signature(func)
|
||||
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||
# Preserve/evaluate annotations; ensure 'request' is explicitly FastAPI Request
|
||||
try:
|
||||
wrapper.__annotations__ = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
|
||||
evaluated = get_type_hints(func, globalns=getattr(func, "__globals__", None)) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
# Fallback to original annotations (may be string-based) if evaluation fails
|
||||
wrapper.__annotations__ = getattr(func, "__annotations__", {})
|
||||
evaluated = getattr(func, "__annotations__", {})
|
||||
# Force request annotation if present in params
|
||||
if 'request' in sig.parameters:
|
||||
try:
|
||||
from fastapi import Request as _FastapiRequest # local import to avoid cycles
|
||||
evaluated = dict(evaluated or {})
|
||||
evaluated['request'] = _FastapiRequest
|
||||
except Exception:
|
||||
pass
|
||||
wrapper.__annotations__ = evaluated # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ from adapters.api.v1.support.priorities import router as support_priorities_rout
|
|||
from adapters.api.v1.support.statuses import router as support_statuses_router
|
||||
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
|
||||
from adapters.api.v1.admin.email_config import router as admin_email_config_router
|
||||
from adapters.api.v1.admin.system_settings import router as admin_system_settings_router
|
||||
from adapters.api.v1.admin.wallet_admin import router as admin_wallet_router
|
||||
from adapters.api.v1.receipts_payments import router as receipts_payments_router
|
||||
from adapters.api.v1.transfers import router as transfers_router
|
||||
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||
|
|
@ -37,6 +39,10 @@ from adapters.api.v1.expense_income import router as expense_income_router
|
|||
from adapters.api.v1.documents import router as documents_router
|
||||
from adapters.api.v1.kardex import router as kardex_router
|
||||
from adapters.api.v1.inventory_transfers import router as inventory_transfers_router
|
||||
from adapters.api.v1.opening_balance import router as opening_balance_router
|
||||
from adapters.api.v1.report_templates import router as report_templates_router
|
||||
from adapters.api.v1.wallet import router as wallet_router
|
||||
from adapters.api.v1.wallet_webhook import router as wallet_webhook_router
|
||||
from app.core.i18n import negotiate_locale, Translator
|
||||
from app.core.error_handlers import register_error_handlers
|
||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||
|
|
@ -301,6 +307,8 @@ def create_app() -> FastAPI:
|
|||
application.include_router(categories_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.warehouse_docs import router as warehouse_docs_router
|
||||
application.include_router(warehouse_docs_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.warehouses import router as warehouses_router
|
||||
application.include_router(warehouses_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.boms import router as boms_router
|
||||
|
|
@ -323,6 +331,14 @@ def create_app() -> FastAPI:
|
|||
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(inventory_transfers_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(opening_balance_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(report_templates_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(wallet_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(wallet_webhook_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.payment_gateways import router as payment_gateways_router
|
||||
application.include_router(payment_gateways_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.payment_callbacks import router as payment_callbacks_router
|
||||
application.include_router(payment_callbacks_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
# Support endpoints
|
||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||
|
|
@ -334,6 +350,10 @@ def create_app() -> FastAPI:
|
|||
# Admin endpoints
|
||||
application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(admin_email_config_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(admin_system_settings_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(admin_wallet_router, prefix=settings.api_v1_prefix)
|
||||
from adapters.api.v1.admin.payment_gateways import router as admin_payment_gateways_router
|
||||
application.include_router(admin_payment_gateways_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
register_error_handlers(application)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ from adapters.db.models.document import Document
|
|||
from adapters.db.models.document_line import DocumentLine
|
||||
from adapters.db.models.account import Account
|
||||
from adapters.db.models.currency import Currency
|
||||
from adapters.db.models.bank_account import BankAccount
|
||||
from adapters.db.models.cash_register import CashRegister
|
||||
from adapters.db.models.petty_cash import PettyCash
|
||||
from adapters.db.models.check import Check
|
||||
from adapters.db.models.user import User
|
||||
from adapters.db.models.fiscal_year import FiscalYear
|
||||
from adapters.db.models.person import Person
|
||||
from adapters.db.models.product import Product
|
||||
from adapters.db.models.invoice_item_line import InvoiceItemLine
|
||||
from app.core.responses import ApiError
|
||||
import jdatetime
|
||||
|
||||
|
|
@ -399,9 +404,6 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
|
|||
total = Decimal(0)
|
||||
for line in lines:
|
||||
info = line.get("extra_info") or {}
|
||||
# فقط برای کالاهای دارای کنترل موجودی
|
||||
if not bool(info.get("inventory_tracked")):
|
||||
continue
|
||||
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
|
||||
if info.get("inventory_posted") is False:
|
||||
continue
|
||||
|
|
@ -616,16 +618,9 @@ def create_invoice(
|
|||
if totals_missing:
|
||||
totals = _extract_totals_from_lines(lines_input)
|
||||
|
||||
# Inventory validation and costing pre-calculation
|
||||
# Inventory posting is decoupled; no stock validation here
|
||||
post_inventory: bool = _is_inventory_posting_enabled(data)
|
||||
# Determine outgoing lines for stock checks
|
||||
movement_hint, _ = _movement_from_type(invoice_type)
|
||||
outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out":
|
||||
outgoing_lines.append(ln)
|
||||
|
||||
# Resolve inventory tracking per product and annotate lines
|
||||
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
|
||||
|
|
@ -644,44 +639,14 @@ def create_invoice(
|
|||
info = dict(ln.get("extra_info") or {})
|
||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||
ln["extra_info"] = info
|
||||
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
|
||||
if post_inventory:
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
inv_tracked = bool(info.get("inventory_tracked"))
|
||||
mv = info.get("movement") or movement_hint
|
||||
if inv_tracked and mv in ("in", "out"):
|
||||
wh = info.get("warehouse_id")
|
||||
if wh is None:
|
||||
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیفهای دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
|
||||
# انبار از فاکتور جدا شده است؛ انتخاب انبار در فاکتور اجباری نیست
|
||||
|
||||
|
||||
# Filter outgoing lines to only inventory-tracked products for stock checks
|
||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in outgoing_lines:
|
||||
pid = ln.get("product_id")
|
||||
if pid and track_map.get(int(pid)):
|
||||
tracked_outgoing_lines.append(ln)
|
||||
|
||||
# Ensure stock sufficiency for outgoing (only for tracked products)
|
||||
if post_inventory and tracked_outgoing_lines:
|
||||
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
|
||||
# بدون کنترل کسری در مرحله فاکتور؛ کنترل در پست حواله انجام میشود
|
||||
|
||||
# Costing method (only for tracked products)
|
||||
costing_method = _get_costing_method(data)
|
||||
if post_inventory and costing_method == "fifo" and tracked_outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
|
||||
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
|
||||
i = 0
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out" and info.get("inventory_tracked"):
|
||||
amt = fifo_costs[i]
|
||||
i += 1
|
||||
info = dict(info)
|
||||
info["cogs_amount"] = float(amt)
|
||||
ln["extra_info"] = info
|
||||
# محاسبه COGS به پست حواله منتقل میشود
|
||||
|
||||
# Create document
|
||||
doc_code = _build_invoice_code(db, business_id, invoice_type)
|
||||
|
|
@ -711,26 +676,23 @@ def create_invoice(
|
|||
db.add(document)
|
||||
db.flush()
|
||||
|
||||
# Create product lines (no debit/credit)
|
||||
# ذخیره اقلام فاکتور در جدول مجزا (invoice_item_lines)
|
||||
for line in lines_input:
|
||||
product_id = line.get("product_id")
|
||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||
if not product_id or qty <= 0:
|
||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
||||
extra_info = dict(line.get("extra_info") or {})
|
||||
# علامتگذاری اینکه این خط در انبار پست شده/نشده است
|
||||
extra_info["inventory_posted"] = bool(post_inventory)
|
||||
db.add(DocumentLine(
|
||||
extra_info.pop("inventory_posted", None)
|
||||
db.add(InvoiceItemLine(
|
||||
document_id=document.id,
|
||||
product_id=int(product_id),
|
||||
quantity=qty,
|
||||
debit=Decimal(0),
|
||||
credit=Decimal(0),
|
||||
description=line.get("description"),
|
||||
extra_info=extra_info,
|
||||
))
|
||||
|
||||
# Accounting lines for finalized invoices
|
||||
# Accounting lines for finalized invoices (بدون خطوط COGS/Inventory؛ به حواله موکول شد)
|
||||
if not document.is_proforma:
|
||||
accounts = _resolve_accounts_for_invoice(db, data)
|
||||
|
||||
|
|
@ -738,8 +700,7 @@ def create_invoice(
|
|||
tax = Decimal(str(totals["tax"]))
|
||||
total_with_tax = net + tax
|
||||
|
||||
# COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمیشوند)
|
||||
cogs_total = _extract_cogs_total(lines_input)
|
||||
# COGS به پست حواله منتقل شد
|
||||
|
||||
# Sales
|
||||
if invoice_type == INVOICE_SALES:
|
||||
|
|
@ -769,66 +730,8 @@ def create_invoice(
|
|||
credit=tax,
|
||||
description="مالیات بر ارزش افزوده خروجی",
|
||||
))
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=accounts["cogs"].id,
|
||||
debit=cogs_total,
|
||||
credit=Decimal(0),
|
||||
description="بهای تمامشده کالای فروشرفته",
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=accounts["inventory"].id,
|
||||
debit=Decimal(0),
|
||||
credit=cogs_total,
|
||||
description="خروج از موجودی بابت فروش",
|
||||
))
|
||||
# COGS/Inventory در پست حواله ثبت خواهد شد
|
||||
|
||||
# --- پورسانت فروشنده/بازاریاب (در صورت وجود) ---
|
||||
# محاسبه و ثبت پورسانت برای فروش و برگشت از فروش
|
||||
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
|
||||
if seller_id and commission_amount > 0:
|
||||
# هزینه پورسانت: 70702، بستانکار: پرداختنی به فروشنده 20201
|
||||
commission_expense = _get_fixed_account_by_code(db, "70702")
|
||||
seller_payable = _get_fixed_account_by_code(db, "20201")
|
||||
if invoice_type == INVOICE_SALES:
|
||||
# بدهکار هزینه، بستانکار فروشنده
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=commission_expense.id,
|
||||
debit=commission_amount,
|
||||
credit=Decimal(0),
|
||||
description="هزینه پورسانت فروش",
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=seller_payable.id,
|
||||
person_id=int(seller_id),
|
||||
debit=Decimal(0),
|
||||
credit=commission_amount,
|
||||
description="بابت پورسانت فروشنده/بازاریاب",
|
||||
extra_info={"seller_id": int(seller_id)},
|
||||
))
|
||||
else:
|
||||
# برگشت از فروش: معکوس
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=seller_payable.id,
|
||||
person_id=int(seller_id),
|
||||
debit=commission_amount,
|
||||
credit=Decimal(0),
|
||||
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
|
||||
extra_info={"seller_id": int(seller_id)},
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=commission_expense.id,
|
||||
debit=Decimal(0),
|
||||
credit=commission_amount,
|
||||
description="تعدیل هزینه پورسانت",
|
||||
))
|
||||
|
||||
# Sales Return
|
||||
elif invoice_type == INVOICE_SALES_RETURN:
|
||||
|
|
@ -857,21 +760,7 @@ def create_invoice(
|
|||
credit=Decimal(0),
|
||||
description="تعدیل VAT برگشت از فروش",
|
||||
))
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=accounts["inventory"].id,
|
||||
debit=cogs_total,
|
||||
credit=Decimal(0),
|
||||
description="ورود به موجودی بابت برگشت",
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=accounts["cogs"].id,
|
||||
debit=Decimal(0),
|
||||
credit=cogs_total,
|
||||
description="تعدیل بهای تمامشده برگشت",
|
||||
))
|
||||
# ورود موجودی/تعدیل COGS در پست حواله انجام میشود
|
||||
|
||||
# Purchase
|
||||
elif invoice_type == INVOICE_PURCHASE:
|
||||
|
|
@ -931,6 +820,8 @@ def create_invoice(
|
|||
|
||||
# Direct consumption
|
||||
elif invoice_type == INVOICE_DIRECT_CONSUMPTION:
|
||||
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
|
||||
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
|
|
@ -949,6 +840,8 @@ def create_invoice(
|
|||
|
||||
# Waste
|
||||
elif invoice_type == INVOICE_WASTE:
|
||||
cogs_lines = [l for l in lines_input if ((l.get("extra_info") or {}).get("movement") or movement_hint) == "out"]
|
||||
cogs_total = _extract_cogs_total(cogs_lines if cogs_lines else lines_input)
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
|
|
@ -1002,6 +895,47 @@ def create_invoice(
|
|||
description="انتقال از کاردرجریان",
|
||||
))
|
||||
|
||||
# --- پورسانت فروشنده/بازاریاب (تکمیلی پس از ثبت خطوط انواع فاکتور) ---
|
||||
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
|
||||
if seller_id and commission_amount > 0:
|
||||
commission_expense = _get_fixed_account_by_code(db, "70702")
|
||||
seller_payable = _get_fixed_account_by_code(db, "20201")
|
||||
if invoice_type == INVOICE_SALES:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=commission_expense.id,
|
||||
debit=commission_amount,
|
||||
credit=Decimal(0),
|
||||
description="هزینه پورسانت فروش",
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=seller_payable.id,
|
||||
person_id=int(seller_id),
|
||||
debit=Decimal(0),
|
||||
credit=commission_amount,
|
||||
description="بابت پورسانت فروشنده/بازاریاب",
|
||||
extra_info={"seller_id": int(seller_id)},
|
||||
))
|
||||
else:
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=seller_payable.id,
|
||||
person_id=int(seller_id),
|
||||
debit=commission_amount,
|
||||
credit=Decimal(0),
|
||||
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
|
||||
extra_info={"seller_id": int(seller_id)},
|
||||
))
|
||||
db.add(DocumentLine(
|
||||
document_id=document.id,
|
||||
account_id=commission_expense.id,
|
||||
debit=Decimal(0),
|
||||
credit=commission_amount,
|
||||
description="تعدیل هزینه پورسانت",
|
||||
))
|
||||
|
||||
# Persist invoice first
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
|
|
@ -1018,18 +952,62 @@ def create_invoice(
|
|||
# Aggregate amounts into one receipt/payment with multiple account_lines
|
||||
account_lines: List[Dict[str, Any]] = []
|
||||
total_amount = Decimal(0)
|
||||
# Validate currency of payment accounts vs invoice currency
|
||||
invoice_currency_id = int(currency_id)
|
||||
for p in payments:
|
||||
amount = Decimal(str(p.get("amount", 0) or 0))
|
||||
if amount <= 0:
|
||||
continue
|
||||
total_amount += amount
|
||||
account_lines.append({
|
||||
ttype = str(p.get("transaction_type") or "").strip().lower()
|
||||
# Currency match checks for money accounts
|
||||
if ttype in ("bank", "cash_register", "petty_cash", "check"):
|
||||
if ttype == "bank":
|
||||
ref_id = p.get("bank_id")
|
||||
if ref_id:
|
||||
acct = db.query(BankAccount).filter(BankAccount.id == int(ref_id)).first()
|
||||
if not acct:
|
||||
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Bank account not found", http_status=404)
|
||||
if int(acct.currency_id) != invoice_currency_id:
|
||||
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of bank account does not match invoice currency", http_status=400)
|
||||
elif ttype == "cash_register":
|
||||
ref_id = p.get("cash_register_id")
|
||||
if ref_id:
|
||||
acct = db.query(CashRegister).filter(CashRegister.id == int(ref_id)).first()
|
||||
if not acct:
|
||||
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Cash register not found", http_status=404)
|
||||
if int(acct.currency_id) != invoice_currency_id:
|
||||
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of cash register does not match invoice currency", http_status=400)
|
||||
elif ttype == "petty_cash":
|
||||
ref_id = p.get("petty_cash_id")
|
||||
if ref_id:
|
||||
acct = db.query(PettyCash).filter(PettyCash.id == int(ref_id)).first()
|
||||
if not acct:
|
||||
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Petty cash not found", http_status=404)
|
||||
if int(acct.currency_id) != invoice_currency_id:
|
||||
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of petty cash does not match invoice currency", http_status=400)
|
||||
elif ttype == "check":
|
||||
ref_id = p.get("check_id")
|
||||
if ref_id:
|
||||
chk = db.query(Check).filter(Check.id == int(ref_id)).first()
|
||||
if not chk:
|
||||
raise ApiError("PAYMENT_ACCOUNT_NOT_FOUND", "Check not found", http_status=404)
|
||||
if int(chk.currency_id) != invoice_currency_id:
|
||||
raise ApiError("PAYMENT_CURRENCY_MISMATCH", "Currency of check does not match invoice currency", http_status=400)
|
||||
|
||||
# Build account line entry including ids/names for linking
|
||||
account_line: Dict[str, Any] = {
|
||||
"transaction_type": p.get("transaction_type"),
|
||||
"amount": float(amount),
|
||||
"description": p.get("description"),
|
||||
"transaction_date": p.get("transaction_date"),
|
||||
"commission": p.get("commission"),
|
||||
})
|
||||
}
|
||||
# pass through reference ids/names if provided
|
||||
for key in ("bank_id", "bank_name", "cash_register_id", "cash_register_name", "petty_cash_id", "petty_cash_name", "check_id", "check_number", "person_id", "account_id"):
|
||||
if p.get(key) is not None:
|
||||
account_line[key] = p.get(key)
|
||||
account_lines.append(account_line)
|
||||
|
||||
if total_amount > 0 and account_lines:
|
||||
is_receipt = invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN}
|
||||
|
|
@ -1062,6 +1040,42 @@ def create_invoice(
|
|||
db.commit()
|
||||
db.refresh(document)
|
||||
|
||||
# ایجاد حواله انبار draft در صورت نیاز و جدا از فاکتور
|
||||
try:
|
||||
if bool(data.get("extra_info", {}).get("post_inventory", True)):
|
||||
from app.services.warehouse_service import create_from_invoice
|
||||
created_wh_ids: List[int] = []
|
||||
if invoice_type == INVOICE_PRODUCTION:
|
||||
out_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "out"]
|
||||
in_lines = [ln for ln in lines_input if (ln.get("extra_info") or {}).get("movement") == "in"]
|
||||
if out_lines:
|
||||
wh_issue = create_from_invoice(db, business_id, document, out_lines, "issue", user_id)
|
||||
created_wh_ids.append(int(wh_issue.id))
|
||||
if in_lines:
|
||||
wh_receipt = create_from_invoice(db, business_id, document, in_lines, "receipt", user_id)
|
||||
created_wh_ids.append(int(wh_receipt.id))
|
||||
else:
|
||||
if invoice_type in {INVOICE_SALES, INVOICE_PURCHASE_RETURN, INVOICE_WASTE, INVOICE_DIRECT_CONSUMPTION}:
|
||||
wh_type = "issue"
|
||||
elif invoice_type in {INVOICE_PURCHASE, INVOICE_SALES_RETURN}:
|
||||
wh_type = "receipt"
|
||||
else:
|
||||
wh_type = "issue"
|
||||
wh = create_from_invoice(db, business_id, document, lines_input, wh_type, user_id)
|
||||
created_wh_ids.append(int(wh.id))
|
||||
|
||||
if created_wh_ids:
|
||||
# ذخیره لینک حوالهها در extra_info.links
|
||||
extra = document.extra_info or {}
|
||||
links = dict((extra.get("links") or {}))
|
||||
links["warehouse_document_ids"] = created_wh_ids
|
||||
extra["links"] = links
|
||||
document.extra_info = extra
|
||||
db.commit()
|
||||
except Exception:
|
||||
# عدم موفقیت در ساخت حواله نباید مانع بازگشت فاکتور شود
|
||||
db.rollback()
|
||||
|
||||
return invoice_document_to_dict(db, document)
|
||||
|
||||
|
||||
|
|
@ -1108,22 +1122,17 @@ def update_invoice(
|
|||
if data.get("description") is not None:
|
||||
document.description = data.get("description")
|
||||
|
||||
# Recreate lines
|
||||
# Recreate lines: حذف سطرهای حسابداری و اقلام فاکتور و بازایجاد
|
||||
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
|
||||
db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).delete(synchronize_session=False)
|
||||
|
||||
lines_input: List[Dict[str, Any]] = list(data.get("lines") or [])
|
||||
if not lines_input:
|
||||
raise ApiError("LINES_REQUIRED", "At least one line is required", http_status=400)
|
||||
|
||||
# Inventory validation and costing before re-adding lines
|
||||
# Inventory decoupled from invoices
|
||||
inv_type = document.document_type
|
||||
movement_hint, _ = _movement_from_type(inv_type)
|
||||
outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out":
|
||||
outgoing_lines.append(ln)
|
||||
|
||||
# Resolve and annotate inventory tracking for all lines
|
||||
all_product_ids = [int(ln.get("product_id")) for ln in lines_input if ln.get("product_id")]
|
||||
|
|
@ -1141,41 +1150,10 @@ def update_invoice(
|
|||
info = dict(ln.get("extra_info") or {})
|
||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||
ln["extra_info"] = info
|
||||
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
|
||||
if post_inventory_update:
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
inv_tracked = bool(info.get("inventory_tracked"))
|
||||
mv = info.get("movement") or movement_hint
|
||||
if inv_tracked and mv in ("in", "out"):
|
||||
wh = info.get("warehouse_id")
|
||||
if wh is None:
|
||||
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیفهای دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
|
||||
|
||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||
for ln in outgoing_lines:
|
||||
pid = ln.get("product_id")
|
||||
if pid and track_map.get(int(pid)):
|
||||
tracked_outgoing_lines.append(ln)
|
||||
# انتخاب انبار در مرحله فاکتور الزامی نیست
|
||||
|
||||
header_for_costing = data if data else {"extra_info": document.extra_info}
|
||||
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
|
||||
if post_inventory_update and tracked_outgoing_lines:
|
||||
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||
|
||||
costing_method = _get_costing_method(header_for_costing)
|
||||
if post_inventory_update and costing_method == "fifo" and tracked_outgoing_lines:
|
||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||
i = 0
|
||||
for ln in lines_input:
|
||||
info = ln.get("extra_info") or {}
|
||||
mv = info.get("movement") or movement_hint
|
||||
if mv == "out" and info.get("inventory_tracked"):
|
||||
amt = fifo_costs[i]
|
||||
i += 1
|
||||
info = dict(info)
|
||||
info["cogs_amount"] = float(amt)
|
||||
ln["extra_info"] = info
|
||||
|
||||
for line in lines_input:
|
||||
product_id = line.get("product_id")
|
||||
|
|
@ -1183,13 +1161,10 @@ def update_invoice(
|
|||
if not product_id or qty <= 0:
|
||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
||||
extra_info = dict(line.get("extra_info") or {})
|
||||
extra_info["inventory_posted"] = bool(post_inventory_update)
|
||||
db.add(DocumentLine(
|
||||
db.add(InvoiceItemLine(
|
||||
document_id=document.id,
|
||||
product_id=int(product_id),
|
||||
quantity=qty,
|
||||
debit=Decimal(0),
|
||||
credit=Decimal(0),
|
||||
description=line.get("description"),
|
||||
extra_info=extra_info,
|
||||
))
|
||||
|
|
@ -1206,7 +1181,7 @@ def update_invoice(
|
|||
tax = Decimal(str(totals.get("tax", 0)))
|
||||
total_with_tax = net + tax
|
||||
person_id = _person_id_from_header({"extra_info": header_extra})
|
||||
cogs_total = _extract_cogs_total(lines_input)
|
||||
# inventory/COGS handled in warehouse posting
|
||||
|
||||
if inv_type == INVOICE_SALES:
|
||||
if person_id:
|
||||
|
|
@ -1214,47 +1189,35 @@ def update_invoice(
|
|||
db.add(DocumentLine(document_id=document.id, account_id=accounts["revenue"].id, debit=Decimal(0), credit=net, description="درآمد فروش"))
|
||||
if tax > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_out"].id, debit=Decimal(0), credit=tax, description="مالیات خروجی"))
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=cogs_total, credit=Decimal(0), description="بهای تمامشده"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
|
||||
# COGS/Inventory by warehouse posting
|
||||
elif inv_type == INVOICE_SALES_RETURN:
|
||||
if person_id:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["sales_return"].id, debit=net, credit=Decimal(0), description="برگشت از فروش"))
|
||||
if tax > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="تعدیل VAT"))
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=cogs_total, credit=Decimal(0), description="ورود موجودی"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["cogs"].id, debit=Decimal(0), credit=cogs_total, description="تعدیل بهای تمامشده"))
|
||||
# Inventory/COGS handled in warehouse posting
|
||||
elif inv_type == INVOICE_PURCHASE:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=net, credit=Decimal(0), description="ورود موجودی"))
|
||||
# Inventory via warehouse posting; invoice handles VAT/AP only (or GRNI if فعال)
|
||||
if tax > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=tax, credit=Decimal(0), description="مالیات ورودی"))
|
||||
if person_id:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=Decimal(0), credit=total_with_tax, description=document.description))
|
||||
elif inv_type == INVOICE_PURCHASE_RETURN:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=net, description="خروج موجودی"))
|
||||
# Inventory via warehouse posting
|
||||
if tax > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["vat_in"].id, debit=Decimal(0), credit=tax, description="تعدیل VAT ورودی"))
|
||||
if person_id:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["person"].id, person_id=person_id, debit=total_with_tax, credit=Decimal(0), description=document.description))
|
||||
elif inv_type == INVOICE_DIRECT_CONSUMPTION:
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["direct_consumption"].id, debit=cogs_total, credit=Decimal(0), description="مصرف مستقیم"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
|
||||
# Expense/Inventory in warehouse posting
|
||||
pass
|
||||
elif inv_type == INVOICE_WASTE:
|
||||
if cogs_total > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["waste_expense"].id, debit=cogs_total, credit=Decimal(0), description="ضایعات"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=cogs_total, description="خروج موجودی"))
|
||||
# Expense/Inventory in warehouse posting
|
||||
pass
|
||||
elif inv_type == INVOICE_PRODUCTION:
|
||||
materials_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "out"])
|
||||
if materials_cost > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=materials_cost, credit=Decimal(0), description="انتقال به کاردرجریان"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory"].id, debit=Decimal(0), credit=materials_cost, description="خروج مواد"))
|
||||
finished_cost = _extract_cogs_total([l for l in lines_input if (l.get("extra_info") or {}).get("movement") == "in"])
|
||||
if finished_cost > 0:
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساختهشده"))
|
||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان"))
|
||||
# WIP/Inventory in warehouse posting
|
||||
pass
|
||||
|
||||
# --- پورسانت فروشنده/بازاریاب (بهصورت تکمیلی) ---
|
||||
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||
|
|
@ -1303,35 +1266,36 @@ def update_invoice(
|
|||
|
||||
|
||||
def invoice_document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
|
||||
|
||||
# اقلام فاکتور از جدول مجزا خوانده میشوند
|
||||
item_rows = db.query(InvoiceItemLine).filter(InvoiceItemLine.document_id == document.id).all()
|
||||
product_lines: List[Dict[str, Any]] = []
|
||||
account_lines: List[Dict[str, Any]] = []
|
||||
for it in item_rows:
|
||||
product = db.query(Product).filter(Product.id == it.product_id).first()
|
||||
product_lines.append({
|
||||
"id": it.id,
|
||||
"product_id": it.product_id,
|
||||
"product_name": getattr(product, "name", None),
|
||||
"quantity": float(it.quantity) if it.quantity else None,
|
||||
"description": it.description,
|
||||
"extra_info": it.extra_info,
|
||||
})
|
||||
|
||||
for line in lines:
|
||||
if line.product_id:
|
||||
product = db.query(Product).filter(Product.id == line.product_id).first()
|
||||
product_lines.append({
|
||||
"id": line.id,
|
||||
"product_id": line.product_id,
|
||||
"product_name": getattr(product, "name", None),
|
||||
"quantity": float(line.quantity) if line.quantity else None,
|
||||
"description": line.description,
|
||||
"extra_info": line.extra_info,
|
||||
})
|
||||
elif line.account_id:
|
||||
account = db.query(Account).filter(Account.id == line.account_id).first()
|
||||
account_lines.append({
|
||||
"id": line.id,
|
||||
"account_id": line.account_id,
|
||||
"account_name": getattr(account, "name", None),
|
||||
"account_code": getattr(account, "code", None),
|
||||
"debit": float(line.debit),
|
||||
"credit": float(line.credit),
|
||||
"person_id": line.person_id,
|
||||
"description": line.description,
|
||||
"extra_info": line.extra_info,
|
||||
})
|
||||
# سطرهای حسابداری از document_lines خوانده میشوند
|
||||
acc_rows = db.query(DocumentLine).filter(DocumentLine.document_id == document.id, DocumentLine.account_id != None).all() # noqa: E711
|
||||
account_lines: List[Dict[str, Any]] = []
|
||||
for line in acc_rows:
|
||||
account = db.query(Account).filter(Account.id == line.account_id).first()
|
||||
account_lines.append({
|
||||
"id": line.id,
|
||||
"account_id": line.account_id,
|
||||
"account_name": getattr(account, "name", None),
|
||||
"account_code": getattr(account, "code", None),
|
||||
"debit": float(line.debit),
|
||||
"credit": float(line.credit),
|
||||
"person_id": line.person_id,
|
||||
"description": line.description,
|
||||
"extra_info": line.extra_info,
|
||||
})
|
||||
|
||||
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
|
||||
created_by_name = f"{getattr(created_by, 'first_name', '')} {getattr(created_by, 'last_name', '')}".strip() if created_by else None
|
||||
|
|
|
|||
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" # حسابهای پرداختنی
|
||||
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
||||
account = _get_fixed_account_by_code(db, account_code)
|
||||
elif transaction_type == "wallet":
|
||||
# کیفپول (حساب نزد پرداختیار)
|
||||
account_code = "10204"
|
||||
logger.info(f"انتخاب حساب کیفپول با کد: {account_code}")
|
||||
account = _get_fixed_account_by_code(db, account_code)
|
||||
elif account_id:
|
||||
# اگر account_id مشخص باشد، از آن استفاده کن
|
||||
logger.info(f"استفاده از account_id مشخص: {account_id}")
|
||||
|
|
|
|||
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 typing import Dict, Any, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from adapters.db.models.warehouse_document import WarehouseDocument
|
||||
from adapters.db.models.warehouse_document_line import WarehouseDocumentLine
|
||||
from adapters.db.models.document import Document
|
||||
from adapters.db.models.document_line import DocumentLine
|
||||
from adapters.db.models.product import Product
|
||||
from adapters.db.models.account import Account
|
||||
from adapters.db.models.fiscal_year import FiscalYear
|
||||
from app.core.responses import ApiError
|
||||
from adapters.db.models.warehouse import Warehouse
|
||||
from adapters.db.repositories.warehouse_repository import WarehouseRepository
|
||||
|
|
@ -12,114 +22,301 @@ from adapters.api.v1.schemas import QueryInfo, FilterItem
|
|||
from app.services.query_service import QueryService
|
||||
|
||||
|
||||
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
|
||||
fy = db.query(FiscalYear).filter(and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True)).first()
|
||||
if not fy:
|
||||
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
|
||||
return fy
|
||||
|
||||
|
||||
def _build_wh_code(prefix_base: str) -> str:
|
||||
today = datetime.now().date()
|
||||
return f"{prefix_base}-{today.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}"
|
||||
|
||||
|
||||
def create_from_invoice(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
invoice: Document,
|
||||
lines: List[Dict[str, Any]],
|
||||
wh_doc_type: str,
|
||||
created_by_user_id: Optional[int] = None,
|
||||
) -> WarehouseDocument:
|
||||
"""ساخت حواله انبار draft از روی فاکتور (بدون پست)."""
|
||||
fy = _get_current_fiscal_year(db, business_id)
|
||||
code = _build_wh_code("WH")
|
||||
wh = WarehouseDocument(
|
||||
business_id=business_id,
|
||||
fiscal_year_id=fy.id,
|
||||
code=code,
|
||||
document_date=invoice.document_date,
|
||||
status="draft",
|
||||
doc_type=wh_doc_type,
|
||||
source_type="invoice",
|
||||
source_document_id=invoice.id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
db.add(wh)
|
||||
db.flush()
|
||||
for ln in lines:
|
||||
pid = ln.get("product_id")
|
||||
qty = Decimal(str(ln.get("quantity") or 0))
|
||||
if not pid or qty <= 0:
|
||||
continue
|
||||
extra = ln.get("extra_info") or {}
|
||||
mv = (extra.get("movement") or ("out" if wh_doc_type in ("issue", "production_out") else "in"))
|
||||
# warehouse_id عمداً تعیین نمیشود؛ انباردار بعداً مشخص میکند
|
||||
wline = WarehouseDocumentLine(
|
||||
warehouse_document_id=wh.id,
|
||||
product_id=int(pid),
|
||||
warehouse_id=None,
|
||||
movement=str(mv),
|
||||
quantity=qty,
|
||||
extra_info=extra,
|
||||
)
|
||||
db.add(wline)
|
||||
db.flush()
|
||||
return wh
|
||||
|
||||
|
||||
def post_warehouse_document(db: Session, wh_id: int) -> Dict[str, Any]:
|
||||
"""پست حواله: کنترل کسری برای خروجها و محاسبه COGS ساده (fallback به unit_price).
|
||||
در پایان، در صورت وجود لینک به فاکتور منبع، سطرهای حسابداری COGS/Inventory را به همان سند اضافه میکند.
|
||||
"""
|
||||
wh = db.query(WarehouseDocument).filter(WarehouseDocument.id == wh_id).first()
|
||||
if not wh:
|
||||
raise ApiError("NOT_FOUND", "Warehouse document not found", http_status=404)
|
||||
if wh.status == "posted":
|
||||
return {"id": wh.id, "status": wh.status}
|
||||
|
||||
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
|
||||
# کنترل کسری برای خروجها (اگر allow_negative_stock نباشد)
|
||||
for ln in lines:
|
||||
if ln.movement == "out":
|
||||
# در این نسخه اولیه صرفاً چک نرم (بدون ایندکس کاردکس): اگر quantity<=0 خطا
|
||||
if not ln.quantity or Decimal(str(ln.quantity)) <= 0:
|
||||
raise ApiError("INVALID_QUANTITY", "Quantity must be positive", http_status=400)
|
||||
|
||||
# محاسبه cogs_amount (ساده)
|
||||
for ln in lines:
|
||||
qty = Decimal(str(ln.quantity or 0))
|
||||
if qty <= 0:
|
||||
continue
|
||||
if ln.cogs_amount is None:
|
||||
unit = Decimal(str((ln.cost_price or 0)))
|
||||
if unit <= 0:
|
||||
# تلاش برای fallback از extra_info.unit_price
|
||||
u = None
|
||||
try:
|
||||
u = Decimal(str((ln.extra_info or {}).get("unit_price", 0)))
|
||||
except Exception:
|
||||
u = Decimal(0)
|
||||
unit = u
|
||||
ln.cogs_amount = unit * qty
|
||||
|
||||
wh.status = "posted"
|
||||
wh.touch()
|
||||
db.flush()
|
||||
|
||||
# در صورت اتصال به فاکتور، بر اساس نوع فاکتور سطرهای حسابداری ثبت کن
|
||||
if wh.source_type == "invoice" and wh.source_document_id:
|
||||
inv: Document = db.query(Document).filter(Document.id == int(wh.source_document_id)).first()
|
||||
if inv:
|
||||
# حسابها
|
||||
def get_fixed(db: Session, code: str) -> Account:
|
||||
return db.query(Account).filter(and_(Account.business_id == None, Account.code == code)).first() # noqa: E711
|
||||
acc_inventory = get_fixed(db, "10102")
|
||||
acc_inventory_finished = get_fixed(db, "10102")
|
||||
acc_cogs = get_fixed(db, "40001")
|
||||
acc_direct = get_fixed(db, "70406")
|
||||
acc_waste = get_fixed(db, "70407")
|
||||
acc_wip = get_fixed(db, "10106")
|
||||
acc_grni = get_fixed(db, "30101") # Goods Received Not Invoiced
|
||||
|
||||
inv_type = inv.document_type
|
||||
# جمع مبالغ بر اساس حرکت
|
||||
out_total = Decimal(0)
|
||||
in_total = Decimal(0)
|
||||
for ln in lines:
|
||||
amt = Decimal(str(ln.cogs_amount or 0))
|
||||
if ln.movement == "out":
|
||||
out_total += amt
|
||||
elif ln.movement == "in":
|
||||
in_total += amt
|
||||
|
||||
if inv_type == "invoice_sales":
|
||||
if out_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_cogs.id, debit=out_total, credit=Decimal(0), description="بهای تمامشده (پست حواله فروش)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (پست حواله فروش)"))
|
||||
elif inv_type == "invoice_sales_return":
|
||||
if in_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=in_total, credit=Decimal(0), description="ورود موجودی (پست حواله برگشت از فروش)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_cogs.id, debit=Decimal(0), credit=in_total, description="تعدیل بهای تمامشده (پست حواله برگشت از فروش)"))
|
||||
elif inv_type == "invoice_direct_consumption":
|
||||
if out_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_direct.id, debit=out_total, credit=Decimal(0), description="مصرف مستقیم (پست حواله)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (مصرف مستقیم)"))
|
||||
elif inv_type == "invoice_waste":
|
||||
if out_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_waste.id, debit=out_total, credit=Decimal(0), description="ضایعات (پست حواله)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی (ضایعات)"))
|
||||
elif inv_type == "invoice_purchase":
|
||||
if in_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=in_total, credit=Decimal(0), description="ورود موجودی خرید (پست حواله)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_grni.id, debit=Decimal(0), credit=in_total, description="ثبت GRNI خرید"))
|
||||
elif inv_type == "invoice_purchase_return":
|
||||
if out_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_grni.id, debit=out_total, credit=Decimal(0), description="ثبت GRNI برگشت خرید"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج موجودی برگشت خرید (پست حواله)"))
|
||||
elif inv_type == "invoice_production":
|
||||
# مواد مصرفی (out): بدهکار WIP، بستانکار موجودی
|
||||
if out_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_wip.id, debit=out_total, credit=Decimal(0), description="انتقال مواد به کاردرجریان (پست حواله)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory.id, debit=Decimal(0), credit=out_total, description="خروج مواد اولیه (پست حواله)"))
|
||||
# کالای ساخته شده (in): بدهکار موجودی ساختهشده، بستانکار WIP
|
||||
if in_total > 0:
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_inventory_finished.id, debit=in_total, credit=Decimal(0), description="ورود کالای ساختهشده (پست حواله)"))
|
||||
db.add(DocumentLine(document_id=inv.id, account_id=acc_wip.id, debit=Decimal(0), credit=in_total, description="انتقال از کاردرجریان (پست حواله)"))
|
||||
return {"id": wh.id, "status": wh.status}
|
||||
|
||||
|
||||
def warehouse_document_to_dict(db: Session, wh: WarehouseDocument) -> Dict[str, Any]:
|
||||
lines = db.query(WarehouseDocumentLine).filter(WarehouseDocumentLine.warehouse_document_id == wh.id).all()
|
||||
return {
|
||||
"id": wh.id,
|
||||
"code": wh.code,
|
||||
"business_id": wh.business_id,
|
||||
"fiscal_year_id": wh.fiscal_year_id,
|
||||
"document_date": wh.document_date.isoformat() if wh.document_date else None,
|
||||
"status": wh.status,
|
||||
"doc_type": wh.doc_type,
|
||||
"warehouse_id_from": wh.warehouse_id_from,
|
||||
"warehouse_id_to": wh.warehouse_id_to,
|
||||
"source_type": wh.source_type,
|
||||
"source_document_id": wh.source_document_id,
|
||||
"extra_info": wh.extra_info,
|
||||
"lines": [
|
||||
{
|
||||
"id": ln.id,
|
||||
"product_id": ln.product_id,
|
||||
"warehouse_id": ln.warehouse_id,
|
||||
"movement": ln.movement,
|
||||
"quantity": float(ln.quantity),
|
||||
"cost_price": float(ln.cost_price) if ln.cost_price is not None else None,
|
||||
"cogs_amount": float(ln.cogs_amount) if ln.cogs_amount is not None else None,
|
||||
"extra_info": ln.extra_info,
|
||||
}
|
||||
for ln in lines
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": obj.id,
|
||||
"business_id": obj.business_id,
|
||||
"code": obj.code,
|
||||
"name": obj.name,
|
||||
"description": obj.description,
|
||||
"is_default": obj.is_default,
|
||||
"created_at": obj.created_at,
|
||||
"updated_at": obj.updated_at,
|
||||
}
|
||||
return {
|
||||
"id": obj.id,
|
||||
"business_id": obj.business_id,
|
||||
"code": obj.code,
|
||||
"name": obj.name,
|
||||
"description": obj.description,
|
||||
"is_default": obj.is_default,
|
||||
"created_at": obj.created_at,
|
||||
"updated_at": obj.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def create_warehouse(db: Session, business_id: int, payload: WarehouseCreateRequest) -> Dict[str, Any]:
|
||||
code = payload.code.strip()
|
||||
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
|
||||
if dup:
|
||||
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
|
||||
repo = WarehouseRepository(db)
|
||||
obj = repo.create(
|
||||
business_id=business_id,
|
||||
code=code,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
is_default=bool(payload.is_default),
|
||||
)
|
||||
if obj.is_default:
|
||||
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
|
||||
db.commit()
|
||||
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
|
||||
code = payload.code.strip()
|
||||
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
|
||||
if dup:
|
||||
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
|
||||
repo = WarehouseRepository(db)
|
||||
obj = repo.create(
|
||||
business_id=business_id,
|
||||
code=code,
|
||||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
is_default=bool(payload.is_default),
|
||||
)
|
||||
if obj.is_default:
|
||||
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
|
||||
db.commit()
|
||||
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
|
||||
|
||||
|
||||
def list_warehouses(db: Session, business_id: int) -> Dict[str, Any]:
|
||||
repo = WarehouseRepository(db)
|
||||
rows = repo.list(business_id)
|
||||
return {"items": [_to_dict(w) for w in rows]}
|
||||
repo = WarehouseRepository(db)
|
||||
rows = repo.list(business_id)
|
||||
return {"items": [_to_dict(w) for w in rows]}
|
||||
|
||||
|
||||
def get_warehouse(db: Session, business_id: int, warehouse_id: int) -> Optional[Dict[str, Any]]:
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return None
|
||||
return _to_dict(obj)
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return None
|
||||
return _to_dict(obj)
|
||||
|
||||
|
||||
def update_warehouse(db: Session, business_id: int, warehouse_id: int, payload: WarehouseUpdateRequest) -> Optional[Dict[str, Any]]:
|
||||
repo = WarehouseRepository(db)
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return None
|
||||
if payload.code and payload.code.strip() != obj.code:
|
||||
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
|
||||
if dup:
|
||||
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
|
||||
repo = WarehouseRepository(db)
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return None
|
||||
if payload.code and payload.code.strip() != obj.code:
|
||||
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
|
||||
if dup:
|
||||
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
|
||||
|
||||
updated = repo.update(
|
||||
warehouse_id,
|
||||
code=payload.code.strip() if isinstance(payload.code, str) else None,
|
||||
name=payload.name.strip() if isinstance(payload.name, str) else None,
|
||||
description=payload.description,
|
||||
is_default=payload.is_default if payload.is_default is not None else None,
|
||||
)
|
||||
if not updated:
|
||||
return None
|
||||
if updated.is_default:
|
||||
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
|
||||
db.commit()
|
||||
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
|
||||
updated = repo.update(
|
||||
warehouse_id,
|
||||
code=payload.code.strip() if isinstance(payload.code, str) else None,
|
||||
name=payload.name.strip() if isinstance(payload.name, str) else None,
|
||||
description=payload.description,
|
||||
is_default=payload.is_default if payload.is_default is not None else None,
|
||||
)
|
||||
if not updated:
|
||||
return None
|
||||
if updated.is_default:
|
||||
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
|
||||
db.commit()
|
||||
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
|
||||
|
||||
|
||||
def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return False
|
||||
repo = WarehouseRepository(db)
|
||||
return repo.delete(warehouse_id)
|
||||
|
||||
obj = db.get(Warehouse, warehouse_id)
|
||||
if not obj or obj.business_id != business_id:
|
||||
return False
|
||||
repo = WarehouseRepository(db)
|
||||
return repo.delete(warehouse_id)
|
||||
|
||||
|
||||
def query_warehouses(db: Session, business_id: int, query_info: QueryInfo) -> Dict[str, Any]:
|
||||
"""Query warehouses with filters/search/sorting/pagination scoped to business."""
|
||||
# Ensure business scoping via filters
|
||||
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
|
||||
merged_filters = [base_filter]
|
||||
if query_info.filters:
|
||||
merged_filters.extend(query_info.filters)
|
||||
# Ensure business scoping via filters
|
||||
base_filter = FilterItem(property="business_id", operator="=", value=business_id)
|
||||
merged_filters = [base_filter]
|
||||
if query_info.filters:
|
||||
merged_filters.extend(query_info.filters)
|
||||
|
||||
effective_query = QueryInfo(
|
||||
sort_by=query_info.sort_by,
|
||||
sort_desc=query_info.sort_desc,
|
||||
take=query_info.take,
|
||||
skip=query_info.skip,
|
||||
search=query_info.search,
|
||||
search_fields=query_info.search_fields,
|
||||
filters=merged_filters,
|
||||
)
|
||||
effective_query = QueryInfo(
|
||||
sort_by=query_info.sort_by,
|
||||
sort_desc=query_info.sort_desc,
|
||||
take=query_info.take,
|
||||
skip=query_info.skip,
|
||||
search=query_info.search,
|
||||
search_fields=query_info.search_fields,
|
||||
filters=merged_filters,
|
||||
)
|
||||
|
||||
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
|
||||
items = [_to_dict(w) for w in results]
|
||||
limit = max(1, effective_query.take)
|
||||
page = (effective_query.skip // limit) + 1
|
||||
total_pages = (total + limit - 1) // limit
|
||||
results, total = QueryService.query_with_filters(Warehouse, db, effective_query)
|
||||
items = [_to_dict(w) for w in results]
|
||||
limit = max(1, effective_query.take)
|
||||
page = (effective_query.skip // limit) + 1
|
||||
total_pages = (total + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,20 +22,30 @@ adapters/api/v1/health.py
|
|||
adapters/api/v1/inventory_transfers.py
|
||||
adapters/api/v1/invoices.py
|
||||
adapters/api/v1/kardex.py
|
||||
adapters/api/v1/opening_balance.py
|
||||
adapters/api/v1/payment_callbacks.py
|
||||
adapters/api/v1/payment_gateways.py
|
||||
adapters/api/v1/persons.py
|
||||
adapters/api/v1/petty_cash.py
|
||||
adapters/api/v1/price_lists.py
|
||||
adapters/api/v1/product_attributes.py
|
||||
adapters/api/v1/products.py
|
||||
adapters/api/v1/receipts_payments.py
|
||||
adapters/api/v1/report_templates.py
|
||||
adapters/api/v1/schemas.py
|
||||
adapters/api/v1/tax_types.py
|
||||
adapters/api/v1/tax_units.py
|
||||
adapters/api/v1/transfers.py
|
||||
adapters/api/v1/users.py
|
||||
adapters/api/v1/wallet.py
|
||||
adapters/api/v1/wallet_webhook.py
|
||||
adapters/api/v1/warehouse_docs.py
|
||||
adapters/api/v1/warehouses.py
|
||||
adapters/api/v1/admin/email_config.py
|
||||
adapters/api/v1/admin/file_storage.py
|
||||
adapters/api/v1/admin/payment_gateways.py
|
||||
adapters/api/v1/admin/system_settings.py
|
||||
adapters/api/v1/admin/wallet_admin.py
|
||||
adapters/api/v1/schema_models/__init__.py
|
||||
adapters/api/v1/schema_models/account.py
|
||||
adapters/api/v1/schema_models/bank_account.py
|
||||
|
|
@ -75,7 +85,9 @@ adapters/db/models/document_line.py
|
|||
adapters/db/models/email_config.py
|
||||
adapters/db/models/file_storage.py
|
||||
adapters/db/models/fiscal_year.py
|
||||
adapters/db/models/invoice_item_line.py
|
||||
adapters/db/models/password_reset.py
|
||||
adapters/db/models/payment_gateway.py
|
||||
adapters/db/models/person.py
|
||||
adapters/db/models/petty_cash.py
|
||||
adapters/db/models/price_list.py
|
||||
|
|
@ -83,10 +95,15 @@ adapters/db/models/product.py
|
|||
adapters/db/models/product_attribute.py
|
||||
adapters/db/models/product_attribute_link.py
|
||||
adapters/db/models/product_bom.py
|
||||
adapters/db/models/report_template.py
|
||||
adapters/db/models/system_setting.py
|
||||
adapters/db/models/tax_type.py
|
||||
adapters/db/models/tax_unit.py
|
||||
adapters/db/models/user.py
|
||||
adapters/db/models/wallet.py
|
||||
adapters/db/models/warehouse.py
|
||||
adapters/db/models/warehouse_document.py
|
||||
adapters/db/models/warehouse_document_line.py
|
||||
adapters/db/models/support/__init__.py
|
||||
adapters/db/models/support/category.py
|
||||
adapters/db/models/support/message.py
|
||||
|
|
@ -150,6 +167,8 @@ app/services/file_storage_service.py
|
|||
app/services/inventory_transfer_service.py
|
||||
app/services/invoice_service.py
|
||||
app/services/kardex_service.py
|
||||
app/services/opening_balance_service.py
|
||||
app/services/payment_service.py
|
||||
app/services/person_service.py
|
||||
app/services/petty_cash_service.py
|
||||
app/services/price_list_service.py
|
||||
|
|
@ -157,7 +176,10 @@ app/services/product_attribute_service.py
|
|||
app/services/product_service.py
|
||||
app/services/query_service.py
|
||||
app/services/receipt_payment_service.py
|
||||
app/services/report_template_service.py
|
||||
app/services/system_settings_service.py
|
||||
app/services/transfer_service.py
|
||||
app/services/wallet_service.py
|
||||
app/services/warehouse_service.py
|
||||
app/services/pdf/__init__.py
|
||||
app/services/pdf/base_pdf_service.py
|
||||
|
|
@ -222,6 +244,12 @@ migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
|
|||
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
|
||||
migrations/versions/20251021_000601_add_bom_and_warehouses.py
|
||||
migrations/versions/20251102_120001_add_check_status_fields.py
|
||||
migrations/versions/20251107_150001_add_warehouse_docs.py
|
||||
migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py
|
||||
migrations/versions/20251108_230001_add_report_templates.py
|
||||
migrations/versions/20251108_231201_add_system_settings.py
|
||||
migrations/versions/20251108_232101_add_wallet_tables.py
|
||||
migrations/versions/20251109_120001_add_payment_gateways.py
|
||||
migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('documents', sa.Column('description', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
# افزودن ستون فقط اگر قبلاً وجود ندارد
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
cols = [c['name'] for c in inspector.get_columns('documents')]
|
||||
if 'description' not in cols:
|
||||
op.add_column('documents', sa.Column('description', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('documents', 'description')
|
||||
# ### end Alembic commands ###
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
cols = [c['name'] for c in inspector.get_columns('documents')]
|
||||
if 'description' in cols:
|
||||
op.drop_column('documents', 'description')
|
||||
|
|
|
|||
|
|
@ -189,23 +189,35 @@ class AuthStore with ChangeNotifier {
|
|||
final response = await apiClient.get('/api/v1/auth/me');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final user = data['user'] as Map<String, dynamic>?;
|
||||
final root = response.data;
|
||||
if (root is Map<String, dynamic>) {
|
||||
// پاسخ API در فیلد data قرار دارد
|
||||
final payload = (root['data'] is Map<String, dynamic>)
|
||||
? root['data'] as Map<String, dynamic>
|
||||
: root;
|
||||
final user = payload['user'] as Map<String, dynamic>?;
|
||||
final permsObj = payload['permissions'] as Map<String, dynamic>?;
|
||||
Map<String, dynamic>? appPermissions;
|
||||
bool isSuperAdmin = false;
|
||||
int? userId;
|
||||
|
||||
if (user != null) {
|
||||
final appPermissions = user['app_permissions'] as Map<String, dynamic>?;
|
||||
final isSuperAdmin = appPermissions?['superadmin'] == true;
|
||||
final userId = user['id'] as int?;
|
||||
|
||||
if (appPermissions != null) {
|
||||
await saveAppPermissions(appPermissions, isSuperAdmin);
|
||||
}
|
||||
|
||||
if (userId != null) {
|
||||
_currentUserId = userId;
|
||||
notifyListeners();
|
||||
appPermissions = user['app_permissions'] as Map<String, dynamic>?;
|
||||
userId = user['id'] as int?;
|
||||
}
|
||||
// fallback: اگر در permissions هم مقدار باشد از آن بخوان
|
||||
if (!isSuperAdmin && permsObj != null) {
|
||||
final pIs = permsObj['is_superadmin'];
|
||||
if (pIs is bool) {
|
||||
isSuperAdmin = pIs;
|
||||
}
|
||||
}
|
||||
if (!isSuperAdmin && appPermissions != null) {
|
||||
isSuperAdmin = appPermissions['superadmin'] == true;
|
||||
}
|
||||
|
||||
// ذخیره در استور و لوکال
|
||||
await saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -506,3 +518,4 @@ class AuthStore with ChangeNotifier {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,38 @@
|
|||
"accountTypePerson": "Person",
|
||||
"accountTypeProduct": "Product",
|
||||
"accountTypeService": "Service",
|
||||
"accountTypeAccountingDocument": "Accounting Document"
|
||||
"accountTypeAccountingDocument": "Accounting Document",
|
||||
"printTemplatePublished": "Print template (Published)",
|
||||
"noCustomTemplate": "— No custom template —",
|
||||
"reload": "Reload",
|
||||
"presetInvoicesList": "Invoices/List",
|
||||
"presetInvoicesDetail": "Invoices/Detail",
|
||||
"presetReceiptsPaymentsList": "ReceiptsPayments/List",
|
||||
"presetReceiptsPaymentsDetail": "ReceiptsPayments/Detail",
|
||||
"presetExpenseIncomeList": "ExpenseIncome/List",
|
||||
"presetDocumentsList": "Documents/List",
|
||||
"presetDocumentsDetail": "Documents/Detail",
|
||||
"printPdf": "Print PDF",
|
||||
"generating": "Generating...",
|
||||
"pdfSuccess": "PDF generated successfully",
|
||||
"pdfError": "Error generating PDF",
|
||||
"printTemplate": "Print template",
|
||||
"templateStandard": "Standard template",
|
||||
"templateCompact": "Compact template",
|
||||
"templateDetailed": "Detailed template",
|
||||
"templateCustom": "Custom template"
|
||||
,
|
||||
"paymentGateways": "Payment Gateways",
|
||||
"addPaymentGateway": "Add Payment Gateway",
|
||||
"editPaymentGateway": "Edit Payment Gateway",
|
||||
"provider": "Provider",
|
||||
"displayName": "Display name",
|
||||
"useSuggestedCallback": "Use suggested callback",
|
||||
"successRedirectOptional": "success_redirect (optional)",
|
||||
"failureRedirectOptional": "failure_redirect (optional)",
|
||||
"operationSuccessful": "Operation completed successfully",
|
||||
"callbackNote": "Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.",
|
||||
"create": "Create",
|
||||
"deletedSuccessfully": "Deleted successfully"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,35 @@
|
|||
{
|
||||
"printTemplatePublished": "قالب چاپ (منتشرشده)",
|
||||
"noCustomTemplate": "— بدون قالب سفارشی —",
|
||||
"reload": "بارگذاری مجدد",
|
||||
"presetInvoicesList": "Invoices/List",
|
||||
"presetInvoicesDetail": "Invoices/Detail",
|
||||
"presetReceiptsPaymentsList": "ReceiptsPayments/List",
|
||||
"presetReceiptsPaymentsDetail": "ReceiptsPayments/Detail",
|
||||
"presetExpenseIncomeList": "ExpenseIncome/List",
|
||||
"presetDocumentsList": "Documents/List",
|
||||
"presetDocumentsDetail": "Documents/Detail",
|
||||
"printPdf": "چاپ PDF",
|
||||
"generating": "در حال تولید...",
|
||||
"pdfSuccess": "فایل PDF با موفقیت تولید شد",
|
||||
"pdfError": "خطا در تولید PDF",
|
||||
"printTemplate": "قالب چاپ",
|
||||
"templateStandard": "قالب استاندارد",
|
||||
"templateCompact": "قالب فشرده",
|
||||
"templateDetailed": "قالب تفصیلی",
|
||||
"templateCustom": "قالب سفارشی",
|
||||
"paymentGateways": "درگاههای پرداخت",
|
||||
"addPaymentGateway": "ایجاد درگاه پرداخت",
|
||||
"editPaymentGateway": "ویرایش درگاه پرداخت",
|
||||
"provider": "ارائهدهنده",
|
||||
"displayName": "نام نمایشی",
|
||||
"useSuggestedCallback": "استفاده از کالبک پیشنهادی",
|
||||
"successRedirectOptional": "success_redirect (اختیاری)",
|
||||
"failureRedirectOptional": "failure_redirect (اختیاری)",
|
||||
"operationSuccessful": "عملیات با موفقیت انجام شد",
|
||||
"callbackNote": "نکته: پارامتر tx_id بهصورت خودکار به callback اضافه میشود. پس از بازگشت، در صورت تنظیم success/failure redirect، کاربر به آدرسهای مربوطه هدایت میشود.",
|
||||
"create": "ایجاد",
|
||||
"deletedSuccessfully": "با موفقیت حذف شد",
|
||||
"@@locale": "fa",
|
||||
"appTitle": "حسابیکس",
|
||||
"login": "ورود",
|
||||
|
|
|
|||
|
|
@ -5767,6 +5767,186 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Accounting Document'**
|
||||
String get accountTypeAccountingDocument;
|
||||
|
||||
/// No description provided for @printTemplatePublished.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Print template (Published)'**
|
||||
String get printTemplatePublished;
|
||||
|
||||
/// No description provided for @noCustomTemplate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'— No custom template —'**
|
||||
String get noCustomTemplate;
|
||||
|
||||
/// No description provided for @reload.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reload'**
|
||||
String get reload;
|
||||
|
||||
/// No description provided for @presetInvoicesList.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invoices/List'**
|
||||
String get presetInvoicesList;
|
||||
|
||||
/// No description provided for @presetInvoicesDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Invoices/Detail'**
|
||||
String get presetInvoicesDetail;
|
||||
|
||||
/// No description provided for @presetReceiptsPaymentsList.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReceiptsPayments/List'**
|
||||
String get presetReceiptsPaymentsList;
|
||||
|
||||
/// No description provided for @presetReceiptsPaymentsDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReceiptsPayments/Detail'**
|
||||
String get presetReceiptsPaymentsDetail;
|
||||
|
||||
/// No description provided for @presetExpenseIncomeList.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ExpenseIncome/List'**
|
||||
String get presetExpenseIncomeList;
|
||||
|
||||
/// No description provided for @presetDocumentsList.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Documents/List'**
|
||||
String get presetDocumentsList;
|
||||
|
||||
/// No description provided for @presetDocumentsDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Documents/Detail'**
|
||||
String get presetDocumentsDetail;
|
||||
|
||||
/// No description provided for @printPdf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Print PDF'**
|
||||
String get printPdf;
|
||||
|
||||
/// No description provided for @generating.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Generating...'**
|
||||
String get generating;
|
||||
|
||||
/// No description provided for @pdfSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'PDF generated successfully'**
|
||||
String get pdfSuccess;
|
||||
|
||||
/// No description provided for @pdfError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Error generating PDF'**
|
||||
String get pdfError;
|
||||
|
||||
/// No description provided for @printTemplate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Print template'**
|
||||
String get printTemplate;
|
||||
|
||||
/// No description provided for @templateStandard.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Standard template'**
|
||||
String get templateStandard;
|
||||
|
||||
/// No description provided for @templateCompact.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Compact template'**
|
||||
String get templateCompact;
|
||||
|
||||
/// No description provided for @templateDetailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Detailed template'**
|
||||
String get templateDetailed;
|
||||
|
||||
/// No description provided for @templateCustom.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Custom template'**
|
||||
String get templateCustom;
|
||||
|
||||
/// No description provided for @paymentGateways.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Payment Gateways'**
|
||||
String get paymentGateways;
|
||||
|
||||
/// No description provided for @addPaymentGateway.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add Payment Gateway'**
|
||||
String get addPaymentGateway;
|
||||
|
||||
/// No description provided for @editPaymentGateway.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit Payment Gateway'**
|
||||
String get editPaymentGateway;
|
||||
|
||||
/// No description provided for @provider.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Provider'**
|
||||
String get provider;
|
||||
|
||||
/// No description provided for @displayName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Display name'**
|
||||
String get displayName;
|
||||
|
||||
/// No description provided for @useSuggestedCallback.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use suggested callback'**
|
||||
String get useSuggestedCallback;
|
||||
|
||||
/// No description provided for @successRedirectOptional.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'success_redirect (optional)'**
|
||||
String get successRedirectOptional;
|
||||
|
||||
/// No description provided for @failureRedirectOptional.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'failure_redirect (optional)'**
|
||||
String get failureRedirectOptional;
|
||||
|
||||
/// No description provided for @operationSuccessful.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Operation completed successfully'**
|
||||
String get operationSuccessful;
|
||||
|
||||
/// No description provided for @callbackNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.'**
|
||||
String get callbackNote;
|
||||
|
||||
/// No description provided for @create.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get create;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -2921,4 +2921,95 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get accountTypeAccountingDocument => 'Accounting Document';
|
||||
|
||||
@override
|
||||
String get printTemplatePublished => 'Print template (Published)';
|
||||
|
||||
@override
|
||||
String get noCustomTemplate => '— No custom template —';
|
||||
|
||||
@override
|
||||
String get reload => 'Reload';
|
||||
|
||||
@override
|
||||
String get presetInvoicesList => 'Invoices/List';
|
||||
|
||||
@override
|
||||
String get presetInvoicesDetail => 'Invoices/Detail';
|
||||
|
||||
@override
|
||||
String get presetReceiptsPaymentsList => 'ReceiptsPayments/List';
|
||||
|
||||
@override
|
||||
String get presetReceiptsPaymentsDetail => 'ReceiptsPayments/Detail';
|
||||
|
||||
@override
|
||||
String get presetExpenseIncomeList => 'ExpenseIncome/List';
|
||||
|
||||
@override
|
||||
String get presetDocumentsList => 'Documents/List';
|
||||
|
||||
@override
|
||||
String get presetDocumentsDetail => 'Documents/Detail';
|
||||
|
||||
@override
|
||||
String get printPdf => 'Print PDF';
|
||||
|
||||
@override
|
||||
String get generating => 'Generating...';
|
||||
|
||||
@override
|
||||
String get pdfSuccess => 'PDF generated successfully';
|
||||
|
||||
@override
|
||||
String get pdfError => 'Error generating PDF';
|
||||
|
||||
@override
|
||||
String get printTemplate => 'Print template';
|
||||
|
||||
@override
|
||||
String get templateStandard => 'Standard template';
|
||||
|
||||
@override
|
||||
String get templateCompact => 'Compact template';
|
||||
|
||||
@override
|
||||
String get templateDetailed => 'Detailed template';
|
||||
|
||||
@override
|
||||
String get templateCustom => 'Custom template';
|
||||
|
||||
@override
|
||||
String get paymentGateways => 'Payment Gateways';
|
||||
|
||||
@override
|
||||
String get addPaymentGateway => 'Add Payment Gateway';
|
||||
|
||||
@override
|
||||
String get editPaymentGateway => 'Edit Payment Gateway';
|
||||
|
||||
@override
|
||||
String get provider => 'Provider';
|
||||
|
||||
@override
|
||||
String get displayName => 'Display name';
|
||||
|
||||
@override
|
||||
String get useSuggestedCallback => 'Use suggested callback';
|
||||
|
||||
@override
|
||||
String get successRedirectOptional => 'success_redirect (optional)';
|
||||
|
||||
@override
|
||||
String get failureRedirectOptional => 'failure_redirect (optional)';
|
||||
|
||||
@override
|
||||
String get operationSuccessful => 'Operation completed successfully';
|
||||
|
||||
@override
|
||||
String get callbackNote =>
|
||||
'Note: tx_id will be appended to callback automatically. If success/failure redirects are set, user will be redirected accordingly.';
|
||||
|
||||
@override
|
||||
String get create => 'Create';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2900,4 +2900,95 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get accountTypeAccountingDocument => 'سند حسابداری';
|
||||
|
||||
@override
|
||||
String get printTemplatePublished => 'قالب چاپ (منتشرشده)';
|
||||
|
||||
@override
|
||||
String get noCustomTemplate => '— بدون قالب سفارشی —';
|
||||
|
||||
@override
|
||||
String get reload => 'بارگذاری مجدد';
|
||||
|
||||
@override
|
||||
String get presetInvoicesList => 'Invoices/List';
|
||||
|
||||
@override
|
||||
String get presetInvoicesDetail => 'Invoices/Detail';
|
||||
|
||||
@override
|
||||
String get presetReceiptsPaymentsList => 'ReceiptsPayments/List';
|
||||
|
||||
@override
|
||||
String get presetReceiptsPaymentsDetail => 'ReceiptsPayments/Detail';
|
||||
|
||||
@override
|
||||
String get presetExpenseIncomeList => 'ExpenseIncome/List';
|
||||
|
||||
@override
|
||||
String get presetDocumentsList => 'Documents/List';
|
||||
|
||||
@override
|
||||
String get presetDocumentsDetail => 'Documents/Detail';
|
||||
|
||||
@override
|
||||
String get printPdf => 'چاپ PDF';
|
||||
|
||||
@override
|
||||
String get generating => 'در حال تولید...';
|
||||
|
||||
@override
|
||||
String get pdfSuccess => 'فایل PDF با موفقیت تولید شد';
|
||||
|
||||
@override
|
||||
String get pdfError => 'خطا در تولید PDF';
|
||||
|
||||
@override
|
||||
String get printTemplate => 'قالب چاپ';
|
||||
|
||||
@override
|
||||
String get templateStandard => 'قالب استاندارد';
|
||||
|
||||
@override
|
||||
String get templateCompact => 'قالب فشرده';
|
||||
|
||||
@override
|
||||
String get templateDetailed => 'قالب تفصیلی';
|
||||
|
||||
@override
|
||||
String get templateCustom => 'قالب سفارشی';
|
||||
|
||||
@override
|
||||
String get paymentGateways => 'درگاههای پرداخت';
|
||||
|
||||
@override
|
||||
String get addPaymentGateway => 'ایجاد درگاه پرداخت';
|
||||
|
||||
@override
|
||||
String get editPaymentGateway => 'ویرایش درگاه پرداخت';
|
||||
|
||||
@override
|
||||
String get provider => 'ارائهدهنده';
|
||||
|
||||
@override
|
||||
String get displayName => 'نام نمایشی';
|
||||
|
||||
@override
|
||||
String get useSuggestedCallback => 'استفاده از کالبک پیشنهادی';
|
||||
|
||||
@override
|
||||
String get successRedirectOptional => 'success_redirect (اختیاری)';
|
||||
|
||||
@override
|
||||
String get failureRedirectOptional => 'failure_redirect (اختیاری)';
|
||||
|
||||
@override
|
||||
String get operationSuccessful => 'عملیات با موفقیت انجام شد';
|
||||
|
||||
@override
|
||||
String get callbackNote =>
|
||||
'نکته: پارامتر tx_id بهصورت خودکار به callback اضافه میشود. پس از بازگشت، در صورت تنظیم success/failure redirect، کاربر به آدرسهای مربوطه هدایت میشود.';
|
||||
|
||||
@override
|
||||
String get create => 'ایجاد';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,12 @@ import 'pages/business/users_permissions_page.dart';
|
|||
import 'pages/business/accounts_page.dart';
|
||||
import 'pages/business/bank_accounts_page.dart';
|
||||
import 'pages/business/wallet_page.dart';
|
||||
import 'pages/business/wallet_payment_result_page.dart';
|
||||
import 'pages/admin/wallet_settings_page.dart';
|
||||
import 'pages/admin/payment_gateways_page.dart';
|
||||
import 'pages/business/invoices_list_page.dart';
|
||||
import 'pages/business/new_invoice_page.dart';
|
||||
import 'pages/business/edit_invoice_page.dart';
|
||||
import 'pages/business/settings_page.dart';
|
||||
import 'pages/business/business_info_settings_page.dart';
|
||||
import 'pages/business/reports_page.dart';
|
||||
|
|
@ -56,6 +60,8 @@ import 'core/auth_store.dart';
|
|||
import 'core/permission_guard.dart';
|
||||
import 'widgets/simple_splash_screen.dart';
|
||||
import 'widgets/url_tracker.dart';
|
||||
import 'pages/business/opening_balance_page.dart';
|
||||
import 'pages/business/report_templates_page.dart';
|
||||
|
||||
void main() {
|
||||
// Use path-based routing instead of hash routing
|
||||
|
|
@ -372,6 +378,11 @@ class _MyAppState extends State<MyApp> {
|
|||
authStore: _authStore!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wallet/payment-result',
|
||||
name: 'wallet_payment_result',
|
||||
builder: (context, state) => WalletPaymentResultPage(authStore: _authStore!),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ProfileShell(
|
||||
authStore: _authStore!,
|
||||
|
|
@ -430,22 +441,54 @@ class _MyAppState extends State<MyApp> {
|
|||
path: '/user/profile/system-settings',
|
||||
name: 'profile_system_settings',
|
||||
builder: (context, state) {
|
||||
// بررسی دسترسی SuperAdmin
|
||||
// بررسی دسترسی تنظیمات سیستم (SuperAdmin یا مجوز system_settings)
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
|
||||
if (!_authStore!.isSuperAdmin) {
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const SystemSettingsPage();
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'wallet',
|
||||
name: 'system_settings_wallet',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const WalletSettingsPage();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'payment-gateways',
|
||||
name: 'system_settings_payment_gateways',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const PaymentGatewaysPage();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'storage',
|
||||
name: 'system_settings_storage',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null || !_authStore!.isSuperAdmin) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const AdminStorageManagementPage();
|
||||
|
|
@ -455,7 +498,11 @@ class _MyAppState extends State<MyApp> {
|
|||
path: 'configuration',
|
||||
name: 'system_settings_configuration',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null || !_authStore!.isSuperAdmin) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const SystemConfigurationPage();
|
||||
|
|
@ -465,7 +512,11 @@ class _MyAppState extends State<MyApp> {
|
|||
path: 'users',
|
||||
name: 'system_settings_users',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null || !_authStore!.isSuperAdmin) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const UserManagementPage();
|
||||
|
|
@ -475,7 +526,11 @@ class _MyAppState extends State<MyApp> {
|
|||
path: 'logs',
|
||||
name: 'system_settings_logs',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null || !_authStore!.isSuperAdmin) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const SystemLogsPage();
|
||||
|
|
@ -485,7 +540,11 @@ class _MyAppState extends State<MyApp> {
|
|||
path: 'email',
|
||||
name: 'system_settings_email',
|
||||
builder: (context, state) {
|
||||
if (_authStore == null || !_authStore!.isSuperAdmin) {
|
||||
if (_authStore == null) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
final allowed = _authStore!.isSuperAdmin || _authStore!.hasAppPermission('system_settings');
|
||||
if (!allowed) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return const EmailSettingsPage();
|
||||
|
|
@ -531,6 +590,19 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/opening-balance',
|
||||
name: 'business_opening_balance',
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return NoTransitionPage(
|
||||
child: OpeningBalancePage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/chart-of-accounts',
|
||||
name: 'business_chart_of_accounts',
|
||||
|
|
@ -593,6 +665,13 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/system-settings/wallet',
|
||||
name: 'system_wallet_settings',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(
|
||||
child: WalletSettingsPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/invoice',
|
||||
name: 'business_invoice',
|
||||
|
|
@ -622,6 +701,22 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/invoice/:invoice_id/edit',
|
||||
name: 'business_edit_invoice',
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
final invoiceId = int.parse(state.pathParameters['invoice_id']!);
|
||||
return NoTransitionPage(
|
||||
child: EditInvoicePage(
|
||||
businessId: businessId,
|
||||
invoiceId: invoiceId,
|
||||
authStore: _authStore!,
|
||||
calendarController: _calendarController!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/reports',
|
||||
name: 'business_reports',
|
||||
|
|
@ -864,6 +959,19 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/report-templates',
|
||||
name: 'business_report_templates',
|
||||
pageBuilder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return NoTransitionPage(
|
||||
child: ReportTemplatesPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/business/:business_id/checks',
|
||||
name: 'business_checks',
|
||||
|
|
|
|||
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,
|
||||
excelEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/excel',
|
||||
pdfEndpoint: '/api/v1/bank-accounts/businesses/${widget.businessId}/bank-accounts/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'bank_accounts',
|
||||
reportSubtype: 'list',
|
||||
getExportParams: () => {'business_id': widget.businessId},
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
|
|
|
|||
|
|
@ -424,6 +424,13 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
path: '/business/${widget.businessId}/settings',
|
||||
type: _MenuItemType.simple,
|
||||
),
|
||||
_MenuItem(
|
||||
label: t.templates,
|
||||
icon: Icons.picture_as_pdf,
|
||||
selectedIcon: Icons.picture_as_pdf,
|
||||
path: '/business/${widget.businessId}/report-templates',
|
||||
type: _MenuItemType.simple,
|
||||
),
|
||||
_MenuItem(
|
||||
label: t.pluginMarketplace,
|
||||
icon: Icons.store,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ class _CashRegistersPageState extends State<CashRegistersPage> {
|
|||
title: t.cashBox,
|
||||
excelEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/excel',
|
||||
pdfEndpoint: '/api/v1/cash-registers/businesses/${widget.businessId}/cash-registers/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'cash_registers',
|
||||
reportSubtype: 'list',
|
||||
getExportParams: () => {'business_id': widget.businessId},
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
|
|
|
|||
|
|
@ -407,7 +407,11 @@ class _DocumentsPageState extends State<DocumentsPage> {
|
|||
enableMultiRowSelection: true,
|
||||
showExportButtons: true,
|
||||
showExcelExport: true,
|
||||
showPdfExport: false,
|
||||
pdfEndpoint: '/businesses/${widget.businessId}/documents/export/pdf',
|
||||
showPdfExport: true,
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'documents',
|
||||
reportSubtype: 'list',
|
||||
defaultPageSize: 50,
|
||||
pageSizeOptions: [20, 50, 100, 200],
|
||||
onRowSelectionChanged: (rows) {
|
||||
|
|
|
|||
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: 'هزینه و درآمد',
|
||||
excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
|
||||
pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'expense_income',
|
||||
reportSubtype: 'list',
|
||||
// دکمه حذف گروهی در هدر جدول
|
||||
customHeaderActions: [
|
||||
Tooltip(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ class _InventoryTransfersPageState extends State<InventoryTransfersPage> {
|
|||
endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query',
|
||||
excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel',
|
||||
pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'inventory_transfers',
|
||||
reportSubtype: 'list',
|
||||
title: 'انتقال موجودی بین انبارها',
|
||||
showBackButton: true,
|
||||
showSearch: false,
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
|
|||
title: 'فاکتورها',
|
||||
excelEndpoint: '/invoices/business/${widget.businessId}/export/excel',
|
||||
pdfEndpoint: '/invoices/business/${widget.businessId}/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'invoices',
|
||||
reportSubtype: 'list',
|
||||
columns: [
|
||||
// عملیات
|
||||
ActionColumn(
|
||||
|
|
@ -230,6 +233,12 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
|
|||
label: 'مشاهده',
|
||||
onTap: (item) => _onView(item as InvoiceListItem),
|
||||
),
|
||||
if (widget.authStore.canWriteSection('invoices'))
|
||||
DataTableAction(
|
||||
icon: Icons.edit,
|
||||
label: 'ویرایش',
|
||||
onTap: (item) => _onEdit(item as InvoiceListItem),
|
||||
),
|
||||
],
|
||||
),
|
||||
// کد سند
|
||||
|
|
@ -305,6 +314,19 @@ class _InvoicesListPageState extends State<InvoicesListPage> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEdit(InvoiceListItem item) async {
|
||||
if (!mounted) return;
|
||||
await context.pushNamed(
|
||||
'business_edit_invoice',
|
||||
pathParameters: {
|
||||
'business_id': widget.businessId.toString(),
|
||||
'invoice_id': item.id.toString(),
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
_refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,9 @@ class _KardexPageState extends State<KardexPage> {
|
|||
endpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines',
|
||||
excelEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/excel',
|
||||
pdfEndpoint: '/api/v1/kardex/businesses/${widget.businessId}/lines/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'kardex',
|
||||
reportSubtype: 'list',
|
||||
columns: [
|
||||
DateColumn('document_date', 'تاریخ سند',
|
||||
formatter: (item) => (item as Map<String, dynamic>)['document_date']?.toString()),
|
||||
|
|
|
|||
|
|
@ -971,7 +971,6 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
'tax_amount': taxAmount,
|
||||
'line_total': lineTotal,
|
||||
if (movement != null) 'movement': movement,
|
||||
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
|
||||
// اطلاعات اضافی برای ردیابی
|
||||
'unit': e.selectedUnit ?? e.mainUnit,
|
||||
'unit_price_source': e.unitPriceSource,
|
||||
|
|
@ -1048,6 +1047,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
businessId: widget.businessId,
|
||||
calendarController: widget.calendarController,
|
||||
invoiceType: _selectedInvoiceType ?? InvoiceType.sales,
|
||||
selectedCurrencyId: _selectedCurrencyId,
|
||||
onChanged: (transactions) {
|
||||
setState(() {
|
||||
_transactions = transactions;
|
||||
|
|
@ -1188,15 +1188,15 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
// انتخاب قالب چاپ
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedPrintTemplate,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'قالب چاپ',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context).printTemplate,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'standard', child: Text('قالب استاندارد')),
|
||||
DropdownMenuItem(value: 'compact', child: Text('قالب فشرده')),
|
||||
DropdownMenuItem(value: 'detailed', child: Text('قالب تفصیلی')),
|
||||
DropdownMenuItem(value: 'custom', child: Text('قالب سفارشی')),
|
||||
items: [
|
||||
DropdownMenuItem(value: 'standard', child: Text(AppLocalizations.of(context).templateStandard)),
|
||||
DropdownMenuItem(value: 'compact', child: Text(AppLocalizations.of(context).templateCompact)),
|
||||
DropdownMenuItem(value: 'detailed', child: Text(AppLocalizations.of(context).templateDetailed)),
|
||||
DropdownMenuItem(value: 'custom', child: Text(AppLocalizations.of(context).templateCustom)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
|
||||
pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'persons',
|
||||
reportSubtype: 'list',
|
||||
getExportParams: () => {
|
||||
'business_id': widget.businessId,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ class _PettyCashPageState extends State<PettyCashPage> {
|
|||
title: (t.localeName == 'fa') ? 'تنخواه گردان' : 'Petty Cash',
|
||||
excelEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/excel',
|
||||
pdfEndpoint: '/api/v1/petty-cash/businesses/${widget.businessId}/petty-cash/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'petty_cash',
|
||||
reportSubtype: 'list',
|
||||
getExportParams: () => {'business_id': widget.businessId},
|
||||
showBackButton: true,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ class _ProductsPageState extends State<ProductsPage> {
|
|||
title: t.products,
|
||||
excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel',
|
||||
pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'products',
|
||||
reportSubtype: 'list',
|
||||
showRowNumbers: true,
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
|
|
|
|||
|
|
@ -236,6 +236,9 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
|||
title: t.receiptsAndPayments,
|
||||
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
||||
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'receipts_payments',
|
||||
reportSubtype: 'list',
|
||||
// دکمه حذف گروهی در هدر جدول
|
||||
customHeaderActions: [
|
||||
Tooltip(
|
||||
|
|
@ -1601,7 +1604,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.picture_as_pdf),
|
||||
label: Text(_isGeneratingPdf ? 'در حال تولید...' : 'خروجی PDF'),
|
||||
label: Text(_isGeneratingPdf ? AppLocalizations.of(context).generating : AppLocalizations.of(context).exportToPdf),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1625,7 +1628,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
|
|||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('فایل PDF با موفقیت تولید شد'),
|
||||
content: Text(AppLocalizations.of(context).pdfSuccess),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
|
@ -1634,7 +1637,7 @@ class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
|
|||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('خطا در تولید PDF: $e'),
|
||||
content: Text('${AppLocalizations.of(context).pdfError}: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
excelEndpoint: '/businesses/${widget.businessId}/transfers/export/excel',
|
||||
pdfEndpoint: '/businesses/${widget.businessId}/transfers/export/pdf',
|
||||
businessId: widget.businessId,
|
||||
reportModuleKey: 'transfers',
|
||||
reportSubtype: 'list',
|
||||
getExportParams: () => {
|
||||
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
|
||||
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ import 'package:flutter/material.dart';
|
|||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../widgets/permission/access_denied_page.dart';
|
||||
// import '../../core/api_client.dart'; // duplicate removed
|
||||
import '../../services/wallet_service.dart';
|
||||
import '../../widgets/invoice/bank_account_combobox_widget.dart';
|
||||
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../services/payment_gateway_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class WalletPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -18,6 +26,269 @@ class WalletPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _WalletPageState extends State<WalletPage> {
|
||||
late final WalletService _service;
|
||||
bool _loading = true;
|
||||
Map<String, dynamic>? _overview;
|
||||
String? _error;
|
||||
List<Map<String, dynamic>> _transactions = const <Map<String, dynamic>>[];
|
||||
Map<String, dynamic>? _metrics;
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_service = WalletService(ApiClient());
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final res = await _service.getOverview(businessId: widget.businessId);
|
||||
final now = DateTime.now();
|
||||
_toDate = now;
|
||||
_fromDate = now.subtract(const Duration(days: 30));
|
||||
final tx = await _service.listTransactions(businessId: widget.businessId, limit: 20, fromDate: _fromDate, toDate: _toDate);
|
||||
final m = await _service.getMetrics(businessId: widget.businessId, fromDate: _fromDate, toDate: _toDate);
|
||||
setState(() {
|
||||
_overview = res;
|
||||
_transactions = tx;
|
||||
_metrics = m;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = '$e';
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPayoutDialog() async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
int? bankId;
|
||||
final amountCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: Text('درخواست تسویه'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BankAccountComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedAccountId: bankId?.toString(),
|
||||
onChanged: (opt) => bankId = int.tryParse(opt?.id ?? ''),
|
||||
hintText: 'انتخاب حساب بانکی',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: amountCtrl,
|
||||
decoration: const InputDecoration(labelText: 'مبلغ'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'الزامی' : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'توضیحات (اختیاری)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() == true && bankId != null) {
|
||||
Navigator.pop(ctx, true);
|
||||
}
|
||||
},
|
||||
child: Text(t.confirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == true && bankId != null) {
|
||||
try {
|
||||
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
|
||||
await _service.requestPayout(
|
||||
businessId: widget.businessId,
|
||||
bankAccountId: bankId!,
|
||||
amount: amount,
|
||||
description: descCtrl.text,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست تسویه ثبت شد')));
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTopUpDialog() async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final amountCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final pgService = PaymentGatewayService(ApiClient());
|
||||
List<Map<String, dynamic>> gateways = const <Map<String, dynamic>>[];
|
||||
int? gatewayId;
|
||||
try {
|
||||
gateways = await pgService.listBusinessGateways(widget.businessId);
|
||||
if (gateways.isNotEmpty) {
|
||||
gatewayId = int.tryParse('${gateways.first['id']}');
|
||||
}
|
||||
} catch (_) {}
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('افزایش اعتبار'),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: amountCtrl,
|
||||
decoration: const InputDecoration(labelText: 'مبلغ'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) => (v == null || v.isEmpty) ? 'الزامی' : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'توضیحات (اختیاری)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (gateways.isNotEmpty)
|
||||
DropdownButtonFormField<int>(
|
||||
value: gatewayId,
|
||||
decoration: const InputDecoration(labelText: 'درگاه پرداخت'),
|
||||
items: gateways
|
||||
.map((g) => DropdownMenuItem<int>(
|
||||
value: int.tryParse('${g['id']}'),
|
||||
child: Text('${g['display_name']} (${g['provider']})'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => gatewayId = v,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(t.cancel)),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() == true && (gateways.isEmpty || gatewayId != null)) {
|
||||
Navigator.pop(ctx, true);
|
||||
}
|
||||
},
|
||||
child: Text(t.confirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == true) {
|
||||
try {
|
||||
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
|
||||
final data = await _service.topUp(
|
||||
businessId: widget.businessId,
|
||||
amount: amount,
|
||||
description: descCtrl.text,
|
||||
gatewayId: gatewayId,
|
||||
);
|
||||
final paymentUrl = (data['payment_url'] ?? '').toString();
|
||||
if (paymentUrl.isNotEmpty) {
|
||||
final uri = Uri.parse(paymentUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست افزایش اعتبار ثبت شد')));
|
||||
}
|
||||
}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFromDate() async {
|
||||
final initial = _fromDate ?? DateTime.now().subtract(const Duration(days: 30));
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2000, 1, 1),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
initialDate: initial,
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _fromDate = picked);
|
||||
await _reloadRange();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickToDate() async {
|
||||
final initial = _toDate ?? DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2000, 1, 1),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
initialDate: initial,
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() => _toDate = picked);
|
||||
await _reloadRange();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadRange() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final tx = await _service.listTransactions(
|
||||
businessId: widget.businessId,
|
||||
limit: 20,
|
||||
fromDate: _fromDate,
|
||||
toDate: _toDate,
|
||||
);
|
||||
final m = await _service.getMetrics(
|
||||
businessId: widget.businessId,
|
||||
fromDate: _fromDate,
|
||||
toDate: _toDate,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_transactions = tx;
|
||||
_metrics = m;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
|
@ -26,33 +297,197 @@ class _WalletPageState extends State<WalletPage> {
|
|||
return AccessDeniedPage(message: t.accessDenied);
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final overview = _overview;
|
||||
final currency = overview?['base_currency_code'] ?? 'IRR';
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wallet,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.wallet,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'صفحه کیف پول در حال توسعه است',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
appBar: AppBar(title: Text(t.wallet)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.wallet, size: 32, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text('کیفپول کسبوکار', style: theme.textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
Chip(label: Text(currency)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده قابل برداشت', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('${overview?['available_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده در انتظار تایید', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('${overview?['pending_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickFromDate,
|
||||
icon: const Icon(Icons.date_range),
|
||||
label: Text(_fromDate != null ? _fromDate!.toIso8601String().split('T').first : 'از تاریخ'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickToDate,
|
||||
icon: const Icon(Icons.event),
|
||||
label: Text(_toDate != null ? _toDate!.toIso8601String().split('T').first : 'تا تاریخ'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _openPayoutDialog,
|
||||
icon: const Icon(Icons.account_balance),
|
||||
label: const Text('درخواست تسویه'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openTopUpDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('افزایش اعتبار'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_metrics != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('گزارش ۳۰ روز اخیر', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('ورودی ناخالص: ${_metrics?['totals']?['gross_in'] ?? 0}')),
|
||||
Chip(label: Text('کارمزد ورودی: ${_metrics?['totals']?['fees_in'] ?? 0}')),
|
||||
Chip(label: Text('ورودی خالص: ${_metrics?['totals']?['net_in'] ?? 0}')),
|
||||
Chip(label: Text('خروجی ناخالص: ${_metrics?['totals']?['gross_out'] ?? 0}')),
|
||||
Chip(label: Text('کارمزد خروجی: ${_metrics?['totals']?['fees_out'] ?? 0}')),
|
||||
Chip(label: Text('خروجی خالص: ${_metrics?['totals']?['net_out'] ?? 0}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final api = ApiClient();
|
||||
final path = '/businesses/${widget.businessId}/wallet/transactions/export'
|
||||
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
|
||||
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
|
||||
try {
|
||||
await api.downloadExcel(path); // bytes download and save handled
|
||||
// Save as CSV file
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
// ignore: unused_import
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV تراکنشها: $e')));
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('دانلود CSV تراکنشها'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final api = ApiClient();
|
||||
final path = '/businesses/${widget.businessId}/wallet/metrics/export'
|
||||
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
|
||||
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
|
||||
try {
|
||||
await api.downloadExcel(path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV خلاصه: $e')));
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.table_view),
|
||||
label: const Text('دانلود CSV خلاصه'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('تراکنشهای اخیر', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: ListView.separated(
|
||||
itemCount: _transactions.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final m = _transactions[i];
|
||||
final amount = m['amount'] ?? 0;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
m['type'] == 'payout_request' ? Icons.account_balance : Icons.swap_horiz,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text('${m['type']} - ${m['status']}'),
|
||||
subtitle: Text('${m['description'] ?? ''}'),
|
||||
trailing: Text('${formatWithThousands((amount is num) ? amount : double.tryParse('$amount') ?? 0)}'),
|
||||
onTap: () async {
|
||||
final docId = m['document_id'];
|
||||
if (!mounted) return;
|
||||
await context.pushNamed(
|
||||
'business_documents',
|
||||
pathParameters: {'business_id': widget.businessId.toString()},
|
||||
extra: {'focus_document_id': docId},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>[
|
||||
...destinations,
|
||||
if (widget.authStore.canAccessSupportOperator) ...operatorDestinations,
|
||||
if (widget.authStore.isSuperAdmin) ...adminDestinations,
|
||||
if (widget.authStore.isSuperAdmin || widget.authStore.hasAppPermission('system_settings')) ...adminDestinations,
|
||||
];
|
||||
|
||||
int selectedIndex = 0;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
|||
color: const Color(0xFF4CAF50),
|
||||
route: '/user/profile/system-settings/configuration',
|
||||
),
|
||||
SettingsItem(
|
||||
title: 'تنظیمات کیفپول',
|
||||
description: 'تعیین ارز پایه و سیاستها',
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
color: const Color(0xFF009688),
|
||||
route: '/user/profile/system-settings/wallet',
|
||||
),
|
||||
SettingsItem(
|
||||
title: 'درگاههای پرداخت',
|
||||
description: 'مدیریت و پیکربندی درگاهها',
|
||||
icon: Icons.payment_outlined,
|
||||
color: const Color(0xFF3F51B5),
|
||||
route: '/user/profile/system-settings/payment-gateways',
|
||||
),
|
||||
SettingsItem(
|
||||
title: 'userManagement',
|
||||
description: 'userManagementDescription',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
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 showExcelExport;
|
||||
final bool showPdfExport;
|
||||
// Report templates scope (for PDF custom templates)
|
||||
final int? businessId; // needed to fetch templates
|
||||
final String? reportModuleKey;
|
||||
final String? reportSubtype;
|
||||
|
||||
// Column settings configuration
|
||||
final String? tableId;
|
||||
|
|
@ -327,6 +331,9 @@ class DataTableConfig<T> {
|
|||
this.showExportButtons = false,
|
||||
this.showExcelExport = true,
|
||||
this.showPdfExport = true,
|
||||
this.businessId,
|
||||
this.reportModuleKey,
|
||||
this.reportSubtype,
|
||||
this.tableId,
|
||||
this.enableColumnSettings = true,
|
||||
this.showColumnSettingsButton = true,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import 'package:dio/dio.dart';
|
|||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||
import 'package:hesabix_ui/services/report_template_service.dart';
|
||||
import 'data_table_config.dart';
|
||||
import 'data_table_search_dialog.dart';
|
||||
import 'column_settings_dialog.dart';
|
||||
|
|
@ -70,6 +71,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
// Row selection state
|
||||
final Set<int> _selectedRows = <int>{};
|
||||
bool _isExporting = false;
|
||||
int? _templateIdForExport;
|
||||
final TextEditingController _templateIdCtrl = TextEditingController();
|
||||
// Report templates (for PDF export)
|
||||
List<Map<String, dynamic>> _availableTemplates = const [];
|
||||
bool _loadingTemplates = false;
|
||||
int? _selectedTemplateIdFromList;
|
||||
|
||||
// Column settings state
|
||||
ColumnSettings? _columnSettings;
|
||||
|
|
@ -125,6 +132,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_searchDebounce?.cancel();
|
||||
_horizontalScrollController.dispose();
|
||||
_tableFocusNode.dispose();
|
||||
_templateIdCtrl.dispose();
|
||||
for (var controller in _columnSearchControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
|
|
@ -631,6 +639,10 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
if (selectedOnly && _selectedRows.isNotEmpty) {
|
||||
params['selected_indices'] = _selectedRows.toList();
|
||||
}
|
||||
// Optional report template for PDF
|
||||
if (format == 'pdf' && _templateIdForExport != null) {
|
||||
params['template_id'] = _templateIdForExport;
|
||||
}
|
||||
|
||||
// Add export columns in current visible order (excluding ActionColumn)
|
||||
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
||||
|
|
@ -1161,6 +1173,34 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
AppLocalizations t,
|
||||
ThemeData theme,
|
||||
) {
|
||||
Future<void> _ensureTemplatesLoaded() async {
|
||||
if (widget.config.pdfEndpoint == null) return;
|
||||
if (widget.config.businessId == null || widget.config.reportModuleKey == null) return;
|
||||
setState(() => _loadingTemplates = true);
|
||||
try {
|
||||
final service = ReportTemplateService(ApiClient());
|
||||
final list = await service.listTemplates(
|
||||
businessId: widget.config.businessId!,
|
||||
moduleKey: widget.config.reportModuleKey,
|
||||
subtype: widget.config.reportSubtype,
|
||||
status: 'published',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableTemplates = list;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_availableTemplates = const [];
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingTemplates = false);
|
||||
}
|
||||
}
|
||||
_ensureTemplatesLoaded();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
|
@ -1202,6 +1242,109 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
|
||||
const Divider(height: 1),
|
||||
|
||||
if (widget.config.pdfEndpoint != null) ...[
|
||||
if (widget.config.businessId != null && widget.config.reportModuleKey != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.description_outlined, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _loadingTemplates
|
||||
? const LinearProgressIndicator(minHeight: 2)
|
||||
: DropdownButtonFormField<int>(
|
||||
value: _selectedTemplateIdFromList,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context).printTemplatePublished,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<int>(
|
||||
value: null,
|
||||
child: Text(AppLocalizations.of(context).noCustomTemplate),
|
||||
),
|
||||
..._availableTemplates.map((tpl) {
|
||||
final id = (tpl['id'] as num).toInt();
|
||||
final name = (tpl['name'] ?? 'Template').toString();
|
||||
final isDefault = tpl['is_default'] == true;
|
||||
return DropdownMenuItem<int>(
|
||||
value: id,
|
||||
child: Row(
|
||||
children: [
|
||||
if (isDefault) const Icon(Icons.star, size: 16),
|
||||
if (isDefault) const SizedBox(width: 6),
|
||||
Expanded(child: Text(name)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedTemplateIdFromList = val;
|
||||
_templateIdForExport = val;
|
||||
if (val != null) {
|
||||
_templateIdCtrl.text = val.toString();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: AppLocalizations.of(context).reload,
|
||||
onPressed: _loadingTemplates ? null : _ensureTemplatesLoaded,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.tune, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _templateIdCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'template_id (اختیاری برای PDF سفارشی)',
|
||||
hintText: 'مثلاً 101',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) {
|
||||
final n = int.tryParse(v.trim());
|
||||
setState(() {
|
||||
_templateIdForExport = n;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_templateIdForExport != null)
|
||||
IconButton(
|
||||
tooltip: 'پاککردن قالب سفارشی',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_templateIdForExport = null;
|
||||
_templateIdCtrl.clear();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
|
||||
// Excel options
|
||||
if (widget.config.excelEndpoint != null) ...[
|
||||
ListTile(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import 'package:hesabix_ui/services/document_service.dart';
|
|||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
|
||||
import 'package:hesabix_ui/services/warehouse_service.dart';
|
||||
import 'dart:html' as html;
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
|
||||
/// دیالوگ نمایش جزئیات کامل سند حسابداری
|
||||
class DocumentDetailsDialog extends StatefulWidget {
|
||||
|
|
@ -25,6 +28,9 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
|||
DocumentModel? _document;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
bool _isGeneratingPdf = false;
|
||||
final _warehouseService = WarehouseService();
|
||||
List<dynamic> _relatedWhDocs = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -33,6 +39,53 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
|||
_loadDocument();
|
||||
}
|
||||
|
||||
Future<void> _generatePdf() async {
|
||||
if (_document == null) return;
|
||||
setState(() => _isGeneratingPdf = true);
|
||||
try {
|
||||
final api = ApiClient();
|
||||
final doc = _document!;
|
||||
String path;
|
||||
// اگر فاکتور است، از endpoint اختصاصی فاکتور استفاده کنیم تا قالب invoices/detail اعمال شود
|
||||
if (doc.documentType.startsWith('invoice')) {
|
||||
path = '/invoices/business/${doc.businessId}/${doc.id}/pdf';
|
||||
} else {
|
||||
// سایر اسناد: endpoint عمومی با قالب documents/detail
|
||||
path = '/documents/${doc.id}/pdf';
|
||||
}
|
||||
final bytes = await api.downloadPdf(path);
|
||||
await _savePdfFile(bytes, doc.code);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context).pdfSuccess)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${AppLocalizations.of(context).pdfError}: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isGeneratingPdf = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePdfFile(List<int> bytes, String filename) async {
|
||||
try {
|
||||
final name = filename.endsWith('.pdf') ? filename : '$filename.pdf';
|
||||
final blob = html.Blob([bytes], 'application/pdf');
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', name)
|
||||
..click();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
// ignore: avoid_print
|
||||
print('✅ PDF downloaded successfully: $name');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('❌ Error downloading PDF: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDocument() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -47,6 +100,22 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
|||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
// load related warehouse docs
|
||||
try {
|
||||
final data = await _warehouseService.search(
|
||||
businessId: doc.businessId,
|
||||
limit: 50,
|
||||
filters: {
|
||||
'source_type': 'invoice',
|
||||
'source_document_id': widget.documentId,
|
||||
},
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_relatedWhDocs = List<dynamic>.from(data['items'] ?? const []);
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -62,27 +131,77 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
|||
final theme = Theme.of(context);
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: Column(
|
||||
children: [
|
||||
// هدر
|
||||
_buildHeader(theme),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1100),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 240, child: Center(child: CircularProgressIndicator()))
|
||||
: _errorMessage != null
|
||||
? SizedBox(height: 240, child: Center(child: Text(_errorMessage!)))
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// هدر
|
||||
_buildHeader(theme),
|
||||
|
||||
// محتوای اصلی
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorMessage != null
|
||||
? _buildError()
|
||||
: _buildContent(theme),
|
||||
),
|
||||
// محتوای اصلی
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorMessage != null
|
||||
? _buildError()
|
||||
: _buildContent(theme),
|
||||
),
|
||||
|
||||
// فوتر
|
||||
_buildFooter(),
|
||||
],
|
||||
// فوتر
|
||||
_buildFooter(),
|
||||
const SizedBox(height: 16),
|
||||
if (_relatedWhDocs.isNotEmpty) ...[
|
||||
Text('حوالههای مرتبط', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _relatedWhDocs.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final it = _relatedWhDocs[index] as Map<String, dynamic>;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text('${it['code'] ?? '-'} • ${it['doc_type'] ?? ''} • ${it['status'] ?? ''}'),
|
||||
subtitle: Text(it['document_date'] ?? ''),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.publish),
|
||||
onPressed: (it['status'] == 'draft') ? () async {
|
||||
try {
|
||||
await _warehouseService.postDoc(
|
||||
businessId: _document!.businessId,
|
||||
docId: it['id'],
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('حواله پست شد')),
|
||||
);
|
||||
_loadDocument();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در پست حواله: $e')),
|
||||
);
|
||||
}
|
||||
} : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -447,14 +566,12 @@ class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
|
|||
children: [
|
||||
// دکمه چاپ PDF
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: پیادهسازی چاپ PDF
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('چاپ PDF در حال پیادهسازی است')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: const Text('چاپ PDF'),
|
||||
onPressed: _isGeneratingPdf ? null : _generatePdf,
|
||||
icon: _isGeneratingPdf
|
||||
? const SizedBox(
|
||||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.picture_as_pdf),
|
||||
label: Text(_isGeneratingPdf ? AppLocalizations.of(context).generating : AppLocalizations.of(context).printPdf),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// دکمه بستن
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ class CheckOption {
|
|||
final String? personName;
|
||||
final String? bankName;
|
||||
final String? sayadCode;
|
||||
final int? currencyId;
|
||||
const CheckOption({
|
||||
required this.id,
|
||||
required this.number,
|
||||
this.personName,
|
||||
this.bankName,
|
||||
this.sayadCode,
|
||||
this.currencyId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ class CheckComboboxWidget extends StatefulWidget {
|
|||
final ValueChanged<CheckOption?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final int? filterCurrencyId;
|
||||
|
||||
const CheckComboboxWidget({
|
||||
super.key,
|
||||
|
|
@ -31,6 +34,7 @@ class CheckComboboxWidget extends StatefulWidget {
|
|||
this.selectedCheckId,
|
||||
this.label = 'چک',
|
||||
this.hintText = 'جستوجو و انتخاب چک',
|
||||
this.filterCurrencyId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -101,7 +105,7 @@ class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
|
|||
final dynamic itemsRaw = (res['data'] != null && res['data'] is Map && (res['data'] as Map)['items'] != null)
|
||||
? (res['data'] as Map)['items']
|
||||
: res['items'];
|
||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
var items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
final m = Map<String, dynamic>.from(e as Map);
|
||||
return CheckOption(
|
||||
id: '${m['id']}',
|
||||
|
|
@ -109,8 +113,14 @@ class _CheckComboboxWidgetState extends State<CheckComboboxWidget> {
|
|||
personName: (m['person_name'] ?? m['holder_name'])?.toString(),
|
||||
bankName: (m['bank_name'] ?? '').toString(),
|
||||
sayadCode: (m['sayad_code'] ?? '').toString(),
|
||||
currencyId: (m['currency_id'] ?? m['currencyId']) is int
|
||||
? (m['currency_id'] ?? m['currencyId']) as int
|
||||
: int.tryParse('${m['currency_id'] ?? m['currencyId'] ?? ''}'),
|
||||
);
|
||||
}).toList();
|
||||
if (widget.filterCurrencyId != null) {
|
||||
items = items.where((it) => it.currencyId == widget.filterCurrencyId).toList();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = items;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'bank_account_combobox_widget.dart';
|
|||
import 'cash_register_combobox_widget.dart';
|
||||
import 'petty_cash_combobox_widget.dart';
|
||||
import 'account_tree_combobox_widget.dart';
|
||||
import 'check_combobox_widget.dart';
|
||||
import '../../models/invoice_type_model.dart';
|
||||
|
||||
class InvoiceTransactionsWidget extends StatefulWidget {
|
||||
|
|
@ -24,6 +25,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
|
|||
final int businessId;
|
||||
final CalendarController calendarController;
|
||||
final InvoiceType invoiceType;
|
||||
final int? selectedCurrencyId;
|
||||
|
||||
const InvoiceTransactionsWidget({
|
||||
super.key,
|
||||
|
|
@ -32,6 +34,7 @@ class InvoiceTransactionsWidget extends StatefulWidget {
|
|||
required this.businessId,
|
||||
required this.calendarController,
|
||||
required this.invoiceType,
|
||||
this.selectedCurrencyId,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -327,6 +330,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
|||
businessId: widget.businessId,
|
||||
calendarController: widget.calendarController,
|
||||
invoiceType: widget.invoiceType,
|
||||
selectedCurrencyId: widget.selectedCurrencyId,
|
||||
onSave: (newTransaction) {
|
||||
if (index != null) {
|
||||
// ویرایش تراکنش موجود
|
||||
|
|
@ -351,6 +355,7 @@ class TransactionDialog extends StatefulWidget {
|
|||
final CalendarController calendarController;
|
||||
final ValueChanged<InvoiceTransaction> onSave;
|
||||
final InvoiceType invoiceType;
|
||||
final int? selectedCurrencyId;
|
||||
|
||||
const TransactionDialog({
|
||||
super.key,
|
||||
|
|
@ -358,6 +363,7 @@ class TransactionDialog extends StatefulWidget {
|
|||
required this.businessId,
|
||||
required this.calendarController,
|
||||
required this.invoiceType,
|
||||
this.selectedCurrencyId,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
|
|
@ -387,6 +393,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
String? _selectedCashRegisterId;
|
||||
String? _selectedPettyCashId;
|
||||
String? _selectedCheckId;
|
||||
int? _selectedCheckCurrencyId;
|
||||
String? _selectedPersonId;
|
||||
AccountTreeNode? _selectedAccount;
|
||||
|
||||
|
|
@ -728,6 +735,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
return BankAccountComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedAccountId: _selectedBankId,
|
||||
filterCurrencyId: widget.selectedCurrencyId,
|
||||
onChanged: (opt) {
|
||||
setState(() {
|
||||
_selectedBankId = opt?.id;
|
||||
|
|
@ -743,6 +751,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
return CashRegisterComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedRegisterId: _selectedCashRegisterId,
|
||||
filterCurrencyId: widget.selectedCurrencyId,
|
||||
onChanged: (opt) {
|
||||
setState(() {
|
||||
_selectedCashRegisterId = opt?.id;
|
||||
|
|
@ -758,6 +767,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
return PettyCashComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedPettyCashId: _selectedPettyCashId,
|
||||
filterCurrencyId: widget.selectedCurrencyId,
|
||||
onChanged: (opt) {
|
||||
setState(() {
|
||||
_selectedPettyCashId = opt?.id;
|
||||
|
|
@ -770,21 +780,18 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
}
|
||||
|
||||
Widget _buildCheckFields() {
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _selectedCheckId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'چک *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'check1', child: Text('چک شماره 123456')),
|
||||
DropdownMenuItem(value: 'check2', child: Text('چک شماره 789012')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
return CheckComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedCheckId: _selectedCheckId,
|
||||
filterCurrencyId: widget.selectedCurrencyId,
|
||||
onChanged: (opt) {
|
||||
setState(() {
|
||||
_selectedCheckId = value;
|
||||
_selectedCheckId = opt?.id;
|
||||
_selectedCheckCurrencyId = opt?.currencyId;
|
||||
});
|
||||
},
|
||||
label: 'چک *',
|
||||
hintText: 'جستوجو و انتخاب چک',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -884,6 +891,58 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
final commission = _commissionController.text.isNotEmpty
|
||||
? double.parse(_commissionController.text)
|
||||
: null;
|
||||
// اعتبارسنجی همخوانی ارز با ارز فاکتور برای انواع دارای ارز
|
||||
final invoiceCurrencyId = widget.selectedCurrencyId;
|
||||
if (invoiceCurrencyId != null) {
|
||||
if (_selectedType == TransactionType.bank && _selectedBankId != null) {
|
||||
final bank = _banks.firstWhere(
|
||||
(b) => b['id']?.toString() == _selectedBankId,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
final bankCurrencyId = int.tryParse('${bank['currency_id'] ?? bank['currencyId'] ?? ''}');
|
||||
if (bankCurrencyId != null && bankCurrencyId != invoiceCurrencyId) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('ارز بانک انتخابی با ارز فاکتور همخوانی ندارد')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_selectedType == TransactionType.cashRegister && _selectedCashRegisterId != null) {
|
||||
final cr = _cashRegisters.firstWhere(
|
||||
(c) => c['id']?.toString() == _selectedCashRegisterId,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
final crCurrencyId = int.tryParse('${cr['currency_id'] ?? cr['currencyId'] ?? ''}');
|
||||
if (crCurrencyId != null && crCurrencyId != invoiceCurrencyId) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('ارز صندوق انتخابی با ارز فاکتور همخوانی ندارد')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_selectedType == TransactionType.pettyCash && _selectedPettyCashId != null) {
|
||||
final pc = _pettyCashList.firstWhere(
|
||||
(p) => p['id']?.toString() == _selectedPettyCashId,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
final pcCurrencyId = int.tryParse('${pc['currency_id'] ?? pc['currencyId'] ?? ''}');
|
||||
if (pcCurrencyId != null && pcCurrencyId != invoiceCurrencyId) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('ارز تنخواهگردان انتخابی با ارز فاکتور همخوانی ندارد')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_selectedType == TransactionType.check && _selectedCheckId != null) {
|
||||
final chkCurrencyId = _selectedCheckCurrencyId;
|
||||
if (chkCurrencyId != null && chkCurrencyId != invoiceCurrencyId) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('ارز چک انتخابی با ارز فاکتور همخوانی ندارد')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final transaction = InvoiceTransaction(
|
||||
id: widget.transaction?.id ?? _uuid.v4(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
|||
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
||||
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
||||
final bool postInventory;
|
||||
final List<InvoiceLineItem>? initialRows; // برای مقداردهی اولیه (ویرایش فاکتور)
|
||||
|
||||
const InvoiceLineItemsTable({
|
||||
super.key,
|
||||
|
|
@ -21,6 +22,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
|||
this.onChanged,
|
||||
this.invoiceType = 'sales',
|
||||
this.postInventory = true,
|
||||
this.initialRows,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -73,6 +75,11 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if ((widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
|
||||
_rows.clear();
|
||||
_rows.addAll(widget.initialRows!);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -84,6 +91,13 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
// invalidate inline price list cache if currency changed
|
||||
_inlinePriceList = null;
|
||||
}
|
||||
|
||||
// اگر والد پس از لود اولیه، ردیفهای اولیه را فراهم کرد و جدول خالی است، آنها را ست کن
|
||||
if (_rows.isEmpty && (widget.initialRows ?? const <InvoiceLineItem>[]).isNotEmpty) {
|
||||
_rows.clear();
|
||||
_rows.addAll(widget.initialRows!);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
// لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام میشود
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <file_saver/file_saver_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
||||
|
|
@ -20,4 +21,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
file_saver
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import file_selector_macos
|
|||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
|
|
@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -650,6 +650,70 @@ packages:
|
|||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.24"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.3.5"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.4"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -708,4 +772,4 @@ packages:
|
|||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.9.2 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ dependencies:
|
|||
data_table_2: ^2.5.12
|
||||
file_picker: ^10.3.3
|
||||
file_selector: ^1.0.4
|
||||
url_launcher: ^6.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <file_saver/file_saver_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSaverPluginRegisterWithRegistrar(
|
||||
|
|
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
file_saver
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in a new issue