progress in persons

This commit is contained in:
Hesabix 2025-09-27 21:19:00 +03:30
parent bd34093dac
commit a8e5c3d14c
53 changed files with 2926 additions and 189 deletions

View file

@ -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)

View file

@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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"<td>{escape(value)}</td>")
rows_html.append(f"<tr>{''.join(tds)}</tr>")
headers_html = ''.join(f"<th>{escape(h)}</th>" 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"""
<html lang=\"fa\" dir=\"rtl\">
<head>
<meta charset='utf-8'>
<style>
@page {{
size: A4 landscape;
margin: 12mm;
@bottom-right {{
content: "صفحه " counter(page) " از " counter(pages);
font-size: 10px;
color: #666;
}}
}}
body {{
font-family: sans-serif;
font-size: 11px;
color: #222;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 2px solid #444;
padding-bottom: 6px;
}}
.title {{
font-size: 16px;
font-weight: 700;
}}
.meta {{
font-size: 11px;
color: #555;
}}
.table-wrapper {{
width: 100%;
}}
table.report-table {{
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}}
thead th {{
background: #f0f3f7;
border: 1px solid #c7cdd6;
padding: 6px 4px;
text-align: center;
font-weight: 700;
white-space: nowrap;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: left;
}}
</style>
</head>
<body>
<div class=\"header\">
<div>
<div class=\"title\">گزارش لیست اشخاص</div>
<div class=\"meta\">نام کسب‌وکار: {escape(business_name)}</div>
</div>
<div class=\"meta\">تاریخ گزارش: {escape(now)}</div>
</div>
<div class=\"table-wrapper\">
<table class=\"report-table\">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class=\"footer\">تولید شده توسط Hesabix</div>
</body>
</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="دریافت جزئیات یک شخص",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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="شناسه ملی")

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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": "", "code": "PLN"},
{"name": "Czech Koruna", "title": "Koruna", "symbol": "", "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)})

View file

@ -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})

View file

@ -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
"""
)

View file

@ -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})

View file

@ -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')

View file

@ -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 ###

View file

@ -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

View file

@ -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<Response<T>> get<T>(String path, {Map<String, dynamic>? 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<Response<T>> post<T>(String path, {Object? data, Map<String, dynamic>? 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<Response<T>> put<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
path = _resolveApiPath(path);
return _dio.put<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
Future<Response<T>> patch<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
path = _resolveApiPath(path);
return _dio.patch<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
}
Future<Response<T>> delete<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
path = _resolveApiPath(path);
return _dio.delete<T>(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'//+'), '/');
}

View file

@ -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<MyApp> {
// برای سایر صفحات (شامل صفحات 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: <RouteBase>[
@ -354,7 +368,7 @@ class _MyAppState extends State<MyApp> {
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<MyApp> {
);
},
),
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',

View file

@ -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<FiscalYearData> fiscalYears;
BusinessData({
this.name = '',
this.businessType,
@ -57,14 +61,16 @@ class BusinessData {
this.country,
this.province,
this.city,
});
List<FiscalYearData>? fiscalYears,
}) : fiscalYears = fiscalYears ?? <FiscalYearData>[];
// تبدیل به Map برای ارسال به API
Map<String, dynamic> 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<FiscalYearData>? 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<String, dynamic> 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();
}
}

View file

@ -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]);

View file

@ -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<PersonBankAccount> 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<String, dynamic> json) {
@ -191,6 +209,14 @@ class Person {
bankAccounts: (json['bank_accounts'] as List<dynamic>?)
?.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<PersonBankAccount> 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<String, dynamic> 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<String, dynamic> 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;
}

View file

@ -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<AccountsPage> createState() => _AccountsPageState();
}
class _AccountsPageState extends State<AccountsPage> {
bool _loading = true;
String? _error;
List<dynamic> _tree = const [];
@override
void initState() {
super.initState();
_fetch();
}
Future<void> _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<String, dynamic> 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<Widget>((c) => _buildNode(Map<String, dynamic>.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<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
),
),
);
}
}

View file

@ -460,7 +460,7 @@ class _BusinessShellState extends State<BusinessShell> {
context.go('/login');
}
Future<void> _showAddPersonDialog() async {
Future<void> showAddPersonDialog() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => PersonFormDialog(
@ -647,7 +647,7 @@ class _BusinessShellState extends State<BusinessShell> {
// 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<BusinessShell> {
return GestureDetector(
onTap: () {
if (item.label == t.people) {
_showAddPersonDialog();
showAddPersonDialog();
}
// سایر مسیرهای افزودن در آینده متصل میشوند
},
@ -898,7 +898,7 @@ class _BusinessShellState extends State<BusinessShell> {
context.pop();
// در حال حاضر فقط اشخاص پشتیبانی میشود
if (item.label == t.people) {
_showAddPersonDialog();
showAddPersonDialog();
}
},
child: Container(

View file

@ -38,22 +38,6 @@ class _PersonsPageState extends State<PersonsPage> {
}
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<Person>(
key: _personsTableKey,
config: _buildDataTableConfig(t),
@ -66,6 +50,14 @@ class _PersonsPageState extends State<PersonsPage> {
return DataTableConfig<Person>(
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<PersonsPage> {
'تاریخ ایجاد',
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<PersonsPage> {
'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),
),
),
),
],
);
}

View file

@ -416,7 +416,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
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<UsersPermissionsPage> {
}
if (_error != null) {
return Container(
return SizedBox(
height: 200,
child: Center(
child: Column(
@ -475,7 +475,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
}
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 = <String, List<String>>{
'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())) {

View file

@ -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<NewBusinessPage> createState() => _NewBusinessPageState();
@ -16,15 +21,162 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
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<NewBusinessPage> {
return _businessData.isStep2Valid();
case 2:
return _businessData.isStep3Valid();
case 3:
return _businessData.isFiscalStepValid();
default:
return false;
}
@ -84,6 +238,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
case 2:
return t.businessLegalInfo;
case 3:
return 'سال مالی';
case 4:
return t.businessConfirmation;
default:
return '';
@ -122,7 +278,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
duration: const Duration(seconds: 2),
),
);
context.pop();
context.goNamed('profile_businesses');
}
} catch (e) {
if (mounted) {
@ -170,7 +326,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
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<NewBusinessPage> {
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<NewBusinessPage> {
_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<NewBusinessPage> {
_buildStep1(),
_buildStep2(),
_buildStep3(),
_buildFiscalStep(),
_buildStep4(),
],
),
@ -326,9 +484,9 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
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<NewBusinessPage> {
),
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<NewBusinessPage> {
_buildSummaryItem(t.city, _businessData.city!),
if (_businessData.postalCode?.isNotEmpty == true)
_buildSummaryItem(t.postalCode, _businessData.postalCode!),
if (_businessData.fiscalYears.isNotEmpty)
_buildSummaryItem('سال مالی', _businessData.fiscalYears.first.title),
],
),
),

View file

@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/person_model.dart';

View file

@ -193,6 +193,10 @@ class DataTableConfig<T> {
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<T> {
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<T> {
) {
final data = json['data'] as Map<String, dynamic>;
final itemsList = data['items'] as List? ?? [];
// Support both old and new pagination shapes
final pagination = data['pagination'] as Map<String, dynamic>?;
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<T>(
items: itemsList.map((item) => fromJsonT(item as Map<String, dynamic>)).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,
);
}
}

View file

@ -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<T> extends State<DataTableWidget<T>> {
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<T> extends State<DataTableWidget<T>> {
}
}
// Platform-specific download functions for Linux
// Cross-platform save using conditional FileSaver
Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
List<int> bytes;
if (data is List<int>) {
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<void> _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<void> _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<T> extends State<DataTableWidget<T>> {
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<T> extends State<DataTableWidget<T>> {
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(

View file

@ -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 = <String>[];
final userVisible = Set<String>.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 = <String>[];
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);

View file

@ -0,0 +1,4 @@
// Conditional export of platform-specific implementations
export 'file_saver_io.dart' if (dart.library.html) 'file_saver_web.dart';

View file

@ -0,0 +1,16 @@
import 'dart:io';
class FileSaver {
static Future<String?> saveBytes(List<int> 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;
}
}

View file

@ -0,0 +1,15 @@
import 'dart:html' as html;
class FileSaver {
static Future<String?> saveBytes(List<int> 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;
}
}

View file

@ -31,6 +31,13 @@ class _JalaliDatePickerState extends State<JalaliDatePicker> {
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

View file

@ -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<PersonFormDialog> {
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<PersonType> _selectedPersonTypes = <PersonType>{};
@ -96,6 +106,26 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
..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<PersonFormDialog> {
_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<PersonFormDialog> {
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<PersonFormDialog> {
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<PersonFormDialog> {
} 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<PersonFormDialog> {
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<PersonFormDialog> {
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>[
Tab(text: t.personBasicInfo),
Tab(text: t.personEconomicInfo),
Tab(text: t.personContactInfo),
Tab(text: t.personBankInfo),
];
final views = <Widget>[
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<PersonFormDialog> {
);
}
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<PersonFormDialog> {
],
),
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(

View file

@ -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<UrlTracker> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
try {
final currentUrl = GoRouterState.of(context).uri.path;
final currentUrl = Uri.base.path;
if (currentUrl != _lastTrackedUrl &&
currentUrl.isNotEmpty &&
currentUrl != '/' &&