From b0884a33fd2d490df1a58e6203c960814dc487e1 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 9 Nov 2025 05:16:37 +0000 Subject: [PATCH] progress in some parts --- deploy.sh | 249 ++++++ .../adapters/api/v1/admin/payment_gateways.py | 180 ++++ .../adapters/api/v1/admin/system_settings.py | 52 ++ .../adapters/api/v1/admin/wallet_admin.py | 81 ++ hesabixAPI/adapters/api/v1/bank_accounts.py | 35 +- hesabixAPI/adapters/api/v1/cash_registers.py | 35 +- hesabixAPI/adapters/api/v1/documents.py | 286 +++++- hesabixAPI/adapters/api/v1/expense_income.py | 178 +++- .../adapters/api/v1/inventory_transfers.py | 51 +- hesabixAPI/adapters/api/v1/invoices.py | 182 +++- hesabixAPI/adapters/api/v1/kardex.py | 44 +- hesabixAPI/adapters/api/v1/opening_balance.py | 93 ++ .../adapters/api/v1/payment_callbacks.py | 108 +++ .../adapters/api/v1/payment_gateways.py | 56 ++ hesabixAPI/adapters/api/v1/persons.py | 35 +- hesabixAPI/adapters/api/v1/petty_cash.py | 35 +- hesabixAPI/adapters/api/v1/products.py | 35 +- .../adapters/api/v1/receipts_payments.py | 80 +- .../adapters/api/v1/report_templates.py | 241 ++++++ hesabixAPI/adapters/api/v1/transfers.py | 35 +- hesabixAPI/adapters/api/v1/wallet.py | 285 ++++++ hesabixAPI/adapters/api/v1/wallet_webhook.py | 46 + hesabixAPI/adapters/api/v1/warehouse_docs.py | 115 +++ .../adapters/db/models/invoice_item_line.py | 19 + .../adapters/db/models/payment_gateway.py | 48 ++ .../adapters/db/models/report_template.py | 46 + .../adapters/db/models/system_setting.py | 31 + hesabixAPI/adapters/db/models/wallet.py | 109 +++ .../adapters/db/models/warehouse_document.py | 34 + .../db/models/warehouse_document_line.py | 24 + hesabixAPI/app/core/permissions.py | 19 +- hesabixAPI/app/main.py | 20 + hesabixAPI/app/services/invoice_service.py | 410 ++++----- .../app/services/opening_balance_service.py | 236 +++++ hesabixAPI/app/services/payment_service.py | 229 +++++ .../app/services/receipt_payment_service.py | 5 + .../app/services/report_template_service.py | 257 ++++++ .../app/services/system_settings_service.py | 64 ++ hesabixAPI/app/services/wallet_service.py | 631 ++++++++++++++ hesabixAPI/app/services/warehouse_service.py | 373 ++++++-- hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 28 + .../20251107_150001_add_warehouse_docs.py | 85 ++ ...0101_add_invoice_item_lines_and_migrate.py | 72 ++ .../20251108_230001_add_report_templates.py | 68 ++ .../20251108_231201_add_system_settings.py | 70 ++ .../20251108_232101_add_wallet_tables.py | 116 +++ .../20251109_120001_add_payment_gateways.py | 68 ++ ...06b0cb880a_add_description_to_documents.py | 17 +- hesabixUI/hesabix_ui/lib/core/auth_store.dart | 41 +- hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 34 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 31 + .../lib/l10n/app_localizations.dart | 180 ++++ .../lib/l10n/app_localizations_en.dart | 91 ++ .../lib/l10n/app_localizations_fa.dart | 91 ++ hesabixUI/hesabix_ui/lib/main.dart | 124 ++- .../pages/admin/payment_gateways_page.dart | 425 +++++++++ .../lib/pages/admin/wallet_settings_page.dart | 106 +++ .../pages/business/bank_accounts_page.dart | 3 + .../lib/pages/business/business_shell.dart | 7 + .../pages/business/cash_registers_page.dart | 3 + .../lib/pages/business/documents_page.dart | 6 +- .../lib/pages/business/edit_invoice_page.dart | 547 ++++++++++++ .../business/expense_income_list_page.dart | 3 + .../business/inventory_transfers_page.dart | 3 + .../pages/business/invoices_list_page.dart | 22 + .../lib/pages/business/kardex_page.dart | 3 + .../lib/pages/business/new_invoice_page.dart | 18 +- .../pages/business/opening_balance_page.dart | 816 ++++++++++++++++++ .../lib/pages/business/persons_page.dart | 3 + .../lib/pages/business/petty_cash_page.dart | 3 + .../lib/pages/business/products_page.dart | 3 + .../business/receipts_payments_list_page.dart | 9 +- .../pages/business/report_templates_page.dart | 476 ++++++++++ .../lib/pages/business/settings_page.dart | 8 + .../lib/pages/business/transfers_page.dart | 3 + .../lib/pages/business/wallet_page.dart | 487 ++++++++++- .../business/wallet_payment_result_page.dart | 108 +++ .../lib/pages/profile/profile_shell.dart | 2 +- .../lib/pages/system_settings_page.dart | 14 + .../pages/warehouse/warehouse_docs_page.dart | 78 ++ .../lib/services/opening_balance_service.dart | 50 ++ .../lib/services/payment_gateway_service.dart | 71 ++ .../lib/services/report_template_service.dart | 138 +++ .../lib/services/system_settings_service.dart | 22 + .../lib/services/wallet_service.dart | 80 ++ .../lib/services/warehouse_service.dart | 51 ++ .../widgets/data_table/data_table_config.dart | 7 + .../widgets/data_table/data_table_widget.dart | 143 +++ .../document/document_details_dialog.dart | 171 +++- .../invoice/check_combobox_widget.dart | 12 +- .../invoice/invoice_transactions_widget.dart | 83 +- .../lib/widgets/invoice/line_items_table.dart | 14 + .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + hesabixUI/hesabix_ui/pubspec.lock | 66 +- hesabixUI/hesabix_ui/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 99 files changed, 9823 insertions(+), 461 deletions(-) create mode 100644 deploy.sh create mode 100644 hesabixAPI/adapters/api/v1/admin/payment_gateways.py create mode 100644 hesabixAPI/adapters/api/v1/admin/system_settings.py create mode 100644 hesabixAPI/adapters/api/v1/admin/wallet_admin.py create mode 100644 hesabixAPI/adapters/api/v1/opening_balance.py create mode 100644 hesabixAPI/adapters/api/v1/payment_callbacks.py create mode 100644 hesabixAPI/adapters/api/v1/payment_gateways.py create mode 100644 hesabixAPI/adapters/api/v1/report_templates.py create mode 100644 hesabixAPI/adapters/api/v1/wallet.py create mode 100644 hesabixAPI/adapters/api/v1/wallet_webhook.py create mode 100644 hesabixAPI/adapters/api/v1/warehouse_docs.py create mode 100644 hesabixAPI/adapters/db/models/invoice_item_line.py create mode 100644 hesabixAPI/adapters/db/models/payment_gateway.py create mode 100644 hesabixAPI/adapters/db/models/report_template.py create mode 100644 hesabixAPI/adapters/db/models/system_setting.py create mode 100644 hesabixAPI/adapters/db/models/wallet.py create mode 100644 hesabixAPI/adapters/db/models/warehouse_document.py create mode 100644 hesabixAPI/adapters/db/models/warehouse_document_line.py create mode 100644 hesabixAPI/app/services/opening_balance_service.py create mode 100644 hesabixAPI/app/services/payment_service.py create mode 100644 hesabixAPI/app/services/report_template_service.py create mode 100644 hesabixAPI/app/services/system_settings_service.py create mode 100644 hesabixAPI/app/services/wallet_service.py create mode 100644 hesabixAPI/migrations/versions/20251107_150001_add_warehouse_docs.py create mode 100644 hesabixAPI/migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py create mode 100644 hesabixAPI/migrations/versions/20251108_230001_add_report_templates.py create mode 100644 hesabixAPI/migrations/versions/20251108_231201_add_system_settings.py create mode 100644 hesabixAPI/migrations/versions/20251108_232101_add_wallet_tables.py create mode 100644 hesabixAPI/migrations/versions/20251109_120001_add_payment_gateways.py create mode 100644 hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/opening_balance_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/report_templates_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/wallet_payment_result_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/warehouse/warehouse_docs_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/opening_balance_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/payment_gateway_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/report_template_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/system_settings_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/wallet_service.dart diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..8462ffd --- /dev/null +++ b/deploy.sh @@ -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 < /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 < 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") + + + diff --git a/hesabixAPI/adapters/api/v1/admin/system_settings.py b/hesabixAPI/adapters/api/v1/admin/system_settings.py new file mode 100644 index 0000000..07d9fda --- /dev/null +++ b/hesabixAPI/adapters/api/v1/admin/system_settings.py @@ -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") + + diff --git a/hesabixAPI/adapters/api/v1/admin/wallet_admin.py b/hesabixAPI/adapters/api/v1/admin/wallet_admin.py new file mode 100644 index 0000000..a060f3a --- /dev/null +++ b/hesabixAPI/adapters/api/v1/admin/wallet_admin.py @@ -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") + diff --git a/hesabixAPI/adapters/api/v1/bank_accounts.py b/hesabixAPI/adapters/api/v1/bank_accounts.py index fae0e41..dee725d 100644 --- a/hesabixAPI/adapters/api/v1/bank_accounts.py +++ b/hesabixAPI/adapters/api/v1/bank_accounts.py @@ -469,6 +469,38 @@ async def export_bank_accounts_pdf( headers_html = ''.join(f"{escape_val(h)}" 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""" @@ -560,8 +592,9 @@ async def export_bank_accounts_pdf( """ + 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", diff --git a/hesabixAPI/adapters/api/v1/cash_registers.py b/hesabixAPI/adapters/api/v1/cash_registers.py index 879e8b3..1d1159c 100644 --- a/hesabixAPI/adapters/api/v1/cash_registers.py +++ b/hesabixAPI/adapters/api/v1/cash_registers.py @@ -440,6 +440,38 @@ async def export_cash_registers_pdf( headers_html = ''.join(f"{esc(h)}" 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""" @@ -464,8 +496,9 @@ async def export_cash_registers_pdf( """ + 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", diff --git a/hesabixAPI/adapters/api/v1/documents.py b/hesabixAPI/adapters/api/v1/documents.py index 93d2efd..75a5539 100644 --- a/hesabixAPI/adapters/api/v1/documents.py +++ b/hesabixAPI/adapters/api/v1/documents.py @@ -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'{escape(header)}' 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'{escape(str(value))}') + rows_html.append(f'{"".join(row_cells)}') + # کانتکست قالب + 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""" + + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+ + {headers_html} + {''.join(rows_html)} +
+
{footer_text}
+ + + """ + 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""" + + + + + + + +

{escape(doc.get("document_type_name") or ("سند" if is_fa else "Document"))}

+
+
{'کسب‌وکار' if is_fa else 'Business'}: {escape(business_name or "-")}
+
{'کد' if is_fa else 'Code'}: {escape(doc.get("code") or "-")}
+
{'تاریخ' if is_fa else 'Date'}: {escape(doc.get("document_date") or "-")}
+
+ + + + + + + + + + {''.join([ + f"" + for line in (doc.get('lines') or []) + ])} + +
{'شرح' if is_fa else 'Description'}{'بدهکار' if is_fa else 'Debit'}{'بستانکار' if is_fa else 'Credit'}
{escape(str(line.get('description') or '-'))}{escape(str(line.get('debit') or ''))}{escape(str(line.get('credit') or ''))}
+ + + """ + 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", + }, ) diff --git a/hesabixAPI/adapters/api/v1/expense_income.py b/hesabixAPI/adapters/api/v1/expense_income.py index a5569da..e0ce99a 100644 --- a/hesabixAPI/adapters/api/v1/expense_income.py +++ b/hesabixAPI/adapters/api/v1/expense_income.py @@ -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'{escape(header)}' 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'{escape(str(value))}') + rows_html.append(f'{"".join(row_cells)}') + # کانتکست برای قالب سفارشی + 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""" + + + + + {title_text} + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+ + {headers_html} + {''.join(rows_html)} +
+
{footer_text}
+ + + """ + 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", + }, ) diff --git a/hesabixAPI/adapters/api/v1/inventory_transfers.py b/hesabixAPI/adapters/api/v1/inventory_transfers.py index a531e29..3aa9236 100644 --- a/hesabixAPI/adapters/api/v1/inventory_transfers.py +++ b/hesabixAPI/adapters/api/v1/inventory_transfers.py @@ -160,6 +160,54 @@ def export_inventory_transfers_pdf( f"" 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 = "کد سندتاریخ سندشرح" + 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""" @@ -189,8 +237,9 @@ def export_inventory_transfers_pdf( """ + 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, diff --git a/hesabixAPI/adapters/api/v1/invoices.py b/hesabixAPI/adapters/api/v1/invoices.py index 442b29f..fd467a7 100644 --- a/hesabixAPI/adapters/api/v1/invoices.py +++ b/hesabixAPI/adapters/api/v1/invoices.py @@ -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""" + + + + + + + +

{escape(item.get("title") or ("فاکتور" if is_fa else "Invoice"))}

+
+
{'کسب‌وکار' if is_fa else 'Business'}: {escape(business_name or "-")}
+
{'کد' if is_fa else 'Code'}: {escape(item.get("code") or "-")}
+
{'تاریخ' if is_fa else 'Date'}: {escape(item.get("issue_date") or "-")}
+
+ + + + + + + + + + + + {''.join([ + f"" + for i, line in enumerate(item.get('lines') or []) + ])} + +
{'ردیف' if is_fa else 'No.'}{'شرح کالا/خدمت' if is_fa else 'Item'}{'تعداد' if is_fa else 'Qty'}{'فی' if is_fa else 'Price'}{'مبلغ' if is_fa else 'Amount'}
{i+1}{escape(str(line.get('product_name') or line.get('description') or '-'))}{escape(str(line.get('quantity') or ''))}{escape(str(line.get('unit_price') or ''))}{escape(str(line.get('line_total') or ''))}
+
+
{'جمع جزء' if is_fa else 'Subtotal'}: {escape(str(item.get('subtotal') or ''))}
+
{'مالیات' if is_fa else 'Tax'}: {escape(str(item.get('tax_total') or ''))}
+
{'قابل پرداخت' if is_fa else 'Payable'}: {escape(str(item.get('payable_total') or ''))}
+
+ + + """ + + 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'{escape(str(value))}') rows_html.append(f'{"".join(row_cells)}') - 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""" diff --git a/hesabixAPI/adapters/api/v1/kardex.py b/hesabixAPI/adapters/api/v1/kardex.py index 66573f5..58e902d 100644 --- a/hesabixAPI/adapters/api/v1/kardex.py +++ b/hesabixAPI/adapters/api/v1/kardex.py @@ -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"{h}" 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""" @@ -277,8 +318,9 @@ async def export_kardex_pdf_endpoint( """ + 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( diff --git a/hesabixAPI/adapters/api/v1/opening_balance.py b/hesabixAPI/adapters/api/v1/opening_balance.py new file mode 100644 index 0000000..8e7ed6d --- /dev/null +++ b/hesabixAPI/adapters/api/v1/opening_balance.py @@ -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") + + diff --git a/hesabixAPI/adapters/api/v1/payment_callbacks.py b/hesabixAPI/adapters/api/v1/payment_callbacks.py new file mode 100644 index 0000000..eae0f7c --- /dev/null +++ b/hesabixAPI/adapters/api/v1/payment_callbacks.py @@ -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") + + + diff --git a/hesabixAPI/adapters/api/v1/payment_gateways.py b/hesabixAPI/adapters/api/v1/payment_gateways.py new file mode 100644 index 0000000..4bf8056 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/payment_gateways.py @@ -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) + + + diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index a1aecf4..71cfd46 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -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""" @@ -629,8 +661,9 @@ async def export_persons_pdf( """ + 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 = "" diff --git a/hesabixAPI/adapters/api/v1/petty_cash.py b/hesabixAPI/adapters/api/v1/petty_cash.py index 3cfa823..a79350b 100644 --- a/hesabixAPI/adapters/api/v1/petty_cash.py +++ b/hesabixAPI/adapters/api/v1/petty_cash.py @@ -436,6 +436,38 @@ async def export_petty_cash_pdf( headers_html = ''.join(f"{esc(h)}" 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""" @@ -460,8 +492,9 @@ async def export_petty_cash_pdf( """ + 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", diff --git a/hesabixAPI/adapters/api/v1/products.py b/hesabixAPI/adapters/api/v1/products.py index e60c86b..5a55774 100644 --- a/hesabixAPI/adapters/api/v1/products.py +++ b/hesabixAPI/adapters/api/v1/products.py @@ -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""" @@ -826,8 +858,9 @@ async def export_products_pdf( """ + 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 diff --git a/hesabixAPI/adapters/api/v1/receipts_payments.py b/hesabixAPI/adapters/api/v1/receipts_payments.py index 6de6d29..814a37a 100644 --- a/hesabixAPI/adapters/api/v1/receipts_payments.py +++ b/hesabixAPI/adapters/api/v1/receipts_payments.py @@ -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""" @@ -810,7 +846,41 @@ async def export_receipts_payments_pdf( row_cells.append(f'{escape(str(value))}') rows_html.append(f'{"".join(row_cells)}') - # 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""" @@ -914,8 +984,10 @@ async def export_receipts_payments_pdf( """ + 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: diff --git a/hesabixAPI/adapters/api/v1/report_templates.py b/hesabixAPI/adapters/api/v1/report_templates.py new file mode 100644 index 0000000..3ddc47c --- /dev/null +++ b/hesabixAPI/adapters/api/v1/report_templates.py @@ -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, + } + + diff --git a/hesabixAPI/adapters/api/v1/transfers.py b/hesabixAPI/adapters/api/v1/transfers.py index 48753c2..26c7f59 100644 --- a/hesabixAPI/adapters/api/v1/transfers.py +++ b/hesabixAPI/adapters/api/v1/transfers.py @@ -299,6 +299,38 @@ async def export_transfers_pdf( rows_html.append(f'{"".join(row_cells)}') 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""" @@ -329,8 +361,9 @@ async def export_transfers_pdf( """ + 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, diff --git a/hesabixAPI/adapters/api/v1/wallet.py b/hesabixAPI/adapters/api/v1/wallet.py new file mode 100644 index 0000000..66633d4 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/wallet.py @@ -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") + + diff --git a/hesabixAPI/adapters/api/v1/wallet_webhook.py b/hesabixAPI/adapters/api/v1/wallet_webhook.py new file mode 100644 index 0000000..af95760 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/wallet_webhook.py @@ -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") + + diff --git a/hesabixAPI/adapters/api/v1/warehouse_docs.py b/hesabixAPI/adapters/api/v1/warehouse_docs.py new file mode 100644 index 0000000..5ebb6a7 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/warehouse_docs.py @@ -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) + + diff --git a/hesabixAPI/adapters/db/models/invoice_item_line.py b/hesabixAPI/adapters/db/models/invoice_item_line.py new file mode 100644 index 0000000..df4a1be --- /dev/null +++ b/hesabixAPI/adapters/db/models/invoice_item_line.py @@ -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) + + diff --git a/hesabixAPI/adapters/db/models/payment_gateway.py b/hesabixAPI/adapters/db/models/payment_gateway.py new file mode 100644 index 0000000..31bf235 --- /dev/null +++ b/hesabixAPI/adapters/db/models/payment_gateway.py @@ -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") + + + diff --git a/hesabixAPI/adapters/db/models/report_template.py b/hesabixAPI/adapters/db/models/report_template.py new file mode 100644 index 0000000..a2661a8 --- /dev/null +++ b/hesabixAPI/adapters/db/models/report_template.py @@ -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) + + diff --git a/hesabixAPI/adapters/db/models/system_setting.py b/hesabixAPI/adapters/db/models/system_setting.py new file mode 100644 index 0000000..92622a4 --- /dev/null +++ b/hesabixAPI/adapters/db/models/system_setting.py @@ -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) + + diff --git a/hesabixAPI/adapters/db/models/wallet.py b/hesabixAPI/adapters/db/models/wallet.py new file mode 100644 index 0000000..95dc0f1 --- /dev/null +++ b/hesabixAPI/adapters/db/models/wallet.py @@ -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) + + diff --git a/hesabixAPI/adapters/db/models/warehouse_document.py b/hesabixAPI/adapters/db/models/warehouse_document.py new file mode 100644 index 0000000..ed6b011 --- /dev/null +++ b/hesabixAPI/adapters/db/models/warehouse_document.py @@ -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() + + diff --git a/hesabixAPI/adapters/db/models/warehouse_document_line.py b/hesabixAPI/adapters/db/models/warehouse_document_line.py new file mode 100644 index 0000000..0770881 --- /dev/null +++ b/hesabixAPI/adapters/db/models/warehouse_document_line.py @@ -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") + + diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index fef0364..e9abc28 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -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 diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 1d64b53..05ebfda 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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) diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py index efa2886..79325cd 100644 --- a/hesabixAPI/app/services/invoice_service.py +++ b/hesabixAPI/app/services/invoice_service.py @@ -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 diff --git a/hesabixAPI/app/services/opening_balance_service.py b/hesabixAPI/app/services/opening_balance_service.py new file mode 100644 index 0000000..622d45f --- /dev/null +++ b/hesabixAPI/app/services/opening_balance_service.py @@ -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 {} + + diff --git a/hesabixAPI/app/services/payment_service.py b/hesabixAPI/app/services/payment_service.py new file mode 100644 index 0000000..7e284d8 --- /dev/null +++ b/hesabixAPI/app/services/payment_service.py @@ -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} + + + diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py index 3f2e51c..96981eb 100644 --- a/hesabixAPI/app/services/receipt_payment_service.py +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -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}") diff --git a/hesabixAPI/app/services/report_template_service.py b/hesabixAPI/app/services/report_template_service.py new file mode 100644 index 0000000..7519c09 --- /dev/null +++ b/hesabixAPI/app/services/report_template_service.py @@ -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 "" in html: + html = html.replace("", f"") + else: + html = f"{html}" + except Exception: + # اگر مشکلی بود، رندر را متوقف نکنیم + pass + + # درج CSS سفارشی در ") + else: + html = f"{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 + + diff --git a/hesabixAPI/app/services/system_settings_service.py b/hesabixAPI/app/services/system_settings_service.py new file mode 100644 index 0000000..47c99a4 --- /dev/null +++ b/hesabixAPI/app/services/system_settings_service.py @@ -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, + } + + diff --git a/hesabixAPI/app/services/wallet_service.py b/hesabixAPI/app/services/wallet_service.py new file mode 100644 index 0000000..5503a91 --- /dev/null +++ b/hesabixAPI/app/services/wallet_service.py @@ -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) diff --git a/hesabixAPI/app/services/warehouse_service.py b/hesabixAPI/app/services/warehouse_service.py index dc2dbfb..b27282d 100644 --- a/hesabixAPI/app/services/warehouse_service.py +++ b/hesabixAPI/app/services/warehouse_service.py @@ -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, + } diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 9977015..3940a7c 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -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 diff --git a/hesabixAPI/migrations/versions/20251107_150001_add_warehouse_docs.py b/hesabixAPI/migrations/versions/20251107_150001_add_warehouse_docs.py new file mode 100644 index 0000000..5b2de31 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251107_150001_add_warehouse_docs.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 + + diff --git a/hesabixAPI/migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py b/hesabixAPI/migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py new file mode 100644 index 0000000..df5454e --- /dev/null +++ b/hesabixAPI/migrations/versions/20251107_170101_add_invoice_item_lines_and_migrate.py @@ -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 + + diff --git a/hesabixAPI/migrations/versions/20251108_230001_add_report_templates.py b/hesabixAPI/migrations/versions/20251108_230001_add_report_templates.py new file mode 100644 index 0000000..aa3d05e --- /dev/null +++ b/hesabixAPI/migrations/versions/20251108_230001_add_report_templates.py @@ -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 + + diff --git a/hesabixAPI/migrations/versions/20251108_231201_add_system_settings.py b/hesabixAPI/migrations/versions/20251108_231201_add_system_settings.py new file mode 100644 index 0000000..43bd57d --- /dev/null +++ b/hesabixAPI/migrations/versions/20251108_231201_add_system_settings.py @@ -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') + + diff --git a/hesabixAPI/migrations/versions/20251108_232101_add_wallet_tables.py b/hesabixAPI/migrations/versions/20251108_232101_add_wallet_tables.py new file mode 100644 index 0000000..ea6bfe0 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251108_232101_add_wallet_tables.py @@ -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 + + diff --git a/hesabixAPI/migrations/versions/20251109_120001_add_payment_gateways.py b/hesabixAPI/migrations/versions/20251109_120001_add_payment_gateways.py new file mode 100644 index 0000000..0e07955 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251109_120001_add_payment_gateways.py @@ -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 + + + diff --git a/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py b/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py index f87363e..5a84e7d 100644 --- a/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py +++ b/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py @@ -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') diff --git a/hesabixUI/hesabix_ui/lib/core/auth_store.dart b/hesabixUI/hesabix_ui/lib/core/auth_store.dart index 218739c..d9dcd21 100644 --- a/hesabixUI/hesabix_ui/lib/core/auth_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/auth_store.dart @@ -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) { - final user = data['user'] as Map?; + final root = response.data; + if (root is Map) { + // پاسخ API در فیلد data قرار دارد + final payload = (root['data'] is Map) + ? root['data'] as Map + : root; + final user = payload['user'] as Map?; + final permsObj = payload['permissions'] as Map?; + Map? appPermissions; + bool isSuperAdmin = false; + int? userId; + if (user != null) { - final appPermissions = user['app_permissions'] as Map?; - 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?; + 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 { } + diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index c650439..87e06da 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -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" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 5da4f42..60dae4f 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -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": "ورود", diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index bf0efd1..1cb97a5 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index eaceb47..536eec8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 4de620f..c6dc7fa 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -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 => 'ایجاد'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 580b9b2..0c727f8 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { ); }, ), + 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 { ); }, ), + 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 { ); }, ), + 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 { ); }, ), + 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', diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart new file mode 100644 index 0000000..44d2734 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart @@ -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 createState() => _PaymentGatewaysPageState(); +} + +class _PaymentGatewaysPageState extends State { + late final PaymentGatewayService _service; + bool _loading = true; + String? _error; + List> _items = const >[]; + int? _editingId; + + // Create form + final _formKey = GlobalKey(); + 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 _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 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) ? it['config'] as Map : {}; + 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 _buildConfig() { + final cfg = {}; + 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 _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 _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( + 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 _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 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( + 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), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart new file mode 100644 index 0000000..2885df1 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/admin/wallet_settings_page.dart @@ -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 createState() => _WalletSettingsPageState(); +} + +class _WalletSettingsPageState extends State { + final _formKey = GlobalKey(); + late final SystemSettingsService _settingsService; + late final CurrencyService _currencyService; + + bool _loading = true; + String? _error; + String? _selectedCurrencyCode; + List> _currencies = const >[]; + + @override + void initState() { + super.initState(); + final api = ApiClient(); + _settingsService = SystemSettingsService(api); + _currencyService = CurrencyService(api); + _load(); + } + + Future _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 _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( + value: _selectedCurrencyCode, + decoration: const InputDecoration(labelText: 'ارز پایه کیف‌پول'), + items: _currencies + .map((c) => DropdownMenuItem( + 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('ذخیره'), + ), + ], + ), + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart index 71e7f81..ef425e8 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/bank_accounts_page.dart @@ -89,6 +89,9 @@ class _BankAccountsPageState extends State { 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(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index c2138a7..bd4ac3e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -424,6 +424,13 @@ class _BusinessShellState extends State { 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, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart index 89e0cb1..6494ee4 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/cash_registers_page.dart @@ -87,6 +87,9 @@ class _CashRegistersPageState extends State { 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(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart index 8c7677f..5297813 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart @@ -407,7 +407,11 @@ class _DocumentsPageState extends State { 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) { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart new file mode 100644 index 0000000..7f8c0ba --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/edit_invoice_page.dart @@ -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 createState() => _EditInvoicePageState(); +} + +class _EditInvoicePageState extends State 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 _lineItems = []; + num _sumSubtotal = 0; + num _sumDiscount = 0; + num _sumTax = 0; + num _sumTotal = 0; + + // For preserving and merging extra_info + Map _originalExtraInfo = {}; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _loadInvoice(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _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.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.from(item['extra_info'] ?? const {}); + _postInventory = (_originalExtraInfo['post_inventory'] is bool) ? _originalExtraInfo['post_inventory'] as bool : true; + + // lines + final List lines = List.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((raw) { + final Map r = Map.from(raw as Map); + final Map info = Map.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(0, (acc, e) => acc + e.subtotal); + _sumDiscount = _lineItems.fold(0, (acc, e) => acc + e.discountAmount); + _sumTax = _lineItems.fold(0, (acc, e) => acc + e.taxAmount); + _sumTotal = _lineItems.fold(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 _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; + + 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 = {..._originalExtraInfo}; + mergedExtra['post_inventory'] = _postInventory; + mergedExtra['totals'] = { + 'gross': _sumSubtotal, + 'discount': _sumDiscount, + 'tax': _sumTax, + 'net': _sumTotal, + }; + + String _convertInvoiceTypeToApi(InvoiceType type) => 'invoice_${type.value}'; + + final payload = { + '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 _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 { + '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, + }, + }; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart index 5fd4a00..6cd8802 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart @@ -228,6 +228,9 @@ class _ExpenseIncomeListPageState extends State { 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( diff --git a/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart index 795f2d0..d53abf5 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/inventory_transfers_page.dart @@ -61,6 +61,9 @@ class _InventoryTransfersPageState extends State { 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, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart index 367f9b5..f42e52c 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/invoices_list_page.dart @@ -219,6 +219,9 @@ class _InvoicesListPageState extends State { 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 { 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 { ), ); } + + Future _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(); + } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart index 362f34f..95e040a 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/kardex_page.dart @@ -366,6 +366,9 @@ class _KardexPageState extends State { 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)['document_date']?.toString()), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart index e2e28ad..b688d71 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/new_invoice_page.dart @@ -971,7 +971,6 @@ class _NewInvoicePageState extends State 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 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 with SingleTickerProvid // انتخاب قالب چاپ DropdownButtonFormField( 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(() { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/opening_balance_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/opening_balance_page.dart new file mode 100644 index 0000000..90b2244 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/opening_balance_page.dart @@ -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 createState() => _OpeningBalancePageState(); +} + +class _OpeningBalancePageState extends State { + late final OpeningBalanceService _service; + bool _loading = false; + Map? _document; + // Local form state + final List> _bankCashPettyLines = >[]; + final List> _personLines = >[]; + final List> _inventoryLines = >[]; + final List> _otherAccountLines = >[]; + 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 _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 _loadDefaultAccounts() async { + try { + final accountService = AccountService(); + Future findByCode(String code) async { + final res = await accountService.searchAccounts(businessId: widget.businessId, searchQuery: code, limit: 50); + final items = (res['items'] as List? ?? const []); + for (final it in items) { + final acc = Account.fromJson(Map.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 _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 _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 _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 _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 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 _save() async { + final accountLines = >[]; + 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 = >[]; + for (final m in _inventoryLines) { + final product = (m['product'] as Map?); + 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 = { + '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 _post() async { + final posted = await _service.post(businessId: widget.businessId); + setState(() => _document = posted); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('نهایی شد'))); + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 978ccef..2eb3831 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -56,6 +56,9 @@ class _PersonsPageState extends State { 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, }, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart index 0e7c3f3..c1ab2ea 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/petty_cash_page.dart @@ -87,6 +87,9 @@ class _PettyCashPageState extends State { 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(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart index ab9b485..631fbfe 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart @@ -41,6 +41,9 @@ class _ProductsPageState extends State { 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, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart index a5d54e9..d46672f 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart @@ -236,6 +236,9 @@ class _ReceiptsPaymentsListPageState extends State { 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 { 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 { 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 { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('خطا در تولید PDF: $e'), + content: Text('${AppLocalizations.of(context).pdfError}: $e'), backgroundColor: Colors.red, ), ); diff --git a/hesabixUI/hesabix_ui/lib/pages/business/report_templates_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/report_templates_page.dart new file mode 100644 index 0000000..b1f079e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/report_templates_page.dart @@ -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 createState() => _ReportTemplatesPageState(); +} + +class _ReportTemplatesPageState extends State { + late final ReportTemplateService _service; + final _moduleCtrl = TextEditingController(text: 'invoices'); + final _subtypeCtrl = TextEditingController(text: 'list'); + String? _statusFilter; // draft/published/null + + bool _loading = false; + List> _items = const []; + + // Create/Edit form + final _nameCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final _htmlCtrl = TextEditingController(text: "

{{ title_text }}

"); + 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 _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 _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 _togglePublish(Map 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 _previewTemplate(Map 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 {}, + ); + 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 _editDialog(Map 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 = { + '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 _setDefault(Map 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 _delete(Map 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( + 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), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart index ae3fa88..457ee3e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/settings_page.dart @@ -70,6 +70,14 @@ class _SettingsPageState extends State { 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'), + ), ], ), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart index b013d64..a4fcea9 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart @@ -172,6 +172,9 @@ class _TransfersPageState extends State { 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(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart index 5aa8edb..7664a32 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart @@ -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 { + late final WalletService _service; + bool _loading = true; + Map? _overview; + String? _error; + List> _transactions = const >[]; + Map? _metrics; + DateTime? _fromDate; + DateTime? _toDate; + + @override + void initState() { + super.initState(); + _service = WalletService(ApiClient()); + _load(); + } + + Future _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 _openPayoutDialog() async { + final t = AppLocalizations.of(context); + final formKey = GlobalKey(); + int? bankId; + final amountCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final result = await showDialog( + 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 _openTopUpDialog() async { + final t = AppLocalizations.of(context); + final formKey = GlobalKey(); + final amountCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final pgService = PaymentGatewayService(ApiClient()); + List> gateways = const >[]; + int? gatewayId; + try { + gateways = await pgService.listBusinessGateways(widget.businessId); + if (gateways.isNotEmpty) { + gatewayId = int.tryParse('${gateways.first['id']}'); + } + } catch (_) {} + final result = await showDialog( + 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( + value: gatewayId, + decoration: const InputDecoration(labelText: 'درگاه پرداخت'), + items: gateways + .map((g) => DropdownMenuItem( + 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 _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 _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 _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 { 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}, + ); + }, + ); + }, + ), + ), + ), + ], + ), + ), ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/wallet_payment_result_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/wallet_payment_result_page.dart new file mode 100644 index 0000000..5695c7e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/wallet_payment_result_page.dart @@ -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 createState() => _WalletPaymentResultPageState(); +} + +class _WalletPaymentResultPageState extends State { + bool _loading = false; + String? _error; + Map? _tx; + + @override + void initState() { + super.initState(); + _checkStatusIfPossible(); + } + + Future _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: () => {}, + ); + 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('بازگشت به کیف‌پول'), + ), + ], + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart index 3d0e22d..4e67f4d 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/profile_shell.dart @@ -77,7 +77,7 @@ class _ProfileShellState extends State { 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; diff --git a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart index 0011243..6056878 100644 --- a/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/system_settings_page.dart @@ -30,6 +30,20 @@ class _SystemSettingsPageState extends State { 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', diff --git a/hesabixUI/hesabix_ui/lib/pages/warehouse/warehouse_docs_page.dart b/hesabixUI/hesabix_ui/lib/pages/warehouse/warehouse_docs_page.dart new file mode 100644 index 0000000..be17e58 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/warehouse/warehouse_docs_page.dart @@ -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 createState() => _WarehouseDocsPageState(); +} + +class _WarehouseDocsPageState extends State { + final _svc = WarehouseService(); + bool _loading = true; + String? _error; + List _items = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final res = await _svc.search(businessId: widget.businessId, limit: 50); + setState(() { _items = List.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; + 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, + ), + ); + }, + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/opening_balance_service.dart b/hesabixUI/hesabix_ui/lib/services/opening_balance_service.dart new file mode 100644 index 0000000..2d4adcb --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/opening_balance_service.dart @@ -0,0 +1,50 @@ +import 'package:hesabix_ui/core/api_client.dart'; + +class OpeningBalanceService { + final ApiClient _apiClient; + + OpeningBalanceService(this._apiClient); + + Future?> 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?) ?? {}; + } + throw Exception('خطا در دریافت تراز افتتاحیه: ${resp.statusMessage}'); + } + + Future> save({ + required int businessId, + required Map payload, + }) async { + final resp = await _apiClient.put( + '/businesses/$businessId/opening-balance', + data: payload, + ); + if (resp.statusCode == 200) { + return (resp.data?['data'] as Map? ?? {}); + } + throw Exception('خطا در ذخیره تراز افتتاحیه: ${resp.statusMessage}'); + } + + Future> 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? ?? {}); + } + throw Exception('خطا در نهایی‌سازی تراز افتتاحیه: ${resp.statusMessage}'); + } + +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/payment_gateway_service.dart b/hesabixUI/hesabix_ui/lib/services/payment_gateway_service.dart new file mode 100644 index 0000000..f42a980 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/payment_gateway_service.dart @@ -0,0 +1,71 @@ +import '../core/api_client.dart'; + +class PaymentGatewayService { + final ApiClient _api; + PaymentGatewayService(this._api); + + // Admin CRUD + Future>> listAdmin() async { + final res = await _api.get>('/admin/payment-gateways'); + final body = res.data; + final items = (body is Map) ? body['data'] : body; + if (items is List) { + return items.map>((e) => Map.from(e as Map)).toList(); + } + return const >[]; + } + + Future> createAdmin({ + required String provider, + required String displayName, + required Map config, + bool isActive = true, + bool isSandbox = true, + }) async { + final res = await _api.post>('/admin/payment-gateways', data: { + 'provider': provider, + 'display_name': displayName, + 'is_active': isActive, + 'is_sandbox': isSandbox, + 'config': config, + }); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } + + Future> updateAdmin({ + required int gatewayId, + String? provider, + String? displayName, + bool? isActive, + bool? isSandbox, + Map? config, + }) async { + final res = await _api.put>('/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; + return Map.from(body['data'] as Map); + } + + Future deleteAdmin(int gatewayId) async { + await _api.delete('/admin/payment-gateways/$gatewayId'); + } + + // Business visible gateways + Future>> listBusinessGateways(int businessId) async { + final res = await _api.get>('/businesses/$businessId/wallet/gateways'); + final body = res.data; + final items = (body is Map) ? body['data'] : body; + if (items is List) { + return items.map>((e) => Map.from(e as Map)).toList(); + } + return const >[]; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/report_template_service.dart b/hesabixUI/hesabix_ui/lib/services/report_template_service.dart new file mode 100644 index 0000000..087ac8d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/report_template_service.dart @@ -0,0 +1,138 @@ +import '../core/api_client.dart'; + +class ReportTemplateService { + final ApiClient _api; + ReportTemplateService(this._api); + + Future>> listTemplates({ + required int businessId, + String? moduleKey, + String? subtype, + String? status, + }) async { + final res = await _api.get>( + '/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>(); + } + + Future 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? margins, + Map? assets, + }) async { + final res = await _api.post>( + '/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> updateTemplate({ + required int businessId, + required int templateId, + Map? changes, + }) async { + final res = await _api.put>( + '/report-templates/$templateId/business/$businessId', + data: changes ?? const {}, + ); + return res.data ?? const {}; + } + + Future> getTemplate({ + required int businessId, + required int templateId, + }) async { + final res = await _api.get>( + '/report-templates/$templateId/business/$businessId', + ); + return res.data ?? const {}; + } + + Future deleteTemplate({ + required int businessId, + required int templateId, + }) async { + await _api.delete>( + '/report-templates/$templateId/business/$businessId', + ); + } + + Future> publish({ + required int businessId, + required int templateId, + required bool published, + }) async { + final res = await _api.post>( + '/report-templates/$templateId/business/$businessId/publish', + data: {'published': published}, + ); + return res.data ?? const {}; + } + + Future> setDefault({ + required int businessId, + required String moduleKey, + String? subtype, + required int templateId, + }) async { + final res = await _api.post>( + '/report-templates/business/$businessId/set-default', + data: { + 'module_key': moduleKey, + if (subtype != null) 'subtype': subtype, + 'template_id': templateId, + }, + ); + return res.data ?? const {}; + } + + Future> preview({ + required int businessId, + required String contentHtml, + String? contentCss, + Map? context, + }) async { + final res = await _api.post>( + '/report-templates/business/$businessId/preview', + data: { + 'content_html': contentHtml, + if (contentCss != null) 'content_css': contentCss, + 'context': context ?? const {}, + }, + ); + return res.data ?? const {}; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/system_settings_service.dart b/hesabixUI/hesabix_ui/lib/services/system_settings_service.dart new file mode 100644 index 0000000..4288af7 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/system_settings_service.dart @@ -0,0 +1,22 @@ +import '../core/api_client.dart'; + +class SystemSettingsService { + final ApiClient _api; + SystemSettingsService(this._api); + + Future> getWalletSettings() async { + final res = await _api.get>('/admin/system-settings/wallet'); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } + + Future> setWalletBaseCurrencyCode(String code) async { + final res = await _api.put>('/admin/system-settings/wallet', data: { + 'wallet_base_currency_code': code, + }); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/wallet_service.dart b/hesabixUI/hesabix_ui/lib/services/wallet_service.dart new file mode 100644 index 0000000..2c35edd --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/wallet_service.dart @@ -0,0 +1,80 @@ +import '../core/api_client.dart'; + +class WalletService { + final ApiClient _api; + WalletService(this._api); + + Future> getOverview({required int businessId}) async { + final res = await _api.get>('/businesses/$businessId/wallet'); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } + + Future>> listTransactions({ + required int businessId, + int skip = 0, + int limit = 50, + DateTime? fromDate, + DateTime? toDate, + }) async { + final query = { + 'skip': '$skip', + 'limit': '$limit', + if (fromDate != null) 'from_date': fromDate.toIso8601String(), + if (toDate != null) 'to_date': toDate.toIso8601String(), + }; + final res = await _api.get>('/businesses/$businessId/wallet/transactions', query: query); + final body = res.data; + final items = (body is Map) ? body['data'] : body; + if (items is List) { + return items.map>((e) => Map.from(e as Map)).toList(); + } + return const >[]; + } + + Future> getMetrics({ + required int businessId, + DateTime? fromDate, + DateTime? toDate, + }) async { + final query = { + if (fromDate != null) 'from_date': fromDate.toIso8601String(), + if (toDate != null) 'to_date': toDate.toIso8601String(), + }; + final res = await _api.get>('/businesses/$businessId/wallet/metrics', query: query); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } + + Future> requestPayout({ + required int businessId, + required int bankAccountId, + required double amount, + String? description, + }) async { + final res = await _api.post>('/businesses/$businessId/wallet/payouts', data: { + 'bank_account_id': bankAccountId, + 'amount': amount, + if (description != null && description.isNotEmpty) 'description': description, + }); + final body = res.data as Map; + return Map.from(body['data'] as Map); + } + + Future> topUp({ + required int businessId, + required double amount, + String? description, + int? gatewayId, + }) async { + final res = await _api.post>('/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; + return Map.from(body['data'] as Map); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart b/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart index d614436..6935661 100644 --- a/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart @@ -34,6 +34,57 @@ class WarehouseService { final res = await _api.delete>('/api/v1/warehouses/business/$businessId/$warehouseId'); return res.statusCode == 200 && (res.data?['data']?['deleted'] == true); } + + Future> createFromInvoice({ + required int businessId, + required int invoiceId, + Map? body, + }) async { + final res = await _api.post>( + '/api/v1/warehouse-docs/business/$businessId/from-invoice/$invoiceId', + data: body ?? const {}, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> postDoc({ + required int businessId, + required int docId, + }) async { + final res = await _api.post>( + '/api/v1/warehouse-docs/business/$businessId/$docId/post', + data: const {}, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> getDoc({ + required int businessId, + required int docId, + }) async { + final res = await _api.get>( + '/api/v1/warehouse-docs/business/$businessId/$docId', + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> search({ + required int businessId, + int page = 1, + int limit = 20, + Map? filters, + }) async { + final body = { + 'take': limit, + 'skip': (page - 1) * limit, + ...?filters, + }; + final res = await _api.post>( + '/api/v1/warehouse-docs/business/$businessId/search', + data: body, + ); + return Map.from(res.data?['data'] ?? const {}); + } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart index 18285c0..0f7002b 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -249,6 +249,10 @@ class DataTableConfig { 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 { this.showExportButtons = false, this.showExcelExport = true, this.showPdfExport = true, + this.businessId, + this.reportModuleKey, + this.reportSubtype, this.tableId, this.enableColumnSettings = true, this.showColumnSettingsButton = true, diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index bd3dd85..0c5ed22 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -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 extends State> { // Row selection state final Set _selectedRows = {}; bool _isExporting = false; + int? _templateIdForExport; + final TextEditingController _templateIdCtrl = TextEditingController(); + // Report templates (for PDF export) + List> _availableTemplates = const []; + bool _loadingTemplates = false; + int? _selectedTemplateIdFromList; // Column settings state ColumnSettings? _columnSettings; @@ -125,6 +132,7 @@ class _DataTableWidgetState extends State> { _searchDebounce?.cancel(); _horizontalScrollController.dispose(); _tableFocusNode.dispose(); + _templateIdCtrl.dispose(); for (var controller in _columnSearchControllers.values) { controller.dispose(); } @@ -631,6 +639,10 @@ class _DataTableWidgetState extends State> { 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 extends State> { AppLocalizations t, ThemeData theme, ) { + Future _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 extends State> { 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( + value: _selectedTemplateIdFromList, + isExpanded: true, + decoration: InputDecoration( + labelText: AppLocalizations.of(context).printTemplatePublished, + isDense: true, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + 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( + 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( diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart index ec9b02d..abe5298 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart @@ -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 { DocumentModel? _document; bool _isLoading = true; String? _errorMessage; + bool _isGeneratingPdf = false; + final _warehouseService = WarehouseService(); + List _relatedWhDocs = const []; @override void initState() { @@ -33,6 +39,53 @@ class _DocumentDetailsDialogState extends State { _loadDocument(); } + Future _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 _savePdfFile(List 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 _loadDocument() async { setState(() { _isLoading = true; @@ -47,6 +100,22 @@ class _DocumentDetailsDialogState extends State { _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.from(data['items'] ?? const []); + }); + } + } catch (_) {} } catch (e) { if (mounted) { setState(() { @@ -62,27 +131,77 @@ class _DocumentDetailsDialogState extends State { 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; + 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 { 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), // دکمه بستن diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart index 31cfe1d..6255189 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/check_combobox_widget.dart @@ -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 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 { 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? ?? const [])).map((e) { + var items = ((itemsRaw as List? ?? const [])).map((e) { final m = Map.from(e as Map); return CheckOption( id: '${m['id']}', @@ -109,8 +113,14 @@ class _CheckComboboxWidgetState extends State { 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; diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart index 73d7511..724fe2d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart @@ -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 { 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 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 { String? _selectedCashRegisterId; String? _selectedPettyCashId; String? _selectedCheckId; + int? _selectedCheckCurrencyId; String? _selectedPersonId; AccountTreeNode? _selectedAccount; @@ -728,6 +735,7 @@ class _TransactionDialogState extends State { return BankAccountComboboxWidget( businessId: widget.businessId, selectedAccountId: _selectedBankId, + filterCurrencyId: widget.selectedCurrencyId, onChanged: (opt) { setState(() { _selectedBankId = opt?.id; @@ -743,6 +751,7 @@ class _TransactionDialogState extends State { return CashRegisterComboboxWidget( businessId: widget.businessId, selectedRegisterId: _selectedCashRegisterId, + filterCurrencyId: widget.selectedCurrencyId, onChanged: (opt) { setState(() { _selectedCashRegisterId = opt?.id; @@ -758,6 +767,7 @@ class _TransactionDialogState extends State { return PettyCashComboboxWidget( businessId: widget.businessId, selectedPettyCashId: _selectedPettyCashId, + filterCurrencyId: widget.selectedCurrencyId, onChanged: (opt) { setState(() { _selectedPettyCashId = opt?.id; @@ -770,21 +780,18 @@ class _TransactionDialogState extends State { } Widget _buildCheckFields() { - return DropdownButtonFormField( - 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 { 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: () => {}, + ); + 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: () => {}, + ); + 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: () => {}, + ); + 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(), diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart index 04b32b0..beda726 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/line_items_table.dart @@ -13,6 +13,7 @@ class InvoiceLineItemsTable extends StatefulWidget { final ValueChanged>? onChanged; final String invoiceType; // sales | purchase | sales_return | purchase_return | ... final bool postInventory; + final List? 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 { @override void initState() { super.initState(); + if ((widget.initialRows ?? const []).isNotEmpty) { + _rows.clear(); + _rows.addAll(widget.initialRows!); + _notify(); + } } @override @@ -84,6 +91,13 @@ class _InvoiceLineItemsTableState extends State { // invalidate inline price list cache if currency changed _inlinePriceList = null; } + + // اگر والد پس از لود اولیه، ردیف‌های اولیه را فراهم کرد و جدول خالی است، آن‌ها را ست کن + if (_rows.isEmpty && (widget.initialRows ?? const []).isNotEmpty) { + _rows.clear(); + _rows.addAll(widget.initialRows!); + _notify(); + } } // لیست قیمت سراسری حذف شده است؛ انتخاب قیمت از داخل سلول انجام می‌شود diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc index cc06ecd..d8a4468 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include 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); } diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake index 3ff8707..08dfac1 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake @@ -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 diff --git a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift index b034421..a726cd4 100644 --- a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index f11cc48..a43cecf 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -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" diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index afa91a2..3cb3449 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -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: diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc index 4f36997..2d2da1f 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include 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")); } diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake index 88921e5..dc99a46 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake @@ -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