diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py
new file mode 100644
index 0000000..4f8f62e
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/accounts.py
@@ -0,0 +1,57 @@
+from typing import List, Dict, Any
+
+from fastapi import APIRouter, Depends, Request
+from sqlalchemy.orm import Session
+
+from adapters.db.session import get_db
+from adapters.api.v1.schemas import SuccessResponse
+from adapters.api.v1.schema_models.account import AccountTreeNode
+from app.core.responses import success_response
+from app.core.auth_dependency import get_current_user, AuthContext
+from app.core.permissions import require_business_access
+from adapters.db.models.account import Account
+
+
+router = APIRouter(prefix="/accounts", tags=["accounts"])
+
+
+def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
+ by_id: dict[int, AccountTreeNode] = {}
+ roots: list[AccountTreeNode] = []
+ for n in nodes:
+ node = AccountTreeNode(
+ id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
+ )
+ by_id[node.id] = node
+ for node in list(by_id.values()):
+ pid = node.parent_id
+ if pid and pid in by_id:
+ by_id[pid].children.append(node)
+ else:
+ roots.append(node)
+ return roots
+
+
+@router.get("/business/{business_id}/tree",
+ summary="دریافت درخت حسابها برای یک کسب و کار",
+ description="لیست حسابهای عمومی و حسابهای اختصاصی کسب و کار به صورت درختی",
+)
+@require_business_access("business_id")
+def get_accounts_tree(
+ request: Request,
+ business_id: int,
+ ctx: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db)
+) -> dict:
+ # دریافت حسابهای عمومی (business_id IS NULL) و حسابهای مختص این کسب و کار
+ rows = db.query(Account).filter(
+ (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
+ ).order_by(Account.code.asc()).all()
+ flat = [
+ {"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
+ for r in rows
+ ]
+ tree = _build_tree(flat)
+ return success_response({"items": [n.model_dump() for n in tree]}, request)
+
+
diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py
index 5a7783a..1e5645a 100644
--- a/hesabixAPI/adapters/api/v1/persons.py
+++ b/hesabixAPI/adapters/api/v1/persons.py
@@ -1,6 +1,6 @@
-from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body
from sqlalchemy.orm import Session
-from typing import Dict, Any
+from typing import Dict, Any, List, Optional
from adapters.db.session import get_db
from adapters.api.v1.schema_models.person import (
@@ -16,6 +16,7 @@ from app.services.person_service import (
update_person, delete_person, get_person_summary
)
from adapters.db.models.person import Person
+from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"])
@@ -132,6 +133,332 @@ async def get_persons_endpoint(
)
+@router.post("/businesses/{business_id}/persons/export/excel",
+ summary="خروجی Excel لیست اشخاص",
+ description="خروجی Excel لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+async def export_persons_excel(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import io
+ import json
+ import datetime
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from fastapi.responses import Response
+
+ # Build query dict similar to list endpoint from flat body
+ query_dict = {
+ "take": int(body.get("take", 20)),
+ "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"),
+ }
+
+ result = get_persons_by_business(db, business_id, query_dict)
+
+ items = result.get('items', [])
+ # Format date/time fields using existing helper
+ items = [format_datetime_fields(item, request) for item in items]
+
+ # Apply selected indices filter if requested
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
+
+ # Prepare headers based on export_columns (order + visibility)
+ 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:
+ # Fallback to item keys if no columns provided
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+
+ # Create workbook
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Persons"
+
+ header_font = Font(bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
+ header_alignment = Alignment(horizontal="center", vertical="center")
+ border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
+
+ # Write header row
+ for col_idx, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col_idx, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+ cell.border = border
+
+ # Write data rows
+ for row_idx, item in enumerate(items, 2):
+ for col_idx, key in enumerate(keys, 1):
+ value = item.get(key, "")
+ if isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ ws.cell(row=row_idx, column=col_idx, value=value).border = border
+
+ # Auto-width columns
+ for column in ws.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ try:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ except Exception:
+ pass
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+
+ # Save to bytes
+ buffer = io.BytesIO()
+ wb.save(buffer)
+ buffer.seek(0)
+
+ filename = f"persons_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ content = buffer.getvalue()
+ return Response(
+ content=content,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f"attachment; filename={filename}",
+ "Content-Length": str(len(content)),
+ },
+ )
+
+
+@router.post("/businesses/{business_id}/persons/export/pdf",
+ summary="خروجی PDF لیست اشخاص",
+ description="خروجی PDF لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
+)
+async def export_persons_pdf(
+ business_id: int,
+ request: Request,
+ body: Dict[str, Any] = Body(...),
+ auth_context: AuthContext = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ import json
+ import datetime
+ from fastapi.responses import Response
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+
+ # Build query dict from flat body
+ query_dict = {
+ "take": int(body.get("take", 20)),
+ "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"),
+ }
+
+ result = get_persons_by_business(db, business_id, query_dict)
+ items = result.get('items', [])
+ items = [format_datetime_fields(item, request) for item in items]
+
+ selected_only = bool(body.get('selected_only', False))
+ selected_indices = body.get('selected_indices')
+ if selected_only and selected_indices is not None:
+ indices = None
+ if isinstance(selected_indices, str):
+ try:
+ indices = json.loads(selected_indices)
+ except (json.JSONDecodeError, TypeError):
+ indices = None
+ elif isinstance(selected_indices, list):
+ indices = selected_indices
+ if isinstance(indices, list):
+ items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(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:
+ if items:
+ keys = list(items[0].keys())
+ headers = keys
+
+ # Load business info for header
+ business_name = ""
+ try:
+ biz = db.query(Business).filter(Business.id == business_id).first()
+ if biz is not None:
+ business_name = biz.name
+ except Exception:
+ business_name = ""
+
+ # Styled HTML (A4 landscape, RTL)
+ def escape(s: Any) -> str:
+ try:
+ return str(s).replace('&', '&').replace('<', '<').replace('>', '>')
+ except Exception:
+ return str(s)
+
+ rows_html = []
+ for item in items:
+ tds = []
+ for key in keys:
+ value = item.get(key)
+ if value is None:
+ value = ""
+ elif isinstance(value, list):
+ value = ", ".join(str(v) for v in value)
+ tds.append(f"
{escape(value)} | ")
+ rows_html.append(f"{''.join(tds)}
")
+
+ headers_html = ''.join(f"{escape(h)} | " for h in headers)
+ # Format report datetime based on X-Calendar-Type header
+ calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower()
+ try:
+ from app.core.calendar import CalendarConverter
+ formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(),
+ "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian")
+ now = formatted_now.get('formatted', formatted_now.get('date_time', ''))
+ except Exception:
+ now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
+ table_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {headers_html}
+
+
+ {''.join(rows_html)}
+
+
+
+
+
+
+ """
+
+ font_config = FontConfiguration()
+ pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
+
+ filename = f"persons_export_{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)),
+ },
+ )
+
+
@router.get("/persons/{person_id}",
summary="جزئیات شخص",
description="دریافت جزئیات یک شخص",
diff --git a/hesabixAPI/adapters/api/v1/schema_models/account.py b/hesabixAPI/adapters/api/v1/schema_models/account.py
new file mode 100644
index 0000000..581b091
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/schema_models/account.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+
+class AccountTreeNode(BaseModel):
+ id: int = Field(..., description="ID حساب")
+ code: str = Field(..., description="کد حساب")
+ name: str = Field(..., description="نام حساب")
+ account_type: Optional[str] = Field(default=None, description="نوع حساب")
+ parent_id: Optional[int] = Field(default=None, description="شناسه والد")
+ level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
+ children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
+
+ class Config:
+ from_attributes = True
+
+
diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py
index cffb79d..31ca7b4 100644
--- a/hesabixAPI/adapters/api/v1/schema_models/person.py
+++ b/hesabixAPI/adapters/api/v1/schema_models/person.py
@@ -12,6 +12,7 @@ class PersonType(str, Enum):
SUPPLIER = "تامینکننده"
PARTNER = "همکار"
SELLER = "فروشنده"
+ SHAREHOLDER = "سهامدار"
class PersonBankAccountCreateRequest(BaseModel):
@@ -78,6 +79,38 @@ class PersonCreateRequest(BaseModel):
# حسابهای بانکی
bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حسابهای بانکی")
+ # سهام
+ share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار، اجباری و حداقل 1)")
+ # پورسانت (برای بازاریاب/فروشنده)
+ commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @staticmethod
+ def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
+ if person_type == PersonType.SHAREHOLDER:
+ return True
+ if person_types:
+ return PersonType.SHAREHOLDER in person_types
+ return False
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ # اعتبارسنجی شرطی سهامدار
+ if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
+ sc = getattr(obj, 'share_count', None)
+ if sc is None or (isinstance(sc, int) and sc <= 0):
+ raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
+ return obj
class PersonUpdateRequest(BaseModel):
@@ -111,6 +144,38 @@ class PersonUpdateRequest(BaseModel):
# وضعیت
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
+ # سهام
+ share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)")
+ # پورسانت
+ commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=None, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=None, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=None, description="ثبت پورسانت در سند فاکتور")
+
+ @classmethod
+ def __get_validators__(cls):
+ yield from super().__get_validators__()
+
+ @staticmethod
+ def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool:
+ if person_type == PersonType.SHAREHOLDER:
+ return True
+ if person_types:
+ return PersonType.SHAREHOLDER in person_types
+ return False
+
+ @classmethod
+ def validate(cls, value): # type: ignore[override]
+ obj = super().validate(value)
+ # اگر ورودیها مشخصاً به سهامدار اشاره دارند، share_count باید معتبر باشد
+ if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)):
+ sc = getattr(obj, 'share_count', None)
+ if sc is None or (isinstance(sc, int) and sc <= 0):
+ raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد")
+ return obj
class PersonResponse(BaseModel):
@@ -154,6 +219,16 @@ class PersonResponse(BaseModel):
# حسابهای بانکی
bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حسابهای بانکی")
+ # سهام
+ share_count: Optional[int] = Field(default=None, description="تعداد سهام")
+ # پورسانت
+ commission_sale_percent: Optional[float] = Field(default=None, description="درصد پورسانت از فروش")
+ commission_sales_return_percent: Optional[float] = Field(default=None, description="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Optional[float] = Field(default=None, description="مبلغ فروش مبنا")
+ commission_sales_return_amount: Optional[float] = Field(default=None, description="مبلغ برگشت از فروش مبنا")
+ commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف")
+ commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
+ commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
class Config:
from_attributes = True
diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py
index 2532062..53a9b85 100644
--- a/hesabixAPI/adapters/api/v1/schemas.py
+++ b/hesabixAPI/adapters/api/v1/schemas.py
@@ -1,7 +1,7 @@
from typing import Any, List, Optional, Union, Generic, TypeVar
from pydantic import BaseModel, EmailStr, Field
from enum import Enum
-from datetime import datetime
+from datetime import datetime, date
T = TypeVar('T')
@@ -177,6 +177,7 @@ class BusinessCreateRequest(BaseModel):
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
+ fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سالهای مالی برای ایجاد اولیه")
class BusinessUpdateRequest(BaseModel):
@@ -248,6 +249,14 @@ class PaginatedResponse(BaseModel, Generic[T]):
)
+# Fiscal Year Schemas
+class FiscalYearCreate(BaseModel):
+ title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی")
+ start_date: date = Field(..., description="تاریخ شروع سال مالی")
+ end_date: date = Field(..., description="تاریخ پایان سال مالی")
+ is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟")
+
+
# Business User Schemas
class BusinessUserSchema(BaseModel):
id: int
diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py
index b505ba6..610b9e5 100644
--- a/hesabixAPI/adapters/db/models/__init__.py
+++ b/hesabixAPI/adapters/db/models/__init__.py
@@ -20,3 +20,13 @@ from .file_storage import *
from .email_config import EmailConfig # noqa: F401, F403
+# Accounting / Fiscal models
+from .fiscal_year import FiscalYear # noqa: F401
+
+# Currency models
+from .currency import Currency, BusinessCurrency # noqa: F401
+
+# Documents
+from .document import Document # noqa: F401
+from .document_line import DocumentLine # noqa: F401
+from .account import Account # noqa: F401
diff --git a/hesabixAPI/adapters/db/models/account.py b/hesabixAPI/adapters/db/models/account.py
new file mode 100644
index 0000000..a5e38a6
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/account.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Account(Base):
+ __tablename__ = "accounts"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_accounts_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
+ business_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=True, index=True)
+ account_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ code: Mapped[str] = mapped_column(String(50), nullable=False)
+ parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=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 = relationship("Business", back_populates="accounts")
+ parent = relationship("Account", remote_side="Account.id", back_populates="children")
+ children = relationship("Account", back_populates="parent", cascade="all, delete-orphan")
+ document_lines = relationship("DocumentLine", back_populates="account")
+
+
diff --git a/hesabixAPI/adapters/db/models/business.py b/hesabixAPI/adapters/db/models/business.py
index b3813e2..befab27 100644
--- a/hesabixAPI/adapters/db/models/business.py
+++ b/hesabixAPI/adapters/db/models/business.py
@@ -56,3 +56,7 @@ class Business(Base):
# Relationships
persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")
+ fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
+ currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
+ documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
+ accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")
diff --git a/hesabixAPI/adapters/db/models/currency.py b/hesabixAPI/adapters/db/models/currency.py
new file mode 100644
index 0000000..776fd05
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/currency.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Currency(Base):
+ __tablename__ = "currencies"
+ __table_args__ = (
+ UniqueConstraint('name', name='uq_currencies_name'),
+ UniqueConstraint('code', name='uq_currencies_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ title: Mapped[str] = mapped_column(String(100), nullable=False)
+ symbol: Mapped[str] = mapped_column(String(16), nullable=False)
+ code: Mapped[str] = mapped_column(String(16), nullable=False) # نام کوتاه
+ 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
+ businesses = relationship("Business", secondary="business_currencies", back_populates="currencies")
+ documents = relationship("Document", back_populates="currency")
+
+
+class BusinessCurrency(Base):
+ __tablename__ = "business_currencies"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'currency_id', name='uq_business_currencies_business_currency'),
+ )
+
+ 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)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="CASCADE"), nullable=False, index=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/document.py b/hesabixAPI/adapters/db/models/document.py
new file mode 100644
index 0000000..6d290cd
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/document.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class Document(Base):
+ __tablename__ = "documents"
+ __table_args__ = (
+ UniqueConstraint('business_id', 'code', name='uq_documents_business_code'),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
+ currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
+ created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
+ registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
+ document_date: Mapped[date] = mapped_column(Date, nullable=False)
+ document_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ developer_settings: Mapped[dict | None] = mapped_column(JSON, 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 = relationship("Business", back_populates="documents")
+ currency = relationship("Currency", back_populates="documents")
+ created_by = relationship("User", foreign_keys=[created_by_user_id])
+ lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
+
+
diff --git a/hesabixAPI/adapters/db/models/document_line.py b/hesabixAPI/adapters/db/models/document_line.py
new file mode 100644
index 0000000..494012b
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/document_line.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import Integer, DateTime, ForeignKey, JSON, Text, Numeric
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class DocumentLine(Base):
+ __tablename__ = "document_lines"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
+ account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
+ debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
+ credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ developer_data: Mapped[dict | None] = mapped_column(JSON, 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
+ document = relationship("Document", back_populates="lines")
+ account = relationship("Account", back_populates="document_lines")
+
+
diff --git a/hesabixAPI/adapters/db/models/fiscal_year.py b/hesabixAPI/adapters/db/models/fiscal_year.py
new file mode 100644
index 0000000..a3026b7
--- /dev/null
+++ b/hesabixAPI/adapters/db/models/fiscal_year.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from sqlalchemy import String, Date, DateTime, Integer, Boolean, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from adapters.db.session import Base
+
+
+class FiscalYear(Base):
+ __tablename__ = "fiscal_years"
+
+ 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)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ start_date: Mapped[date] = mapped_column(Date, nullable=False)
+ end_date: Mapped[date] = mapped_column(Date, nullable=False)
+ is_last: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ 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 = relationship("Business", back_populates="fiscal_years")
+
+
diff --git a/hesabixAPI/adapters/db/models/person.py b/hesabixAPI/adapters/db/models/person.py
index 0a3cfea..f130c07 100644
--- a/hesabixAPI/adapters/db/models/person.py
+++ b/hesabixAPI/adapters/db/models/person.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
-from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint
+from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint, Numeric, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
@@ -17,6 +17,7 @@ class PersonType(str, Enum):
SUPPLIER = "تامینکننده" # تامینکننده
PARTNER = "همکار" # همکار
SELLER = "فروشنده" # فروشنده
+ SHAREHOLDER = "سهامدار" # سهامدار
class Person(Base):
@@ -33,10 +34,25 @@ class Person(Base):
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
- person_type: Mapped[PersonType] = mapped_column(SQLEnum(PersonType), nullable=False, comment="نوع شخص")
+ person_type: Mapped[PersonType] = mapped_column(
+ SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
+ nullable=False,
+ comment="نوع شخص"
+ )
person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
+ # سهام
+ share_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="تعداد سهام (فقط برای سهامدار)")
+
+ # تنظیمات پورسانت برای بازاریاب/فروشنده
+ commission_sale_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از فروش")
+ commission_sales_return_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از برگشت از فروش")
+ commission_sales_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ فروش مبنا برای پورسانت")
+ commission_sales_return_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ برگشت از فروش مبنا برای پورسانت")
+ commission_exclude_discounts: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه تخفیف در پورسانت")
+ commission_exclude_additions_deductions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه اضافات و کسورات فاکتور در پورسانت")
+ commission_post_in_invoice_document: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="ثبت پورسانت در سند حسابداری فاکتور")
# اطلاعات اقتصادی
national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی")
diff --git a/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py
new file mode 100644
index 0000000..b4cf059
--- /dev/null
+++ b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from datetime import date
+from sqlalchemy.orm import Session
+
+from .base_repo import BaseRepository
+from ..models.fiscal_year import FiscalYear
+
+
+class FiscalYearRepository(BaseRepository[FiscalYear]):
+ """Repository برای مدیریت سالهای مالی"""
+
+ def __init__(self, db: Session) -> None:
+ super().__init__(db, FiscalYear)
+
+ def create_fiscal_year(
+ self,
+ *,
+ business_id: int,
+ title: str,
+ start_date: date,
+ end_date: date,
+ is_last: bool = True,
+ ) -> FiscalYear:
+ fiscal_year = FiscalYear(
+ business_id=business_id,
+ title=title,
+ start_date=start_date,
+ end_date=end_date,
+ is_last=is_last,
+ )
+ self.db.add(fiscal_year)
+ self.db.commit()
+ self.db.refresh(fiscal_year)
+ return fiscal_year
+
+
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index 7c6b342..9ac253c 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -9,6 +9,7 @@ from adapters.api.v1.users import router as users_router
from adapters.api.v1.businesses import router as businesses_router
from adapters.api.v1.business_dashboard import router as business_dashboard_router
from adapters.api.v1.business_users import router as business_users_router
+from adapters.api.v1.accounts import router as accounts_router
from adapters.api.v1.persons import router as persons_router
from adapters.api.v1.support.tickets import router as support_tickets_router
from adapters.api.v1.support.operator import router as support_operator_router
@@ -274,6 +275,7 @@ def create_app() -> FastAPI:
application.include_router(businesses_router, prefix=settings.api_v1_prefix)
application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
application.include_router(business_users_router, prefix=settings.api_v1_prefix)
+ application.include_router(accounts_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix)
# Support endpoints
diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py
index 9fc62b1..42b3fb4 100644
--- a/hesabixAPI/app/services/business_service.py
+++ b/hesabixAPI/app/services/business_service.py
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func
from adapters.db.repositories.business_repo import BusinessRepository
+from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.business import Business, BusinessType, BusinessField
from adapters.api.v1.schemas import (
@@ -17,6 +18,7 @@ from app.core.responses import format_datetime_fields
def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: int) -> Dict[str, Any]:
"""ایجاد کسب و کار جدید"""
business_repo = BusinessRepository(db)
+ fiscal_repo = FiscalYearRepository(db)
# تبدیل enum values به مقادیر فارسی
# business_data.business_type و business_data.business_field قبلاً مقادیر فارسی هستند
@@ -41,6 +43,22 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
postal_code=business_data.postal_code
)
+ # ایجاد سالهای مالی اولیه (در صورت ارسال)
+ if getattr(business_data, "fiscal_years", None):
+ # فقط یک سال با is_last=True نگه داریم (آخرین مورد True باشد)
+ last_true_index = None
+ for idx, fy in enumerate(business_data.fiscal_years or []):
+ if fy.is_last:
+ last_true_index = idx
+ for idx, fy in enumerate(business_data.fiscal_years or []):
+ fiscal_repo.create_fiscal_year(
+ business_id=created_business.id,
+ title=fy.title,
+ start_date=fy.start_date,
+ end_date=fy.end_date,
+ is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1)
+ )
+
# تبدیل به response format
return _business_to_dict(created_business)
diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py
index 4d27c3d..6c26dba 100644
--- a/hesabixAPI/app/services/person_service.py
+++ b/hesabixAPI/app/services/person_service.py
@@ -34,14 +34,39 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
t = person_data.person_type
types_list = [t.value if hasattr(t, 'value') else str(t)]
+ # اعتبارسنجی سهام برای سهامدار
+ is_shareholder = False
+ if types_list:
+ is_shareholder = 'سهامدار' in types_list
+ if not is_shareholder and incoming_single_type is not None:
+ try:
+ is_shareholder = (getattr(incoming_single_type, 'value', str(incoming_single_type)) == 'سهامدار')
+ except Exception:
+ is_shareholder = False
+ if is_shareholder:
+ sc_val = getattr(person_data, 'share_count', None)
+ if sc_val is None or not isinstance(sc_val, int) or sc_val <= 0:
+ raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
+
# ایجاد شخص
+ # نگاشت person_type دریافتی از اسکیما به Enum مدل
+ incoming_single_type = getattr(person_data, 'person_type', None)
+ mapped_single_type = None
+ if incoming_single_type is not None:
+ try:
+ # incoming_single_type.value مقدار فارسی مانند "سهامدار"
+ mapped_single_type = PersonType(getattr(incoming_single_type, 'value', str(incoming_single_type)))
+ except Exception:
+ mapped_single_type = None
+
person = Person(
business_id=business_id,
code=code,
alias_name=person_data.alias_name,
first_name=person_data.first_name,
last_name=person_data.last_name,
- person_type=person_data.person_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER),
+ # ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
+ person_type=(mapped_single_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER)),
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
company_name=person_data.company_name,
payment_id=person_data.payment_id,
@@ -58,6 +83,14 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
fax=person_data.fax,
email=person_data.email,
website=person_data.website,
+ share_count=getattr(person_data, 'share_count', None),
+ commission_sale_percent=getattr(person_data, 'commission_sale_percent', None),
+ commission_sales_return_percent=getattr(person_data, 'commission_sales_return_percent', None),
+ commission_sales_amount=getattr(person_data, 'commission_sales_amount', None),
+ commission_sales_return_amount=getattr(person_data, 'commission_sales_return_amount', None),
+ commission_exclude_discounts=bool(getattr(person_data, 'commission_exclude_discounts', False)),
+ commission_exclude_additions_deductions=bool(getattr(person_data, 'commission_exclude_additions_deductions', False)),
+ commission_post_in_invoice_document=bool(getattr(person_data, 'commission_post_in_invoice_document', False)),
)
db.add(person)
@@ -333,11 +366,37 @@ def update_person(
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
# همگام کردن person_type تکی برای سازگاری
if types_list:
+ # مقدار Enum را با مقدار فارسی ست میکنیم
try:
person.person_type = PersonType(types_list[0])
except Exception:
pass
+ # مدیریت person_type تکی از اسکیما
+ if 'person_type' in update_data and update_data['person_type'] is not None:
+ single_type = update_data['person_type']
+ # نگاشت به Enum (مقدار فارسی)
+ try:
+ person.person_type = PersonType(getattr(single_type, 'value', str(single_type)))
+ except Exception:
+ pass
+ # پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود
+ update_data.pop('person_type', None)
+
+ # اگر شخص سهامدار شد، share_count معتبر باشد
+ resulting_types: List[str] = []
+ if person.person_types:
+ try:
+ tmp = json.loads(person.person_types)
+ if isinstance(tmp, list):
+ resulting_types = [str(x) for x in tmp]
+ except Exception:
+ resulting_types = []
+ if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
+ sc_val2 = update_data.get('share_count', person.share_count)
+ if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
+ raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
+
# سایر فیلدها
for field in list(update_data.keys()):
if field in {'code', 'person_types'}:
@@ -416,6 +475,14 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
'person_types': types_list,
'company_name': person.company_name,
'payment_id': person.payment_id,
+ 'share_count': person.share_count,
+ 'commission_sale_percent': float(person.commission_sale_percent) if getattr(person, 'commission_sale_percent', None) is not None else None,
+ 'commission_sales_return_percent': float(person.commission_sales_return_percent) if getattr(person, 'commission_sales_return_percent', None) is not None else None,
+ 'commission_sales_amount': float(person.commission_sales_amount) if getattr(person, 'commission_sales_amount', None) is not None else None,
+ 'commission_sales_return_amount': float(person.commission_sales_return_amount) if getattr(person, 'commission_sales_return_amount', None) is not None else None,
+ 'commission_exclude_discounts': bool(person.commission_exclude_discounts),
+ 'commission_exclude_additions_deductions': bool(person.commission_exclude_additions_deductions),
+ 'commission_post_in_invoice_document': bool(person.commission_post_in_invoice_document),
'national_id': person.national_id,
'registration_number': person.registration_number,
'economic_id': person.economic_id,
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index 2740258..9129a0c 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -3,6 +3,7 @@ pyproject.toml
adapters/__init__.py
adapters/api/__init__.py
adapters/api/v1/__init__.py
+adapters/api/v1/accounts.py
adapters/api/v1/auth.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
@@ -14,6 +15,7 @@ adapters/api/v1/users.py
adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py
+adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
@@ -27,12 +29,17 @@ adapters/api/v1/support/tickets.py
adapters/db/__init__.py
adapters/db/session.py
adapters/db/models/__init__.py
+adapters/db/models/account.py
adapters/db/models/api_key.py
adapters/db/models/business.py
adapters/db/models/business_permission.py
adapters/db/models/captcha.py
+adapters/db/models/currency.py
+adapters/db/models/document.py
+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/password_reset.py
adapters/db/models/person.py
adapters/db/models/user.py
@@ -48,6 +55,7 @@ adapters/db/repositories/business_permission_repo.py
adapters/db/repositories/business_repo.py
adapters/db/repositories/email_config_repository.py
adapters/db/repositories/file_storage_repository.py
+adapters/db/repositories/fiscal_year_repo.py
adapters/db/repositories/password_reset_repo.py
adapters/db/repositories/user_repo.py
adapters/db/repositories/support/__init__.py
@@ -104,7 +112,20 @@ migrations/versions/20250915_000001_init_auth_tables.py
migrations/versions/20250916_000002_add_referral_fields.py
migrations/versions/20250926_000010_add_person_code_and_types.py
migrations/versions/20250926_000011_drop_person_is_active.py
+migrations/versions/20250927_000012_add_fiscal_years_table.py
+migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
+migrations/versions/20250927_000014_add_documents_table.py
+migrations/versions/20250927_000015_add_document_lines_table.py
+migrations/versions/20250927_000016_add_accounts_table.py
+migrations/versions/20250927_000017_add_account_id_to_document_lines.py
+migrations/versions/20250927_000018_seed_currencies.py
+migrations/versions/20250927_000019_seed_accounts_chart.py
+migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
+migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
+migrations/versions/20250927_000022_add_person_commission_fields.py
migrations/versions/5553f8745c6e_add_support_tables.py
+migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
+migrations/versions/f876bfa36805_merge_multiple_heads.py
tests/__init__.py
tests/test_health.py
tests/test_permissions.py
\ No newline at end of file
diff --git a/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py b/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py
index 0531336..739c141 100644
--- a/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py
+++ b/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py
@@ -2,6 +2,7 @@ from __future__ import annotations
from alembic import op
import sqlalchemy as sa
+from sqlalchemy import inspect
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
@@ -12,23 +13,30 @@ depends_on = None
def upgrade() -> None:
- # Create businesses table
- op.create_table(
- 'businesses',
- sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
- sa.Column('name', sa.String(length=255), nullable=False),
- sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False),
- sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False),
- sa.Column('owner_id', sa.Integer(), nullable=False),
- sa.Column('created_at', sa.DateTime(), nullable=False),
- sa.Column('updated_at', sa.DateTime(), nullable=False),
- sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id')
- )
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ # Create businesses table if not exists
+ if 'businesses' not in inspector.get_table_names():
+ op.create_table(
+ 'businesses',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False),
+ sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False),
+ sa.Column('owner_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
- # Create indexes
- op.create_index('ix_businesses_name', 'businesses', ['name'])
- op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id'])
+ # Create indexes if not exists
+ existing_indexes = {idx['name'] for idx in inspector.get_indexes('businesses')} if 'businesses' in inspector.get_table_names() else set()
+ if 'ix_businesses_name' not in existing_indexes:
+ op.create_index('ix_businesses_name', 'businesses', ['name'])
+ if 'ix_businesses_owner_id' not in existing_indexes:
+ op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id'])
def downgrade() -> None:
diff --git a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py
index 2badcdf..e65145b 100644
--- a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py
+++ b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py
@@ -1,5 +1,6 @@
from alembic import op
import sqlalchemy as sa
+from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '20250926_000010_add_person_code_and_types'
@@ -9,10 +10,18 @@ depends_on = None
def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
with op.batch_alter_table('persons') as batch_op:
- batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
- batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True))
- batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code'])
+ if 'code' not in cols:
+ batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
+ if 'person_types' not in cols:
+ batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True))
+ # unique constraint if not exists
+ existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('persons')}
+ if 'uq_persons_business_code' not in existing_uniques:
+ batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code'])
def downgrade() -> None:
diff --git a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py
new file mode 100644
index 0000000..e3cc8f0
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000012_add_fiscal_years_table'
+down_revision = '20250926_000011_drop_person_is_active'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ # Create fiscal_years table if not exists
+ if 'fiscal_years' not in inspector.get_table_names():
+ op.create_table(
+ 'fiscal_years',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('start_date', sa.Date(), nullable=False),
+ sa.Column('end_date', sa.Date(), nullable=False),
+ sa.Column('is_last', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Indexes if not exists
+ existing_indexes = {idx['name'] for idx in inspector.get_indexes('fiscal_years')} if 'fiscal_years' in inspector.get_table_names() else set()
+ if 'ix_fiscal_years_business_id' not in existing_indexes:
+ op.create_index('ix_fiscal_years_business_id', 'fiscal_years', ['business_id'])
+ if 'ix_fiscal_years_title' not in existing_indexes:
+ op.create_index('ix_fiscal_years_title', 'fiscal_years', ['title'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_fiscal_years_title', table_name='fiscal_years')
+ op.drop_index('ix_fiscal_years_business_id', table_name='fiscal_years')
+ op.drop_table('fiscal_years')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
new file mode 100644
index 0000000..12b61f5
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000013_add_currencies_and_business_currencies'
+down_revision = '20250927_000012_add_fiscal_years_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create currencies table
+ op.create_table(
+ 'currencies',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('title', sa.String(length=100), nullable=False),
+ sa.Column('symbol', sa.String(length=16), nullable=False),
+ sa.Column('code', sa.String(length=16), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+ # Unique constraints and indexes
+ op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
+ op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
+ op.create_index('ix_currencies_name', 'currencies', ['name'])
+
+ # Create business_currencies association table
+ op.create_table(
+ 'business_currencies',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+ # Unique and indexes for association
+ op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
+ op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
+ op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
+ op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
+ op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
+ op.drop_table('business_currencies')
+
+ op.drop_index('ix_currencies_name', table_name='currencies')
+ op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
+ op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
+ op.drop_table('currencies')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py b/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py
new file mode 100644
index 0000000..8f94d86
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000014_add_documents_table'
+down_revision = '20250927_000013_add_currencies_and_business_currencies'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create documents table
+ op.create_table(
+ 'documents',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=False),
+ sa.Column('currency_id', sa.Integer(), nullable=False),
+ sa.Column('created_by_user_id', sa.Integer(), nullable=False),
+ sa.Column('registered_at', sa.DateTime(), nullable=False),
+ sa.Column('document_date', sa.Date(), nullable=False),
+ sa.Column('document_type', sa.String(length=50), nullable=False),
+ sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default=sa.text('0')),
+ sa.Column('extra_info', sa.JSON(), nullable=True),
+ sa.Column('developer_settings', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
+ sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ # Unique per business code
+ op.create_unique_constraint('uq_documents_business_code', 'documents', ['business_id', 'code'])
+
+ # Indexes
+ op.create_index('ix_documents_code', 'documents', ['code'])
+ op.create_index('ix_documents_business_id', 'documents', ['business_id'])
+ op.create_index('ix_documents_currency_id', 'documents', ['currency_id'])
+ op.create_index('ix_documents_created_by_user_id', 'documents', ['created_by_user_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_documents_created_by_user_id', table_name='documents')
+ op.drop_index('ix_documents_currency_id', table_name='documents')
+ op.drop_index('ix_documents_business_id', table_name='documents')
+ op.drop_index('ix_documents_code', table_name='documents')
+ op.drop_constraint('uq_documents_business_code', 'documents', type_='unique')
+ op.drop_table('documents')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py b/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py
new file mode 100644
index 0000000..be3bbc8
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000015_add_document_lines_table'
+down_revision = '20250927_000014_add_documents_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'document_lines',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('document_id', sa.Integer(), nullable=False),
+ sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
+ sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('extra_info', sa.JSON(), nullable=True),
+ sa.Column('developer_data', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_document_lines_document_id', table_name='document_lines')
+ op.drop_table('document_lines')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py
new file mode 100644
index 0000000..236159f
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000016_add_accounts_table'
+down_revision = '20250927_000015_add_document_lines_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'accounts',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('business_id', sa.Integer(), nullable=True),
+ sa.Column('account_type', sa.String(length=50), nullable=False),
+ sa.Column('code', sa.String(length=50), nullable=False),
+ sa.Column('parent_id', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['parent_id'], ['accounts.id'], ondelete='SET NULL'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_charset='utf8mb4'
+ )
+
+ op.create_unique_constraint('uq_accounts_business_code', 'accounts', ['business_id', 'code'])
+ op.create_index('ix_accounts_name', 'accounts', ['name'])
+ op.create_index('ix_accounts_business_id', 'accounts', ['business_id'])
+ op.create_index('ix_accounts_parent_id', 'accounts', ['parent_id'])
+
+
+def downgrade() -> None:
+ op.drop_index('ix_accounts_parent_id', table_name='accounts')
+ op.drop_index('ix_accounts_business_id', table_name='accounts')
+ op.drop_index('ix_accounts_name', table_name='accounts')
+ op.drop_constraint('uq_accounts_business_code', 'accounts', type_='unique')
+ op.drop_table('accounts')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py b/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py
new file mode 100644
index 0000000..687e483
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000017_add_account_id_to_document_lines'
+down_revision = '20250927_000016_add_accounts_table'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('document_lines') as batch_op:
+ batch_op.add_column(sa.Column('account_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key('fk_document_lines_account_id_accounts', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT')
+ batch_op.create_index('ix_document_lines_account_id', ['account_id'])
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('document_lines') as batch_op:
+ batch_op.drop_index('ix_document_lines_account_id')
+ batch_op.drop_constraint('fk_document_lines_account_id_accounts', type_='foreignkey')
+ batch_op.drop_column('account_id')
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py b/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py
new file mode 100644
index 0000000..24ee953
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000018_seed_currencies'
+down_revision = 'f876bfa36805'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ insert_sql = sa.text(
+ """
+ INSERT INTO currencies (name, title, symbol, code, created_at, updated_at)
+ VALUES (:name, :title, :symbol, :code, NOW(), NOW())
+ ON DUPLICATE KEY UPDATE
+ title = VALUES(title),
+ symbol = VALUES(symbol),
+ updated_at = VALUES(updated_at)
+ """
+ )
+
+ currencies = [
+ {"name": "Iranian Rial", "title": "ریال ایران", "symbol": "﷼", "code": "IRR"},
+ {"name": "United States Dollar", "title": "US Dollar", "symbol": "$", "code": "USD"},
+ {"name": "Euro", "title": "Euro", "symbol": "€", "code": "EUR"},
+ {"name": "British Pound", "title": "Pound Sterling", "symbol": "£", "code": "GBP"},
+ {"name": "Japanese Yen", "title": "Yen", "symbol": "¥", "code": "JPY"},
+ {"name": "Chinese Yuan", "title": "Yuan", "symbol": "¥", "code": "CNY"},
+ {"name": "Swiss Franc", "title": "Swiss Franc", "symbol": "CHF", "code": "CHF"},
+ {"name": "Canadian Dollar", "title": "Canadian Dollar", "symbol": "$", "code": "CAD"},
+ {"name": "Australian Dollar", "title": "Australian Dollar", "symbol": "$", "code": "AUD"},
+ {"name": "New Zealand Dollar", "title": "New Zealand Dollar", "symbol": "$", "code": "NZD"},
+ {"name": "Russian Ruble", "title": "Ruble", "symbol": "₽", "code": "RUB"},
+ {"name": "Turkish Lira", "title": "Lira", "symbol": "₺", "code": "TRY"},
+ {"name": "UAE Dirham", "title": "Dirham", "symbol": "د.إ", "code": "AED"},
+ {"name": "Saudi Riyal", "title": "Riyal", "symbol": "﷼", "code": "SAR"},
+ {"name": "Qatari Riyal", "title": "Qatari Riyal", "symbol": "﷼", "code": "QAR"},
+ {"name": "Kuwaiti Dinar", "title": "Kuwaiti Dinar", "symbol": "د.ك", "code": "KWD"},
+ {"name": "Omani Rial", "title": "Omani Rial", "symbol": "﷼", "code": "OMR"},
+ {"name": "Bahraini Dinar", "title": "Bahraini Dinar", "symbol": ".د.ب", "code": "BHD"},
+ {"name": "Iraqi Dinar", "title": "Iraqi Dinar", "symbol": "ع.د", "code": "IQD"},
+ {"name": "Afghan Afghani", "title": "Afghani", "symbol": "؋", "code": "AFN"},
+ {"name": "Pakistani Rupee", "title": "Rupee", "symbol": "₨", "code": "PKR"},
+ {"name": "Indian Rupee", "title": "Rupee", "symbol": "₹", "code": "INR"},
+ {"name": "Armenian Dram", "title": "Dram", "symbol": "֏", "code": "AMD"},
+ {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
+ {"name": "Georgian Lari", "title": "Lari", "symbol": "₾", "code": "GEL"},
+ {"name": "Kazakhstani Tenge", "title": "Tenge", "symbol": "₸", "code": "KZT"},
+ {"name": "Uzbekistani Som", "title": "Som", "symbol": "so'm", "code": "UZS"},
+ {"name": "Tajikistani Somoni", "title": "Somoni", "symbol": "ЅМ", "code": "TJS"},
+ {"name": "Turkmenistani Manat", "title": "Manat", "symbol": "m", "code": "TMT"},
+ {"name": "Afgani Lek", "title": "Lek", "symbol": "L", "code": "ALL"},
+ {"name": "Bulgarian Lev", "title": "Lev", "symbol": "лв", "code": "BGN"},
+ {"name": "Romanian Leu", "title": "Leu", "symbol": "lei", "code": "RON"},
+ {"name": "Polish Złoty", "title": "Zloty", "symbol": "zł", "code": "PLN"},
+ {"name": "Czech Koruna", "title": "Koruna", "symbol": "Kč", "code": "CZK"},
+ {"name": "Hungarian Forint", "title": "Forint", "symbol": "Ft", "code": "HUF"},
+ {"name": "Danish Krone", "title": "Krone", "symbol": "kr", "code": "DKK"},
+ {"name": "Norwegian Krone", "title": "Krone", "symbol": "kr", "code": "NOK"},
+ {"name": "Swedish Krona", "title": "Krona", "symbol": "kr", "code": "SEK"},
+ {"name": "Icelandic Króna", "title": "Krona", "symbol": "kr", "code": "ISK"},
+ {"name": "Croatian Kuna", "title": "Kuna", "symbol": "kn", "code": "HRK"},
+ {"name": "Serbian Dinar", "title": "Dinar", "symbol": "дин.", "code": "RSD"},
+ {"name": "Bosnia and Herzegovina Mark", "title": "Mark", "symbol": "KM", "code": "BAM"},
+ {"name": "Ukrainian Hryvnia", "title": "Hryvnia", "symbol": "₴", "code": "UAH"},
+ {"name": "Belarusian Ruble", "title": "Ruble", "symbol": "Br", "code": "BYN"},
+ {"name": "Egyptian Pound", "title": "Pound", "symbol": "£", "code": "EGP"},
+ {"name": "South African Rand", "title": "Rand", "symbol": "R", "code": "ZAR"},
+ {"name": "Nigerian Naira", "title": "Naira", "symbol": "₦", "code": "NGN"},
+ {"name": "Kenyan Shilling", "title": "Shilling", "symbol": "Sh", "code": "KES"},
+ {"name": "Ethiopian Birr", "title": "Birr", "symbol": "Br", "code": "ETB"},
+ {"name": "Moroccan Dirham", "title": "Dirham", "symbol": "د.م.", "code": "MAD"},
+ {"name": "Tunisian Dinar", "title": "Dinar", "symbol": "د.ت", "code": "TND"},
+ {"name": "Algerian Dinar", "title": "Dinar", "symbol": "د.ج", "code": "DZD"},
+ {"name": "Israeli New Shekel", "title": "Shekel", "symbol": "₪", "code": "ILS"},
+ {"name": "Jordanian Dinar", "title": "Dinar", "symbol": "د.ا", "code": "JOD"},
+ {"name": "Lebanese Pound", "title": "Pound", "symbol": "ل.ل", "code": "LBP"},
+ {"name": "Syrian Pound", "title": "Pound", "symbol": "£", "code": "SYP"},
+ {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"},
+ {"name": "Singapore Dollar", "title": "Singapore Dollar", "symbol": "$", "code": "SGD"},
+ {"name": "Hong Kong Dollar", "title": "Hong Kong Dollar", "symbol": "$", "code": "HKD"},
+ {"name": "Thai Baht", "title": "Baht", "symbol": "฿", "code": "THB"},
+ {"name": "Malaysian Ringgit", "title": "Ringgit", "symbol": "RM", "code": "MYR"},
+ {"name": "Indonesian Rupiah", "title": "Rupiah", "symbol": "Rp", "code": "IDR"},
+ {"name": "Philippine Peso", "title": "Peso", "symbol": "₱", "code": "PHP"},
+ {"name": "Vietnamese Dong", "title": "Dong", "symbol": "₫", "code": "VND"},
+ {"name": "South Korean Won", "title": "Won", "symbol": "₩", "code": "KRW"},
+ {"name": "Taiwan New Dollar", "title": "New Dollar", "symbol": "$", "code": "TWD"},
+ {"name": "Mexican Peso", "title": "Peso", "symbol": "$", "code": "MXN"},
+ {"name": "Brazilian Real", "title": "Real", "symbol": "R$", "code": "BRL"},
+ {"name": "Argentine Peso", "title": "Peso", "symbol": "$", "code": "ARS"},
+ {"name": "Chilean Peso", "title": "Peso", "symbol": "$", "code": "CLP"},
+ {"name": "Colombian Peso", "title": "Peso", "symbol": "$", "code": "COP"},
+ {"name": "Peruvian Sol", "title": "Sol", "symbol": "S/.", "code": "PEN"},
+ {"name": "Uruguayan Peso", "title": "Peso", "symbol": "$U", "code": "UYU"},
+ {"name": "Paraguayan Guarani", "title": "Guarani", "symbol": "₲", "code": "PYG"},
+ {"name": "Bolivian Boliviano", "title": "Boliviano", "symbol": "Bs.", "code": "BOB"},
+ {"name": "Dominican Peso", "title": "Peso", "symbol": "RD$", "code": "DOP"},
+ {"name": "Cuban Peso", "title": "Peso", "symbol": "$", "code": "CUP"},
+ {"name": "Costa Rican Colon", "title": "Colon", "symbol": "₡", "code": "CRC"},
+ {"name": "Guatemalan Quetzal", "title": "Quetzal", "symbol": "Q", "code": "GTQ"},
+ {"name": "Honduran Lempira", "title": "Lempira", "symbol": "L", "code": "HNL"},
+ {"name": "Nicaraguan Córdoba", "title": "Cordoba", "symbol": "C$", "code": "NIO"},
+ {"name": "Panamanian Balboa", "title": "Balboa", "symbol": "B/.", "code": "PAB"},
+ {"name": "Venezuelan Bolívar", "title": "Bolivar", "symbol": "Bs.", "code": "VES"},
+ ]
+
+ for row in currencies:
+ conn.execute(insert_sql, row)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ codes = [
+ 'IRR','USD','EUR','GBP','JPY','CNY','CHF','CAD','AUD','NZD','RUB','TRY','AED','SAR','QAR','KWD','OMR','BHD','IQD','AFN','PKR','INR','AMD','AZN','GEL','KZT','UZS','TJS','TMT','ALL','BGN','RON','PLN','CZK','HUF','DKK','NOK','SEK','ISK','HRK','RSD','BAM','UAH','BYN','EGP','ZAR','NGN','KES','ETB','MAD','TND','DZD','ILS','JOD','LBP','SYP','SGD','HKD','THB','MYR','IDR','PHP','VND','KRW','TWD','MXN','BRL','ARS','CLP','COP','PEN','UYU','PYG','BOB','DOP','CUP','CRC','GTQ','HNL','NIO','PAB','VES'
+ ]
+ delete_sql = sa.text("DELETE FROM currencies WHERE code IN :codes")
+ conn.execute(delete_sql, {"codes": tuple(codes)})
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py b/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py
new file mode 100644
index 0000000..333a1d7
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000019_seed_accounts_chart'
+down_revision = '20250927_000018_seed_currencies'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+
+ # دادهها (خلاصهشده برای خوانایی؛ از JSON کاربر)
+ accounts = [
+ {"id":2452,"level":1,"code":"1","name":"دارایی ها","parentId":0,"accountType":0},
+ {"id":2453,"level":2,"code":"101","name":"دارایی های جاری","parentId":2452,"accountType":0},
+ {"id":2454,"level":3,"code":"102","name":"موجودی نقد و بانک","parentId":2453,"accountType":0},
+ {"id":2455,"level":4,"code":"10201","name":"تنخواه گردان","parentId":2454,"accountType":2},
+ {"id":2456,"level":4,"code":"10202","name":"صندوق","parentId":2454,"accountType":1},
+ {"id":2457,"level":4,"code":"10203","name":"بانک","parentId":2454,"accountType":3},
+ {"id":2458,"level":4,"code":"10204","name":"وجوه در راه","parentId":2454,"accountType":0},
+ {"id":2459,"level":3,"code":"103","name":"سپرده های کوتاه مدت","parentId":2453,"accountType":0},
+ {"id":2460,"level":4,"code":"10301","name":"سپرده شرکت در مناقصه و مزایده","parentId":2459,"accountType":0},
+ {"id":2461,"level":4,"code":"10302","name":"ضمانت نامه بانکی","parentId":2459,"accountType":0},
+ {"id":2462,"level":4,"code":"10303","name":"سایر سپرده ها","parentId":2459,"accountType":0},
+ {"id":2463,"level":3,"code":"104","name":"حساب های دریافتنی","parentId":2453,"accountType":0},
+ {"id":2464,"level":4,"code":"10401","name":"حساب های دریافتنی","parentId":2463,"accountType":4},
+ {"id":2465,"level":4,"code":"10402","name":"ذخیره مطالبات مشکوک الوصول","parentId":2463,"accountType":0},
+ {"id":2466,"level":4,"code":"10403","name":"اسناد دریافتنی","parentId":2463,"accountType":5},
+ {"id":2467,"level":4,"code":"10404","name":"اسناد در جریان وصول","parentId":2463,"accountType":6},
+ {"id":2468,"level":3,"code":"105","name":"سایر حساب های دریافتنی","parentId":2453,"accountType":0},
+ {"id":2469,"level":4,"code":"10501","name":"وام کارکنان","parentId":2468,"accountType":0},
+ {"id":2470,"level":4,"code":"10502","name":"سایر حساب های دریافتنی","parentId":2468,"accountType":0},
+ {"id":2471,"level":3,"code":"10101","name":"پیش پرداخت ها","parentId":2453,"accountType":0},
+ {"id":2472,"level":3,"code":"10102","name":"موجودی کالا","parentId":2453,"accountType":7},
+ {"id":2473,"level":3,"code":"10103","name":"ملزومات","parentId":2453,"accountType":0},
+ {"id":2474,"level":3,"code":"10104","name":"مالیات بر ارزش افزوده خرید","parentId":2453,"accountType":8},
+ {"id":2475,"level":2,"code":"106","name":"دارایی های غیر جاری","parentId":2452,"accountType":0},
+ {"id":2476,"level":3,"code":"107","name":"دارایی های ثابت","parentId":2475,"accountType":0},
+ {"id":2477,"level":4,"code":"10701","name":"زمین","parentId":2476,"accountType":0},
+ {"id":2478,"level":4,"code":"10702","name":"ساختمان","parentId":2476,"accountType":0},
+ {"id":2479,"level":4,"code":"10703","name":"وسائط نقلیه","parentId":2476,"accountType":0},
+ {"id":2480,"level":4,"code":"10704","name":"اثاثیه اداری","parentId":2476,"accountType":0},
+ {"id":2481,"level":3,"code":"108","name":"استهلاک انباشته","parentId":2475,"accountType":0},
+ {"id":2482,"level":4,"code":"10801","name":"استهلاک انباشته ساختمان","parentId":2481,"accountType":0},
+ {"id":2483,"level":4,"code":"10802","name":"استهلاک انباشته وسائط نقلیه","parentId":2481,"accountType":0},
+ {"id":2484,"level":4,"code":"10803","name":"استهلاک انباشته اثاثیه اداری","parentId":2481,"accountType":0},
+ {"id":2485,"level":3,"code":"109","name":"سپرده های بلندمدت","parentId":2475,"accountType":0},
+ {"id":2486,"level":3,"code":"110","name":"سایر دارائی ها","parentId":2475,"accountType":0},
+ {"id":2487,"level":4,"code":"11001","name":"حق الامتیازها","parentId":2486,"accountType":0},
+ {"id":2488,"level":4,"code":"11002","name":"نرم افزارها","parentId":2486,"accountType":0},
+ {"id":2489,"level":4,"code":"11003","name":"سایر دارایی های نامشهود","parentId":2486,"accountType":0},
+ {"id":2490,"level":1,"code":"2","name":"بدهی ها","parentId":0,"accountType":0},
+ {"id":2491,"level":2,"code":"201","name":"بدهیهای جاری","parentId":2490,"accountType":0},
+ {"id":2492,"level":3,"code":"202","name":"حساب ها و اسناد پرداختنی","parentId":2491,"accountType":0},
+ {"id":2493,"level":4,"code":"20201","name":"حساب های پرداختنی","parentId":2492,"accountType":9},
+ {"id":2494,"level":4,"code":"20202","name":"اسناد پرداختنی","parentId":2492,"accountType":10},
+ {"id":2495,"level":3,"code":"203","name":"سایر حساب های پرداختنی","parentId":2491,"accountType":0},
+ {"id":2496,"level":4,"code":"20301","name":"ذخیره مالیات بر درآمد پرداختنی","parentId":2495,"accountType":40},
+ {"id":2497,"level":4,"code":"20302","name":"مالیات بر درآمد پرداختنی","parentId":2495,"accountType":12},
+ {"id":2498,"level":4,"code":"20303","name":"مالیات حقوق و دستمزد پرداختنی","parentId":2495,"accountType":0},
+ {"id":2499,"level":4,"code":"20304","name":"حق بیمه پرداختنی","parentId":2495,"accountType":0},
+ {"id":2500,"level":4,"code":"20305","name":"حقوق و دستمزد پرداختنی","parentId":2495,"accountType":42},
+ {"id":2501,"level":4,"code":"20306","name":"عیدی و پاداش پرداختنی","parentId":2495,"accountType":0},
+ {"id":2502,"level":4,"code":"20307","name":"سایر هزینه های پرداختنی","parentId":2495,"accountType":0},
+ {"id":2503,"level":3,"code":"204","name":"پیش دریافت ها","parentId":2491,"accountType":0},
+ {"id":2504,"level":4,"code":"20401","name":"پیش دریافت فروش","parentId":2503,"accountType":0},
+ {"id":2505,"level":4,"code":"20402","name":"سایر پیش دریافت ها","parentId":2503,"accountType":0},
+ {"id":2506,"level":3,"code":"20101","name":"مالیات بر ارزش افزوده فروش","parentId":2491,"accountType":11},
+ {"id":2507,"level":2,"code":"205","name":"بدهیهای غیر جاری","parentId":2490,"accountType":0},
+ {"id":2508,"level":3,"code":"206","name":"حساب ها و اسناد پرداختنی بلندمدت","parentId":2507,"accountType":0},
+ {"id":2509,"level":4,"code":"20601","name":"حساب های پرداختنی بلندمدت","parentId":2508,"accountType":0},
+ {"id":2510,"level":4,"code":"20602","name":"اسناد پرداختنی بلندمدت","parentId":2508,"accountType":0},
+ {"id":2511,"level":3,"code":"20501","name":"وام پرداختنی","parentId":2507,"accountType":0},
+ {"id":2512,"level":3,"code":"20502","name":"ذخیره مزایای پایان خدمت کارکنان","parentId":2507,"accountType":0},
+ {"id":2513,"level":1,"code":"3","name":"حقوق صاحبان سهام","parentId":0,"accountType":0},
+ {"id":2514,"level":2,"code":"301","name":"سرمایه","parentId":2513,"accountType":0},
+ {"id":2515,"level":3,"code":"30101","name":"سرمایه اولیه","parentId":2514,"accountType":13},
+ {"id":2516,"level":3,"code":"30102","name":"افزایش یا کاهش سرمایه","parentId":2514,"accountType":14},
+ {"id":2517,"level":3,"code":"30103","name":"اندوخته قانونی","parentId":2514,"accountType":15},
+ {"id":2518,"level":3,"code":"30104","name":"برداشت ها","parentId":2514,"accountType":16},
+ {"id":2519,"level":3,"code":"30105","name":"سهم سود و زیان","parentId":2514,"accountType":17},
+ {"id":2520,"level":3,"code":"30106","name":"سود یا زیان انباشته (سنواتی)","parentId":2514,"accountType":18},
+ {"id":2521,"level":1,"code":"4","name":"بهای تمام شده کالای فروخته شده","parentId":0,"accountType":0},
+ {"id":2522,"level":2,"code":"40001","name":"بهای تمام شده کالای فروخته شده","parentId":2521,"accountType":19},
+ {"id":2523,"level":2,"code":"40002","name":"برگشت از خرید","parentId":2521,"accountType":20},
+ {"id":2524,"level":2,"code":"40003","name":"تخفیفات نقدی خرید","parentId":2521,"accountType":21},
+ {"id":2525,"level":1,"code":"5","name":"فروش","parentId":0,"accountType":0},
+ {"id":2526,"level":2,"code":"50001","name":"فروش کالا","parentId":2525,"accountType":22},
+ {"id":2527,"level":2,"code":"50002","name":"برگشت از فروش","parentId":2525,"accountType":23},
+ {"id":2528,"level":2,"code":"50003","name":"تخفیفات نقدی فروش","parentId":2525,"accountType":24},
+ {"id":2529,"level":1,"code":"6","name":"درآمد","parentId":0,"accountType":0},
+ {"id":2530,"level":2,"code":"601","name":"درآمد های عملیاتی","parentId":2529,"accountType":0},
+ {"id":2531,"level":3,"code":"60101","name":"درآمد حاصل از فروش خدمات","parentId":2530,"accountType":25},
+ {"id":2532,"level":3,"code":"60102","name":"برگشت از خرید خدمات","parentId":2530,"accountType":26},
+ {"id":2533,"level":3,"code":"60103","name":"درآمد اضافه کالا","parentId":2530,"accountType":27},
+ {"id":2534,"level":3,"code":"60104","name":"درآمد حمل کالا","parentId":2530,"accountType":28},
+ {"id":2535,"level":2,"code":"602","name":"درآمد های غیر عملیاتی","parentId":2529,"accountType":0},
+ {"id":2536,"level":3,"code":"60201","name":"درآمد حاصل از سرمایه گذاری","parentId":2535,"accountType":0},
+ {"id":2537,"level":3,"code":"60202","name":"درآمد سود سپرده ها","parentId":2535,"accountType":0},
+ {"id":2538,"level":3,"code":"60203","name":"سایر درآمد ها","parentId":2535,"accountType":0},
+ {"id":2539,"level":3,"code":"60204","name":"درآمد تسعیر ارز","parentId":2535,"accountType":36},
+ {"id":2540,"level":1,"code":"7","name":"هزینه ها","parentId":0,"accountType":0},
+ {"id":2541,"level":2,"code":"701","name":"هزینه های پرسنلی","parentId":2540,"accountType":0},
+ {"id":2542,"level":3,"code":"702","name":"هزینه حقوق و دستمزد","parentId":2541,"accountType":0},
+ {"id":2543,"level":4,"code":"70201","name":"حقوق پایه","parentId":2542,"accountType":0},
+ {"id":2544,"level":4,"code":"70202","name":"اضافه کار","parentId":2542,"accountType":0},
+ {"id":2545,"level":4,"code":"70203","name":"حق شیفت و شب کاری","parentId":2542,"accountType":0},
+ {"id":2546,"level":4,"code":"70204","name":"حق نوبت کاری","parentId":2542,"accountType":0},
+ {"id":2547,"level":4,"code":"70205","name":"حق ماموریت","parentId":2542,"accountType":0},
+ {"id":2548,"level":4,"code":"70206","name":"فوق العاده مسکن و خاروبار","parentId":2542,"accountType":0},
+ {"id":2549,"level":4,"code":"70207","name":"حق اولاد","parentId":2542,"accountType":0},
+ {"id":2550,"level":4,"code":"70208","name":"عیدی و پاداش","parentId":2542,"accountType":0},
+ {"id":2551,"level":4,"code":"70209","name":"بازخرید سنوات خدمت کارکنان","parentId":2542,"accountType":0},
+ {"id":2552,"level":4,"code":"70210","name":"بازخرید مرخصی","parentId":2542,"accountType":0},
+ {"id":2553,"level":4,"code":"70211","name":"بیمه سهم کارفرما","parentId":2542,"accountType":0},
+ {"id":2554,"level":4,"code":"70212","name":"بیمه بیکاری","parentId":2542,"accountType":0},
+ {"id":2555,"level":4,"code":"70213","name":"حقوق مزایای متفرقه","parentId":2542,"accountType":0},
+ {"id":2556,"level":3,"code":"703","name":"سایر هزینه های کارکنان","parentId":2541,"accountType":0},
+ {"id":2557,"level":4,"code":"70301","name":"سفر و ماموریت","parentId":2556,"accountType":0},
+ {"id":2558,"level":4,"code":"70302","name":"ایاب و ذهاب","parentId":2556,"accountType":0},
+ {"id":2559,"level":4,"code":"70303","name":"سایر هزینه های کارکنان","parentId":2556,"accountType":0},
+ {"id":2560,"level":2,"code":"704","name":"هزینه های عملیاتی","parentId":2540,"accountType":0},
+ {"id":2561,"level":3,"code":"70401","name":"خرید خدمات","parentId":2560,"accountType":30},
+ {"id":2562,"level":3,"code":"70402","name":"برگشت از فروش خدمات","parentId":2560,"accountType":29},
+ {"id":2563,"level":3,"code":"70403","name":"هزینه حمل کالا","parentId":2560,"accountType":31},
+ {"id":2564,"level":3,"code":"70404","name":"تعمیر و نگهداری اموال و اثاثیه","parentId":2560,"accountType":0},
+ {"id":2565,"level":3,"code":"70405","name":"هزینه اجاره محل","parentId":2560,"accountType":0},
+ {"id":2566,"level":2,"code":"705","name":"هزینه های عمومی","parentId":2540,"accountType":0},
+ {"id":2567,"level":4,"code":"70501","name":"هزینه آب و برق و گاز و تلفن","parentId":2566,"accountType":0},
+ {"id":2568,"level":4,"code":"70502","name":"هزینه پذیرایی و آبدارخانه","parentId":2566,"accountType":0},
+ {"id":2569,"level":3,"code":"70406","name":"هزینه ملزومات مصرفی","parentId":2560,"accountType":0},
+ {"id":2570,"level":3,"code":"70407","name":"هزینه کسری و ضایعات کالا","parentId":2560,"accountType":32},
+ {"id":2571,"level":3,"code":"70408","name":"بیمه دارایی های ثابت","parentId":2560,"accountType":0},
+ {"id":2572,"level":2,"code":"706","name":"هزینه های استهلاک","parentId":2540,"accountType":0},
+ {"id":2573,"level":3,"code":"70601","name":"هزینه استهلاک ساختمان","parentId":2572,"accountType":0},
+ {"id":2574,"level":3,"code":"70602","name":"هزینه استهلاک وسائط نقلیه","parentId":2572,"accountType":0},
+ {"id":2575,"level":3,"code":"70603","name":"هزینه استهلاک اثاثیه","parentId":2572,"accountType":0},
+ {"id":2576,"level":2,"code":"707","name":"هزینه های بازاریابی و توزیع و فروش","parentId":2540,"accountType":0},
+ {"id":2577,"level":3,"code":"70701","name":"هزینه آگهی و تبلیغات","parentId":2576,"accountType":0},
+ {"id":2578,"level":3,"code":"70702","name":"هزینه بازاریابی و پورسانت","parentId":2576,"accountType":0},
+ {"id":2579,"level":3,"code":"70703","name":"سایر هزینه های توزیع و فروش","parentId":2576,"accountType":0},
+ {"id":2580,"level":2,"code":"708","name":"هزینه های غیرعملیاتی","parentId":2540,"accountType":0},
+ {"id":2581,"level":3,"code":"709","name":"هزینه های بانکی","parentId":2580,"accountType":0},
+ {"id":2582,"level":4,"code":"70901","name":"سود و کارمزد وامها","parentId":2581,"accountType":0},
+ {"id":2583,"level":4,"code":"70902","name":"کارمزد خدمات بانکی","parentId":2581,"accountType":33},
+ {"id":2584,"level":4,"code":"70903","name":"جرائم دیرکرد بانکی","parentId":2581,"accountType":0},
+ {"id":2585,"level":3,"code":"70801","name":"هزینه تسعیر ارز","parentId":2580,"accountType":37},
+ {"id":2586,"level":3,"code":"70802","name":"هزینه مطالبات سوخت شده","parentId":2580,"accountType":0},
+ {"id":2587,"level":1,"code":"8","name":"سایر حساب ها","parentId":0,"accountType":0},
+ {"id":2588,"level":2,"code":"801","name":"حساب های انتظامی","parentId":2587,"accountType":0},
+ {"id":2589,"level":3,"code":"80101","name":"حساب های انتظامی","parentId":2588,"accountType":0},
+ {"id":2590,"level":3,"code":"80102","name":"طرف حساب های انتظامی","parentId":2588,"accountType":0},
+ {"id":2591,"level":2,"code":"802","name":"حساب های کنترلی","parentId":2587,"accountType":0},
+ {"id":2592,"level":3,"code":"80201","name":"کنترل کسری و اضافه کالا","parentId":2591,"accountType":34},
+ {"id":2593,"level":2,"code":"803","name":"حساب خلاصه سود و زیان","parentId":2587,"accountType":0},
+ {"id":2594,"level":3,"code":"80301","name":"خلاصه سود و زیان","parentId":2593,"accountType":35},
+ {"id":2595,"level":5,"code":"70503","name":"هزینه آب","parentId":2567,"accountType":0},
+ {"id":2596,"level":5,"code":"70504","name":"هزینه برق","parentId":2567,"accountType":0},
+ {"id":2597,"level":5,"code":"70505","name":"هزینه گاز","parentId":2567,"accountType":0},
+ {"id":2598,"level":5,"code":"70506","name":"هزینه تلفن","parentId":2567,"accountType":0},
+ {"id":2600,"level":4,"code":"20503","name":"وام از بانک ملت","parentId":2511,"accountType":0},
+ {"id":2601,"level":4,"code":"10405","name":"سود تحقق نیافته فروش اقساطی","parentId":2463,"accountType":39},
+ {"id":2602,"level":3,"code":"60205","name":"سود فروش اقساطی","parentId":2535,"accountType":38},
+ {"id":2603,"level":4,"code":"70214","name":"حق تاهل","parentId":2542,"accountType":0},
+ {"id":2604,"level":4,"code":"20504","name":"وام از بانک پارسیان","parentId":2511,"accountType":0},
+ {"id":2605,"level":3,"code":"10105","name":"مساعده","parentId":2453,"accountType":0},
+ {"id":2606,"level":3,"code":"60105","name":"تعمیرات لوازم آشپزخانه","parentId":2530,"accountType":0},
+ {"id":2607,"level":4,"code":"10705","name":"کامپیوتر","parentId":2476,"accountType":0},
+ {"id":2608,"level":3,"code":"60206","name":"درامد حاصل از فروش ضایعات","parentId":2535,"accountType":0},
+ {"id":2609,"level":3,"code":"60207","name":"سود فروش دارایی","parentId":2535,"accountType":0},
+ {"id":2610,"level":3,"code":"70803","name":"زیان فروش دارایی","parentId":2580,"accountType":0},
+ {"id":2611,"level":3,"code":"10106","name":"موجودی کالای در جریان ساخت","parentId":2453,"accountType":41},
+ {"id":2612,"level":3,"code":"20102","name":"سربار تولید پرداختنی","parentId":2491,"accountType":43},
+ ]
+
+ # نقشه id خارجی به id داخلی
+ ext_to_internal: dict[int, int] = {}
+
+ # کوئریها
+ select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1")
+ insert_q = sa.text(
+ """
+ INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at)
+ VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW())
+ """
+ )
+ update_q = sa.text(
+ """
+ UPDATE accounts
+ SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW()
+ WHERE id = :id
+ """
+ )
+
+ for item in accounts:
+ parent_internal = None
+ if item.get("parentId") and item["parentId"] in ext_to_internal:
+ parent_internal = ext_to_internal[item["parentId"]]
+
+ # وجودی؟
+ res = conn.execute(select_existing, {"code": item["code"]})
+ row = res.fetchone()
+ if row is None:
+ result = conn.execute(
+ insert_q,
+ {
+ "name": item["name"],
+ "account_type": str(item.get("accountType", 0)),
+ "code": item["code"],
+ "parent_id": parent_internal,
+ },
+ )
+ new_id = result.lastrowid if hasattr(result, "lastrowid") else None
+ if new_id is None:
+ # fallback: انتخاب بر اساس code
+ res2 = conn.execute(select_existing, {"code": item["code"]})
+ row2 = res2.fetchone()
+ if row2:
+ new_id = row2[0]
+ else:
+ pass
+ if new_id is not None:
+ ext_to_internal[item["id"]] = int(new_id)
+ else:
+ acc_id = int(row[0])
+ conn.execute(
+ update_q,
+ {
+ "id": acc_id,
+ "name": item["name"],
+ "account_type": str(item.get("accountType", 0)),
+ "parent_id": parent_internal,
+ },
+ )
+ ext_to_internal[item["id"]] = acc_id
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ # حذف بر اساس کدها (فقط حسابهای عمومی یعنی business_id IS NULL)
+ codes = [
+ "1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102"
+ ]
+ delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
+ for code in codes:
+ conn.execute(delete_q, {"code": code})
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py b/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
new file mode 100644
index 0000000..23f423b
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
@@ -0,0 +1,45 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000020_add_share_count_and_shareholder_type'
+down_revision = '20250927_000019_seed_accounts_chart'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ b = op.get_bind()
+ inspector = inspect(b)
+ cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
+ with op.batch_alter_table('persons') as batch_op:
+ if 'share_count' not in cols:
+ batch_op.add_column(sa.Column('share_count', sa.Integer(), nullable=True))
+
+ # افزودن مقدار جدید به ENUM ستون person_type (برای MySQL)
+ # مقادیر فارسی مطابق Enum مدل: 'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده'
+ # مقدار جدید: 'سهامدار'
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.drop_column('share_count')
+
+ # بازگردانی ENUM بدون مقدار سهامدار
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده') NOT NULL
+ """
+ )
+
+
diff --git a/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py b/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
new file mode 100644
index 0000000..a2c1be3
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
@@ -0,0 +1,59 @@
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000021_update_person_type_enum_to_persian'
+down_revision = 'd3e84892c1c2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # 1) Allow both English and Persian, plus new 'سهامدار'
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER',
+ 'مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+ # 2) Migrate existing data from English to Persian
+ op.execute("UPDATE persons SET person_type = 'مشتری' WHERE person_type = 'CUSTOMER'")
+ op.execute("UPDATE persons SET person_type = 'بازاریاب' WHERE person_type = 'MARKETER'")
+ op.execute("UPDATE persons SET person_type = 'کارمند' WHERE person_type = 'EMPLOYEE'")
+ op.execute("UPDATE persons SET person_type = 'تامینکننده' WHERE person_type = 'SUPPLIER'")
+ op.execute("UPDATE persons SET person_type = 'همکار' WHERE person_type = 'PARTNER'")
+ op.execute("UPDATE persons SET person_type = 'فروشنده' WHERE person_type = 'SELLER'")
+
+ # 3) Restrict enum to Persian only (including 'سهامدار')
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('مشتری','بازاریاب','کارمند','تامینکننده','همکار','فروشنده','سهامدار') NOT NULL
+ """
+ )
+
+
+def downgrade() -> None:
+ # Revert to English-only (without shareholder)
+ op.execute(
+ """
+ ALTER TABLE persons
+ MODIFY COLUMN person_type
+ ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER') NOT NULL
+ """
+ )
+
+ # Convert data back from Persian to English
+ reverse_mapping = {
+ 'مشتری': 'CUSTOMER',
+ 'بازاریاب': 'MARKETER',
+ 'کارمند': 'EMPLOYEE',
+ 'تامینکننده': 'SUPPLIER',
+ 'همکار': 'PARTNER',
+ 'فروشنده': 'SELLER',
+ }
+ for fa, en in reverse_mapping.items():
+ op.execute(text("UPDATE persons SET person_type = :en WHERE person_type = :fa"), {"fa": fa, "en": en})
diff --git a/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py b/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py
new file mode 100644
index 0000000..da6e07e
--- /dev/null
+++ b/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py
@@ -0,0 +1,43 @@
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+# revision identifiers, used by Alembic.
+revision = '20250927_000022_add_person_commission_fields'
+down_revision = '20250927_000021_update_person_type_enum_to_persian'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
+ with op.batch_alter_table('persons') as batch_op:
+ if 'commission_sale_percent' not in cols:
+ batch_op.add_column(sa.Column('commission_sale_percent', sa.Numeric(5, 2), nullable=True))
+ if 'commission_sales_return_percent' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_return_percent', sa.Numeric(5, 2), nullable=True))
+ if 'commission_sales_amount' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_amount', sa.Numeric(12, 2), nullable=True))
+ if 'commission_sales_return_amount' not in cols:
+ batch_op.add_column(sa.Column('commission_sales_return_amount', sa.Numeric(12, 2), nullable=True))
+ if 'commission_exclude_discounts' not in cols:
+ batch_op.add_column(sa.Column('commission_exclude_discounts', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+ if 'commission_exclude_additions_deductions' not in cols:
+ batch_op.add_column(sa.Column('commission_exclude_additions_deductions', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+ if 'commission_post_in_invoice_document' not in cols:
+ batch_op.add_column(sa.Column('commission_post_in_invoice_document', sa.Boolean(), server_default=sa.text('0'), nullable=False))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('persons') as batch_op:
+ batch_op.drop_column('commission_post_in_invoice_document')
+ batch_op.drop_column('commission_exclude_additions_deductions')
+ batch_op.drop_column('commission_exclude_discounts')
+ batch_op.drop_column('commission_sales_return_amount')
+ batch_op.drop_column('commission_sales_amount')
+ batch_op.drop_column('commission_sales_return_percent')
+ batch_op.drop_column('commission_sale_percent')
+
+
diff --git a/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py b/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
new file mode 100644
index 0000000..7ba9699
--- /dev/null
+++ b/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
@@ -0,0 +1,129 @@
+"""sync person_type enum values_callable to persian
+
+Revision ID: d3e84892c1c2
+Revises: 20250927_000020_add_share_count_and_shareholder_type
+Create Date: 2025-09-27 19:18:06.253391
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'd3e84892c1c2'
+down_revision = '20250927_000020_add_share_count_and_shareholder_type'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('storage_configs',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('storage_type', sa.String(length=20), nullable=False),
+ sa.Column('is_default', sa.Boolean(), nullable=False),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('config_data', sa.JSON(), nullable=False),
+ sa.Column('created_by', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('file_storage',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('original_name', sa.String(length=255), nullable=False),
+ sa.Column('stored_name', sa.String(length=255), nullable=False),
+ sa.Column('file_path', sa.String(length=500), nullable=False),
+ sa.Column('file_size', sa.Integer(), nullable=False),
+ sa.Column('mime_type', sa.String(length=100), nullable=False),
+ sa.Column('storage_type', sa.String(length=20), nullable=False),
+ sa.Column('storage_config_id', sa.String(length=36), nullable=True),
+ sa.Column('uploaded_by', sa.Integer(), nullable=False),
+ sa.Column('module_context', sa.String(length=50), nullable=False),
+ sa.Column('context_id', sa.String(length=36), nullable=True),
+ sa.Column('developer_data', sa.JSON(), nullable=True),
+ sa.Column('checksum', sa.String(length=64), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('is_temporary', sa.Boolean(), nullable=False),
+ sa.Column('is_verified', sa.Boolean(), nullable=False),
+ sa.Column('verification_token', sa.String(length=100), nullable=True),
+ sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ),
+ sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('file_verifications',
+ sa.Column('id', sa.String(length=36), nullable=False),
+ sa.Column('file_id', sa.String(length=36), nullable=False),
+ sa.Column('module_name', sa.String(length=50), nullable=False),
+ sa.Column('verification_token', sa.String(length=100), nullable=False),
+ sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('verified_by', sa.Integer(), nullable=True),
+ sa.Column('verification_data', sa.JSON(), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ),
+ sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years')
+ op.alter_column('person_bank_accounts', 'person_id',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='شناسه شخص',
+ existing_nullable=False)
+ op.alter_column('persons', 'business_id',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='شناسه کسب و کار',
+ existing_nullable=False)
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'share_count',
+ existing_type=mysql.INTEGER(),
+ comment='تعداد سهام (فقط برای سهامدار)',
+ existing_nullable=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('persons', 'share_count',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='تعداد سهام (فقط برای سهامدار)',
+ existing_nullable=True)
+ op.alter_column('persons', 'person_types',
+ existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
+ comment=None,
+ existing_comment='لیست انواع شخص به صورت JSON',
+ existing_nullable=True)
+ op.alter_column('persons', 'code',
+ existing_type=mysql.INTEGER(),
+ comment=None,
+ existing_comment='کد یکتا در هر کسب و کار',
+ existing_nullable=True)
+ op.alter_column('persons', 'business_id',
+ existing_type=mysql.INTEGER(),
+ comment='شناسه کسب و کار',
+ existing_nullable=False)
+ op.alter_column('person_bank_accounts', 'person_id',
+ existing_type=mysql.INTEGER(),
+ comment='شناسه شخص',
+ existing_nullable=False)
+ op.create_index(op.f('ix_fiscal_years_title'), 'fiscal_years', ['title'], unique=False)
+ op.drop_table('file_verifications')
+ op.drop_table('file_storage')
+ op.drop_table('storage_configs')
+ # ### end Alembic commands ###
diff --git a/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py b/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py
new file mode 100644
index 0000000..d318963
--- /dev/null
+++ b/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py
@@ -0,0 +1,24 @@
+"""merge multiple heads
+
+Revision ID: f876bfa36805
+Revises: 20250117_000009, 20250120_000002, 20250927_000017_add_account_id_to_document_lines
+Create Date: 2025-09-27 12:29:57.080003
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f876bfa36805'
+down_revision = ('20250117_000009', '20250120_000002', '20250927_000017_add_account_id_to_document_lines')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart
index 7da7fd1..f8eb32f 100644
--- a/hesabixUI/hesabix_ui/lib/core/api_client.dart
+++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart
@@ -71,7 +71,7 @@ class ApiClient {
if (calendarType != null && calendarType.isNotEmpty) {
options.headers['X-Calendar-Type'] = calendarType;
}
- // Inject X-Business-ID header when path targets a specific business
+ // Inject X-Business-ID header when request targets a specific business
try {
final uri = options.uri;
final path = uri.path;
@@ -80,7 +80,8 @@ class ApiClient {
int? resolvedBusinessId = currentBusinessId;
// Fallback: detect business_id from URL like /api/v1/business/{id}/...
if (resolvedBusinessId == null) {
- final match = RegExp(r"/api/v1/business/(\d+)/").firstMatch(path);
+ // Match any occurrence of /business/{id} in the path
+ final match = RegExp(r"/business/(\d+)(/|$)").firstMatch(path);
if (match != null) {
final idStr = match.group(1);
if (idStr != null) {
@@ -88,6 +89,14 @@ class ApiClient {
}
}
}
+ // Fallback: query parameter business_id or businessId
+ if (resolvedBusinessId == null && uri.queryParameters.isNotEmpty) {
+ final qp = uri.queryParameters;
+ final idStr = qp['business_id'] ?? qp['businessId'];
+ if (idStr != null && idStr.isNotEmpty) {
+ resolvedBusinessId = int.tryParse(idStr);
+ }
+ }
if (resolvedBusinessId != null) {
options.headers['X-Business-ID'] = resolvedBusinessId.toString();
}
@@ -121,6 +130,7 @@ class ApiClient {
}
Future> get(String path, {Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
+ path = _resolveApiPath(path);
final requestOptions = options ?? Options();
if (responseType != null) {
requestOptions.responseType = responseType;
@@ -129,6 +139,7 @@ class ApiClient {
}
Future> post(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
+ path = _resolveApiPath(path);
final requestOptions = options ?? Options();
if (responseType != null) {
requestOptions.responseType = responseType;
@@ -137,14 +148,17 @@ class ApiClient {
}
Future> put(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) {
+ path = _resolveApiPath(path);
return _dio.put(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
Future> patch(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) {
+ path = _resolveApiPath(path);
return _dio.patch(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
Future> delete(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) {
+ path = _resolveApiPath(path);
return _dio.delete(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
@@ -165,4 +179,19 @@ class ApiClient {
}
}
+// Utilities
+String _resolveApiPath(String path) {
+ // Absolute URL → leave as is
+ if (path.startsWith('http://') || path.startsWith('https://')) {
+ return path;
+ }
+ // Ensure leading slash
+ final p = path.startsWith('/') ? path : '/$path';
+ // If already versioned, keep
+ if (p.startsWith('/api/')) {
+ return p;
+ }
+ // Auto-prefix with api version
+ return '/api/v1$p'.replaceAll(RegExp(r'//+'), '/');
+}
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index 0eb0819..e82cfd5 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -22,6 +22,7 @@ import 'pages/admin/email_settings_page.dart';
import 'pages/business/business_shell.dart';
import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart';
+import 'pages/business/accounts_page.dart';
import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart';
import 'pages/error_404_page.dart';
@@ -324,6 +325,19 @@ class _MyAppState extends State {
// برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند)
// این مهم است: اگر کاربر در صفحات profile یا business است، بماند
print('🔍 REDIRECT DEBUG: On other page ($currentPath), staying on current path');
+ // ذخیره مسیر فعلی به عنوان آخرین URL معتبر
+ if (currentPath.isNotEmpty &&
+ currentPath != '/' &&
+ currentPath != '/login' &&
+ (currentPath.startsWith('/user/profile/') || currentPath.startsWith('/business/'))) {
+ try {
+ await _authStore!.saveLastUrl(currentPath);
+ print('🔍 REDIRECT DEBUG: Saved last URL: $currentPath');
+ } catch (e) {
+ // صرفاً لاگ برای خطای غیر بحرانی ذخیره آدرس
+ print('🔍 REDIRECT DEBUG: Error saving last URL: $e');
+ }
+ }
return null;
},
routes: [
@@ -354,7 +368,7 @@ class _MyAppState extends State {
GoRoute(
path: '/user/profile/new-business',
name: 'profile_new_business',
- builder: (context, state) => const NewBusinessPage(),
+ builder: (context, state) => NewBusinessPage(calendarController: _calendarController!),
),
GoRoute(
path: '/user/profile/businesses',
@@ -509,6 +523,36 @@ class _MyAppState extends State {
);
},
),
+ GoRoute(
+ path: 'chart-of-accounts',
+ name: 'business_chart_of_accounts',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: AccountsPage(businessId: businessId),
+ );
+ },
+ ),
+ GoRoute(
+ path: 'accounts',
+ name: 'business_accounts',
+ builder: (context, state) {
+ final businessId = int.parse(state.pathParameters['business_id']!);
+ return BusinessShell(
+ businessId: businessId,
+ authStore: _authStore!,
+ localeController: controller,
+ calendarController: _calendarController!,
+ themeController: themeController,
+ child: AccountsPage(businessId: businessId),
+ );
+ },
+ ),
GoRoute(
path: 'settings',
name: 'business_settings',
diff --git a/hesabixUI/hesabix_ui/lib/models/business_models.dart b/hesabixUI/hesabix_ui/lib/models/business_models.dart
index cae500d..1f431e6 100644
--- a/hesabixUI/hesabix_ui/lib/models/business_models.dart
+++ b/hesabixUI/hesabix_ui/lib/models/business_models.dart
@@ -1,3 +1,4 @@
+import 'package:shamsi_date/shamsi_date.dart';
enum BusinessType {
company('شرکت'),
shop('مغازه'),
@@ -43,6 +44,9 @@ class BusinessData {
String? province;
String? city;
+ // مرحله 5: سال(های) مالی
+ List fiscalYears;
+
BusinessData({
this.name = '',
this.businessType,
@@ -57,14 +61,16 @@ class BusinessData {
this.country,
this.province,
this.city,
- });
+ List? fiscalYears,
+ }) : fiscalYears = fiscalYears ?? [];
// تبدیل به Map برای ارسال به API
Map toJson() {
return {
'name': name,
- 'business_type': businessType?.name,
- 'business_field': businessField?.name,
+ // بکاند انتظار مقادیر فارسی enum را دارد
+ 'business_type': businessType?.displayName,
+ 'business_field': businessField?.displayName,
'address': address,
'phone': phone,
'mobile': mobile,
@@ -75,6 +81,7 @@ class BusinessData {
'country': country,
'province': province,
'city': city,
+ 'fiscal_years': fiscalYears.map((e) => e.toJson()).toList(),
};
}
@@ -93,6 +100,7 @@ class BusinessData {
String? country,
String? province,
String? city,
+ List? fiscalYears,
}) {
return BusinessData(
name: name ?? this.name,
@@ -108,6 +116,7 @@ class BusinessData {
country: country ?? this.country,
province: province ?? this.province,
city: city ?? this.city,
+ fiscalYears: fiscalYears ?? this.fiscalYears,
);
}
@@ -147,14 +156,23 @@ class BusinessData {
return true;
}
- // بررسی اعتبار مرحله 4 (اختیاری)
+ // بررسی اعتبار مرحله 4 (اطلاعات جغرافیایی - اختیاری)
bool isStep4Valid() {
- return true; // همه فیلدها اختیاری هستند
+ return true;
+ }
+
+ // بررسی اعتبار مرحله 5 (سال مالی - اجباری)
+ bool isFiscalStepValid() {
+ if (fiscalYears.isEmpty) return false;
+ final fy = fiscalYears.first;
+ if (fy.title.trim().isEmpty || fy.startDate == null || fy.endDate == null) return false;
+ if (fy.startDate!.isAfter(fy.endDate!)) return false;
+ return true;
}
// بررسی اعتبار کل فرم
bool isFormValid() {
- return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid();
+ return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid() && isFiscalStepValid();
}
// اعتبارسنجی شماره موبایل ایرانی
@@ -251,6 +269,29 @@ class BusinessData {
}
}
+class FiscalYearData {
+ String title;
+ DateTime? startDate;
+ DateTime? endDate;
+ bool isLast;
+
+ FiscalYearData({
+ this.title = '',
+ this.startDate,
+ this.endDate,
+ this.isLast = true,
+ });
+
+ Map toJson() {
+ return {
+ 'title': title,
+ 'start_date': startDate?.toIso8601String().split('T').first,
+ 'end_date': endDate?.toIso8601String().split('T').first,
+ 'is_last': isLast,
+ };
+ }
+}
+
class BusinessResponse {
final int id;
final String name;
@@ -307,8 +348,54 @@ class BusinessResponse {
province: json['province'],
city: json['city'],
postalCode: json['postal_code'],
- createdAt: DateTime.parse(json['created_at']),
- updatedAt: DateTime.parse(json['updated_at']),
+ createdAt: _parseDateTime(json['created_at'] ?? json['created_at_raw']),
+ updatedAt: _parseDateTime(json['updated_at'] ?? json['updated_at_raw']),
);
}
+
+ static DateTime _parseDateTime(dynamic value) {
+ if (value == null) return DateTime.now();
+ if (value is DateTime) return value;
+ if (value is int) {
+ // epoch ms
+ return DateTime.fromMillisecondsSinceEpoch(value);
+ }
+ if (value is String) {
+ // Jalali format: YYYY/MM/DD [HH:MM:SS]
+ if (value.contains('/') && !value.contains('-')) {
+ try {
+ final parts = value.split(' ');
+ final dateParts = parts[0].split('/');
+ if (dateParts.length == 3) {
+ final year = int.parse(dateParts[0]);
+ final month = int.parse(dateParts[1]);
+ final day = int.parse(dateParts[2]);
+ int hour = 0, minute = 0, second = 0;
+ if (parts.length > 1) {
+ final timeParts = parts[1].split(':');
+ if (timeParts.length >= 2) {
+ hour = int.parse(timeParts[0]);
+ minute = int.parse(timeParts[1]);
+ if (timeParts.length >= 3) {
+ second = int.parse(timeParts[2]);
+ }
+ }
+ }
+ final j = Jalali(year, month, day);
+ final dt = j.toDateTime();
+ return DateTime(dt.year, dt.month, dt.day, hour, minute, second);
+ }
+ } catch (_) {
+ // fallthrough
+ }
+ }
+ // ISO or other parseable formats
+ try {
+ return DateTime.parse(value);
+ } catch (_) {
+ return DateTime.now();
+ }
+ }
+ return DateTime.now();
+ }
}
diff --git a/hesabixUI/hesabix_ui/lib/models/business_user_model.dart b/hesabixUI/hesabix_ui/lib/models/business_user_model.dart
index f7753c8..7ec97aa 100644
--- a/hesabixUI/hesabix_ui/lib/models/business_user_model.dart
+++ b/hesabixUI/hesabix_ui/lib/models/business_user_model.dart
@@ -54,7 +54,7 @@ class BusinessUser {
try {
// Parse Jalali date format: YYYY/MM/DD HH:MM:SS
final parts = dateValue.split(' ');
- if (parts.length >= 1) {
+ if (parts.isNotEmpty) {
final dateParts = parts[0].split('/');
if (dateParts.length == 3) {
final year = int.parse(dateParts[0]);
diff --git a/hesabixUI/hesabix_ui/lib/models/person_model.dart b/hesabixUI/hesabix_ui/lib/models/person_model.dart
index 87e35c8..5b4b986 100644
--- a/hesabixUI/hesabix_ui/lib/models/person_model.dart
+++ b/hesabixUI/hesabix_ui/lib/models/person_model.dart
@@ -80,7 +80,8 @@ enum PersonType {
employee('کارمند', 'Employee'),
supplier('تامینکننده', 'Supplier'),
partner('همکار', 'Partner'),
- seller('فروشنده', 'Seller');
+ seller('فروشنده', 'Seller'),
+ shareholder('سهامدار', 'Shareholder');
const PersonType(this.persianName, this.englishName);
final String persianName;
@@ -122,6 +123,15 @@ class Person {
final DateTime createdAt;
final DateTime updatedAt;
final List bankAccounts;
+ final int? shareCount;
+ // پورسانت
+ final double? commissionSalePercent;
+ final double? commissionSalesReturnPercent;
+ final double? commissionSalesAmount;
+ final double? commissionSalesReturnAmount;
+ final bool commissionExcludeDiscounts;
+ final bool commissionExcludeAdditionsDeductions;
+ final bool commissionPostInInvoiceDocument;
Person({
this.id,
@@ -151,6 +161,14 @@ class Person {
required this.createdAt,
required this.updatedAt,
this.bankAccounts = const [],
+ this.shareCount,
+ this.commissionSalePercent,
+ this.commissionSalesReturnPercent,
+ this.commissionSalesAmount,
+ this.commissionSalesReturnAmount,
+ this.commissionExcludeDiscounts = false,
+ this.commissionExcludeAdditionsDeductions = false,
+ this.commissionPostInInvoiceDocument = false,
});
factory Person.fromJson(Map json) {
@@ -191,6 +209,14 @@ class Person {
bankAccounts: (json['bank_accounts'] as List?)
?.map((ba) => PersonBankAccount.fromJson(ba))
.toList() ?? [],
+ shareCount: json['share_count'],
+ commissionSalePercent: (json['commission_sale_percent'] as num?)?.toDouble(),
+ commissionSalesReturnPercent: (json['commission_sales_return_percent'] as num?)?.toDouble(),
+ commissionSalesAmount: (json['commission_sales_amount'] as num?)?.toDouble(),
+ commissionSalesReturnAmount: (json['commission_sales_return_amount'] as num?)?.toDouble(),
+ commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false,
+ commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false,
+ commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false,
);
}
@@ -223,6 +249,14 @@ class Person {
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(),
+ 'share_count': shareCount,
+ 'commission_sale_percent': commissionSalePercent,
+ 'commission_sales_return_percent': commissionSalesReturnPercent,
+ 'commission_sales_amount': commissionSalesAmount,
+ 'commission_sales_return_amount': commissionSalesReturnAmount,
+ 'commission_exclude_discounts': commissionExcludeDiscounts,
+ 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions,
+ 'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
};
}
@@ -320,6 +354,14 @@ class PersonCreateRequest {
final String? email;
final String? website;
final List bankAccounts;
+ final int? shareCount;
+ final double? commissionSalePercent;
+ final double? commissionSalesReturnPercent;
+ final double? commissionSalesAmount;
+ final double? commissionSalesReturnAmount;
+ final bool? commissionExcludeDiscounts;
+ final bool? commissionExcludeAdditionsDeductions;
+ final bool? commissionPostInInvoiceDocument;
PersonCreateRequest({
required this.aliasName,
@@ -343,6 +385,14 @@ class PersonCreateRequest {
this.email,
this.website,
this.bankAccounts = const [],
+ this.shareCount,
+ this.commissionSalePercent,
+ this.commissionSalesReturnPercent,
+ this.commissionSalesAmount,
+ this.commissionSalesReturnAmount,
+ this.commissionExcludeDiscounts,
+ this.commissionExcludeAdditionsDeductions,
+ this.commissionPostInInvoiceDocument,
});
Map toJson() {
@@ -377,6 +427,14 @@ class PersonCreateRequest {
'sheba_number': ba.shebaNumber,
})
.toList(),
+ if (shareCount != null) 'share_count': shareCount,
+ if (commissionSalePercent != null) 'commission_sale_percent': commissionSalePercent,
+ if (commissionSalesReturnPercent != null) 'commission_sales_return_percent': commissionSalesReturnPercent,
+ if (commissionSalesAmount != null) 'commission_sales_amount': commissionSalesAmount,
+ if (commissionSalesReturnAmount != null) 'commission_sales_return_amount': commissionSalesReturnAmount,
+ if (commissionExcludeDiscounts != null) 'commission_exclude_discounts': commissionExcludeDiscounts,
+ if (commissionExcludeAdditionsDeductions != null) 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions,
+ if (commissionPostInInvoiceDocument != null) 'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
};
}
}
@@ -404,6 +462,14 @@ class PersonUpdateRequest {
final String? email;
final String? website;
final bool? isActive;
+ final int? shareCount;
+ final double? commissionSalePercent;
+ final double? commissionSalesReturnPercent;
+ final double? commissionSalesAmount;
+ final double? commissionSalesReturnAmount;
+ final bool? commissionExcludeDiscounts;
+ final bool? commissionExcludeAdditionsDeductions;
+ final bool? commissionPostInInvoiceDocument;
PersonUpdateRequest({
this.code,
@@ -428,6 +494,14 @@ class PersonUpdateRequest {
this.email,
this.website,
this.isActive,
+ this.shareCount,
+ this.commissionSalePercent,
+ this.commissionSalesReturnPercent,
+ this.commissionSalesAmount,
+ this.commissionSalesReturnAmount,
+ this.commissionExcludeDiscounts,
+ this.commissionExcludeAdditionsDeductions,
+ this.commissionPostInInvoiceDocument,
});
Map toJson() {
@@ -455,6 +529,14 @@ class PersonUpdateRequest {
if (email != null) json['email'] = email;
if (website != null) json['website'] = website;
if (isActive != null) json['is_active'] = isActive;
+ if (shareCount != null) json['share_count'] = shareCount;
+ if (commissionSalePercent != null) json['commission_sale_percent'] = commissionSalePercent;
+ if (commissionSalesReturnPercent != null) json['commission_sales_return_percent'] = commissionSalesReturnPercent;
+ if (commissionSalesAmount != null) json['commission_sales_amount'] = commissionSalesAmount;
+ if (commissionSalesReturnAmount != null) json['commission_sales_return_amount'] = commissionSalesReturnAmount;
+ if (commissionExcludeDiscounts != null) json['commission_exclude_discounts'] = commissionExcludeDiscounts;
+ if (commissionExcludeAdditionsDeductions != null) json['commission_exclude_additions_deductions'] = commissionExcludeAdditionsDeductions;
+ if (commissionPostInInvoiceDocument != null) json['commission_post_in_invoice_document'] = commissionPostInInvoiceDocument;
return json;
}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
new file mode 100644
index 0000000..2171f62
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+import 'package:hesabix_ui/l10n/app_localizations.dart';
+import 'package:hesabix_ui/core/api_client.dart';
+
+class AccountsPage extends StatefulWidget {
+ final int businessId;
+ const AccountsPage({super.key, required this.businessId});
+
+ @override
+ State createState() => _AccountsPageState();
+}
+
+class _AccountsPageState extends State {
+ bool _loading = true;
+ String? _error;
+ List _tree = const [];
+
+ @override
+ void initState() {
+ super.initState();
+ _fetch();
+ }
+
+ Future _fetch() async {
+ setState(() { _loading = true; _error = null; });
+ try {
+ final api = ApiClient();
+ final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
+ setState(() { _tree = res.data['data']['items'] ?? []; });
+ } catch (e) {
+ setState(() { _error = e.toString(); });
+ } finally {
+ setState(() { _loading = false; });
+ }
+ }
+
+ Widget _buildNode(Map node) {
+ final children = (node['children'] as List?) ?? const [];
+ if (children.isEmpty) {
+ return ListTile(
+ title: Text('${node['code']} - ${node['name']}'),
+ );
+ }
+ return ExpansionTile(
+ title: Text('${node['code']} - ${node['name']}'),
+ children: children.map((c) => _buildNode(Map.from(c))).toList(),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final t = AppLocalizations.of(context);
+ if (_loading) return const Center(child: CircularProgressIndicator());
+ if (_error != null) return Center(child: Text(_error!));
+ return Scaffold(
+ appBar: AppBar(title: Text(t.chartOfAccounts)),
+ body: RefreshIndicator(
+ onRefresh: _fetch,
+ child: ListView(
+ children: _tree.map((n) => _buildNode(Map.from(n))).toList(),
+ ),
+ ),
+ );
+ }
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
index fdc1b86..ba88ea0 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart
@@ -460,7 +460,7 @@ class _BusinessShellState extends State {
context.go('/login');
}
- Future _showAddPersonDialog() async {
+ Future showAddPersonDialog() async {
final result = await showDialog(
context: context,
builder: (context) => PersonFormDialog(
@@ -647,7 +647,7 @@ class _BusinessShellState extends State {
// Navigate to add new item
if (child.label == t.personsList) {
// Navigate to add person
- _showAddPersonDialog();
+ showAddPersonDialog();
} else if (child.label == t.products) {
// Navigate to add product
} else if (child.label == t.priceLists) {
@@ -802,7 +802,7 @@ class _BusinessShellState extends State {
return GestureDetector(
onTap: () {
if (item.label == t.people) {
- _showAddPersonDialog();
+ showAddPersonDialog();
}
// سایر مسیرهای افزودن در آینده متصل میشوند
},
@@ -898,7 +898,7 @@ class _BusinessShellState extends State {
context.pop();
// در حال حاضر فقط اشخاص پشتیبانی میشود
if (item.label == t.people) {
- _showAddPersonDialog();
+ showAddPersonDialog();
}
},
child: Container(
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart
index b347138..61ebb33 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart
@@ -38,22 +38,6 @@ class _PersonsPageState extends State {
}
return Scaffold(
- appBar: AppBar(
- title: Text(t.personsList),
- actions: [
- // دکمه اضافه کردن فقط در صورت داشتن دسترسی
- PermissionButton(
- section: 'people',
- action: 'add',
- authStore: widget.authStore,
- child: IconButton(
- onPressed: _addPerson,
- icon: const Icon(Icons.add),
- tooltip: t.addPerson,
- ),
- ),
- ],
- ),
body: DataTableWidget(
key: _personsTableKey,
config: _buildDataTableConfig(t),
@@ -66,6 +50,14 @@ class _PersonsPageState extends State {
return DataTableConfig(
endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons',
title: t.personsList,
+ excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel',
+ pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf',
+ getExportParams: () => {
+ 'business_id': widget.businessId,
+ },
+ showBackButton: true,
+ onBack: () => Navigator.of(context).maybePop(),
+ showTableIcon: false,
showRowNumbers: true,
enableRowSelection: true,
columns: [
@@ -131,6 +123,105 @@ class _PersonsPageState extends State {
'تاریخ ایجاد',
width: ColumnWidth.medium,
),
+ NumberColumn(
+ 'share_count',
+ 'تعداد سهام',
+ width: ColumnWidth.small,
+ textAlign: TextAlign.center,
+ decimalPlaces: 0,
+ ),
+ NumberColumn(
+ 'commission_sale_percent',
+ 'درصد پورسانت فروش',
+ width: ColumnWidth.medium,
+ decimalPlaces: 2,
+ suffix: '٪',
+ ),
+ NumberColumn(
+ 'commission_sales_return_percent',
+ 'درصد پورسانت برگشت از فروش',
+ width: ColumnWidth.medium,
+ decimalPlaces: 2,
+ suffix: '٪',
+ ),
+ NumberColumn(
+ 'commission_sales_amount',
+ 'مبلغ پورسانت فروش',
+ width: ColumnWidth.large,
+ decimalPlaces: 0,
+ ),
+ NumberColumn(
+ 'commission_sales_return_amount',
+ 'مبلغ پورسانت برگشت از فروش',
+ width: ColumnWidth.large,
+ decimalPlaces: 0,
+ ),
+ TextColumn(
+ 'payment_id',
+ t.personPaymentId,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.paymentId ?? '-',
+ ),
+ TextColumn(
+ 'registration_number',
+ t.personRegistrationNumber,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.registrationNumber ?? '-',
+ ),
+ TextColumn(
+ 'economic_id',
+ t.personEconomicId,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.economicId ?? '-',
+ ),
+ TextColumn(
+ 'country',
+ t.personCountry,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.country ?? '-',
+ ),
+ TextColumn(
+ 'province',
+ t.personProvince,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.province ?? '-',
+ ),
+ TextColumn(
+ 'city',
+ t.personCity,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.city ?? '-',
+ ),
+ TextColumn(
+ 'address',
+ t.personAddress,
+ width: ColumnWidth.extraLarge,
+ formatter: (person) => person.address ?? '-',
+ ),
+ TextColumn(
+ 'postal_code',
+ t.personPostalCode,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.postalCode ?? '-',
+ ),
+ TextColumn(
+ 'phone',
+ t.personPhone,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.phone ?? '-',
+ ),
+ TextColumn(
+ 'fax',
+ t.personFax,
+ width: ColumnWidth.medium,
+ formatter: (person) => person.fax ?? '-',
+ ),
+ TextColumn(
+ 'website',
+ t.personWebsite,
+ width: ColumnWidth.large,
+ formatter: (person) => person.website ?? '-',
+ ),
ActionColumn(
'actions',
'عملیات',
@@ -167,6 +258,21 @@ class _PersonsPageState extends State {
'province',
],
defaultPageSize: 20,
+ // انتقال دکمه افزودن به اکشنهای هدر جدول با کنترل دسترسی
+ customHeaderActions: [
+ PermissionButton(
+ section: 'people',
+ action: 'add',
+ authStore: widget.authStore,
+ child: Tooltip(
+ message: t.addPerson,
+ child: IconButton(
+ onPressed: _addPerson,
+ icon: const Icon(Icons.add),
+ ),
+ ),
+ ),
+ ],
);
}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart
index 49a6f84..2ab360b 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart
@@ -416,7 +416,7 @@ class _UsersPermissionsPageState extends State {
Widget _buildUsersList(AppLocalizations t, ThemeData theme, ColorScheme colorScheme) {
if (_loading) {
- return Container(
+ return SizedBox(
height: 200,
child: Center(
child: Column(
@@ -439,7 +439,7 @@ class _UsersPermissionsPageState extends State {
}
if (_error != null) {
- return Container(
+ return SizedBox(
height: 200,
child: Center(
child: Column(
@@ -475,7 +475,7 @@ class _UsersPermissionsPageState extends State {
}
if (_filteredUsers.isEmpty) {
- return Container(
+ return SizedBox(
height: 200,
child: Center(
child: Column(
@@ -881,23 +881,23 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
'draft': '${t.draft} ${t.warehouseTransfers}',
},
'settings': {
- 'business': '${t.businessSettings}',
- 'print': '${t.printSettings}',
- 'history': '${t.eventHistory}',
- 'users': '${t.usersAndPermissions}',
+ 'business': t.businessSettings,
+ 'print': t.printSettings,
+ 'history': t.eventHistory,
+ 'users': t.usersAndPermissions,
},
'storage': {
'view': '${t.view} ${t.storageSpace}',
'delete': '${t.delete} ${t.deleteFiles}',
},
'sms': {
- 'history': '${t.viewSmsHistory}',
- 'templates': '${t.manageSmsTemplates}',
+ 'history': t.viewSmsHistory,
+ 'templates': t.manageSmsTemplates,
},
'marketplace': {
- 'view': '${t.viewMarketplace}',
- 'buy': '${t.buyPlugins}',
- 'invoices': '${t.viewInvoices}',
+ 'view': t.viewMarketplace,
+ 'buy': t.buyPlugins,
+ 'invoices': t.viewInvoices,
},
};
}
@@ -1235,36 +1235,36 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
String _inferCurrentSectionKey(String title, String description) {
// جستجو بر اساس کلمات کلیدی ساده
final pairs = >{
- 'people': ['${AppLocalizations.of(context).people}'],
+ 'people': [(AppLocalizations.of(context).people)],
'people_transactions': [
- '${AppLocalizations.of(context).receiptsAndPayments}',
- '${AppLocalizations.of(context).receipts}',
- '${AppLocalizations.of(context).payments}',
+ (AppLocalizations.of(context).receiptsAndPayments),
+ (AppLocalizations.of(context).receipts),
+ (AppLocalizations.of(context).payments),
],
- 'products': ['${AppLocalizations.of(context).products}'],
- 'price_lists': ['${AppLocalizations.of(context).priceLists}'],
- 'categories': ['${AppLocalizations.of(context).categories}'],
- 'product_attributes': ['${AppLocalizations.of(context).productAttributes}'],
- 'bank_accounts': ['${AppLocalizations.of(context).bankAccounts}'],
- 'cash': ['${AppLocalizations.of(context).cash}'],
- 'petty_cash': ['${AppLocalizations.of(context).pettyCash}'],
- 'checks': ['${AppLocalizations.of(context).checks}'],
- 'wallet': ['${AppLocalizations.of(context).wallet}'],
- 'transfers': ['${AppLocalizations.of(context).transfers}'],
- 'invoices': ['${AppLocalizations.of(context).invoices}'],
- 'expenses_income': ['${AppLocalizations.of(context).expensesIncome}'],
- 'accounting_documents': ['${AppLocalizations.of(context).accountingDocuments}'],
- 'chart_of_accounts': ['${AppLocalizations.of(context).chartOfAccounts}'],
- 'opening_balance': ['${AppLocalizations.of(context).openingBalance}'],
- 'warehouses': ['${AppLocalizations.of(context).warehouses}'],
- 'warehouse_transfers': ['${AppLocalizations.of(context).warehouseTransfers}'],
- 'settings': ['${AppLocalizations.of(context).businessSettings}'],
- 'storage': ['${AppLocalizations.of(context).storageSpace}'],
- 'sms': ['${AppLocalizations.of(context).smsPanel}'],
- 'marketplace': ['${AppLocalizations.of(context).marketplace}'],
+ 'products': [(AppLocalizations.of(context).products)],
+ 'price_lists': [(AppLocalizations.of(context).priceLists)],
+ 'categories': [(AppLocalizations.of(context).categories)],
+ 'product_attributes': [(AppLocalizations.of(context).productAttributes)],
+ 'bank_accounts': [(AppLocalizations.of(context).bankAccounts)],
+ 'cash': [(AppLocalizations.of(context).cash)],
+ 'petty_cash': [(AppLocalizations.of(context).pettyCash)],
+ 'checks': [(AppLocalizations.of(context).checks)],
+ 'wallet': [(AppLocalizations.of(context).wallet)],
+ 'transfers': [(AppLocalizations.of(context).transfers)],
+ 'invoices': [(AppLocalizations.of(context).invoices)],
+ 'expenses_income': [(AppLocalizations.of(context).expensesIncome)],
+ 'accounting_documents': [(AppLocalizations.of(context).accountingDocuments)],
+ 'chart_of_accounts': [(AppLocalizations.of(context).chartOfAccounts)],
+ 'opening_balance': [(AppLocalizations.of(context).openingBalance)],
+ 'warehouses': [(AppLocalizations.of(context).warehouses)],
+ 'warehouse_transfers': [(AppLocalizations.of(context).warehouseTransfers)],
+ 'settings': [(AppLocalizations.of(context).businessSettings)],
+ 'storage': [(AppLocalizations.of(context).storageSpace)],
+ 'sms': [(AppLocalizations.of(context).smsPanel)],
+ 'marketplace': [(AppLocalizations.of(context).marketplace)],
};
- final hay = (title + ' ' + description).toLowerCase();
+ final hay = ('$title $description').toLowerCase();
for (final entry in pairs.entries) {
for (final token in entry.value) {
if (hay.contains(token.toLowerCase())) {
diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart
index 99c13be..618e02d 100644
--- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart
@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
+import 'package:shamsi_date/shamsi_date.dart';
import '../../models/business_models.dart';
import '../../services/business_api_service.dart';
+import '../../core/calendar_controller.dart';
+import '../../widgets/date_input_field.dart';
+import '../../core/date_utils.dart';
class NewBusinessPage extends StatefulWidget {
- const NewBusinessPage({super.key});
+ final CalendarController calendarController;
+ const NewBusinessPage({super.key, required this.calendarController});
@override
State createState() => _NewBusinessPageState();
@@ -16,15 +21,162 @@ class _NewBusinessPageState extends State {
final BusinessData _businessData = BusinessData();
int _currentStep = 0;
bool _isLoading = false;
+ int _fiscalTabIndex = 0;
+ late TextEditingController _fiscalTitleController;
+
+ @override
+ void initState() {
+ super.initState();
+ widget.calendarController.addListener(_onCalendarChanged);
+ _fiscalTitleController = TextEditingController();
+ // Set default selections for business type and field
+ _businessData.businessType ??= BusinessType.shop;
+ _businessData.businessField ??= BusinessField.commercial;
+ }
@override
void dispose() {
+ widget.calendarController.removeListener(_onCalendarChanged);
_pageController.dispose();
+ _fiscalTitleController.dispose();
super.dispose();
}
+ void _onCalendarChanged() {
+ if (_businessData.fiscalYears.isEmpty) return;
+ final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
+ if (fiscal.endDate != null) {
+ const autoPrefix = 'سال مالی منتهی به';
+ if (fiscal.title.trim().isEmpty || fiscal.title.trim().startsWith(autoPrefix)) {
+ setState(() {
+ final isJalali = widget.calendarController.isJalali;
+ final endStr = HesabixDateUtils.formatForDisplay(fiscal.endDate, isJalali);
+ fiscal.title = '$autoPrefix $endStr';
+ _fiscalTitleController.text = fiscal.title;
+ });
+ }
+ }
+ }
+
+ Widget _buildFiscalStep() {
+ if (_businessData.fiscalYears.isEmpty) {
+ _businessData.fiscalYears.add(FiscalYearData(isLast: true));
+ }
+ final fiscal = _businessData.fiscalYears[_fiscalTabIndex];
+
+ String _autoTitle() {
+ final isJalali = widget.calendarController.isJalali;
+ final end = fiscal.endDate;
+ if (end == null) return fiscal.title;
+ final endStr = HesabixDateUtils.formatForDisplay(end, isJalali);
+ return 'سال مالی منتهی به $endStr';
+ }
+
+ return Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 800),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'سال مالی',
+ style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
+ ),
+ ),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: DateInputField(
+ value: fiscal.startDate,
+ labelText: 'تاریخ شروع *',
+ lastDate: fiscal.endDate,
+ calendarController: widget.calendarController,
+ onChanged: (d) {
+ setState(() {
+ fiscal.startDate = d;
+ if (fiscal.startDate != null) {
+ if (widget.calendarController.isJalali) {
+ final j = Jalali.fromDateTime(fiscal.startDate!);
+ final jNext = Jalali(j.year + 1, j.month, j.day);
+ fiscal.endDate = jNext.toDateTime();
+ } else {
+ final s = fiscal.startDate!;
+ fiscal.endDate = DateTime(s.year + 1, s.month, s.day);
+ }
+ fiscal.title = _autoTitle();
+ _fiscalTitleController.text = fiscal.title;
+ }
+ });
+ },
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: DateInputField(
+ value: fiscal.endDate,
+ labelText: 'تاریخ پایان *',
+ firstDate: fiscal.startDate,
+ calendarController: widget.calendarController,
+ onChanged: (d) {
+ setState(() {
+ fiscal.endDate = d;
+ if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) {
+ fiscal.title = _autoTitle();
+ _fiscalTitleController.text = fiscal.title;
+ }
+ });
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _fiscalTitleController,
+ decoration: const InputDecoration(
+ labelText: 'عنوان سال مالی *',
+ border: OutlineInputBorder(),
+ ),
+ onChanged: (v) {
+ setState(() {
+ fiscal.title = v;
+ });
+ },
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 8),
+ Align(
+ alignment: Alignment.centerRight,
+ child: Text(
+ 'پرکردن عنوان، تاریخ شروع و پایان الزامی است.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
+ ),
+ ),
+ )
+ ],
+ ),
+ ),
+ ),
+ );
+ }
void _nextStep() {
- if (_currentStep < 3) {
+ if (_currentStep < 4) {
setState(() {
_currentStep++;
});
@@ -66,6 +218,8 @@ class _NewBusinessPageState extends State {
return _businessData.isStep2Valid();
case 2:
return _businessData.isStep3Valid();
+ case 3:
+ return _businessData.isFiscalStepValid();
default:
return false;
}
@@ -84,6 +238,8 @@ class _NewBusinessPageState extends State {
case 2:
return t.businessLegalInfo;
case 3:
+ return 'سال مالی';
+ case 4:
return t.businessConfirmation;
default:
return '';
@@ -122,7 +278,7 @@ class _NewBusinessPageState extends State {
duration: const Duration(seconds: 2),
),
);
- context.pop();
+ context.goNamed('profile_businesses');
}
} catch (e) {
if (mounted) {
@@ -170,7 +326,7 @@ class _NewBusinessPageState extends State {
children: [
// Progress bar
Row(
- children: List.generate(4, (index) {
+ children: List.generate(5, (index) {
final isActive = index <= _currentStep;
final isCurrent = index == _currentStep;
@@ -203,7 +359,7 @@ class _NewBusinessPageState extends State {
const SizedBox(height: 8),
// Progress text
Text(
- '${t.step} ${_currentStep + 1} ${t.ofText} 4',
+ '${t.step} ${_currentStep + 1} ${t.ofText} 5',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
@@ -223,7 +379,8 @@ class _NewBusinessPageState extends State {
_buildStepIndicator(0, t.businessBasicInfo),
_buildStepIndicator(1, t.businessContactInfo),
_buildStepIndicator(2, t.businessLegalInfo),
- _buildStepIndicator(3, t.businessConfirmation),
+ _buildStepIndicator(3, 'سال مالی'),
+ _buildStepIndicator(4, t.businessConfirmation),
],
),
),
@@ -293,6 +450,7 @@ class _NewBusinessPageState extends State {
_buildStep1(),
_buildStep2(),
_buildStep3(),
+ _buildFiscalStep(),
_buildStep4(),
],
),
@@ -326,9 +484,9 @@ class _NewBusinessPageState extends State {
SizedBox(
width: double.infinity,
child: _buildNavigationButton(
- text: _currentStep < 3 ? t.next : t.createBusiness,
- icon: _currentStep < 3 ? Icons.arrow_forward_ios : Icons.check,
- onPressed: _currentStep < 3
+ text: _currentStep < 4 ? t.next : t.createBusiness,
+ icon: _currentStep < 4 ? Icons.arrow_forward_ios : Icons.check,
+ onPressed: _currentStep < 4
? (_canGoToNextStep() ? _nextStep : null)
: (_isLoading ? null : _submitBusiness),
isPrimary: true,
@@ -361,7 +519,7 @@ class _NewBusinessPageState extends State {
),
Row(
children: [
- if (_currentStep < 3) ...[
+ if (_currentStep < 4) ...[
_buildNavigationButton(
text: t.next,
icon: Icons.arrow_forward_ios,
@@ -1381,6 +1539,8 @@ class _NewBusinessPageState extends State {
_buildSummaryItem(t.city, _businessData.city!),
if (_businessData.postalCode?.isNotEmpty == true)
_buildSummaryItem(t.postalCode, _businessData.postalCode!),
+ if (_businessData.fiscalYears.isNotEmpty)
+ _buildSummaryItem('سال مالی', _businessData.fiscalYears.first.title),
],
),
),
diff --git a/hesabixUI/hesabix_ui/lib/services/person_service.dart b/hesabixUI/hesabix_ui/lib/services/person_service.dart
index 8149b40..079175b 100644
--- a/hesabixUI/hesabix_ui/lib/services/person_service.dart
+++ b/hesabixUI/hesabix_ui/lib/services/person_service.dart
@@ -1,4 +1,3 @@
-import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/person_model.dart';
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 829c4ad..4133600 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
@@ -193,6 +193,10 @@ class DataTableConfig {
final String? dateRangeField;
final String? title;
final String? subtitle;
+ // Header controls
+ final bool showBackButton;
+ final VoidCallback? onBack;
+ final bool showTableIcon;
final bool showSearch;
final bool showFilters;
final bool showPagination;
@@ -267,6 +271,9 @@ class DataTableConfig {
this.dateRangeField,
this.title,
this.subtitle,
+ this.showBackButton = false,
+ this.onBack,
+ this.showTableIcon = true,
this.showSearch = true,
this.showFilters = false,
this.showPagination = true,
@@ -395,13 +402,27 @@ class DataTableResponse {
) {
final data = json['data'] as Map;
final itemsList = data['items'] as List? ?? [];
+ // Support both old and new pagination shapes
+ final pagination = data['pagination'] as Map?;
+ final total = pagination != null
+ ? (pagination['total'] as num?)?.toInt() ?? 0
+ : (data['total'] as num?)?.toInt() ?? 0;
+ final page = pagination != null
+ ? (pagination['page'] as num?)?.toInt() ?? 1
+ : (data['page'] as num?)?.toInt() ?? 1;
+ final limit = pagination != null
+ ? (pagination['per_page'] as num?)?.toInt() ?? 20
+ : (data['limit'] as num?)?.toInt() ?? 20;
+ final totalPages = pagination != null
+ ? (pagination['total_pages'] as num?)?.toInt() ?? 0
+ : (data['total_pages'] as num?)?.toInt() ?? 0;
return DataTableResponse(
items: itemsList.map((item) => fromJsonT(item as Map)).toList(),
- total: (data['total'] as num?)?.toInt() ?? 0,
- page: (data['page'] as num?)?.toInt() ?? 1,
- limit: (data['limit'] as num?)?.toInt() ?? 20,
- totalPages: (data['total_pages'] as num?)?.toInt() ?? 0,
+ total: total,
+ page: page,
+ limit: limit,
+ totalPages: totalPages,
);
}
}
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 ac42160..af453ad 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
@@ -1,4 +1,6 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
+import 'helpers/file_saver.dart';
// import 'dart:html' as html; // Not available on Linux
import 'package:flutter/material.dart';
import 'package:data_table_2/data_table_2.dart';
@@ -561,6 +563,16 @@ class _DataTableWidgetState extends State> {
params['selected_indices'] = _selectedRows.toList();
}
+ // Add export columns in current visible order (excluding ActionColumn)
+ final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
+ ? _visibleColumns
+ : widget.config.columns;
+ final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
+ params['export_columns'] = dataColumnsToShow.map((c) => {
+ 'key': c.key,
+ 'label': c.label,
+ }).toList();
+
// Add custom export parameters if provided
if (widget.config.getExportParams != null) {
final customParams = widget.config.getExportParams!();
@@ -620,17 +632,25 @@ class _DataTableWidgetState extends State> {
}
}
- // Platform-specific download functions for Linux
+ // Cross-platform save using conditional FileSaver
+ Future _saveBytesToDownloads(dynamic data, String filename) async {
+ List bytes;
+ if (data is List) {
+ bytes = data;
+ } else if (data is Uint8List) {
+ bytes = data.toList();
+ } else {
+ throw Exception('Unsupported binary data type: ${data.runtimeType}');
+ }
+ await FileSaver.saveBytes(bytes, filename);
+ }
+
Future _downloadPdf(dynamic data, String filename) async {
- // For Linux desktop, we'll save to Downloads folder
- debugPrint('Download PDF: $filename (Linux desktop - save to Downloads folder)');
- // TODO: Implement proper file saving for Linux
+ await _saveBytesToDownloads(data, filename);
}
Future _downloadExcel(dynamic data, String filename) async {
- // For Linux desktop, we'll save to Downloads folder
- debugPrint('Download Excel: $filename (Linux desktop - save to Downloads folder)');
- // TODO: Implement proper file saving for Linux
+ await _saveBytesToDownloads(data, filename);
}
@@ -641,15 +661,16 @@ class _DataTableWidgetState extends State> {
return Card(
elevation: widget.config.boxShadow != null ? 2 : 0,
- shape: widget.config.borderRadius != null
- ? RoundedRectangleBorder(borderRadius: widget.config.borderRadius!)
- : null,
+ clipBehavior: Clip.antiAlias,
+ shape: RoundedRectangleBorder(
+ borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
+ ),
child: Container(
padding: widget.config.padding ?? const EdgeInsets.all(16),
margin: widget.config.margin,
decoration: BoxDecoration(
color: widget.config.backgroundColor,
- borderRadius: widget.config.borderRadius,
+ borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
border: widget.config.showBorder
? Border.all(
color: widget.config.borderColor ?? theme.dividerColor,
@@ -719,19 +740,35 @@ class _DataTableWidgetState extends State> {
Widget _buildHeader(AppLocalizations t, ThemeData theme) {
return Row(
children: [
- Container(
- padding: const EdgeInsets.all(6),
- decoration: BoxDecoration(
- color: theme.colorScheme.primaryContainer,
- borderRadius: BorderRadius.circular(6),
+ if (widget.config.showBackButton) ...[
+ Tooltip(
+ message: MaterialLocalizations.of(context).backButtonTooltip,
+ child: IconButton(
+ onPressed: widget.config.onBack ?? () {
+ if (Navigator.of(context).canPop()) {
+ Navigator.of(context).pop();
+ }
+ },
+ icon: const Icon(Icons.arrow_back),
+ ),
),
- child: Icon(
- Icons.table_chart,
- color: theme.colorScheme.onPrimaryContainer,
- size: 18,
+ const SizedBox(width: 8),
+ ],
+ if (widget.config.showTableIcon) ...[
+ Container(
+ padding: const EdgeInsets.all(6),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Icon(
+ Icons.table_chart,
+ color: theme.colorScheme.onPrimaryContainer,
+ size: 18,
+ ),
),
- ),
- const SizedBox(width: 12),
+ const SizedBox(width: 12),
+ ],
Text(
widget.config.title!,
style: theme.textTheme.titleMedium?.copyWith(
diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart
index a706659..2da036d 100644
--- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart
+++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart
@@ -105,9 +105,14 @@ class ColumnSettingsService {
}
// Ensure all default columns are present in visible columns
+ // If new columns are added (not in user settings), include them by default
final visibleColumns = [];
+ final userVisible = Set.from(userSettings.visibleColumns);
for (final key in defaultColumnKeys) {
- if (userSettings.visibleColumns.contains(key)) {
+ if (userVisible.contains(key)) {
+ visibleColumns.add(key);
+ } else {
+ // New column introduced → show by default
visibleColumns.add(key);
}
}
@@ -117,15 +122,13 @@ class ColumnSettingsService {
visibleColumns.add(defaultColumnKeys.first);
}
- // Ensure all visible columns are in the correct order
+ // Build columnOrder: keep user's order for known columns, append new ones at the end
final columnOrder = [];
for (final key in userSettings.columnOrder) {
if (visibleColumns.contains(key)) {
columnOrder.add(key);
}
}
-
- // Add any missing visible columns to the end
for (final key in visibleColumns) {
if (!columnOrder.contains(key)) {
columnOrder.add(key);
diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart
new file mode 100644
index 0000000..e7bf083
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart
@@ -0,0 +1,4 @@
+// Conditional export of platform-specific implementations
+export 'file_saver_io.dart' if (dart.library.html) 'file_saver_web.dart';
+
+
diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart
new file mode 100644
index 0000000..75a2f93
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart
@@ -0,0 +1,16 @@
+import 'dart:io';
+
+class FileSaver {
+ static Future saveBytes(List bytes, String filename) async {
+ final homeDir = Platform.environment['HOME'] ?? Directory.current.path;
+ final downloadsDir = Directory('$homeDir/Downloads');
+ if (!await downloadsDir.exists()) {
+ await downloadsDir.create(recursive: true);
+ }
+ final file = File('${downloadsDir.path}/$filename');
+ await file.writeAsBytes(bytes, flush: true);
+ return file.path;
+ }
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart
new file mode 100644
index 0000000..d002fdc
--- /dev/null
+++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart
@@ -0,0 +1,15 @@
+import 'dart:html' as html;
+
+class FileSaver {
+ static Future saveBytes(List bytes, String filename) async {
+ final blob = html.Blob([bytes]);
+ final url = html.Url.createObjectUrlFromBlob(blob);
+ html.AnchorElement(href: url)
+ ..setAttribute('download', filename)
+ ..click();
+ html.Url.revokeObjectUrl(url);
+ return null;
+ }
+}
+
+
diff --git a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart
index 5db1b42..57a344f 100644
--- a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart
+++ b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart
@@ -31,6 +31,13 @@ class _JalaliDatePickerState extends State {
void initState() {
super.initState();
_selectedDate = widget.initialDate ?? DateTime.now();
+ // Clamp initial within range if provided
+ if (widget.firstDate != null && _selectedDate.isBefore(widget.firstDate!)) {
+ _selectedDate = widget.firstDate!;
+ }
+ if (widget.lastDate != null && _selectedDate.isAfter(widget.lastDate!)) {
+ _selectedDate = widget.lastDate!;
+ }
_selectedJalali = Jalali.fromDateTime(_selectedDate);
}
@@ -218,6 +225,10 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
}
void _selectDate(Jalali date) {
+ // Enforce range limits
+ if (date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0) {
+ return;
+ }
setState(() {
_selectedDate = date;
});
@@ -305,18 +316,23 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
date.month == Jalali.now().month &&
date.day == Jalali.now().day;
+ final isDisabled = date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0;
return GestureDetector(
- onTap: () => _selectDate(date),
+ onTap: isDisabled ? null : () => _selectDate(date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
- color: isSelected
+ color: isDisabled
+ ? theme.disabledColor.withValues(alpha: 0.1)
+ : isSelected
? theme.colorScheme.primary
: isToday
? theme.colorScheme.primary.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
- border: isToday && !isSelected
+ border: isDisabled
+ ? Border.all(color: theme.disabledColor.withValues(alpha: 0.3), width: 1)
+ : isToday && !isSelected
? Border.all(color: theme.colorScheme.primary, width: 1)
: null,
),
@@ -324,7 +340,9 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
child: Text(
day.toString(),
style: theme.textTheme.bodyMedium?.copyWith(
- color: isSelected
+ color: isDisabled
+ ? theme.disabledColor
+ : isSelected
? theme.colorScheme.onPrimary
: isToday
? theme.colorScheme.primary
diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart
index c51557c..dd4880b 100644
--- a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart
+++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
+import 'package:flutter/services.dart';
import '../../models/person_model.dart';
import '../../services/person_service.dart';
@@ -51,6 +52,15 @@ class _PersonFormDialogState extends State {
final _faxController = TextEditingController();
final _emailController = TextEditingController();
final _websiteController = TextEditingController();
+ final _shareCountController = TextEditingController();
+ // Commission controllers & state
+ final _commissionSalePercentController = TextEditingController();
+ final _commissionSalesReturnPercentController = TextEditingController();
+ final _commissionSalesAmountController = TextEditingController();
+ final _commissionSalesReturnAmountController = TextEditingController();
+ bool _commissionExcludeDiscounts = false;
+ bool _commissionExcludeAdditionsDeductions = false;
+ bool _commissionPostInInvoiceDocument = false;
PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
final Set _selectedPersonTypes = {};
@@ -96,6 +106,26 @@ class _PersonFormDialogState extends State {
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
_isActive = person.isActive;
_bankAccounts = List.from(person.bankAccounts);
+ // مقدار اولیه سهام
+ if (person.personTypes.contains(PersonType.shareholder) && person.shareCount != null) {
+ _shareCountController.text = person.shareCount!.toString();
+ }
+ // مقدار اولیه پورسانت
+ if (person.commissionSalePercent != null) {
+ _commissionSalePercentController.text = person.commissionSalePercent!.toString();
+ }
+ if (person.commissionSalesReturnPercent != null) {
+ _commissionSalesReturnPercentController.text = person.commissionSalesReturnPercent!.toString();
+ }
+ if (person.commissionSalesAmount != null) {
+ _commissionSalesAmountController.text = person.commissionSalesAmount!.toString();
+ }
+ if (person.commissionSalesReturnAmount != null) {
+ _commissionSalesReturnAmountController.text = person.commissionSalesReturnAmount!.toString();
+ }
+ _commissionExcludeDiscounts = person.commissionExcludeDiscounts;
+ _commissionExcludeAdditionsDeductions = person.commissionExcludeAdditionsDeductions;
+ _commissionPostInInvoiceDocument = person.commissionPostInInvoiceDocument;
}
}
@@ -120,6 +150,11 @@ class _PersonFormDialogState extends State {
_faxController.dispose();
_emailController.dispose();
_websiteController.dispose();
+ _shareCountController.dispose();
+ _commissionSalePercentController.dispose();
+ _commissionSalesReturnPercentController.dispose();
+ _commissionSalesAmountController.dispose();
+ _commissionSalesReturnAmountController.dispose();
super.dispose();
}
@@ -136,7 +171,7 @@ class _PersonFormDialogState extends State {
final personData = PersonCreateRequest(
code: _autoGenerateCode
? null
- : (int.tryParse(_codeController.text.trim()) ?? null),
+ : (int.tryParse(_codeController.text.trim())),
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
@@ -157,6 +192,31 @@ class _PersonFormDialogState extends State {
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
bankAccounts: _bankAccounts,
+ shareCount: _selectedPersonTypes.contains(PersonType.shareholder)
+ ? int.tryParse(_shareCountController.text.trim())
+ : null,
+ // commission fields only if marketer or seller
+ commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalePercentController.text.trim())
+ : null,
+ commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesReturnPercentController.text.trim())
+ : null,
+ commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesAmountController.text.trim())
+ : null,
+ commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesReturnAmountController.text.trim())
+ : null,
+ commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionExcludeDiscounts
+ : null,
+ commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionExcludeAdditionsDeductions
+ : null,
+ commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionPostInInvoiceDocument
+ : null,
);
await _personService.createPerson(
@@ -166,7 +226,7 @@ class _PersonFormDialogState extends State {
} else {
// Update existing person
final personData = PersonUpdateRequest(
- code: (int.tryParse(_codeController.text.trim()) ?? null),
+ code: (int.tryParse(_codeController.text.trim())),
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
@@ -188,6 +248,30 @@ class _PersonFormDialogState extends State {
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
isActive: _isActive,
+ shareCount: _selectedPersonTypes.contains(PersonType.shareholder)
+ ? int.tryParse(_shareCountController.text.trim())
+ : null,
+ commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalePercentController.text.trim())
+ : null,
+ commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesReturnPercentController.text.trim())
+ : null,
+ commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesAmountController.text.trim())
+ : null,
+ commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? double.tryParse(_commissionSalesReturnAmountController.text.trim())
+ : null,
+ commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionExcludeDiscounts
+ : null,
+ commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionExcludeAdditionsDeductions
+ : null,
+ commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller))
+ ? _commissionPostInInvoiceDocument
+ : null,
);
await _personService.updatePerson(
@@ -288,53 +372,68 @@ class _PersonFormDialogState extends State {
Expanded(
child: Form(
key: _formKey,
- child: DefaultTabController(
- length: 4,
- child: Column(
- children: [
- TabBar(
- isScrollable: true,
- tabs: [
- Tab(text: t.personBasicInfo),
- Tab(text: t.personEconomicInfo),
- Tab(text: t.personContactInfo),
- Tab(text: t.personBankInfo),
- ],
+ child: Builder(builder: (context) {
+ final hasCommissionTab = _selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller);
+ final tabs = [
+ Tab(text: t.personBasicInfo),
+ Tab(text: t.personEconomicInfo),
+ Tab(text: t.personContactInfo),
+ Tab(text: t.personBankInfo),
+ ];
+ final views = [
+ SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ child: _buildBasicInfoFields(t),
),
- const SizedBox(height: 12),
- Expanded(
- child: TabBarView(
- children: [
- SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
- child: _buildBasicInfoFields(t),
- ),
- ),
- SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
- child: _buildEconomicInfoFields(t),
- ),
- ),
- SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
- child: _buildContactInfoFields(t),
- ),
- ),
- SingleChildScrollView(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
- child: _buildBankAccountsSection(t),
- ),
- ),
- ],
+ ),
+ SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ child: _buildEconomicInfoFields(t),
+ ),
+ ),
+ SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ child: _buildContactInfoFields(t),
+ ),
+ ),
+ SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ child: _buildBankAccountsSection(t),
+ ),
+ ),
+ ];
+ if (hasCommissionTab) {
+ tabs.add(const Tab(text: 'پورسانت'));
+ views.add(
+ SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
+ child: _buildCommissionTab(),
),
),
- ],
- ),
- ),
+ );
+ }
+ return DefaultTabController(
+ key: ValueKey(tabs.length),
+ length: tabs.length,
+ child: Column(
+ children: [
+ TabBar(
+ isScrollable: true,
+ tabs: tabs,
+ ),
+ const SizedBox(height: 12),
+ Expanded(
+ child: TabBarView(children: views),
+ ),
+ ],
+ ),
+ );
+ }),
),
),
@@ -367,6 +466,128 @@ class _PersonFormDialogState extends State {
);
}
+ Widget _buildCommissionTab() {
+ final isMarketer = _selectedPersonTypes.contains(PersonType.marketer);
+ final isSeller = _selectedPersonTypes.contains(PersonType.seller);
+ if (!isMarketer && !isSeller) {
+ return Center(
+ child: Text('این بخش فقط برای بازاریاب/فروشنده نمایش داده میشود'),
+ );
+ }
+
+ return Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: TextFormField(
+ controller: _commissionSalePercentController,
+ decoration: const InputDecoration(
+ labelText: 'درصد از فروش',
+ suffixText: '%',
+ ),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
+ validator: (v) {
+ if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
+ final num? val = num.tryParse(v);
+ if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
+ }
+ return null;
+ },
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: TextFormField(
+ controller: _commissionSalesReturnPercentController,
+ decoration: const InputDecoration(
+ labelText: 'درصد از برگشت از فروش',
+ suffixText: '%',
+ ),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
+ validator: (v) {
+ if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
+ final num? val = num.tryParse(v);
+ if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
+ }
+ return null;
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: TextFormField(
+ controller: _commissionSalesAmountController,
+ decoration: const InputDecoration(
+ labelText: 'مبلغ فروش',
+ ),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
+ validator: (v) {
+ if (v != null && v.isNotEmpty) {
+ final num? val = num.tryParse(v);
+ if (val == null || val < 0) return 'باید عدد مثبت باشد';
+ }
+ return null;
+ },
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: TextFormField(
+ controller: _commissionSalesReturnAmountController,
+ decoration: const InputDecoration(
+ labelText: 'مبلغ برگشت از فروش',
+ ),
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
+ validator: (v) {
+ if (v != null && v.isNotEmpty) {
+ final num? val = num.tryParse(v);
+ if (val == null || val < 0) return 'باید عدد مثبت باشد';
+ }
+ return null;
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: SwitchListTile(
+ title: const Text('عدم محاسبه تخفیف'),
+ value: _commissionExcludeDiscounts,
+ onChanged: (v) { setState(() { _commissionExcludeDiscounts = v; }); },
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: SwitchListTile(
+ title: const Text('عدم محاسبه اضافات و کسورات فاکتور'),
+ value: _commissionExcludeAdditionsDeductions,
+ onChanged: (v) { setState(() { _commissionExcludeAdditionsDeductions = v; }); },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ SwitchListTile(
+ title: const Text('ثبت پورسانت در سند حسابداری فاکتور'),
+ value: _commissionPostInInvoiceDocument,
+ onChanged: (v) { setState(() { _commissionPostInInvoiceDocument = v; }); },
+ ),
+ ],
+ );
+ }
+
Widget _buildSectionHeader(String title) {
return Text(
title,
@@ -457,6 +678,34 @@ class _PersonFormDialogState extends State {
],
),
const SizedBox(height: 8),
+ if (_selectedPersonTypes.contains(PersonType.shareholder))
+ Row(
+ children: [
+ Expanded(
+ child: TextFormField(
+ controller: _shareCountController,
+ decoration: const InputDecoration(
+ labelText: 'تعداد سهام',
+ hintText: 'عدد صحیح بدون اعشار',
+ ),
+ keyboardType: TextInputType.number,
+ validator: (value) {
+ if (_selectedPersonTypes.contains(PersonType.shareholder)) {
+ if (value == null || value.trim().isEmpty) {
+ return 'برای سهامدار، تعداد سهام الزامی است';
+ }
+ final parsed = int.tryParse(value.trim());
+ if (parsed == null || parsed <= 0) {
+ return 'تعداد سهام باید عدد صحیح بزرگتر از صفر باشد';
+ }
+ }
+ return null;
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
Row(
children: [
Expanded(
diff --git a/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart b/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart
index 2888de5..41dfa59 100644
--- a/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart
+++ b/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
-import 'package:go_router/go_router.dart';
import '../core/auth_store.dart';
class UrlTracker extends StatefulWidget {
@@ -29,7 +28,7 @@ class _UrlTrackerState extends State {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
- final currentUrl = GoRouterState.of(context).uri.path;
+ final currentUrl = Uri.base.path;
if (currentUrl != _lastTrackedUrl &&
currentUrl.isNotEmpty &&
currentUrl != '/' &&