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 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.db.session import get_db
from adapters.api.v1.schema_models.person import ( 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 update_person, delete_person, get_person_summary
) )
from adapters.db.models.person import Person from adapters.db.models.person import Person
from adapters.db.models.business import Business
router = APIRouter(prefix="/persons", tags=["persons"]) 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}", @router.get("/persons/{person_id}",
summary="جزئیات شخص", summary="جزئیات شخص",
description="دریافت جزئیات یک شخص", 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 = "تامین‌کننده" SUPPLIER = "تامین‌کننده"
PARTNER = "همکار" PARTNER = "همکار"
SELLER = "فروشنده" SELLER = "فروشنده"
SHAREHOLDER = "سهامدار"
class PersonBankAccountCreateRequest(BaseModel): class PersonBankAccountCreateRequest(BaseModel):
@ -78,6 +79,38 @@ class PersonCreateRequest(BaseModel):
# حساب‌های بانکی # حساب‌های بانکی
bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حساب‌های بانکی") 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): class PersonUpdateRequest(BaseModel):
@ -111,6 +144,38 @@ class PersonUpdateRequest(BaseModel):
# وضعیت # وضعیت
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن") 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): class PersonResponse(BaseModel):
@ -154,6 +219,16 @@ class PersonResponse(BaseModel):
# حساب‌های بانکی # حساب‌های بانکی
bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حساب‌های بانکی") 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: class Config:
from_attributes = True from_attributes = True

View file

@ -1,7 +1,7 @@
from typing import Any, List, Optional, Union, Generic, TypeVar from typing import Any, List, Optional, Union, Generic, TypeVar
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime, date
T = TypeVar('T') T = TypeVar('T')
@ -177,6 +177,7 @@ class BusinessCreateRequest(BaseModel):
province: Optional[str] = Field(default=None, max_length=100, description="استان") province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: 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="کد پستی") postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه")
class BusinessUpdateRequest(BaseModel): 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 # Business User Schemas
class BusinessUserSchema(BaseModel): class BusinessUserSchema(BaseModel):
id: int id: int

View file

@ -20,3 +20,13 @@ from .file_storage import *
from .email_config import EmailConfig # noqa: F401, F403 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 # Relationships
persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan") 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 datetime import datetime
from enum import Enum 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base from adapters.db.session import Base
@ -17,6 +17,7 @@ class PersonType(str, Enum):
SUPPLIER = "تامین‌کننده" # تامین‌کننده SUPPLIER = "تامین‌کننده" # تامین‌کننده
PARTNER = "همکار" # همکار PARTNER = "همکار" # همکار
SELLER = "فروشنده" # فروشنده SELLER = "فروشنده" # فروشنده
SHAREHOLDER = "سهامدار" # سهامدار
class Person(Base): class Person(Base):
@ -33,10 +34,25 @@ class Person(Base):
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)") 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="نام") first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
last_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") person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت") company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), 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="شناسه ملی") 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.businesses import router as businesses_router
from adapters.api.v1.business_dashboard import router as business_dashboard_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.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.persons import router as persons_router
from adapters.api.v1.support.tickets import router as support_tickets_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 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(businesses_router, prefix=settings.api_v1_prefix)
application.include_router(business_dashboard_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(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) application.include_router(persons_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from adapters.db.repositories.business_repo import BusinessRepository 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.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.business import Business, BusinessType, BusinessField from adapters.db.models.business import Business, BusinessType, BusinessField
from adapters.api.v1.schemas import ( 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]: def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: int) -> Dict[str, Any]:
"""ایجاد کسب و کار جدید""" """ایجاد کسب و کار جدید"""
business_repo = BusinessRepository(db) business_repo = BusinessRepository(db)
fiscal_repo = FiscalYearRepository(db)
# تبدیل enum values به مقادیر فارسی # تبدیل enum values به مقادیر فارسی
# business_data.business_type و business_data.business_field قبلاً مقادیر فارسی هستند # 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 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 # تبدیل به response format
return _business_to_dict(created_business) 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 t = person_data.person_type
types_list = [t.value if hasattr(t, 'value') else str(t)] 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( person = Person(
business_id=business_id, business_id=business_id,
code=code, code=code,
alias_name=person_data.alias_name, alias_name=person_data.alias_name,
first_name=person_data.first_name, first_name=person_data.first_name,
last_name=person_data.last_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, person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
company_name=person_data.company_name, company_name=person_data.company_name,
payment_id=person_data.payment_id, 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, fax=person_data.fax,
email=person_data.email, email=person_data.email,
website=person_data.website, 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) 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.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
# همگام کردن person_type تکی برای سازگاری # همگام کردن person_type تکی برای سازگاری
if types_list: if types_list:
# مقدار Enum را با مقدار فارسی ست می‌کنیم
try: try:
person.person_type = PersonType(types_list[0]) person.person_type = PersonType(types_list[0])
except Exception: except Exception:
pass 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()): for field in list(update_data.keys()):
if field in {'code', 'person_types'}: if field in {'code', 'person_types'}:
@ -416,6 +475,14 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
'person_types': types_list, 'person_types': types_list,
'company_name': person.company_name, 'company_name': person.company_name,
'payment_id': person.payment_id, '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, 'national_id': person.national_id,
'registration_number': person.registration_number, 'registration_number': person.registration_number,
'economic_id': person.economic_id, 'economic_id': person.economic_id,

View file

@ -3,6 +3,7 @@ pyproject.toml
adapters/__init__.py adapters/__init__.py
adapters/api/__init__.py adapters/api/__init__.py
adapters/api/v1/__init__.py adapters/api/v1/__init__.py
adapters/api/v1/accounts.py
adapters/api/v1/auth.py adapters/api/v1/auth.py
adapters/api/v1/business_dashboard.py adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.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/email_config.py
adapters/api/v1/admin/file_storage.py adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.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/email.py
adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py adapters/api/v1/schema_models/person.py
@ -27,12 +29,17 @@ adapters/api/v1/support/tickets.py
adapters/db/__init__.py adapters/db/__init__.py
adapters/db/session.py adapters/db/session.py
adapters/db/models/__init__.py adapters/db/models/__init__.py
adapters/db/models/account.py
adapters/db/models/api_key.py adapters/db/models/api_key.py
adapters/db/models/business.py adapters/db/models/business.py
adapters/db/models/business_permission.py adapters/db/models/business_permission.py
adapters/db/models/captcha.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/email_config.py
adapters/db/models/file_storage.py adapters/db/models/file_storage.py
adapters/db/models/fiscal_year.py
adapters/db/models/password_reset.py adapters/db/models/password_reset.py
adapters/db/models/person.py adapters/db/models/person.py
adapters/db/models/user.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/business_repo.py
adapters/db/repositories/email_config_repository.py adapters/db/repositories/email_config_repository.py
adapters/db/repositories/file_storage_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/password_reset_repo.py
adapters/db/repositories/user_repo.py adapters/db/repositories/user_repo.py
adapters/db/repositories/support/__init__.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/20250916_000002_add_referral_fields.py
migrations/versions/20250926_000010_add_person_code_and_types.py migrations/versions/20250926_000010_add_person_code_and_types.py
migrations/versions/20250926_000011_drop_person_is_active.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/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/__init__.py
tests/test_health.py tests/test_health.py
tests/test_permissions.py tests/test_permissions.py

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@ -12,23 +13,30 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Create businesses table bind = op.get_bind()
op.create_table( inspector = inspect(bind)
'businesses',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), # Create businesses table if not exists
sa.Column('name', sa.String(length=255), nullable=False), if 'businesses' not in inspector.get_table_names():
sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False), op.create_table(
sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False), 'businesses',
sa.Column('owner_id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False),
sa.PrimaryKeyConstraint('id') 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 # Create indexes if not exists
op.create_index('ix_businesses_name', 'businesses', ['name']) existing_indexes = {idx['name'] for idx in inspector.get_indexes('businesses')} if 'businesses' in inspector.get_table_names() else set()
op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id']) 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: def downgrade() -> None:

View file

@ -1,5 +1,6 @@
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250926_000010_add_person_code_and_types' revision = '20250926_000010_add_person_code_and_types'
@ -9,10 +10,18 @@ depends_on = None
def upgrade() -> 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: with op.batch_alter_table('persons') as batch_op:
batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) if 'code' not in cols:
batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True)) batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code']) 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: 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) { if (calendarType != null && calendarType.isNotEmpty) {
options.headers['X-Calendar-Type'] = calendarType; 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 { try {
final uri = options.uri; final uri = options.uri;
final path = uri.path; final path = uri.path;
@ -80,7 +80,8 @@ class ApiClient {
int? resolvedBusinessId = currentBusinessId; int? resolvedBusinessId = currentBusinessId;
// Fallback: detect business_id from URL like /api/v1/business/{id}/... // Fallback: detect business_id from URL like /api/v1/business/{id}/...
if (resolvedBusinessId == null) { 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) { if (match != null) {
final idStr = match.group(1); final idStr = match.group(1);
if (idStr != null) { 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) { if (resolvedBusinessId != null) {
options.headers['X-Business-ID'] = resolvedBusinessId.toString(); 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}) { Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
path = _resolveApiPath(path);
final requestOptions = options ?? Options(); final requestOptions = options ?? Options();
if (responseType != null) { if (responseType != null) {
requestOptions.responseType = responseType; 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}) { 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(); final requestOptions = options ?? Options();
if (responseType != null) { if (responseType != null) {
requestOptions.responseType = responseType; 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}) { 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); 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}) { 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); 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}) { 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); 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/business_shell.dart';
import 'pages/business/dashboard/business_dashboard_page.dart'; import 'pages/business/dashboard/business_dashboard_page.dart';
import 'pages/business/users_permissions_page.dart'; import 'pages/business/users_permissions_page.dart';
import 'pages/business/accounts_page.dart';
import 'pages/business/settings_page.dart'; import 'pages/business/settings_page.dart';
import 'pages/business/persons_page.dart'; import 'pages/business/persons_page.dart';
import 'pages/error_404_page.dart'; import 'pages/error_404_page.dart';
@ -324,6 +325,19 @@ class _MyAppState extends State<MyApp> {
// برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند) // برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند)
// این مهم است: اگر کاربر در صفحات profile یا business است، بماند // این مهم است: اگر کاربر در صفحات profile یا business است، بماند
print('🔍 REDIRECT DEBUG: On other page ($currentPath), staying on current path'); 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; return null;
}, },
routes: <RouteBase>[ routes: <RouteBase>[
@ -354,7 +368,7 @@ class _MyAppState extends State<MyApp> {
GoRoute( GoRoute(
path: '/user/profile/new-business', path: '/user/profile/new-business',
name: 'profile_new_business', name: 'profile_new_business',
builder: (context, state) => const NewBusinessPage(), builder: (context, state) => NewBusinessPage(calendarController: _calendarController!),
), ),
GoRoute( GoRoute(
path: '/user/profile/businesses', 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( GoRoute(
path: 'settings', path: 'settings',
name: 'business_settings', name: 'business_settings',

View file

@ -1,3 +1,4 @@
import 'package:shamsi_date/shamsi_date.dart';
enum BusinessType { enum BusinessType {
company('شرکت'), company('شرکت'),
shop('مغازه'), shop('مغازه'),
@ -43,6 +44,9 @@ class BusinessData {
String? province; String? province;
String? city; String? city;
// مرحله 5: سال(های) مالی
List<FiscalYearData> fiscalYears;
BusinessData({ BusinessData({
this.name = '', this.name = '',
this.businessType, this.businessType,
@ -57,14 +61,16 @@ class BusinessData {
this.country, this.country,
this.province, this.province,
this.city, this.city,
}); List<FiscalYearData>? fiscalYears,
}) : fiscalYears = fiscalYears ?? <FiscalYearData>[];
// تبدیل به Map برای ارسال به API // تبدیل به Map برای ارسال به API
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'name': name, 'name': name,
'business_type': businessType?.name, // بکاند انتظار مقادیر فارسی enum را دارد
'business_field': businessField?.name, 'business_type': businessType?.displayName,
'business_field': businessField?.displayName,
'address': address, 'address': address,
'phone': phone, 'phone': phone,
'mobile': mobile, 'mobile': mobile,
@ -75,6 +81,7 @@ class BusinessData {
'country': country, 'country': country,
'province': province, 'province': province,
'city': city, 'city': city,
'fiscal_years': fiscalYears.map((e) => e.toJson()).toList(),
}; };
} }
@ -93,6 +100,7 @@ class BusinessData {
String? country, String? country,
String? province, String? province,
String? city, String? city,
List<FiscalYearData>? fiscalYears,
}) { }) {
return BusinessData( return BusinessData(
name: name ?? this.name, name: name ?? this.name,
@ -108,6 +116,7 @@ class BusinessData {
country: country ?? this.country, country: country ?? this.country,
province: province ?? this.province, province: province ?? this.province,
city: city ?? this.city, city: city ?? this.city,
fiscalYears: fiscalYears ?? this.fiscalYears,
); );
} }
@ -147,14 +156,23 @@ class BusinessData {
return true; return true;
} }
// بررسی اعتبار مرحله 4 (اختیاری) // بررسی اعتبار مرحله 4 (اطلاعات جغرافیایی - اختیاری)
bool isStep4Valid() { 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() { 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 { class BusinessResponse {
final int id; final int id;
final String name; final String name;
@ -307,8 +348,54 @@ class BusinessResponse {
province: json['province'], province: json['province'],
city: json['city'], city: json['city'],
postalCode: json['postal_code'], postalCode: json['postal_code'],
createdAt: DateTime.parse(json['created_at']), createdAt: _parseDateTime(json['created_at'] ?? json['created_at_raw']),
updatedAt: DateTime.parse(json['updated_at']), 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 { try {
// Parse Jalali date format: YYYY/MM/DD HH:MM:SS // Parse Jalali date format: YYYY/MM/DD HH:MM:SS
final parts = dateValue.split(' '); final parts = dateValue.split(' ');
if (parts.length >= 1) { if (parts.isNotEmpty) {
final dateParts = parts[0].split('/'); final dateParts = parts[0].split('/');
if (dateParts.length == 3) { if (dateParts.length == 3) {
final year = int.parse(dateParts[0]); final year = int.parse(dateParts[0]);

View file

@ -80,7 +80,8 @@ enum PersonType {
employee('کارمند', 'Employee'), employee('کارمند', 'Employee'),
supplier('تامین‌کننده', 'Supplier'), supplier('تامین‌کننده', 'Supplier'),
partner('همکار', 'Partner'), partner('همکار', 'Partner'),
seller('فروشنده', 'Seller'); seller('فروشنده', 'Seller'),
shareholder('سهامدار', 'Shareholder');
const PersonType(this.persianName, this.englishName); const PersonType(this.persianName, this.englishName);
final String persianName; final String persianName;
@ -122,6 +123,15 @@ class Person {
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final List<PersonBankAccount> bankAccounts; 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({ Person({
this.id, this.id,
@ -151,6 +161,14 @@ class Person {
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
this.bankAccounts = const [], 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) { factory Person.fromJson(Map<String, dynamic> json) {
@ -191,6 +209,14 @@ class Person {
bankAccounts: (json['bank_accounts'] as List<dynamic>?) bankAccounts: (json['bank_accounts'] as List<dynamic>?)
?.map((ba) => PersonBankAccount.fromJson(ba)) ?.map((ba) => PersonBankAccount.fromJson(ba))
.toList() ?? [], .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(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(), '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? email;
final String? website; final String? website;
final List<PersonBankAccount> bankAccounts; 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({ PersonCreateRequest({
required this.aliasName, required this.aliasName,
@ -343,6 +385,14 @@ class PersonCreateRequest {
this.email, this.email,
this.website, this.website,
this.bankAccounts = const [], this.bankAccounts = const [],
this.shareCount,
this.commissionSalePercent,
this.commissionSalesReturnPercent,
this.commissionSalesAmount,
this.commissionSalesReturnAmount,
this.commissionExcludeDiscounts,
this.commissionExcludeAdditionsDeductions,
this.commissionPostInInvoiceDocument,
}); });
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -377,6 +427,14 @@ class PersonCreateRequest {
'sheba_number': ba.shebaNumber, 'sheba_number': ba.shebaNumber,
}) })
.toList(), .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? email;
final String? website; final String? website;
final bool? isActive; 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({ PersonUpdateRequest({
this.code, this.code,
@ -428,6 +494,14 @@ class PersonUpdateRequest {
this.email, this.email,
this.website, this.website,
this.isActive, this.isActive,
this.shareCount,
this.commissionSalePercent,
this.commissionSalesReturnPercent,
this.commissionSalesAmount,
this.commissionSalesReturnAmount,
this.commissionExcludeDiscounts,
this.commissionExcludeAdditionsDeductions,
this.commissionPostInInvoiceDocument,
}); });
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -455,6 +529,14 @@ class PersonUpdateRequest {
if (email != null) json['email'] = email; if (email != null) json['email'] = email;
if (website != null) json['website'] = website; if (website != null) json['website'] = website;
if (isActive != null) json['is_active'] = isActive; 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; 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'); context.go('/login');
} }
Future<void> _showAddPersonDialog() async { Future<void> showAddPersonDialog() async {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (context) => PersonFormDialog( builder: (context) => PersonFormDialog(
@ -647,7 +647,7 @@ class _BusinessShellState extends State<BusinessShell> {
// Navigate to add new item // Navigate to add new item
if (child.label == t.personsList) { if (child.label == t.personsList) {
// Navigate to add person // Navigate to add person
_showAddPersonDialog(); showAddPersonDialog();
} else if (child.label == t.products) { } else if (child.label == t.products) {
// Navigate to add product // Navigate to add product
} else if (child.label == t.priceLists) { } else if (child.label == t.priceLists) {
@ -802,7 +802,7 @@ class _BusinessShellState extends State<BusinessShell> {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (item.label == t.people) { if (item.label == t.people) {
_showAddPersonDialog(); showAddPersonDialog();
} }
// سایر مسیرهای افزودن در آینده متصل میشوند // سایر مسیرهای افزودن در آینده متصل میشوند
}, },
@ -898,7 +898,7 @@ class _BusinessShellState extends State<BusinessShell> {
context.pop(); context.pop();
// در حال حاضر فقط اشخاص پشتیبانی میشود // در حال حاضر فقط اشخاص پشتیبانی میشود
if (item.label == t.people) { if (item.label == t.people) {
_showAddPersonDialog(); showAddPersonDialog();
} }
}, },
child: Container( child: Container(

View file

@ -38,22 +38,6 @@ class _PersonsPageState extends State<PersonsPage> {
} }
return Scaffold( 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>( body: DataTableWidget<Person>(
key: _personsTableKey, key: _personsTableKey,
config: _buildDataTableConfig(t), config: _buildDataTableConfig(t),
@ -66,6 +50,14 @@ class _PersonsPageState extends State<PersonsPage> {
return DataTableConfig<Person>( return DataTableConfig<Person>(
endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons', endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons',
title: t.personsList, 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, showRowNumbers: true,
enableRowSelection: true, enableRowSelection: true,
columns: [ columns: [
@ -131,6 +123,105 @@ class _PersonsPageState extends State<PersonsPage> {
'تاریخ ایجاد', 'تاریخ ایجاد',
width: ColumnWidth.medium, 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( ActionColumn(
'actions', 'actions',
'عملیات', 'عملیات',
@ -167,6 +258,21 @@ class _PersonsPageState extends State<PersonsPage> {
'province', 'province',
], ],
defaultPageSize: 20, 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) { Widget _buildUsersList(AppLocalizations t, ThemeData theme, ColorScheme colorScheme) {
if (_loading) { if (_loading) {
return Container( return SizedBox(
height: 200, height: 200,
child: Center( child: Center(
child: Column( child: Column(
@ -439,7 +439,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
} }
if (_error != null) { if (_error != null) {
return Container( return SizedBox(
height: 200, height: 200,
child: Center( child: Center(
child: Column( child: Column(
@ -475,7 +475,7 @@ class _UsersPermissionsPageState extends State<UsersPermissionsPage> {
} }
if (_filteredUsers.isEmpty) { if (_filteredUsers.isEmpty) {
return Container( return SizedBox(
height: 200, height: 200,
child: Center( child: Center(
child: Column( child: Column(
@ -881,23 +881,23 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
'draft': '${t.draft} ${t.warehouseTransfers}', 'draft': '${t.draft} ${t.warehouseTransfers}',
}, },
'settings': { 'settings': {
'business': '${t.businessSettings}', 'business': t.businessSettings,
'print': '${t.printSettings}', 'print': t.printSettings,
'history': '${t.eventHistory}', 'history': t.eventHistory,
'users': '${t.usersAndPermissions}', 'users': t.usersAndPermissions,
}, },
'storage': { 'storage': {
'view': '${t.view} ${t.storageSpace}', 'view': '${t.view} ${t.storageSpace}',
'delete': '${t.delete} ${t.deleteFiles}', 'delete': '${t.delete} ${t.deleteFiles}',
}, },
'sms': { 'sms': {
'history': '${t.viewSmsHistory}', 'history': t.viewSmsHistory,
'templates': '${t.manageSmsTemplates}', 'templates': t.manageSmsTemplates,
}, },
'marketplace': { 'marketplace': {
'view': '${t.viewMarketplace}', 'view': t.viewMarketplace,
'buy': '${t.buyPlugins}', 'buy': t.buyPlugins,
'invoices': '${t.viewInvoices}', 'invoices': t.viewInvoices,
}, },
}; };
} }
@ -1235,36 +1235,36 @@ class _PermissionsDialogState extends State<_PermissionsDialog> {
String _inferCurrentSectionKey(String title, String description) { String _inferCurrentSectionKey(String title, String description) {
// جستجو بر اساس کلمات کلیدی ساده // جستجو بر اساس کلمات کلیدی ساده
final pairs = <String, List<String>>{ final pairs = <String, List<String>>{
'people': ['${AppLocalizations.of(context).people}'], 'people': [(AppLocalizations.of(context).people)],
'people_transactions': [ 'people_transactions': [
'${AppLocalizations.of(context).receiptsAndPayments}', (AppLocalizations.of(context).receiptsAndPayments),
'${AppLocalizations.of(context).receipts}', (AppLocalizations.of(context).receipts),
'${AppLocalizations.of(context).payments}', (AppLocalizations.of(context).payments),
], ],
'products': ['${AppLocalizations.of(context).products}'], 'products': [(AppLocalizations.of(context).products)],
'price_lists': ['${AppLocalizations.of(context).priceLists}'], 'price_lists': [(AppLocalizations.of(context).priceLists)],
'categories': ['${AppLocalizations.of(context).categories}'], 'categories': [(AppLocalizations.of(context).categories)],
'product_attributes': ['${AppLocalizations.of(context).productAttributes}'], 'product_attributes': [(AppLocalizations.of(context).productAttributes)],
'bank_accounts': ['${AppLocalizations.of(context).bankAccounts}'], 'bank_accounts': [(AppLocalizations.of(context).bankAccounts)],
'cash': ['${AppLocalizations.of(context).cash}'], 'cash': [(AppLocalizations.of(context).cash)],
'petty_cash': ['${AppLocalizations.of(context).pettyCash}'], 'petty_cash': [(AppLocalizations.of(context).pettyCash)],
'checks': ['${AppLocalizations.of(context).checks}'], 'checks': [(AppLocalizations.of(context).checks)],
'wallet': ['${AppLocalizations.of(context).wallet}'], 'wallet': [(AppLocalizations.of(context).wallet)],
'transfers': ['${AppLocalizations.of(context).transfers}'], 'transfers': [(AppLocalizations.of(context).transfers)],
'invoices': ['${AppLocalizations.of(context).invoices}'], 'invoices': [(AppLocalizations.of(context).invoices)],
'expenses_income': ['${AppLocalizations.of(context).expensesIncome}'], 'expenses_income': [(AppLocalizations.of(context).expensesIncome)],
'accounting_documents': ['${AppLocalizations.of(context).accountingDocuments}'], 'accounting_documents': [(AppLocalizations.of(context).accountingDocuments)],
'chart_of_accounts': ['${AppLocalizations.of(context).chartOfAccounts}'], 'chart_of_accounts': [(AppLocalizations.of(context).chartOfAccounts)],
'opening_balance': ['${AppLocalizations.of(context).openingBalance}'], 'opening_balance': [(AppLocalizations.of(context).openingBalance)],
'warehouses': ['${AppLocalizations.of(context).warehouses}'], 'warehouses': [(AppLocalizations.of(context).warehouses)],
'warehouse_transfers': ['${AppLocalizations.of(context).warehouseTransfers}'], 'warehouse_transfers': [(AppLocalizations.of(context).warehouseTransfers)],
'settings': ['${AppLocalizations.of(context).businessSettings}'], 'settings': [(AppLocalizations.of(context).businessSettings)],
'storage': ['${AppLocalizations.of(context).storageSpace}'], 'storage': [(AppLocalizations.of(context).storageSpace)],
'sms': ['${AppLocalizations.of(context).smsPanel}'], 'sms': [(AppLocalizations.of(context).smsPanel)],
'marketplace': ['${AppLocalizations.of(context).marketplace}'], 'marketplace': [(AppLocalizations.of(context).marketplace)],
}; };
final hay = (title + ' ' + description).toLowerCase(); final hay = ('$title $description').toLowerCase();
for (final entry in pairs.entries) { for (final entry in pairs.entries) {
for (final token in entry.value) { for (final token in entry.value) {
if (hay.contains(token.toLowerCase())) { if (hay.contains(token.toLowerCase())) {

View file

@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:shamsi_date/shamsi_date.dart';
import '../../models/business_models.dart'; import '../../models/business_models.dart';
import '../../services/business_api_service.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 { class NewBusinessPage extends StatefulWidget {
const NewBusinessPage({super.key}); final CalendarController calendarController;
const NewBusinessPage({super.key, required this.calendarController});
@override @override
State<NewBusinessPage> createState() => _NewBusinessPageState(); State<NewBusinessPage> createState() => _NewBusinessPageState();
@ -16,15 +21,162 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
final BusinessData _businessData = BusinessData(); final BusinessData _businessData = BusinessData();
int _currentStep = 0; int _currentStep = 0;
bool _isLoading = false; 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 @override
void dispose() { void dispose() {
widget.calendarController.removeListener(_onCalendarChanged);
_pageController.dispose(); _pageController.dispose();
_fiscalTitleController.dispose();
super.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() { void _nextStep() {
if (_currentStep < 3) { if (_currentStep < 4) {
setState(() { setState(() {
_currentStep++; _currentStep++;
}); });
@ -66,6 +218,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
return _businessData.isStep2Valid(); return _businessData.isStep2Valid();
case 2: case 2:
return _businessData.isStep3Valid(); return _businessData.isStep3Valid();
case 3:
return _businessData.isFiscalStepValid();
default: default:
return false; return false;
} }
@ -84,6 +238,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
case 2: case 2:
return t.businessLegalInfo; return t.businessLegalInfo;
case 3: case 3:
return 'سال مالی';
case 4:
return t.businessConfirmation; return t.businessConfirmation;
default: default:
return ''; return '';
@ -122,7 +278,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
context.pop(); context.goNamed('profile_businesses');
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -170,7 +326,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
children: [ children: [
// Progress bar // Progress bar
Row( Row(
children: List.generate(4, (index) { children: List.generate(5, (index) {
final isActive = index <= _currentStep; final isActive = index <= _currentStep;
final isCurrent = index == _currentStep; final isCurrent = index == _currentStep;
@ -203,7 +359,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
const SizedBox(height: 8), const SizedBox(height: 8),
// Progress text // Progress text
Text( Text(
'${t.step} ${_currentStep + 1} ${t.ofText} 4', '${t.step} ${_currentStep + 1} ${t.ofText} 5',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -223,7 +379,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
_buildStepIndicator(0, t.businessBasicInfo), _buildStepIndicator(0, t.businessBasicInfo),
_buildStepIndicator(1, t.businessContactInfo), _buildStepIndicator(1, t.businessContactInfo),
_buildStepIndicator(2, t.businessLegalInfo), _buildStepIndicator(2, t.businessLegalInfo),
_buildStepIndicator(3, t.businessConfirmation), _buildStepIndicator(3, 'سال مالی'),
_buildStepIndicator(4, t.businessConfirmation),
], ],
), ),
), ),
@ -293,6 +450,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
_buildStep1(), _buildStep1(),
_buildStep2(), _buildStep2(),
_buildStep3(), _buildStep3(),
_buildFiscalStep(),
_buildStep4(), _buildStep4(),
], ],
), ),
@ -326,9 +484,9 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: _buildNavigationButton( child: _buildNavigationButton(
text: _currentStep < 3 ? t.next : t.createBusiness, text: _currentStep < 4 ? t.next : t.createBusiness,
icon: _currentStep < 3 ? Icons.arrow_forward_ios : Icons.check, icon: _currentStep < 4 ? Icons.arrow_forward_ios : Icons.check,
onPressed: _currentStep < 3 onPressed: _currentStep < 4
? (_canGoToNextStep() ? _nextStep : null) ? (_canGoToNextStep() ? _nextStep : null)
: (_isLoading ? null : _submitBusiness), : (_isLoading ? null : _submitBusiness),
isPrimary: true, isPrimary: true,
@ -361,7 +519,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
), ),
Row( Row(
children: [ children: [
if (_currentStep < 3) ...[ if (_currentStep < 4) ...[
_buildNavigationButton( _buildNavigationButton(
text: t.next, text: t.next,
icon: Icons.arrow_forward_ios, icon: Icons.arrow_forward_ios,
@ -1381,6 +1539,8 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
_buildSummaryItem(t.city, _businessData.city!), _buildSummaryItem(t.city, _businessData.city!),
if (_businessData.postalCode?.isNotEmpty == true) if (_businessData.postalCode?.isNotEmpty == true)
_buildSummaryItem(t.postalCode, _businessData.postalCode!), _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 '../core/api_client.dart';
import '../models/person_model.dart'; import '../models/person_model.dart';

View file

@ -193,6 +193,10 @@ class DataTableConfig<T> {
final String? dateRangeField; final String? dateRangeField;
final String? title; final String? title;
final String? subtitle; final String? subtitle;
// Header controls
final bool showBackButton;
final VoidCallback? onBack;
final bool showTableIcon;
final bool showSearch; final bool showSearch;
final bool showFilters; final bool showFilters;
final bool showPagination; final bool showPagination;
@ -267,6 +271,9 @@ class DataTableConfig<T> {
this.dateRangeField, this.dateRangeField,
this.title, this.title,
this.subtitle, this.subtitle,
this.showBackButton = false,
this.onBack,
this.showTableIcon = true,
this.showSearch = true, this.showSearch = true,
this.showFilters = false, this.showFilters = false,
this.showPagination = true, this.showPagination = true,
@ -395,13 +402,27 @@ class DataTableResponse<T> {
) { ) {
final data = json['data'] as Map<String, dynamic>; final data = json['data'] as Map<String, dynamic>;
final itemsList = data['items'] as List? ?? []; 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>( return DataTableResponse<T>(
items: itemsList.map((item) => fromJsonT(item as Map<String, dynamic>)).toList(), items: itemsList.map((item) => fromJsonT(item as Map<String, dynamic>)).toList(),
total: (data['total'] as num?)?.toInt() ?? 0, total: total,
page: (data['page'] as num?)?.toInt() ?? 1, page: page,
limit: (data['limit'] as num?)?.toInt() ?? 20, limit: limit,
totalPages: (data['total_pages'] as num?)?.toInt() ?? 0, totalPages: totalPages,
); );
} }
} }

View file

@ -1,4 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'helpers/file_saver.dart';
// import 'dart:html' as html; // Not available on Linux // import 'dart:html' as html; // Not available on Linux
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:data_table_2/data_table_2.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(); 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 // Add custom export parameters if provided
if (widget.config.getExportParams != null) { if (widget.config.getExportParams != null) {
final customParams = widget.config.getExportParams!(); 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 { Future<void> _downloadPdf(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder await _saveBytesToDownloads(data, filename);
debugPrint('Download PDF: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
} }
Future<void> _downloadExcel(dynamic data, String filename) async { Future<void> _downloadExcel(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder await _saveBytesToDownloads(data, filename);
debugPrint('Download Excel: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
} }
@ -641,15 +661,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
return Card( return Card(
elevation: widget.config.boxShadow != null ? 2 : 0, elevation: widget.config.boxShadow != null ? 2 : 0,
shape: widget.config.borderRadius != null clipBehavior: Clip.antiAlias,
? RoundedRectangleBorder(borderRadius: widget.config.borderRadius!) shape: RoundedRectangleBorder(
: null, borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
),
child: Container( child: Container(
padding: widget.config.padding ?? const EdgeInsets.all(16), padding: widget.config.padding ?? const EdgeInsets.all(16),
margin: widget.config.margin, margin: widget.config.margin,
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.config.backgroundColor, color: widget.config.backgroundColor,
borderRadius: widget.config.borderRadius, borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
border: widget.config.showBorder border: widget.config.showBorder
? Border.all( ? Border.all(
color: widget.config.borderColor ?? theme.dividerColor, color: widget.config.borderColor ?? theme.dividerColor,
@ -719,19 +740,35 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
Widget _buildHeader(AppLocalizations t, ThemeData theme) { Widget _buildHeader(AppLocalizations t, ThemeData theme) {
return Row( return Row(
children: [ children: [
Container( if (widget.config.showBackButton) ...[
padding: const EdgeInsets.all(6), Tooltip(
decoration: BoxDecoration( message: MaterialLocalizations.of(context).backButtonTooltip,
color: theme.colorScheme.primaryContainer, child: IconButton(
borderRadius: BorderRadius.circular(6), onPressed: widget.config.onBack ?? () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.arrow_back),
),
), ),
child: Icon( const SizedBox(width: 8),
Icons.table_chart, ],
color: theme.colorScheme.onPrimaryContainer, if (widget.config.showTableIcon) ...[
size: 18, 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( Text(
widget.config.title!, widget.config.title!,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(

View file

@ -105,9 +105,14 @@ class ColumnSettingsService {
} }
// Ensure all default columns are present in visible columns // 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 visibleColumns = <String>[];
final userVisible = Set<String>.from(userSettings.visibleColumns);
for (final key in defaultColumnKeys) { 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); visibleColumns.add(key);
} }
} }
@ -117,15 +122,13 @@ class ColumnSettingsService {
visibleColumns.add(defaultColumnKeys.first); 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>[]; final columnOrder = <String>[];
for (final key in userSettings.columnOrder) { for (final key in userSettings.columnOrder) {
if (visibleColumns.contains(key)) { if (visibleColumns.contains(key)) {
columnOrder.add(key); columnOrder.add(key);
} }
} }
// Add any missing visible columns to the end
for (final key in visibleColumns) { for (final key in visibleColumns) {
if (!columnOrder.contains(key)) { if (!columnOrder.contains(key)) {
columnOrder.add(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() { void initState() {
super.initState(); super.initState();
_selectedDate = widget.initialDate ?? DateTime.now(); _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); _selectedJalali = Jalali.fromDateTime(_selectedDate);
} }
@ -218,6 +225,10 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
} }
void _selectDate(Jalali date) { void _selectDate(Jalali date) {
// Enforce range limits
if (date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0) {
return;
}
setState(() { setState(() {
_selectedDate = date; _selectedDate = date;
}); });
@ -305,18 +316,23 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
date.month == Jalali.now().month && date.month == Jalali.now().month &&
date.day == Jalali.now().day; date.day == Jalali.now().day;
final isDisabled = date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0;
return GestureDetector( return GestureDetector(
onTap: () => _selectDate(date), onTap: isDisabled ? null : () => _selectDate(date),
child: Container( child: Container(
margin: const EdgeInsets.all(2), margin: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isDisabled
? theme.disabledColor.withValues(alpha: 0.1)
: isSelected
? theme.colorScheme.primary ? theme.colorScheme.primary
: isToday : isToday
? theme.colorScheme.primary.withValues(alpha: 0.1) ? theme.colorScheme.primary.withValues(alpha: 0.1)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), 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) ? Border.all(color: theme.colorScheme.primary, width: 1)
: null, : null,
), ),
@ -324,7 +340,9 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> {
child: Text( child: Text(
day.toString(), day.toString(),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: isSelected color: isDisabled
? theme.disabledColor
: isSelected
? theme.colorScheme.onPrimary ? theme.colorScheme.onPrimary
: isToday : isToday
? theme.colorScheme.primary ? theme.colorScheme.primary

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:flutter/services.dart';
import '../../models/person_model.dart'; import '../../models/person_model.dart';
import '../../services/person_service.dart'; import '../../services/person_service.dart';
@ -51,6 +52,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
final _faxController = TextEditingController(); final _faxController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _websiteController = 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) PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
final Set<PersonType> _selectedPersonTypes = <PersonType>{}; final Set<PersonType> _selectedPersonTypes = <PersonType>{};
@ -96,6 +106,26 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]); ..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
_isActive = person.isActive; _isActive = person.isActive;
_bankAccounts = List.from(person.bankAccounts); _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(); _faxController.dispose();
_emailController.dispose(); _emailController.dispose();
_websiteController.dispose(); _websiteController.dispose();
_shareCountController.dispose();
_commissionSalePercentController.dispose();
_commissionSalesReturnPercentController.dispose();
_commissionSalesAmountController.dispose();
_commissionSalesReturnAmountController.dispose();
super.dispose(); super.dispose();
} }
@ -136,7 +171,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
final personData = PersonCreateRequest( final personData = PersonCreateRequest(
code: _autoGenerateCode code: _autoGenerateCode
? null ? null
: (int.tryParse(_codeController.text.trim()) ?? null), : (int.tryParse(_codeController.text.trim())),
aliasName: _aliasNameController.text.trim(), aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.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(), email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(), website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
bankAccounts: _bankAccounts, 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( await _personService.createPerson(
@ -166,7 +226,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
} else { } else {
// Update existing person // Update existing person
final personData = PersonUpdateRequest( final personData = PersonUpdateRequest(
code: (int.tryParse(_codeController.text.trim()) ?? null), code: (int.tryParse(_codeController.text.trim())),
aliasName: _aliasNameController.text.trim(), aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.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(), email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(), website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
isActive: _isActive, 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( await _personService.updatePerson(
@ -288,53 +372,68 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded( Expanded(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: DefaultTabController( child: Builder(builder: (context) {
length: 4, final hasCommissionTab = _selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller);
child: Column( final tabs = <Tab>[
children: [ Tab(text: t.personBasicInfo),
TabBar( Tab(text: t.personEconomicInfo),
isScrollable: true, Tab(text: t.personContactInfo),
tabs: [ Tab(text: t.personBankInfo),
Tab(text: t.personBasicInfo), ];
Tab(text: t.personEconomicInfo), final views = <Widget>[
Tab(text: t.personContactInfo), SingleChildScrollView(
Tab(text: t.personBankInfo), child: Padding(
], padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildBasicInfoFields(t),
), ),
const SizedBox(height: 12), ),
Expanded( SingleChildScrollView(
child: TabBarView( child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
SingleChildScrollView( child: _buildEconomicInfoFields(t),
child: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), ),
child: _buildBasicInfoFields(t), SingleChildScrollView(
), child: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
SingleChildScrollView( child: _buildContactInfoFields(t),
child: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), ),
child: _buildEconomicInfoFields(t), SingleChildScrollView(
), child: Padding(
), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
SingleChildScrollView( child: _buildBankAccountsSection(t),
child: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), ),
child: _buildContactInfoFields(t), ];
), if (hasCommissionTab) {
), tabs.add(const Tab(text: 'پورسانت'));
SingleChildScrollView( views.add(
child: Padding( SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Padding(
child: _buildBankAccountsSection(t), 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) { Widget _buildSectionHeader(String title) {
return Text( return Text(
title, title,
@ -457,6 +678,34 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
], ],
), ),
const SizedBox(height: 8), 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( Row(
children: [ children: [
Expanded( Expanded(

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/auth_store.dart'; import '../core/auth_store.dart';
class UrlTracker extends StatefulWidget { class UrlTracker extends StatefulWidget {
@ -29,7 +28,7 @@ class _UrlTrackerState extends State<UrlTracker> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
try { try {
final currentUrl = GoRouterState.of(context).uri.path; final currentUrl = Uri.base.path;
if (currentUrl != _lastTrackedUrl && if (currentUrl != _lastTrackedUrl &&
currentUrl.isNotEmpty && currentUrl.isNotEmpty &&
currentUrl != '/' && currentUrl != '/' &&