progress in recipies

This commit is contained in:
Hesabix 2025-10-16 13:02:03 +03:30
parent 4c9283ab98
commit 4d7a31409c
16 changed files with 1022 additions and 38 deletions

View file

@ -0,0 +1,71 @@
from typing import Dict, Any
from fastapi import APIRouter, 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 success_response, ApiError, format_datetime_fields
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
router = APIRouter(prefix="/business", tags=["fiscal-years"])
@router.get("/{business_id}/fiscal-years")
@require_business_access("business_id")
def list_fiscal_years(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
repo = FiscalYearRepository(db)
# اطمینان از دسترسی کاربر به کسب و کار
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
items = repo.list_by_business(business_id)
data = [
{
"id": fy.id,
"title": fy.title,
"start_date": fy.start_date,
"end_date": fy.end_date,
"is_current": fy.is_last,
}
for fy in items
]
return success_response(data=format_datetime_fields({"items": data}, request), request=request, message="FISCAL_YEARS_LIST_FETCHED")
@router.get("/{business_id}/fiscal-years/current")
@require_business_access("business_id")
def get_current_fiscal_year(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
repo = FiscalYearRepository(db)
if not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
fy = repo.get_current_for_business(business_id)
if not fy:
return success_response(data=None, request=request, message="NO_CURRENT_FISCAL_YEAR")
data = {
"id": fy.id,
"title": fy.title,
"start_date": fy.start_date,
"end_date": fy.end_date,
"is_current": fy.is_last,
}
return success_response(data=format_datetime_fields(data, request), request=request, message="FISCAL_YEAR_CURRENT_FETCHED")

View file

@ -21,6 +21,7 @@ from app.services.receipt_payment_service import (
get_receipt_payment,
list_receipts_payments,
delete_receipt_payment,
update_receipt_payment,
)
from adapters.db.models.business import Business
@ -67,6 +68,14 @@ async def list_receipts_payments_endpoint(
except Exception:
pass
# دریافت fiscal_year_id از هدر برای اولویت دادن به انتخاب کاربر
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
result = list_receipts_payments(db, business_id, query_dict)
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
@ -208,6 +217,37 @@ async def delete_receipt_payment_endpoint(
)
@router.put(
"/receipts-payments/{document_id}",
summary="ویرایش سند دریافت/پرداخت",
description="به‌روزرسانی یک سند دریافت یا پرداخت",
)
async def update_receipt_payment_endpoint(
request: Request,
document_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""ویرایش سند"""
# دریافت سند برای بررسی دسترسی
result = get_receipt_payment(db, document_id)
if not result:
raise ApiError("DOCUMENT_NOT_FOUND", "Receipt/Payment document not found", http_status=404)
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
updated = update_receipt_payment(db, document_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(updated, request),
request=request,
message="RECEIPT_PAYMENT_UPDATED",
)
@router.post(
"/businesses/{business_id}/receipts-payments/export/excel",
summary="خروجی Excel لیست اسناد دریافت و پرداخت",

View file

@ -34,4 +34,18 @@ class FiscalYearRepository(BaseRepository[FiscalYear]):
self.db.refresh(fiscal_year)
return fiscal_year
def list_by_business(self, business_id: int) -> list[FiscalYear]:
"""لیست سال‌های مالی یک کسب‌وکار بر اساس business_id"""
from sqlalchemy import select
stmt = select(FiscalYear).where(FiscalYear.business_id == business_id).order_by(FiscalYear.start_date.desc())
return list(self.db.execute(stmt).scalars().all())
def get_current_for_business(self, business_id: int) -> FiscalYear | None:
"""دریافت سال مالی جاری یک کسب و کار (بر اساس is_last)"""
from sqlalchemy import select
stmt = select(FiscalYear).where(FiscalYear.business_id == business_id, FiscalYear.is_last == True) # noqa: E712
return self.db.execute(stmt).scalars().first()

View file

@ -31,6 +31,7 @@ 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.receipts_payments import router as receipts_payments_router
from adapters.api.v1.fiscal_years import router as fiscal_years_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
@ -307,6 +308,7 @@ def create_app() -> FastAPI:
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")

View file

@ -584,6 +584,22 @@ def list_receipts_payments(
)
)
# فیلتر بر اساس سال مالی (از query یا پیش فرض سال جاری)
fiscal_year_id = query.get("fiscal_year_id")
if fiscal_year_id is not None:
try:
fiscal_year_id = int(fiscal_year_id)
except (TypeError, ValueError):
fiscal_year_id = None
if fiscal_year_id is None:
try:
fiscal_year = _get_current_fiscal_year(db, business_id)
fiscal_year_id = fiscal_year.id
except Exception:
fiscal_year_id = None
if fiscal_year_id is not None:
q = q.filter(Document.fiscal_year_id == fiscal_year_id)
# فیلتر بر اساس نوع
doc_type = query.get("document_type")
if doc_type:
@ -654,12 +670,348 @@ def delete_receipt_payment(db: Session, document_id: int) -> bool:
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
return False
# 1) جلوگیری از حذف در سال مالی غیر جاری
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
# عبور خطای آگاهانه
raise
except Exception:
# اگر به هر دلیل نتوانستیم وضعیت سال مالی را بررسی کنیم، حذف را متوقف نکن
pass
# 2) جلوگیری از حذف در صورت قفل بودن سند (براساس extra_info یا developer_settings)
try:
locked_flags = []
if isinstance(document.extra_info, dict):
locked_flags.append(bool(document.extra_info.get("locked")))
locked_flags.append(bool(document.extra_info.get("is_locked")))
if isinstance(document.developer_settings, dict):
locked_flags.append(bool(document.developer_settings.get("locked")))
locked_flags.append(bool(document.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
"این سند قفل است و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 3) جلوگیری از حذف اگر خطوط سند به چک مرتبط باشند
try:
has_related_checks = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == document.id,
DocumentLine.check_id.isnot(None),
)
).first() is not None
if has_related_checks:
raise ApiError(
"DOCUMENT_REFERENCED",
"این سند دارای اقلام مرتبط با چک است و قابل حذف نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
db.delete(document)
db.commit()
return True
def update_receipt_payment(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""به‌روزرسانی سند دریافت/پرداخت - استراتژی Full-Replace خطوط"""
document = db.query(Document).filter(Document.id == document_id).first()
if document is None:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
raise ApiError("INVALID_DOCUMENT_TYPE", "Invalid document type", http_status=400)
# 1) محدودیت‌های سال مالی/قفل/وابستگی مشابه حذف
try:
fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first()
if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
try:
locked_flags = []
if isinstance(document.extra_info, dict):
locked_flags.append(bool(document.extra_info.get("locked")))
locked_flags.append(bool(document.extra_info.get("is_locked")))
if isinstance(document.developer_settings, dict):
locked_flags.append(bool(document.developer_settings.get("locked")))
locked_flags.append(bool(document.developer_settings.get("is_locked")))
if any(locked_flags):
raise ApiError(
"DOCUMENT_LOCKED",
"این سند قفل است و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
try:
has_related_checks = db.query(DocumentLine).filter(
and_(
DocumentLine.document_id == document.id,
DocumentLine.check_id.isnot(None),
)
).first() is not None
if has_related_checks:
raise ApiError(
"DOCUMENT_REFERENCED",
"این سند دارای اقلام مرتبط با چک است و قابل ویرایش نمی‌باشد",
http_status=409,
)
except ApiError:
raise
except Exception:
pass
# 2) اعتبارسنجی ورودی‌ها (مشابه create)
document_date = _parse_iso_date(data.get("document_date", document.document_date))
currency_id = data.get("currency_id", document.currency_id)
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
person_lines = data.get("person_lines", [])
account_lines = data.get("account_lines", [])
if not isinstance(person_lines, list) or not person_lines:
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
if not isinstance(account_lines, list) or not account_lines:
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
person_total = sum(float(line.get("amount", 0)) for line in person_lines)
account_total = sum(float(line.get("amount", 0)) for line in account_lines)
if abs(person_total - account_total) > 0.01:
raise ApiError("UNBALANCED_AMOUNTS", "Totals must be balanced", http_status=400)
# 3) اعمال تغییرات در سند (بدون تغییر code و document_type)
document.document_date = document_date
document.currency_id = int(currency_id)
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
document.extra_info = data.get("extra_info")
# تعیین نوع دریافت/پرداخت برای محاسبات بدهکار/بستانکار
is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT)
# حذف خطوط فعلی و ایجاد مجدد
db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False)
# خطوط شخص
for person_line in person_lines:
person_id = person_line.get("person_id")
if not person_id:
continue
amount = Decimal(str(person_line.get("amount", 0)))
if amount <= 0:
continue
description = (person_line.get("description") or "").strip() or None
person_account = _get_person_account(db, document.business_id, int(person_id), is_receivable=is_receipt)
debit_amount = amount if not is_receipt else Decimal(0)
credit_amount = amount if is_receipt else Decimal(0)
line = DocumentLine(
document_id=document.id,
account_id=person_account.id,
person_id=int(person_id),
quantity=person_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info={
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
},
)
db.add(line)
# خطوط حساب‌ها + کارمزدها
total_commission = Decimal(0)
for i, account_line in enumerate(account_lines):
amount = Decimal(str(account_line.get("amount", 0)))
if amount <= 0:
continue
description = (account_line.get("description") or "").strip() or None
transaction_type = account_line.get("transaction_type")
transaction_date = account_line.get("transaction_date")
commission = account_line.get("commission")
if commission:
total_commission += Decimal(str(commission))
# انتخاب حساب بر اساس transaction_type یا account_id
account = None
if transaction_type == "bank":
account = _get_fixed_account_by_code(db, "10203")
elif transaction_type == "cash_register":
account = _get_fixed_account_by_code(db, "10202")
elif transaction_type == "petty_cash":
account = _get_fixed_account_by_code(db, "10201")
elif transaction_type == "check":
account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202")
elif transaction_type == "person":
account = _get_fixed_account_by_code(db, "20201")
elif account_line.get("account_id"):
account = db.query(Account).filter(
and_(
Account.id == int(account_line.get("account_id")),
or_(Account.business_id == document.business_id, Account.business_id == None),
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for transaction_type", http_status=404)
extra_info: Dict[str, Any] = {}
if transaction_type:
extra_info["transaction_type"] = transaction_type
if transaction_date:
extra_info["transaction_date"] = transaction_date
if commission:
extra_info["commission"] = float(commission)
if transaction_type == "bank":
if account_line.get("bank_id"):
extra_info["bank_id"] = account_line.get("bank_id")
if account_line.get("bank_name"):
extra_info["bank_name"] = account_line.get("bank_name")
elif transaction_type == "cash_register":
if account_line.get("cash_register_id"):
extra_info["cash_register_id"] = account_line.get("cash_register_id")
if account_line.get("cash_register_name"):
extra_info["cash_register_name"] = account_line.get("cash_register_name")
elif transaction_type == "petty_cash":
if account_line.get("petty_cash_id"):
extra_info["petty_cash_id"] = account_line.get("petty_cash_id")
if account_line.get("petty_cash_name"):
extra_info["petty_cash_name"] = account_line.get("petty_cash_name")
elif transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = account_line.get("check_id")
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
debit_amount = amount if is_receipt else Decimal(0)
credit_amount = amount if not is_receipt else Decimal(0)
bank_account_id = None
if transaction_type == "bank" and account_line.get("bank_id"):
try:
bank_account_id = int(account_line.get("bank_id"))
except Exception:
bank_account_id = None
person_id_for_line = None
if transaction_type == "person" and account_line.get("person_id"):
try:
person_id_for_line = int(account_line.get("person_id"))
except Exception:
person_id_for_line = None
line = DocumentLine(
document_id=document.id,
account_id=account.id,
person_id=person_id_for_line,
bank_account_id=bank_account_id,
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
quantity=account_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info=extra_info if extra_info else None,
)
db.add(line)
# خطوط کارمزد
if total_commission > 0:
for i, account_line in enumerate(account_lines):
commission = account_line.get("commission")
if not commission or Decimal(str(commission)) <= 0:
continue
commission_amount = Decimal(str(commission))
transaction_type = account_line.get("transaction_type")
commission_account_code = None
if transaction_type == "bank":
commission_account_code = "10203"
elif transaction_type == "cash_register":
commission_account_code = "10202"
elif transaction_type == "petty_cash":
commission_account_code = "10201"
elif transaction_type == "check":
commission_account_code = "10403" if is_receipt else "20202"
elif transaction_type == "person":
commission_account_code = "20201"
if commission_account_code:
commission_account = _get_fixed_account_by_code(db, commission_account_code)
commission_debit = commission_amount if not is_receipt else Decimal(0)
commission_credit = commission_amount if is_receipt else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=commission_account.id,
bank_account_id=account_line.get("bank_id"),
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
"transaction_type": transaction_type,
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
},
))
commission_service_account = _get_fixed_account_by_code(db, "70902")
commission_service_debit = commission_amount if is_receipt else Decimal(0)
commission_service_credit = commission_amount if not is_receipt else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission_service_debit,
credit=commission_service_credit,
description="کارمزد خدمات بانکی",
extra_info={
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
},
))
db.commit()
db.refresh(document)
return document_to_dict(db, document)
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
"""تبدیل سند به دیکشنری"""
# دریافت خطوط سند

View file

@ -399,3 +399,35 @@ msgstr "Storage connection test - to be implemented"
msgid "TEST_STORAGE_CONFIG_ERROR"
msgstr "Error testing storage connection"
# Receipts & Payments
msgid "RECEIPTS_PAYMENTS_LIST_FETCHED"
msgstr "Receipts & payments list retrieved successfully"
msgid "RECEIPT_PAYMENT_CREATED"
msgstr "Receipt/Payment created successfully"
msgid "RECEIPT_PAYMENT_DETAILS"
msgstr "Receipt/Payment details"
msgid "RECEIPT_PAYMENT_DELETED"
msgstr "Receipt/Payment deleted successfully"
# Common errors for receipts/payments
msgid "DOCUMENT_NOT_FOUND"
msgstr "Document not found"
msgid "FORBIDDEN"
msgstr "Access denied"
msgid "FISCAL_YEAR_LOCKED"
msgstr "Document does not belong to the current fiscal year and cannot be deleted"
msgid "DOCUMENT_LOCKED"
msgstr "This document is locked and cannot be deleted"
msgid "DOCUMENT_REFERENCED"
msgstr "This document has dependencies (e.g., check) and cannot be deleted"
msgid "RECEIPT_PAYMENT_UPDATED"
msgstr "Receipt/Payment updated successfully"

View file

@ -422,3 +422,35 @@ msgid "TEST_STORAGE_CONFIG_ERROR"
msgstr "خطا در تست اتصال"
# Receipts & Payments
msgid "RECEIPTS_PAYMENTS_LIST_FETCHED"
msgstr "لیست اسناد دریافت و پرداخت با موفقیت دریافت شد"
msgid "RECEIPT_PAYMENT_CREATED"
msgstr "سند دریافت/پرداخت با موفقیت ایجاد شد"
msgid "RECEIPT_PAYMENT_DETAILS"
msgstr "جزئیات سند دریافت/پرداخت"
msgid "RECEIPT_PAYMENT_DELETED"
msgstr "سند دریافت/پرداخت با موفقیت حذف شد"
# Common errors for receipts/payments
msgid "DOCUMENT_NOT_FOUND"
msgstr "سند یافت نشد"
msgid "FORBIDDEN"
msgstr "دسترسی مجاز نیست"
msgid "FISCAL_YEAR_LOCKED"
msgstr "سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد"
msgid "DOCUMENT_LOCKED"
msgstr "این سند قفل است و قابل حذف نمی‌باشد"
msgid "DOCUMENT_REFERENCED"
msgstr "این سند دارای وابستگی (مانند چک) است و قابل حذف نیست"
msgid "RECEIPT_PAYMENT_UPDATED"
msgstr "سند دریافت/پرداخت با موفقیت ویرایش شد"

View file

@ -23,6 +23,7 @@ class ApiClient {
static Locale? _currentLocale;
static AuthStore? _authStore;
static CalendarController? _calendarController;
static ValueNotifier<int?>? _fiscalYearId;
static void setCurrentLocale(Locale locale) {
_currentLocale = locale;
@ -36,6 +37,11 @@ class ApiClient {
_calendarController = controller;
}
// Fiscal Year binding (allows UI to update selected fiscal year globally)
static void bindFiscalYear(ValueNotifier<int?> fiscalYearId) {
_fiscalYearId = fiscalYearId;
}
ApiClient._(this._dio);
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
@ -71,6 +77,11 @@ class ApiClient {
if (calendarType != null && calendarType.isNotEmpty) {
options.headers['X-Calendar-Type'] = calendarType;
}
// Inject Fiscal Year header if provided
final fyId = _fiscalYearId?.value;
if (fyId != null && fyId > 0) {
options.headers['X-Fiscal-Year-ID'] = fyId.toString();
}
// Inject X-Business-ID header when request targets a specific business
try {
final uri = options.uri;

View file

@ -0,0 +1,33 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FiscalYearController extends ChangeNotifier {
static const String _prefsKey = 'selected_fiscal_year_id';
int? _fiscalYearId;
int? get fiscalYearId => _fiscalYearId;
FiscalYearController._(this._fiscalYearId);
static Future<FiscalYearController> load() async {
final prefs = await SharedPreferences.getInstance();
final id = prefs.getInt(_prefsKey);
return FiscalYearController._(id);
}
Future<void> setFiscalYearId(int? id) async {
if (_fiscalYearId == id) return;
_fiscalYearId = id;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
if (id == null) {
await prefs.remove(_prefsKey);
} else {
await prefs.setInt(_prefsKey, id);
}
}
}

View file

@ -3,6 +3,8 @@ import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../../services/business_dashboard_service.dart';
import '../../../core/api_client.dart';
import '../../../models/business_dashboard_models.dart';
import '../../../core/fiscal_year_controller.dart';
import '../../../widgets/fiscal_year_switcher.dart';
class BusinessDashboardPage extends StatefulWidget {
final int businessId;
@ -14,7 +16,8 @@ class BusinessDashboardPage extends StatefulWidget {
}
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
final BusinessDashboardService _service = BusinessDashboardService(ApiClient());
late final FiscalYearController _fiscalController;
late final BusinessDashboardService _service;
BusinessDashboardResponse? _dashboardData;
bool _loading = true;
String? _error;
@ -22,7 +25,20 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
_fiscalController = await FiscalYearController.load();
_service = BusinessDashboardService(ApiClient(), fiscalYearController: _fiscalController);
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
_fiscalController.addListener(() {
// بهروزرسانی هدر سراسری
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
// رفرش داشبورد
_loadDashboard();
});
await _loadDashboard();
}
Future<void> _loadDashboard() async {
@ -85,10 +101,46 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Row(
children: [
Expanded(
child: Text(
t.businessDashboard,
style: Theme.of(context).textTheme.headlineMedium,
),
),
FutureBuilder<List<Map<String, dynamic>>>(
future: _service.listFiscalYears(widget.businessId),
builder: (context, snapshot) {
final items = snapshot.data ?? const <Map<String, dynamic>>[];
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2));
}
if (items.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.timeline, size: 16),
const SizedBox(width: 6),
FiscalYearSwitcher(
controller: _fiscalController,
fiscalYears: items,
onChanged: () => _loadDashboard(),
),
],
),
);
},
),
],
),
const SizedBox(height: 16),
if (_dashboardData != null) ...[
_buildBusinessInfo(_dashboardData!.businessInfo),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/auth_store.dart';
@ -43,7 +44,9 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
String? _selectedDocumentType;
DateTime? _fromDate;
DateTime? _toDate;
int _refreshKey = 0; // کلید برای تازهسازی جدول
// کلید کنترل جدول برای دسترسی به selection و refresh
final GlobalKey _tableKey = GlobalKey();
int _selectedCount = 0; // تعداد سطرهای انتخابشده
@override
void initState() {
@ -53,9 +56,17 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
/// تازهسازی دادههای جدول
void _refreshData() {
setState(() {
_refreshKey++; // تغییر کلید باعث rebuild شدن جدول میشود
});
final state = _tableKey.currentState;
if (state != null) {
try {
// استفاده از متد عمومی refresh در ویجت جدول
// نوت: دسترسی دینامیک چون State کلاس خصوصی است
// ignore: avoid_dynamic_calls
(state as dynamic).refresh();
return;
} catch (_) {}
}
if (mounted) setState(() {});
}
@override
@ -79,7 +90,7 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DataTableWidget<ReceiptPaymentDocument>(
key: ValueKey(_refreshKey),
key: _tableKey,
config: _buildTableConfig(t),
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
calendarController: widget.calendarController,
@ -223,6 +234,21 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
title: t.receiptsAndPayments,
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
// دکمه حذف گروهی در هدر جدول
customHeaderActions: [
Tooltip(
message: 'حذف انتخاب‌شده‌ها',
child: FilledButton.icon(
onPressed: _selectedCount > 0 ? _onBulkDelete : null,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
),
icon: const Icon(Icons.delete_forever),
label: Text('حذف (${_selectedCount})'),
),
),
],
getExportParams: () => {
'business_id': widget.businessId,
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
@ -336,6 +362,11 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
showPdfExport: true,
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
onRowSelectionChanged: (rows) {
setState(() {
_selectedCount = rows.length;
});
},
additionalParams: {
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
@ -379,13 +410,39 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
}
/// ویرایش سند
void _onEdit(ReceiptPaymentDocument document) {
// TODO: باز کردن صفحه ویرایش سند
void _onEdit(ReceiptPaymentDocument document) async {
try {
// دریافت جزئیات کامل سند
final fullDoc = await _service.getById(document.id);
if (fullDoc == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('ویرایش سند ${document.code}'),
const SnackBar(content: Text('سند یافت نشد')),
);
return;
}
final result = await showDialog<bool>(
context: context,
builder: (_) => BulkSettlementDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isReceipt: fullDoc.isReceipt,
businessInfo: widget.authStore.currentBusiness,
apiClient: widget.apiClient,
initialDocument: fullDoc,
),
);
if (result == true) {
_refreshData();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در آماده‌سازی ویرایش: $e')),
);
}
}
/// حذف سند
@ -394,7 +451,7 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
context: context,
builder: (context) => AlertDialog(
title: const Text('تأیید حذف'),
content: Text('آیا از حذف سند ${document.code} اطمینان دارید؟'),
content: Text('حذف سند ${document.code} غیرقابل بازگشت است. آیا ادامه می‌دهید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -415,30 +472,168 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
/// انجام عملیات حذف
Future<void> _performDelete(ReceiptPaymentDocument document) async {
try {
// نمایش لودینگ هنگام حذف
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final success = await _service.delete(document.id);
if (success) {
if (mounted) {
Navigator.pop(context); // بستن لودینگ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('سند ${document.code} با موفقیت حذف شد'),
backgroundColor: Colors.green,
),
);
setState(() {
_selectedCount = 0; // پاکسازی شمارنده انتخاب پس از حذف
});
_refreshData();
}
} else {
if (mounted) Navigator.pop(context);
throw Exception('خطا در حذف سند');
}
} catch (e) {
if (mounted) {
// بستن لودینگ در صورت بروز خطا
Navigator.pop(context);
String message = 'خطا در حذف سند';
int? statusCode;
if (e is DioException) {
statusCode = e.response?.statusCode;
final data = e.response?.data;
try {
final detail = (data is Map<String, dynamic>) ? data['detail'] : null;
if (detail is Map<String, dynamic>) {
final err = detail['error'];
if (err is Map<String, dynamic>) {
final m = err['message'];
if (m is String && m.trim().isNotEmpty) {
message = m;
}
}
}
} catch (_) {
// ignore parse errors
}
if (statusCode == 404) {
message = 'سند یافت نشد یا قبلاً حذف شده است';
_refreshData();
} else if (statusCode == 403) {
message = 'دسترسی لازم برای حذف این سند را ندارید';
} else if (statusCode == 409) {
// پیام از سرور استخراج شده است (مثلاً سند قفل/دارای وابستگی)
if (message == 'خطا در حذف سند') {
message = 'حذف این سند امکان‌پذیر نیست';
}
}
} else {
message = e.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در حذف سند: $e'),
content: Text(message),
backgroundColor: Colors.red,
),
);
}
}
}
/// حذف گروهی اسناد انتخابشده
Future<void> _onBulkDelete() async {
// استخراج آیتمهای انتخابشده از جدول
final state = _tableKey.currentState;
if (state == null) return;
List<dynamic> selectedItems = const [];
try {
// ignore: avoid_dynamic_calls
selectedItems = (state as dynamic).getSelectedItems();
} catch (_) {}
if (selectedItems.isEmpty) return;
// نگاشت به مدل و شناسهها
final docs = selectedItems.cast<ReceiptPaymentDocument>();
final ids = docs.map((d) => d.id).toList();
final codes = docs.map((d) => d.code).toList();
// تایید کاربر
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('تأیید حذف گروهی'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('تعداد اسناد انتخاب‌شده: ${ids.length}'),
const SizedBox(height: 8),
Text('این عملیات غیرقابل بازگشت است. ادامه می‌دهید؟'),
if (codes.isNotEmpty) ...[
const SizedBox(height: 8),
Text('نمونه کدها: ${codes.take(5).join(', ')}${codes.length > 5 ? ' ...' : ''}'),
],
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('حذف')),
],
);
},
);
if (confirmed != true) return;
// نمایش لودینگ
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
await _service.deleteMultiple(ids);
if (!mounted) return;
Navigator.pop(context); // بستن لودینگ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${ids.length} سند با موفقیت حذف شد'),
backgroundColor: Colors.green,
),
);
setState(() {
_selectedCount = 0; // پاکسازی شمارنده انتخاب پس از حذف گروهی
});
_refreshData();
} catch (e) {
if (!mounted) return;
Navigator.pop(context); // بستن لودینگ
String message = 'خطا در حذف اسناد';
if (e is DioException) {
message = e.message ?? message;
} else {
message = e.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
}
}
class BulkSettlementDialog extends StatefulWidget {
@ -447,6 +642,7 @@ class BulkSettlementDialog extends StatefulWidget {
final bool isReceipt;
final BusinessWithPermission? businessInfo;
final ApiClient apiClient;
final ReceiptPaymentDocument? initialDocument;
const BulkSettlementDialog({
super.key,
required this.businessId,
@ -454,6 +650,7 @@ class BulkSettlementDialog extends StatefulWidget {
required this.isReceipt,
this.businessInfo,
required this.apiClient,
this.initialDocument,
});
@override
@ -472,11 +669,60 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
@override
void initState() {
super.initState();
final initial = widget.initialDocument;
if (initial != null) {
// حالت ویرایش: پرکردن اولیه از سند
_isReceipt = initial.isReceipt;
_docDate = initial.documentDate;
_selectedCurrencyId = initial.currencyId;
// تبدیل خطوط اشخاص
_personLines.clear();
for (final pl in initial.personLines) {
_personLines.add(
_PersonLine(
personId: pl.personId?.toString(),
personName: pl.personName,
amount: pl.amount,
description: pl.description,
),
);
}
// تبدیل خطوط حسابها (حذف خطوط کارمزد)
_centerTransactions.clear();
for (final al in initial.accountLines) {
final isCommission = (al.extraInfo != null && (al.extraInfo!['is_commission_line'] == true));
if (isCommission) continue;
final t = TransactionType.fromValue(al.transactionType ?? '') ?? TransactionType.person;
_centerTransactions.add(
InvoiceTransaction(
id: al.id.toString(),
type: t,
bankId: al.extraInfo?['bank_id']?.toString(),
bankName: al.extraInfo?['bank_name']?.toString(),
cashRegisterId: al.extraInfo?['cash_register_id']?.toString(),
cashRegisterName: al.extraInfo?['cash_register_name']?.toString(),
pettyCashId: al.extraInfo?['petty_cash_id']?.toString(),
pettyCashName: al.extraInfo?['petty_cash_name']?.toString(),
checkId: al.extraInfo?['check_id']?.toString(),
checkNumber: al.extraInfo?['check_number']?.toString(),
personId: al.extraInfo?['person_id']?.toString(),
personName: al.extraInfo?['person_name']?.toString(),
accountId: al.accountId.toString(),
accountName: al.accountName,
transactionDate: al.transactionDate ?? _docDate,
amount: al.amount,
commission: al.commission,
description: al.description,
),
);
}
} else {
// حالت ایجاد
_docDate = DateTime.now();
_isReceipt = widget.isReceipt;
// اگر ارز پیشفرض موجود است، آن را انتخاب کن، در غیر این صورت null بگذار تا CurrencyPickerWidget خودکار انتخاب کند
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
}
}
@override
Widget build(BuildContext context) {
@ -504,6 +750,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
style: Theme.of(context).textTheme.titleLarge,
),
),
if (widget.initialDocument == null)
SegmentedButton<bool>(
segments: [
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
@ -664,7 +911,17 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
},
}).toList();
// ارسال به سرور
// اگر initialDocument وجود دارد، حالت ویرایش
if (widget.initialDocument != null) {
await service.updateReceiptPayment(
documentId: widget.initialDocument!.id,
documentDate: _docDate,
currencyId: _selectedCurrencyId!,
personLines: personLinesData,
accountLines: accountLinesData,
);
} else {
// ایجاد سند جدید
await service.createReceiptPayment(
businessId: widget.businessId,
documentType: _isReceipt ? 'receipt' : 'payment',
@ -673,6 +930,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
personLines: personLinesData,
accountLines: accountLinesData,
);
}
if (!mounted) return;
@ -686,9 +944,9 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isReceipt
? 'سند دریافت با موفقیت ثبت شد'
: 'سند پرداخت با موفقیت ثبت شد',
widget.initialDocument != null
? 'سند با موفقیت ویرایش شد'
: (_isReceipt ? 'سند دریافت با موفقیت ثبت شد' : 'سند پرداخت با موفقیت ثبت شد'),
),
backgroundColor: Colors.green,
),

View file

@ -1,17 +1,23 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/business_dashboard_models.dart';
import '../core/fiscal_year_controller.dart';
class BusinessDashboardService {
final ApiClient _apiClient;
final FiscalYearController? fiscalYearController;
BusinessDashboardService(this._apiClient);
BusinessDashboardService(this._apiClient, {this.fiscalYearController});
/// دریافت داشبورد کسب و کار
Future<BusinessDashboardResponse> getDashboard(int businessId) async {
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard',
options: Options(headers: {
if (fiscalYearController?.fiscalYearId != null)
'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(),
}),
);
if (response.data?['success'] == true) {
@ -62,6 +68,10 @@ class BusinessDashboardService {
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/business/$businessId/statistics',
options: Options(headers: {
if (fiscalYearController?.fiscalYearId != null)
'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(),
}),
);
if (response.data?['success'] == true) {
@ -82,6 +92,13 @@ class BusinessDashboardService {
}
}
Future<List<Map<String, dynamic>>> listFiscalYears(int businessId) async {
final res = await _apiClient.get<Map<String, dynamic>>('/api/v1/business/$businessId/fiscal-years');
final data = res.data as Map<String, dynamic>;
final items = (data['data']?['items'] as List?) ?? const [];
return items.cast<Map<String, dynamic>>();
}
/// دریافت لیست کسب و کارهای کاربر (مالک + عضو)
Future<List<BusinessWithPermission>> getUserBusinesses() async {
try {

View file

@ -98,6 +98,28 @@ class ReceiptPaymentService {
);
}
/// ویرایش سند دریافت/پرداخت
Future<Map<String, dynamic>> updateReceiptPayment({
required int documentId,
required DateTime documentDate,
required int currencyId,
required List<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
Map<String, dynamic>? extraInfo,
}) async {
final response = await _apiClient.put(
'/receipts-payments/$documentId',
data: {
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
'person_lines': personLines,
'account_lines': accountLines,
if (extraInfo != null) 'extra_info': extraInfo,
},
);
return response.data['data'] as Map<String, dynamic>;
}
/// ایجاد سند دریافت
///
/// این متد یک wrapper ساده برای createReceiptPayment است

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../core/fiscal_year_controller.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
class FiscalYearSwitcher extends StatelessWidget {
final FiscalYearController controller;
final List<Map<String, dynamic>> fiscalYears; // [{id, title, start_date, end_date, is_current}]
final VoidCallback? onChanged; // برای رفرش دیتای داشبورد بعد از تغییر
const FiscalYearSwitcher({super.key, required this.controller, required this.fiscalYears, this.onChanged});
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final int? selectedId = controller.fiscalYearId ?? _currentDefaultId();
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: selectedId,
icon: const Icon(Icons.expand_more, size: 18),
items: fiscalYears.map((fy) {
final id = fy['id'] as int;
final title = (fy['title'] as String?) ?? id.toString();
return DropdownMenuItem<int>(
value: id,
child: Text(title, overflow: TextOverflow.ellipsis),
);
}).toList(),
onChanged: (id) async {
await controller.setFiscalYearId(id);
onChanged?.call();
},
hint: const Text('سال مالی'),
),
);
}
int? _currentDefaultId() {
try {
final current = fiscalYears.firstWhere((e) => e['is_current'] == true, orElse: () => {});
return (current['id'] as int?);
} catch (_) {
return null;
}
}
}