progress in persons list

This commit is contained in:
Hesabix 2025-09-26 23:05:20 +03:30
parent e372cac9ae
commit bd34093dac
13 changed files with 637 additions and 166 deletions

View file

@ -10,7 +10,7 @@ from adapters.api.v1.schema_models.person import (
from adapters.api.v1.schemas import QueryInfo, SuccessResponse
from app.core.responses import success_response, format_datetime_fields
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_management
from app.core.permissions import require_business_management_dep
from app.services.person_service import (
create_person, get_person_by_id, get_persons_by_business,
update_person, delete_person, get_person_summary
@ -59,13 +59,15 @@ async def create_person_endpoint(
person_data: PersonCreateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
_: None = Depends(require_business_management_dep),
request: Request = None,
):
"""ایجاد شخص جدید برای کسب و کار"""
result = create_person(db, business_id, person_data)
return success_response(
data=format_datetime_fields(result['data'], request),
request=request,
message=result['message'],
data=format_datetime_fields(result['data'])
)
@ -103,7 +105,8 @@ async def get_persons_endpoint(
business_id: int,
query_info: QueryInfo,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user)
auth_context: AuthContext = Depends(get_current_user),
request: Request = None,
):
"""دریافت لیست اشخاص کسب و کار"""
query_dict = {
@ -111,17 +114,21 @@ async def get_persons_endpoint(
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search
"search": query_info.search,
"search_fields": query_info.search_fields,
"filters": query_info.filters,
}
result = get_persons_by_business(db, business_id, query_dict)
# فرمت کردن تاریخ‌ها
for item in result['items']:
item = format_datetime_fields(item)
result['items'] = [
format_datetime_fields(item, request) for item in result['items']
]
return success_response(
data=result,
request=request,
message="لیست اشخاص با موفقیت دریافت شد",
data=result
)
@ -142,7 +149,8 @@ async def get_person_endpoint(
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
_: None = Depends(require_business_management_dep),
request: Request = None,
):
"""دریافت جزئیات شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@ -155,8 +163,9 @@ async def get_person_endpoint(
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="جزئیات شخص با موفقیت دریافت شد",
data=format_datetime_fields(result)
)
@ -178,7 +187,8 @@ async def update_person_endpoint(
person_data: PersonUpdateRequest,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
_: None = Depends(require_business_management_dep),
request: Request = None,
):
"""ویرایش شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@ -191,8 +201,9 @@ async def update_person_endpoint(
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(
data=format_datetime_fields(result['data'], request),
request=request,
message=result['message'],
data=format_datetime_fields(result['data'])
)
@ -213,7 +224,8 @@ async def delete_person_endpoint(
person_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
_: None = Depends(require_business_management_dep),
request: Request = None,
):
"""حذف شخص"""
# ابتدا باید business_id را از person دریافت کنیم
@ -225,7 +237,7 @@ async def delete_person_endpoint(
if not success:
raise HTTPException(status_code=404, detail="شخص یافت نشد")
return success_response(message="شخص با موفقیت حذف شد")
return success_response(message="شخص با موفقیت حذف شد", request=request)
@router.get("/businesses/{business_id}/persons/summary",
@ -242,12 +254,14 @@ async def get_persons_summary_endpoint(
business_id: int,
db: Session = Depends(get_db),
auth_context: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management())
_: None = Depends(require_business_management_dep),
request: Request = None,
):
"""دریافت خلاصه اشخاص کسب و کار"""
result = get_person_summary(db, business_id)
return success_response(
data=result,
request=request,
message="خلاصه اشخاص با موفقیت دریافت شد",
data=result
)

View file

@ -50,10 +50,12 @@ class PersonBankAccountResponse(BaseModel):
class PersonCreateRequest(BaseModel):
"""درخواست ایجاد شخص جدید"""
# اطلاعات پایه
code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار (در صورت عدم ارسال، خودکار تولید می‌شود)")
alias_name: str = Field(..., min_length=1, max_length=255, description="نام مستعار (الزامی)")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: PersonType = Field(..., description="نوع شخص")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
@ -81,10 +83,12 @@ class PersonCreateRequest(BaseModel):
class PersonUpdateRequest(BaseModel):
"""درخواست ویرایش شخص"""
# اطلاعات پایه
code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار")
alias_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام مستعار")
first_name: Optional[str] = Field(default=None, max_length=100, description="نام")
last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص")
person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)")
person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)")
company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت")
@ -115,10 +119,12 @@ class PersonResponse(BaseModel):
business_id: int = Field(..., description="شناسه کسب و کار")
# اطلاعات پایه
code: Optional[int] = Field(default=None, description="کد یکتا")
alias_name: str = Field(..., description="نام مستعار")
first_name: Optional[str] = Field(default=None, description="نام")
last_name: Optional[str] = Field(default=None, description="نام خانوادگی")
person_type: str = Field(..., description="نوع شخص")
person_types: List[str] = Field(default_factory=list, description="انواع شخص")
company_name: Optional[str] = Field(default=None, description="نام شرکت")
payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت")

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, Boolean
from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
@ -21,15 +21,20 @@ class PersonType(str, Enum):
class Person(Base):
__tablename__ = "persons"
__table_args__ = (
UniqueConstraint('business_id', 'code', name='uq_persons_business_code'),
)
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)
# اطلاعات پایه
code: Mapped[int | None] = mapped_column(Integer, nullable=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="نام")
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
person_type: Mapped[PersonType] = mapped_column(SQLEnum(PersonType), nullable=False, comment="نوع شخص")
person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
@ -50,9 +55,6 @@ class Person(Base):
email: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="پست الکترونیکی")
website: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="وب‌سایت")
# وضعیت
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال بودن")
# زمان‌بندی
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)
@ -74,9 +76,6 @@ class PersonBankAccount(Base):
card_number: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="شماره کارت")
sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="شماره شبا")
# وضعیت
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال بودن")
# زمان‌بندی
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

@ -171,3 +171,24 @@ def require_system_settings():
def require_permission(permission: str):
"""Decorator عمومی برای بررسی دسترسی - wrapper برای require_app_permission"""
return require_app_permission(permission)
# =========================
# FastAPI Dependencies (for Depends)
# =========================
def require_app_permission_dep(permission: str):
"""FastAPI dependency جهت بررسی دسترسی در سطح اپلیکیشن.
استفاده:
_: None = Depends(require_app_permission_dep("business_management"))
"""
def _dependency(auth_context: AuthContext = Depends(get_current_user)) -> None:
if not auth_context.has_app_permission(permission):
raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403)
return _dependency
def require_business_management_dep(auth_context: AuthContext = Depends(get_current_user)) -> None:
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
if not auth_context.has_app_permission("business_management"):
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)

View file

@ -1,4 +1,7 @@
from typing import List, Optional, Dict, Any
import json
from sqlalchemy.exc import IntegrityError
from app.core.responses import ApiError
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.person import Person, PersonBankAccount, PersonType
@ -10,13 +13,36 @@ from app.core.responses import success_response
def create_person(db: Session, business_id: int, person_data: PersonCreateRequest) -> Dict[str, Any]:
"""ایجاد شخص جدید"""
# محاسبه/اعتبارسنجی کد یکتا
code: Optional[int] = getattr(person_data, 'code', None)
if code is not None:
exists = db.query(Person).filter(
and_(Person.business_id == business_id, Person.code == code)
).first()
if exists:
raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
else:
# تولید خودکار کد: بیشینه فعلی + 1 (نسبت به همان کسب و کار)
max_code = db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar()
code = (max_code or 0) + 1
# آماده‌سازی person_types (چندانتخابی) و سازگاری person_type تکی
types_list: List[str] = []
if getattr(person_data, 'person_types', None):
types_list = [t.value if hasattr(t, 'value') else str(t) for t in person_data.person_types] # type: ignore[attr-defined]
elif getattr(person_data, 'person_type', None):
t = person_data.person_type
types_list = [t.value if hasattr(t, 'value') else str(t)]
# ایجاد شخص
person = Person(
business_id=business_id,
code=code,
alias_name=person_data.alias_name,
first_name=person_data.first_name,
last_name=person_data.last_name,
person_type=person_data.person_type,
person_type=person_data.person_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER),
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
company_name=person_data.company_name,
payment_id=person_data.payment_id,
national_id=person_data.national_id,
@ -49,7 +75,11 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
)
db.add(bank_account)
try:
db.commit()
except IntegrityError:
db.rollback()
raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400)
db.refresh(person)
return success_response(
@ -84,6 +114,13 @@ def get_persons_by_business(
search_conditions = []
for field in query_info['search_fields']:
if field == 'code':
# تبدیل به رشته برای جستجو مانند LIKE
try:
code_int = int(query_info['search']) # type: ignore[arg-type]
search_conditions.append(Person.code == code_int)
except Exception:
pass
if field == 'alias_name':
search_conditions.append(Person.alias_name.ilike(search_term))
elif field == 'first_name':
@ -105,28 +142,108 @@ def get_persons_by_business(
# اعمال فیلترها
if query_info.get('filters'):
for filter_item in query_info['filters']:
# پشتیبانی از هر دو حالت: دیکشنری یا شیء Pydantic
if isinstance(filter_item, dict):
field = filter_item.get('property')
operator = filter_item.get('operator')
value = filter_item.get('value')
else:
field = getattr(filter_item, 'property', None)
operator = getattr(filter_item, 'operator', None)
value = getattr(filter_item, 'value', None)
if not field or not operator:
continue
# کد
if field == 'code':
if operator == '=':
query = query.filter(Person.code == value)
elif operator == 'in' and isinstance(value, list):
query = query.filter(Person.code.in_(value))
continue
# نوع شخص تک‌انتخابی
if field == 'person_type':
if operator == '=':
query = query.filter(Person.person_type == value)
elif operator == 'in':
elif operator == 'in' and isinstance(value, list):
query = query.filter(Person.person_type.in_(value))
elif field == 'is_active':
continue
# انواع شخص چندانتخابی (رشته JSON)
if field == 'person_types':
if operator == '=' and isinstance(value, str):
query = query.filter(Person.person_types.ilike(f'%"{value}"%'))
elif operator == 'in' and isinstance(value, list):
sub_filters = [Person.person_types.ilike(f'%"{v}"%') for v in value]
if sub_filters:
query = query.filter(or_(*sub_filters))
continue
# فیلترهای متنی عمومی (حمایت از عملگرهای contains/startsWith/endsWith)
def apply_text_filter(column):
nonlocal query
if operator == '=':
query = query.filter(Person.is_active == value)
elif field == 'country':
if operator == '=':
query = query.filter(Person.country == value)
elif operator == 'like':
query = query.filter(Person.country.ilike(f"%{value}%"))
elif field == 'province':
if operator == '=':
query = query.filter(Person.province == value)
elif operator == 'like':
query = query.filter(Person.province.ilike(f"%{value}%"))
query = query.filter(column == value)
elif operator == 'like' or operator == '*':
query = query.filter(column.ilike(f"%{value}%"))
elif operator == '*?': # starts with
query = query.filter(column.ilike(f"{value}%"))
elif operator == '?*': # ends with
query = query.filter(column.ilike(f"%{value}"))
if field == 'country':
apply_text_filter(Person.country)
continue
if field == 'province':
apply_text_filter(Person.province)
continue
if field == 'alias_name':
apply_text_filter(Person.alias_name)
continue
if field == 'first_name':
apply_text_filter(Person.first_name)
continue
if field == 'last_name':
apply_text_filter(Person.last_name)
continue
if field == 'company_name':
apply_text_filter(Person.company_name)
continue
if field == 'mobile':
apply_text_filter(Person.mobile)
continue
if field == 'email':
apply_text_filter(Person.email)
continue
if field == 'national_id':
apply_text_filter(Person.national_id)
continue
if field == 'registration_number':
apply_text_filter(Person.registration_number)
continue
if field == 'economic_id':
apply_text_filter(Person.economic_id)
continue
if field == 'city':
apply_text_filter(Person.city)
continue
if field == 'address':
apply_text_filter(Person.address)
continue
# شمارش کل رکوردها
total = query.count()
@ -135,7 +252,9 @@ def get_persons_by_business(
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True)
if sort_by == 'alias_name':
if sort_by == 'code':
query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc())
elif sort_by == 'alias_name':
query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc())
elif sort_by == 'first_name':
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
@ -195,8 +314,35 @@ def update_person(
# به‌روزرسانی فیلدها
update_data = person_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(person, field, value)
# مدیریت کد یکتا
if 'code' in update_data and update_data['code'] is not None:
desired_code = update_data['code']
exists = db.query(Person).filter(
and_(Person.business_id == business_id, Person.code == desired_code, Person.id != person_id)
).first()
if exists:
raise ValueError("کد شخص تکراری است")
person.code = desired_code
# مدیریت انواع شخص چندگانه
types_list: Optional[List[str]] = None
if 'person_types' in update_data and update_data['person_types'] is not None:
incoming = update_data['person_types'] or []
types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
# همگام کردن person_type تکی برای سازگاری
if types_list:
try:
person.person_type = PersonType(types_list[0])
except Exception:
pass
# سایر فیلدها
for field in list(update_data.keys()):
if field in {'code', 'person_types'}:
continue
setattr(person, field, update_data[field])
db.commit()
db.refresh(person)
@ -227,12 +373,9 @@ def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
# تعداد کل اشخاص
total_persons = db.query(Person).filter(Person.business_id == business_id).count()
# تعداد اشخاص فعال و غیرفعال
active_persons = db.query(Person).filter(
and_(Person.business_id == business_id, Person.is_active == True)
).count()
inactive_persons = total_persons - active_persons
# حذف مفهوم فعال/غیرفعال
active_persons = 0
inactive_persons = total_persons
# تعداد بر اساس نوع
by_type = {}
@ -252,13 +395,25 @@ def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
def _person_to_dict(person: Person) -> Dict[str, Any]:
"""تبدیل مدل Person به دیکشنری"""
# Parse person_types JSON to list
types_list: List[str] = []
if person.person_types:
try:
types = json.loads(person.person_types)
if isinstance(types, list):
types_list = [str(x) for x in types]
except Exception:
types_list = []
return {
'id': person.id,
'business_id': person.business_id,
'code': person.code,
'alias_name': person.alias_name,
'first_name': person.first_name,
'last_name': person.last_name,
'person_type': person.person_type.value,
'person_types': types_list,
'company_name': person.company_name,
'payment_id': person.payment_id,
'national_id': person.national_id,
@ -274,7 +429,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
'fax': person.fax,
'email': person.email,
'website': person.website,
'is_active': person.is_active,
'created_at': person.created_at.isoformat(),
'updated_at': person.updated_at.isoformat(),
'bank_accounts': [
@ -285,7 +439,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
'account_number': ba.account_number,
'card_number': ba.card_number,
'sheba_number': ba.sheba_number,
'is_active': ba.is_active,
'created_at': ba.created_at.isoformat(),
'updated_at': ba.updated_at.isoformat(),
}

View file

@ -102,6 +102,8 @@ migrations/versions/20250120_000001_add_persons_tables.py
migrations/versions/20250120_000002_add_join_permission.py
migrations/versions/20250915_000001_init_auth_tables.py
migrations/versions/20250916_000002_add_referral_fields.py
migrations/versions/20250926_000010_add_person_code_and_types.py
migrations/versions/20250926_000011_drop_person_is_active.py
migrations/versions/5553f8745c6e_add_support_tables.py
tests/__init__.py
tests/test_health.py

View file

@ -0,0 +1,22 @@
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250926_000010_add_person_code_and_types'
down_revision = '20250916_000002'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('persons') as batch_op:
batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True))
batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code'])
def downgrade() -> None:
with op.batch_alter_table('persons') as batch_op:
batch_op.drop_constraint('uq_persons_business_code', type_='unique')
batch_op.drop_column('person_types')
batch_op.drop_column('code')

View file

@ -0,0 +1,28 @@
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250926_000011_drop_person_is_active'
down_revision = '20250926_000010_add_person_code_and_types'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('persons') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
with op.batch_alter_table('person_bank_accounts') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
def downgrade() -> None:
with op.batch_alter_table('persons') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
with op.batch_alter_table('person_bank_accounts') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))

View file

@ -97,10 +97,12 @@ enum PersonType {
class Person {
final int? id;
final int businessId;
final int? code;
final String aliasName;
final String? firstName;
final String? lastName;
final PersonType personType;
final List<PersonType> personTypes;
final String? companyName;
final String? paymentId;
final String? nationalId;
@ -124,10 +126,12 @@ class Person {
Person({
this.id,
required this.businessId,
this.code,
required this.aliasName,
this.firstName,
this.lastName,
required this.personType,
this.personTypes = const [],
this.companyName,
this.paymentId,
this.nationalId,
@ -150,13 +154,22 @@ class Person {
});
factory Person.fromJson(Map<String, dynamic> json) {
final List<PersonType> types = (json['person_types'] as List?)
?.map((e) => PersonType.fromString(e.toString()))
.toList() ??
[];
final PersonType primaryType = types.isNotEmpty
? types.first
: PersonType.fromString(json['person_type']);
return Person(
id: json['id'],
businessId: json['business_id'],
code: json['code'],
aliasName: json['alias_name'],
firstName: json['first_name'],
lastName: json['last_name'],
personType: PersonType.fromString(json['person_type']),
personType: primaryType,
personTypes: types,
companyName: json['company_name'],
paymentId: json['payment_id'],
nationalId: json['national_id'],
@ -185,10 +198,12 @@ class Person {
return {
'id': id,
'business_id': businessId,
'code': code,
'alias_name': aliasName,
'first_name': firstName,
'last_name': lastName,
'person_type': personType.persianName,
'person_types': personTypes.map((t) => t.persianName).toList(),
'company_name': companyName,
'payment_id': paymentId,
'national_id': nationalId,
@ -285,9 +300,10 @@ class Person {
class PersonCreateRequest {
final String aliasName;
final int? code;
final String? firstName;
final String? lastName;
final PersonType personType;
final List<PersonType> personTypes;
final String? companyName;
final String? paymentId;
final String? nationalId;
@ -307,9 +323,10 @@ class PersonCreateRequest {
PersonCreateRequest({
required this.aliasName,
this.code,
this.firstName,
this.lastName,
required this.personType,
this.personTypes = const [],
this.companyName,
this.paymentId,
this.nationalId,
@ -331,9 +348,10 @@ class PersonCreateRequest {
Map<String, dynamic> toJson() {
return {
'alias_name': aliasName,
if (code != null) 'code': code,
'first_name': firstName,
'last_name': lastName,
'person_type': personType.persianName,
if (personTypes.isNotEmpty) 'person_types': personTypes.map((t) => t.persianName).toList(),
'company_name': companyName,
'payment_id': paymentId,
'national_id': nationalId,
@ -349,16 +367,27 @@ class PersonCreateRequest {
'fax': fax,
'email': email,
'website': website,
'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(),
// Only send fields expected by backend for create
'bank_accounts': bankAccounts
.where((ba) => (ba.bankName).trim().isNotEmpty)
.map((ba) => {
'bank_name': ba.bankName,
'account_number': ba.accountNumber,
'card_number': ba.cardNumber,
'sheba_number': ba.shebaNumber,
})
.toList(),
};
}
}
class PersonUpdateRequest {
final int? code;
final String? aliasName;
final String? firstName;
final String? lastName;
final PersonType? personType;
final List<PersonType>? personTypes;
final String? companyName;
final String? paymentId;
final String? nationalId;
@ -377,10 +406,12 @@ class PersonUpdateRequest {
final bool? isActive;
PersonUpdateRequest({
this.code,
this.aliasName,
this.firstName,
this.lastName,
this.personType,
this.personTypes,
this.companyName,
this.paymentId,
this.nationalId,
@ -402,10 +433,12 @@ class PersonUpdateRequest {
Map<String, dynamic> toJson() {
final Map<String, dynamic> json = {};
if (code != null) json['code'] = code;
if (aliasName != null) json['alias_name'] = aliasName;
if (firstName != null) json['first_name'] = firstName;
if (lastName != null) json['last_name'] = lastName;
if (personType != null) json['person_type'] = personType!.persianName;
if (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList();
if (companyName != null) json['company_name'] = companyName;
if (paymentId != null) json['payment_id'] = paymentId;
if (nationalId != null) json['national_id'] = nationalId;

View file

@ -24,6 +24,7 @@ class PersonsPage extends StatefulWidget {
class _PersonsPageState extends State<PersonsPage> {
final _personService = PersonService();
final GlobalKey _personsTableKey = GlobalKey();
@override
Widget build(BuildContext context) {
@ -54,6 +55,7 @@ class _PersonsPageState extends State<PersonsPage> {
],
),
body: DataTableWidget<Person>(
key: _personsTableKey,
config: _buildDataTableConfig(t),
fromJson: Person.fromJson,
),
@ -64,7 +66,16 @@ class _PersonsPageState extends State<PersonsPage> {
return DataTableConfig<Person>(
endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons',
title: t.personsList,
showRowNumbers: true,
enableRowSelection: true,
columns: [
NumberColumn(
'code',
'کد شخص',
width: ColumnWidth.small,
formatter: (person) => (person.code?.toString() ?? '-'),
textAlign: TextAlign.center,
),
TextColumn(
'alias_name',
t.personAliasName,
@ -87,7 +98,9 @@ class _PersonsPageState extends State<PersonsPage> {
'person_type',
t.personType,
width: ColumnWidth.medium,
formatter: (person) => person.personType.persianName,
formatter: (person) => (person.personTypes.isNotEmpty
? person.personTypes.map((e) => e.persianName).join('، ')
: person.personType.persianName),
),
TextColumn(
'company_name',
@ -108,10 +121,10 @@ class _PersonsPageState extends State<PersonsPage> {
formatter: (person) => person.email ?? '-',
),
TextColumn(
'is_active',
'وضعیت',
width: ColumnWidth.small,
formatter: (person) => person.isActive ? 'فعال' : 'غیرفعال',
'national_id',
t.personNationalId,
width: ColumnWidth.medium,
formatter: (person) => person.nationalId ?? '-',
),
DateColumn(
'created_at',
@ -137,6 +150,7 @@ class _PersonsPageState extends State<PersonsPage> {
),
],
searchFields: [
'code',
'alias_name',
'first_name',
'last_name',
@ -147,6 +161,7 @@ class _PersonsPageState extends State<PersonsPage> {
],
filterFields: [
'person_type',
'person_types',
'is_active',
'country',
'province',
@ -162,7 +177,12 @@ class _PersonsPageState extends State<PersonsPage> {
builder: (context) => PersonFormDialog(
businessId: widget.businessId,
onSuccess: () {
// DataTableWidget will automatically refresh
final state = _personsTableKey.currentState;
try {
// Call public refresh() via dynamic to avoid private state typing
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
},
),
);
@ -175,7 +195,11 @@ class _PersonsPageState extends State<PersonsPage> {
businessId: widget.businessId,
person: person,
onSuccess: () {
// DataTableWidget will automatically refresh
final state = _personsTableKey.currentState;
try {
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
},
),
);

View file

@ -53,6 +53,10 @@ class _DataTableSearchDialogState extends State<DataTableSearchDialog> {
super.initState();
_controller = TextEditingController(text: widget.searchValue);
_selectedType = widget.searchType;
// Enable/disable Apply button reactively on text changes
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override

View file

@ -1232,12 +1232,38 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
));
}
// Add data columns (use visible columns if column settings are enabled)
// Resolve action column (if defined in config)
ActionColumn? actionColumn;
for (final c in widget.config.columns) {
if (c is ActionColumn) {
actionColumn = c;
break;
}
}
// Fixed action column immediately after selection and row number columns
if (actionColumn != null) {
columns.add(DataColumn2(
label: Text(
actionColumn.label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
size: ColumnSize.S,
fixedWidth: 80.0,
));
}
// Add data columns (use visible columns if column settings are enabled), excluding ActionColumn
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
? _visibleColumns
: widget.config.columns;
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
columns.addAll(columnsToShow.map((column) {
columns.addAll(dataColumnsToShow.map((column) {
return DataColumn2(
label: _ColumnHeaderWithSearch(
text: column.label,
@ -1295,7 +1321,22 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
));
}
// Add data cells
// 3) Fixed action cell (immediately after selection and row number)
// Resolve action column once (same logic as header)
ActionColumn? actionColumn;
for (final c in widget.config.columns) {
if (c is ActionColumn) {
actionColumn = c;
break;
}
}
if (actionColumn != null) {
cells.add(DataCell(
_buildActionButtons(item, actionColumn),
));
}
// 4) Add data cells
if (widget.config.customRowBuilder != null) {
cells.add(DataCell(
widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(),
@ -1304,8 +1345,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
? _visibleColumns
: widget.config.columns;
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
cells.addAll(columnsToShow.map((column) {
cells.addAll(dataColumnsToShow.map((column) {
return DataCell(
_buildCellContent(item, column, index),
);
@ -1328,18 +1370,49 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
}
Widget _buildCellContent(dynamic item, DataTableColumn column, int index) {
final value = DataTableUtils.getCellValue(item, column.key);
// 1) Custom widget builder takes precedence
if (column is CustomColumn && column.builder != null) {
return column.builder!(item, index);
}
// 2) Action column
if (column is ActionColumn) {
return _buildActionButtons(item, column);
}
final formattedValue = DataTableUtils.formatCellValue(value, column);
// 3) If a formatter is provided on the column, call it with the full item
// This allows working with strongly-typed objects (not just Map)
if (column is TextColumn && column.formatter != null) {
final text = column.formatter!(item) ?? '';
return Text(
text,
textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column),
overflow: _getOverflow(column),
);
}
if (column is NumberColumn && column.formatter != null) {
final text = column.formatter!(item) ?? '';
return Text(
text,
textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column),
overflow: _getOverflow(column),
);
}
if (column is DateColumn && column.formatter != null) {
final text = column.formatter!(item) ?? '';
return Text(
text,
textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column),
overflow: _getOverflow(column),
);
}
// 4) Fallback: get property value from Map items by key
final value = DataTableUtils.getCellValue(item, column.key);
final formattedValue = DataTableUtils.formatCellValue(value, column);
return Text(
formattedValue,
textAlign: _getTextAlign(column),
@ -1351,24 +1424,35 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
Widget _buildActionButtons(dynamic item, ActionColumn column) {
if (column.actions.isEmpty) return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: column.actions.map((action) {
return IconButton(
onPressed: action.enabled ? () => action.onTap(item) : null,
icon: Icon(
return PopupMenuButton<int>(
tooltip: column.label,
icon: const Icon(Icons.more_vert, size: 20),
onSelected: (index) {
final action = column.actions[index];
if (action.enabled) action.onTap(item);
},
itemBuilder: (context) {
return List.generate(column.actions.length, (index) {
final action = column.actions[index];
return PopupMenuItem<int>(
value: index,
enabled: action.enabled,
child: Row(
children: [
Icon(
action.icon,
color: action.color,
size: 20,
),
tooltip: action.label,
style: IconButton.styleFrom(
foregroundColor: action.isDestructive
color: action.isDestructive
? Theme.of(context).colorScheme.error
: action.color,
: (action.color ?? Theme.of(context).iconTheme.color),
size: 18,
),
const SizedBox(width: 8),
Text(action.label),
],
),
);
}).toList(),
});
},
);
}

View file

@ -24,6 +24,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
final _personService = PersonService();
bool _isLoading = false;
// Code (unique) controls
final _codeController = TextEditingController();
bool _autoGenerateCode = true;
// Controllers for basic info
final _aliasNameController = TextEditingController();
final _firstNameController = TextEditingController();
@ -48,7 +52,8 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
final _emailController = TextEditingController();
final _websiteController = TextEditingController();
PersonType _selectedPersonType = PersonType.customer;
PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility)
final Set<PersonType> _selectedPersonTypes = <PersonType>{};
bool _isActive = true;
// Bank accounts
@ -63,6 +68,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
void _initializeForm() {
if (widget.person != null) {
final person = widget.person!;
if (person.code != null) {
_codeController.text = person.code!.toString();
_autoGenerateCode = false;
}
_aliasNameController.text = person.aliasName;
_firstNameController.text = person.firstName ?? '';
_lastNameController.text = person.lastName ?? '';
@ -82,6 +91,9 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
_emailController.text = person.email ?? '';
_websiteController.text = person.website ?? '';
_selectedPersonType = person.personType;
_selectedPersonTypes
..clear()
..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]);
_isActive = person.isActive;
_bankAccounts = List.from(person.bankAccounts);
}
@ -89,6 +101,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
@override
void dispose() {
_codeController.dispose();
_aliasNameController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
@ -121,10 +134,13 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
if (widget.person == null) {
// Create new person
final personData = PersonCreateRequest(
code: _autoGenerateCode
? null
: (int.tryParse(_codeController.text.trim()) ?? null),
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
personType: _selectedPersonType,
personTypes: _selectedPersonTypes.toList(),
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
nationalId: _nationalIdController.text.trim().isEmpty ? null : _nationalIdController.text.trim(),
@ -150,10 +166,12 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
} else {
// Update existing person
final personData = PersonUpdateRequest(
code: (int.tryParse(_codeController.text.trim()) ?? null),
aliasName: _aliasNameController.text.trim(),
firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(),
lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
personType: _selectedPersonType,
personType: null,
personTypes: _selectedPersonTypes.isNotEmpty ? _selectedPersonTypes.toList() : null,
companyName: _companyNameController.text.trim().isEmpty ? null : _companyNameController.text.trim(),
paymentId: _paymentIdController.text.trim().isEmpty ? null : _paymentIdController.text.trim(),
nationalId: _nationalIdController.text.trim().isEmpty ? null : _nationalIdController.text.trim(),
@ -266,53 +284,54 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
const Divider(),
const SizedBox(height: 16),
// Form
// Form with tabs
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: DefaultTabController(
length: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic Information
_buildSectionHeader(t.personBasicInfo),
const SizedBox(height: 16),
_buildBasicInfoFields(t),
const SizedBox(height: 24),
// Economic Information
_buildSectionHeader(t.personEconomicInfo),
const SizedBox(height: 16),
_buildEconomicInfoFields(t),
const SizedBox(height: 24),
// Contact Information
_buildSectionHeader(t.personContactInfo),
const SizedBox(height: 16),
_buildContactInfoFields(t),
const SizedBox(height: 24),
// Bank Accounts
_buildSectionHeader(t.personBankInfo),
const SizedBox(height: 16),
_buildBankAccountsSection(t),
const SizedBox(height: 24),
// Status (only for editing)
if (isEditing) ...[
_buildSectionHeader('وضعیت'),
const SizedBox(height: 16),
SwitchListTile(
title: Text('فعال'),
value: _isActive,
onChanged: (value) {
setState(() {
_isActive = value;
});
},
),
const SizedBox(height: 24),
TabBar(
isScrollable: true,
tabs: [
Tab(text: t.personBasicInfo),
Tab(text: t.personEconomicInfo),
Tab(text: t.personContactInfo),
Tab(text: t.personBankInfo),
],
),
const SizedBox(height: 12),
Expanded(
child: TabBarView(
children: [
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildBasicInfoFields(t),
),
),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildEconomicInfoFields(t),
),
),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildContactInfoFields(t),
),
),
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: _buildBankAccountsSection(t),
),
),
],
),
),
],
),
),
@ -361,6 +380,61 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Widget _buildBasicInfoFields(AppLocalizations t) {
return Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _codeController,
readOnly: _autoGenerateCode,
decoration: InputDecoration(
labelText: 'کد شخص (اختیاری)',
hintText: 'کد یکتا (عددی)',
suffixIcon: Container(
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: ToggleButtons(
isSelected: [_autoGenerateCode, !_autoGenerateCode],
borderRadius: BorderRadius.circular(6),
constraints: const BoxConstraints(minHeight: 32, minWidth: 64),
onPressed: (index) {
setState(() {
_autoGenerateCode = (index == 0);
});
},
children: const [
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text('اتوماتیک'),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text('دستی'),
),
],
),
),
),
keyboardType: TextInputType.number,
validator: (value) {
if (!_autoGenerateCode) {
if (value == null || value.trim().isEmpty) {
return 'کد شخص الزامی است';
}
if (int.tryParse(value.trim()) == null) {
return 'کد باید عددی باشد';
}
}
return null;
},
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@ -379,36 +453,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<PersonType>(
value: _selectedPersonType,
decoration: InputDecoration(
labelText: t.personType,
),
items: PersonType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.persianName),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedPersonType = value;
});
}
},
validator: (value) {
if (value == null) {
return t.personTypeRequired;
}
return null;
},
),
),
Expanded(child: _buildPersonTypesMultiSelect(t)),
],
),
const SizedBox(height: 16),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@ -432,7 +480,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
),
],
),
const SizedBox(height: 16),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@ -460,6 +508,39 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
);
}
Widget _buildPersonTypesMultiSelect(AppLocalizations t) {
final types = PersonType.values;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(t.personType),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: types.map((type) {
final selected = _selectedPersonTypes.contains(type);
return FilterChip(
label: Text(type.persianName),
selected: selected,
onSelected: (v) {
setState(() {
if (v) {
_selectedPersonTypes.add(type);
} else {
_selectedPersonTypes.remove(type);
}
});
},
);
}).toList(),
),
],
);
}
Widget _buildEconomicInfoFields(AppLocalizations t) {
return Column(
children: [