progress in money and persons

This commit is contained in:
Hesabix 2025-09-28 23:06:53 +03:30
parent af7aac7657
commit a409202f6f
17 changed files with 617 additions and 121 deletions

View file

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.currency import Currency
from app.core.responses import success_response
router = APIRouter(prefix="/currencies", tags=["currencies"])
@router.get(
"",
summary="فهرست ارزها",
description="دریافت فهرست ارزهای قابل استفاده",
)
def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
items = [
{
"id": c.id,
"name": c.name,
"title": c.title,
"symbol": c.symbol,
"code": c.code,
}
for c in db.query(Currency).order_by(Currency.title.asc()).all()
]
return success_response(items, request)

View file

@ -178,6 +178,8 @@ class BusinessCreateRequest(BaseModel):
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="آرایه سال‌های مالی برای ایجاد اولیه") fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه")
default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض")
currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده")
class BusinessUpdateRequest(BaseModel): class BusinessUpdateRequest(BaseModel):
@ -214,6 +216,8 @@ class BusinessResponse(BaseModel):
postal_code: Optional[str] = Field(default=None, description="کد پستی") postal_code: Optional[str] = Field(default=None, description="کد پستی")
created_at: str = Field(..., description="تاریخ ایجاد") created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی") updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض")
currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسب‌وکار")
class BusinessListResponse(BaseModel): class BusinessListResponse(BaseModel):

View file

@ -36,6 +36,7 @@ class Business(Base):
business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False) business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False)
business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False) business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
default_currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
# فیلدهای جدید # فیلدهای جدید
address: Mapped[str | None] = mapped_column(Text, nullable=True) address: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -58,5 +59,6 @@ class Business(Base):
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") fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses") currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False)
documents = relationship("Document", back_populates="business", cascade="all, delete-orphan") documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan") accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")

View file

@ -50,6 +50,7 @@ class BusinessRepository(BaseRepository[Business]):
business_type: BusinessType, business_type: BusinessType,
business_field: BusinessField, business_field: BusinessField,
owner_id: int, owner_id: int,
default_currency_id: int | None = None,
address: str | None = None, address: str | None = None,
phone: str | None = None, phone: str | None = None,
mobile: str | None = None, mobile: str | None = None,
@ -60,13 +61,14 @@ class BusinessRepository(BaseRepository[Business]):
province: str | None = None, province: str | None = None,
city: str | None = None, city: str | None = None,
postal_code: str | None = None postal_code: str | None = None
) -> Business: ) -> Business:
"""ایجاد کسب و کار جدید""" """ایجاد کسب و کار جدید"""
business = Business( business = Business(
name=name, name=name,
business_type=business_type, business_type=business_type,
business_field=business_field, business_field=business_field,
owner_id=owner_id, owner_id=owner_id,
default_currency_id=default_currency_id,
address=address, address=address,
phone=phone, phone=phone,
mobile=mobile, mobile=mobile,

View file

@ -7,6 +7,7 @@ from adapters.api.v1.health import router as health_router
from adapters.api.v1.auth import router as auth_router from adapters.api.v1.auth import router as auth_router
from adapters.api.v1.users import router as users_router 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.currencies import router as currencies_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.accounts import router as accounts_router
@ -273,6 +274,7 @@ def create_app() -> FastAPI:
application.include_router(auth_router, prefix=settings.api_v1_prefix) application.include_router(auth_router, prefix=settings.api_v1_prefix)
application.include_router(users_router, prefix=settings.api_v1_prefix) application.include_router(users_router, prefix=settings.api_v1_prefix)
application.include_router(businesses_router, prefix=settings.api_v1_prefix) application.include_router(businesses_router, prefix=settings.api_v1_prefix)
application.include_router(currencies_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(accounts_router, prefix=settings.api_v1_prefix)

View file

@ -6,6 +6,7 @@ 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.fiscal_year_repo import FiscalYearRepository
from adapters.db.models.currency import Currency, BusinessCurrency
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 (
@ -31,6 +32,7 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
business_type=business_type_enum, business_type=business_type_enum,
business_field=business_field_enum, business_field=business_field_enum,
owner_id=owner_id, owner_id=owner_id,
default_currency_id=getattr(business_data, "default_currency_id", None),
address=business_data.address, address=business_data.address,
phone=business_data.phone, phone=business_data.phone,
mobile=business_data.mobile, mobile=business_data.mobile,
@ -59,6 +61,30 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1) is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1)
) )
# مدیریت ارزها
currency_ids: list[int] = []
if getattr(business_data, "currency_ids", None):
currency_ids = list(dict.fromkeys(business_data.currency_ids)) # unique
default_currency_id = getattr(business_data, "default_currency_id", None)
if default_currency_id:
if default_currency_id not in currency_ids:
currency_ids.insert(0, default_currency_id)
# اعتبارسنجی وجود ارزها
if currency_ids:
existing_ids = [cid for (cid,) in db.query(Currency.id).filter(Currency.id.in_(currency_ids)).all()]
if set(existing_ids) != set(currency_ids):
missing = set(currency_ids) - set(existing_ids)
raise ValueError(f"Invalid currency ids: {sorted(list(missing))}")
# درج ارتباطات در business_currencies
for cid in currency_ids:
bc = BusinessCurrency(business_id=created_business.id, currency_id=cid)
db.add(bc)
db.commit()
db.refresh(created_business)
# تبدیل به response format # تبدیل به response format
return _business_to_dict(created_business) return _business_to_dict(created_business)
@ -269,7 +295,7 @@ def get_business_summary(db: Session, owner_id: int) -> Dict[str, Any]:
def _business_to_dict(business: Business) -> Dict[str, Any]: def _business_to_dict(business: Business) -> Dict[str, Any]:
"""تبدیل مدل کسب و کار به dictionary""" """تبدیل مدل کسب و کار به dictionary"""
return { data = {
"id": business.id, "id": business.id,
"name": business.name, "name": business.name,
"business_type": business.business_type.value, "business_type": business.business_type.value,
@ -288,3 +314,26 @@ def _business_to_dict(business: Business) -> Dict[str, Any]:
"created_at": business.created_at, # datetime object بماند "created_at": business.created_at, # datetime object بماند
"updated_at": business.updated_at # datetime object بماند "updated_at": business.updated_at # datetime object بماند
} }
# ارز پیشفرض
if getattr(business, "default_currency", None):
c = business.default_currency
data["default_currency"] = {
"id": c.id,
"code": c.code,
"title": c.title,
"symbol": c.symbol,
}
else:
data["default_currency"] = None
# ارزهای فعال کسب‌وکار
if getattr(business, "currencies", None):
data["currencies"] = [
{"id": c.id, "code": c.code, "title": c.title, "symbol": c.symbol}
for c in business.currencies
]
else:
data["currencies"] = []
return data

View file

@ -8,6 +8,7 @@ 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
adapters/api/v1/businesses.py adapters/api/v1/businesses.py
adapters/api/v1/currencies.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/persons.py adapters/api/v1/persons.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
@ -123,6 +124,7 @@ migrations/versions/20250927_000019_seed_accounts_chart.py
migrations/versions/20250927_000020_add_share_count_and_shareholder_type.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_000021_update_person_type_enum_to_persian.py
migrations/versions/20250927_000022_add_person_commission_fields.py migrations/versions/20250927_000022_add_person_commission_fields.py
migrations/versions/4b2ea782bcb3_merge_heads.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/d3e84892c1c2_sync_person_type_enum_values_callable_.py
migrations/versions/f876bfa36805_merge_multiple_heads.py migrations/versions/f876bfa36805_merge_multiple_heads.py

View file

@ -51,6 +51,22 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
# Ensure alembic_version.version_num can hold long revision strings
try:
res = connection.exec_driver_sql(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.columns "
"WHERE table_name='alembic_version' AND column_name='version_num';"
)
row = res.fetchone()
if row is not None:
length = row[0] or 0
if length < 255:
connection.exec_driver_sql(
"ALTER TABLE alembic_version MODIFY COLUMN version_num VARCHAR(255) NOT NULL;"
)
except Exception:
# Best-effort; ignore if table doesn't exist yet
pass
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,

View file

@ -12,7 +12,10 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind() bind = op.get_bind()
inspector = inspect(bind) inspector = inspect(bind)
cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set() # اگر جدول persons وجود ندارد، این مایگریشن را نادیده بگیر
if 'persons' not in inspector.get_table_names():
return
cols = {c['name'] for c in inspector.get_columns('persons')}
with op.batch_alter_table('persons') as batch_op: with op.batch_alter_table('persons') as batch_op:
if 'code' not in cols: if 'code' not in cols:
batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
@ -25,7 +28,20 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if 'persons' not in inspector.get_table_names():
return
with op.batch_alter_table('persons') as batch_op: with op.batch_alter_table('persons') as batch_op:
batch_op.drop_constraint('uq_persons_business_code', type_='unique') try:
batch_op.drop_column('person_types') batch_op.drop_constraint('uq_persons_business_code', type_='unique')
batch_op.drop_column('code') except Exception:
pass
try:
batch_op.drop_column('person_types')
except Exception:
pass
try:
batch_op.drop_column('code')
except Exception:
pass

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_000011_drop_person_is_active' revision = '20250926_000011_drop_person_is_active'
@ -9,20 +10,30 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
with op.batch_alter_table('persons') as batch_op: bind = op.get_bind()
try: inspector = inspect(bind)
batch_op.drop_column('is_active') tables = set(inspector.get_table_names())
except Exception: if 'persons' in tables:
pass with op.batch_alter_table('persons') as batch_op:
with op.batch_alter_table('person_bank_accounts') as batch_op: try:
try: batch_op.drop_column('is_active')
batch_op.drop_column('is_active') except Exception:
except Exception: pass
pass if 'person_bank_accounts' in tables:
with op.batch_alter_table('person_bank_accounts') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
def downgrade() -> None: def downgrade() -> None:
with op.batch_alter_table('persons') as batch_op: bind = op.get_bind()
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) inspector = inspect(bind)
with op.batch_alter_table('person_bank_accounts') as batch_op: tables = set(inspector.get_table_names())
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) if 'persons' in tables:
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')))
if 'person_bank_accounts' in tables:
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

@ -9,7 +9,7 @@ from sqlalchemy import inspect
revision = '20250927_000012_add_fiscal_years_table' revision = '20250927_000012_add_fiscal_years_table'
down_revision = '20250926_000011_drop_person_is_active' down_revision = '20250926_000011_drop_person_is_active'
branch_labels = None branch_labels = None
depends_on = None depends_on = ('20250117_000003',)
def upgrade() -> None: def upgrade() -> None:

View file

@ -43,6 +43,17 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4' mysql_charset='utf8mb4'
) )
# Add default_currency_id to businesses if not exists
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'businesses' in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns('businesses')}
if 'default_currency_id' not in cols:
with op.batch_alter_table('businesses') as batch_op:
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
# Unique and indexes for association # Unique and indexes for association
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id']) 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_business_id', 'business_currencies', ['business_id'])
@ -50,6 +61,20 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# Drop index/foreign key/column default_currency_id if exists
with op.batch_alter_table('businesses') as batch_op:
try:
batch_op.drop_index('ix_businesses_default_currency_id')
except Exception:
pass
try:
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_column('default_currency_id')
except Exception:
pass
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies') 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_index('ix_business_currencies_business_id', table_name='business_currencies')
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique') op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')

View file

@ -0,0 +1,29 @@
"""merge_heads
Revision ID: 4b2ea782bcb3
Revises: 20250120_000003, 20250927_000022_add_person_commission_fields
Create Date: 2025-09-28 20:59:14.557570
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '4b2ea782bcb3'
down_revision = ('20250120_000002', '20250927_000022_add_person_commission_fields')
branch_labels = None
depends_on = None
def upgrade() -> None:
# این migration صرفاً برای ادغام شاخه‌ها است و تغییری در اسکیما ایجاد نمی‌کند
pass
def downgrade() -> None:
# بدون تغییر
pass

View file

@ -8,6 +8,8 @@ Create Date: 2025-09-27 19:18:06.253391
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
from sqlalchemy import text
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'd3e84892c1c2' revision = 'd3e84892c1c2'
@ -16,109 +18,156 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, name: str) -> bool:
res = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name=:t"
), {"t": name})
return (res.scalar() or 0) > 0
def _column_exists(conn, table: str, col: str) -> bool:
res = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
), {"t": table, "c": col})
return (res.scalar() or 0) > 0
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - guarded for idempotency ###
op.create_table('storage_configs', bind = op.get_bind()
sa.Column('id', sa.String(length=36), nullable=False), inspector = inspect(bind)
sa.Column('name', sa.String(length=100), nullable=False), existing_tables = set(inspector.get_table_names())
sa.Column('storage_type', sa.String(length=20), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False), if 'storage_configs' not in existing_tables:
sa.Column('is_active', sa.Boolean(), nullable=False), op.create_table('storage_configs',
sa.Column('config_data', sa.JSON(), nullable=False), sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('storage_type', sa.String(length=20), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('is_default', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('config_data', sa.JSON(), nullable=False),
) sa.Column('created_by', sa.Integer(), nullable=False),
op.create_table('file_storage', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False), sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.Column('stored_name', sa.String(length=255), nullable=False), sa.PrimaryKeyConstraint('id')
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), if 'file_storage' not in existing_tables:
sa.Column('storage_type', sa.String(length=20), nullable=False), op.create_table('file_storage',
sa.Column('storage_config_id', sa.String(length=36), nullable=True), sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('uploaded_by', sa.Integer(), nullable=False), sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('module_context', sa.String(length=50), nullable=False), sa.Column('stored_name', sa.String(length=255), nullable=False),
sa.Column('context_id', sa.String(length=36), nullable=True), sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('developer_data', sa.JSON(), nullable=True), sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('checksum', sa.String(length=64), nullable=True), sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('storage_type', sa.String(length=20), nullable=False),
sa.Column('is_temporary', sa.Boolean(), nullable=False), sa.Column('storage_config_id', sa.String(length=36), nullable=True),
sa.Column('is_verified', sa.Boolean(), nullable=False), sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('verification_token', sa.String(length=100), nullable=True), sa.Column('module_context', sa.String(length=50), nullable=False),
sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True), sa.Column('context_id', sa.String(length=36), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), sa.Column('developer_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('checksum', sa.String(length=64), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('is_temporary', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ), sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), sa.Column('verification_token', sa.String(length=100), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True),
) sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
op.create_table('file_verifications', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('file_id', sa.String(length=36), nullable=False), sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('module_name', sa.String(length=50), nullable=False), sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ),
sa.Column('verification_token', sa.String(length=100), nullable=False), sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), sa.PrimaryKeyConstraint('id')
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), if 'file_verifications' not in existing_tables:
sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ), op.create_table('file_verifications',
sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ), sa.Column('id', sa.String(length=36), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('file_id', sa.String(length=36), nullable=False),
) sa.Column('module_name', sa.String(length=50), nullable=False),
op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years') sa.Column('verification_token', sa.String(length=100), nullable=False),
op.alter_column('person_bank_accounts', 'person_id', 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')
)
# Drop index if exists
try:
bind = op.get_bind()
insp = inspect(bind)
if 'fiscal_years' in insp.get_table_names():
existing_indexes = {idx['name'] for idx in insp.get_indexes('fiscal_years')}
if 'ix_fiscal_years_title' in existing_indexes:
op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years')
except Exception:
pass
conn = op.get_bind()
if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
op.alter_column('person_bank_accounts', 'person_id',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment=None, comment=None,
existing_comment='شناسه شخص', existing_comment='شناسه شخص',
existing_nullable=False) existing_nullable=False)
op.alter_column('persons', 'business_id',
existing_type=mysql.INTEGER(), if _table_exists(conn, 'persons'):
comment=None, if _column_exists(conn, 'persons', 'business_id'):
existing_comment='شناسه کسب و کار', op.alter_column('persons', 'business_id',
existing_nullable=False) existing_type=mysql.INTEGER(),
op.alter_column('persons', 'code', comment=None,
existing_type=mysql.INTEGER(), existing_comment='شناسه کسب و کار',
comment='کد یکتا در هر کسب و کار', existing_nullable=False)
existing_nullable=True) if _column_exists(conn, 'persons', 'code'):
op.alter_column('persons', 'person_types', op.alter_column('persons', 'code',
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), existing_type=mysql.INTEGER(),
comment='لیست انواع شخص به صورت JSON', comment='کد یکتا در هر کسب و کار',
existing_nullable=True) existing_nullable=True)
op.alter_column('persons', 'share_count', if _column_exists(conn, 'persons', 'person_types'):
existing_type=mysql.INTEGER(), op.alter_column('persons', 'person_types',
comment='تعداد سهام (فقط برای سهامدار)', existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
existing_nullable=True) comment='لیست انواع شخص به صورت JSON',
existing_nullable=True)
if _column_exists(conn, 'persons', 'share_count'):
op.alter_column('persons', 'share_count',
existing_type=mysql.INTEGER(),
comment='تعداد سهام (فقط برای سهامدار)',
existing_nullable=True)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.alter_column('persons', 'share_count', conn = op.get_bind()
if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'share_count'):
op.alter_column('persons', 'share_count',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment=None, comment=None,
existing_comment='تعداد سهام (فقط برای سهامدار)', existing_comment='تعداد سهام (فقط برای سهامدار)',
existing_nullable=True) existing_nullable=True)
op.alter_column('persons', 'person_types', if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'person_types'):
op.alter_column('persons', 'person_types',
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
comment=None, comment=None,
existing_comment='لیست انواع شخص به صورت JSON', existing_comment='لیست انواع شخص به صورت JSON',
existing_nullable=True) existing_nullable=True)
op.alter_column('persons', 'code', if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'code'):
op.alter_column('persons', 'code',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment=None, comment=None,
existing_comment='کد یکتا در هر کسب و کار', existing_comment='کد یکتا در هر کسب و کار',
existing_nullable=True) existing_nullable=True)
op.alter_column('persons', 'business_id', if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'business_id'):
op.alter_column('persons', 'business_id',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment='شناسه کسب و کار', comment='شناسه کسب و کار',
existing_nullable=False) existing_nullable=False)
op.alter_column('person_bank_accounts', 'person_id', if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
op.alter_column('person_bank_accounts', 'person_id',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment='شناسه شخص', comment='شناسه شخص',
existing_nullable=False) existing_nullable=False)

View file

@ -23,6 +23,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
bool _isLoading = false; bool _isLoading = false;
int _fiscalTabIndex = 0; int _fiscalTabIndex = 0;
late TextEditingController _fiscalTitleController; late TextEditingController _fiscalTitleController;
List<Map<String, dynamic>> _currencies = [];
@override @override
void initState() { void initState() {
@ -32,6 +33,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
// Set default selections for business type and field // Set default selections for business type and field
_businessData.businessType ??= BusinessType.shop; _businessData.businessType ??= BusinessType.shop;
_businessData.businessField ??= BusinessField.commercial; _businessData.businessField ??= BusinessField.commercial;
_loadCurrencies();
} }
@override @override
@ -58,6 +60,27 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
} }
} }
Future<void> _loadCurrencies() async {
try {
final list = await BusinessApiService.getCurrencies();
if (mounted) {
setState(() {
_currencies = list;
final irr = _currencies.firstWhere(
(e) => (e['code'] as String?) == 'IRR',
orElse: () => {} as Map<String, dynamic>,
);
if (irr.isNotEmpty) {
_businessData.defaultCurrencyId ??= irr['id'] as int?;
if (_businessData.defaultCurrencyId != null && !_businessData.currencyIds.contains(_businessData.defaultCurrencyId)) {
_businessData.currencyIds.add(_businessData.defaultCurrencyId!);
}
}
});
}
} catch (_) {}
}
Widget _buildFiscalStep() { Widget _buildFiscalStep() {
if (_businessData.fiscalYears.isEmpty) { if (_businessData.fiscalYears.isEmpty) {
_businessData.fiscalYears.add(FiscalYearData(isLast: true)); _businessData.fiscalYears.add(FiscalYearData(isLast: true));
@ -238,7 +261,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
case 2: case 2:
return t.businessLegalInfo; return t.businessLegalInfo;
case 3: case 3:
return 'سال مالی'; return 'ارز و سال مالی';
case 4: case 4:
return t.businessConfirmation; return t.businessConfirmation;
default: default:
@ -379,7 +402,7 @@ 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, 'سال مالی'), _buildStepIndicator(3, 'ارز و سال مالی'),
_buildStepIndicator(4, t.businessConfirmation), _buildStepIndicator(4, t.businessConfirmation),
], ],
), ),
@ -435,26 +458,21 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
// Form content // Form content
Expanded( Expanded(
child: SingleChildScrollView( child: PageView(
child: SizedBox( controller: _pageController,
height: MediaQuery.of(context).size.height - 200, // ارتفاع مناسب برای اسکرول physics: const NeverScrollableScrollPhysics(),
child: PageView( onPageChanged: (index) {
controller: _pageController, setState(() {
physics: const NeverScrollableScrollPhysics(), _currentStep = index;
onPageChanged: (index) { });
setState(() { },
_currentStep = index; children: [
}); SingleChildScrollView(child: _buildStep1()),
}, SingleChildScrollView(child: _buildStep2()),
children: [ SingleChildScrollView(child: _buildStep3()),
_buildStep1(), SingleChildScrollView(child: _buildCurrencyAndFiscalStep()),
_buildStep2(), SingleChildScrollView(child: _buildStep4()),
_buildStep3(), ],
_buildFiscalStep(),
_buildStep4(),
],
),
),
), ),
), ),
@ -1479,6 +1497,79 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
); );
} }
Widget _buildCurrencyAndFiscalStep() {
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int>(
value: _businessData.defaultCurrencyId,
items: _currencies.map((c) {
return DropdownMenuItem<int>(
value: c['id'] as int,
child: Text('${c['title']} (${c['code']})'),
);
}).toList(),
decoration: const InputDecoration(
labelText: 'ارز پیشفرض *',
border: OutlineInputBorder(),
),
onChanged: (v) {
setState(() {
_businessData.defaultCurrencyId = v;
if (v != null && !_businessData.currencyIds.contains(v)) {
_businessData.currencyIds.add(v);
}
});
},
),
const SizedBox(height: 12),
_CurrencyMultiSelect(
currencies: _currencies,
selectedIds: _businessData.currencyIds,
defaultId: _businessData.defaultCurrencyId,
onChanged: (ids) {
setState(() {
_businessData.currencyIds = ids;
final d = _businessData.defaultCurrencyId;
if (d != null && !_businessData.currencyIds.contains(d)) {
_businessData.currencyIds.add(d);
}
});
},
),
],
),
),
const SizedBox(height: 24),
_buildFiscalStep(),
],
),
),
),
);
}
Widget _buildStep4() { Widget _buildStep4() {
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!; final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
@ -1603,4 +1694,160 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
), ),
); );
} }
}
class _CurrencyMultiSelect extends StatefulWidget {
final List<Map<String, dynamic>> currencies;
final List<int> selectedIds;
final int? defaultId;
final ValueChanged<List<int>> onChanged;
const _CurrencyMultiSelect({
required this.currencies,
required this.selectedIds,
required this.defaultId,
required this.onChanged,
});
@override
State<_CurrencyMultiSelect> createState() => _CurrencyMultiSelectState();
}
class _CurrencyMultiSelectState extends State<_CurrencyMultiSelect> {
late List<int> _selected;
final TextEditingController _searchCtrl = TextEditingController();
bool _panelOpen = false;
@override
void initState() {
super.initState();
_selected = List<int>.from(widget.selectedIds);
}
@override
void didUpdateWidget(covariant _CurrencyMultiSelect oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedIds != widget.selectedIds) {
_selected = List<int>.from(widget.selectedIds);
}
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _toggle(int id) {
setState(() {
if (_selected.contains(id)) {
if (widget.defaultId != id) {
_selected.remove(id);
}
} else {
_selected.add(id);
}
widget.onChanged(List<int>.from(_selected));
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = widget.currencies.where((c) {
final q = _searchCtrl.text.trim();
if (q.isEmpty) return true;
final title = (c['title'] ?? '').toString();
final code = (c['code'] ?? '').toString();
return title.contains(q) || code.toLowerCase().contains(q.toLowerCase());
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ارزهای جانبی', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _panelOpen = !_panelOpen),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surface,
),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
children: _selected.isEmpty
? [
Text(
'انتخاب کنید...',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
)
]
: _selected.map((id) {
final c = widget.currencies.firstWhere((e) => e['id'] == id, orElse: () => {});
final isDefault = widget.defaultId == id;
return Chip(
label: Text('${c['title']} (${c['code']})'),
avatar: isDefault ? const Icon(Icons.star, size: 16) : null,
onDeleted: isDefault ? null : () => _toggle(id),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}).toList(),
),
),
const SizedBox(width: 8),
Icon(_panelOpen ? Icons.expand_less : Icons.expand_more),
],
),
),
),
if (_panelOpen) ...[
const SizedBox(height: 8),
TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'جستجو بر اساس نام یا کد...',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: Scrollbar(
child: ListView.builder(
shrinkWrap: true,
itemCount: filtered.length,
itemBuilder: (context, index) {
final c = filtered[index];
final id = c['id'] as int;
final selected = _selected.contains(id);
final isDefault = widget.defaultId == id;
return CheckboxListTile(
value: selected,
onChanged: (val) => _toggle(id),
dense: true,
title: Text('${c['title']} (${c['code']})'),
secondary: isDefault ? const Icon(Icons.star, size: 18) : null,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
),
],
],
);
}
} }

View file

@ -4,6 +4,7 @@ import '../models/business_models.dart';
class BusinessApiService { class BusinessApiService {
static const String _basePath = '/api/v1/businesses'; static const String _basePath = '/api/v1/businesses';
static final ApiClient _apiClient = ApiClient(); static final ApiClient _apiClient = ApiClient();
static const String _currencyPath = '/api/v1/currencies';
// ایجاد کسب و کار جدید // ایجاد کسب و کار جدید
static Future<BusinessResponse> createBusiness(BusinessData businessData) async { static Future<BusinessResponse> createBusiness(BusinessData businessData) async {
@ -19,6 +20,17 @@ class BusinessApiService {
} }
} }
// دریافت فهرست ارزها
static Future<List<Map<String, dynamic>>> getCurrencies() async {
final response = await _apiClient.get(_currencyPath);
if (response.data['success'] == true) {
final List<dynamic> items = response.data['data'];
return items.cast<Map<String, dynamic>>();
} else {
throw Exception(response.data['message'] ?? 'خطا در دریافت فهرست ارزها');
}
}
// دریافت لیست کسب و کارها // دریافت لیست کسب و کارها
static Future<List<BusinessResponse>> getBusinesses({ static Future<List<BusinessResponse>> getBusinesses({
int page = 1, int page = 1,