From a409202f6f0695a600c58b98ed5307bf324ea3a6 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 28 Sep 2025 23:06:53 +0330 Subject: [PATCH] progress in money and persons --- hesabixAPI/adapters/api/v1/currencies.py | 30 ++ hesabixAPI/adapters/api/v1/schemas.py | 4 + hesabixAPI/adapters/db/models/business.py | 2 + .../adapters/db/repositories/business_repo.py | 4 +- hesabixAPI/app/main.py | 2 + hesabixAPI/app/services/business_service.py | 51 ++- hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 2 + hesabixAPI/migrations/env.py | 16 + ...250926_000010_add_person_code_and_types.py | 24 +- .../20250926_000011_drop_person_is_active.py | 39 ++- .../20250927_000012_add_fiscal_years_table.py | 2 +- ..._add_currencies_and_business_currencies.py | 25 ++ .../versions/4b2ea782bcb3_merge_heads.py | 29 ++ ..._sync_person_type_enum_values_callable_.py | 205 +++++++----- .../lib/models/business_models.dart | Bin 11723 -> 12426 bytes .../lib/pages/profile/new_business_page.dart | 291 ++++++++++++++++-- .../lib/services/business_api_service.dart | 12 + 17 files changed, 617 insertions(+), 121 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/currencies.py create mode 100644 hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py diff --git a/hesabixAPI/adapters/api/v1/currencies.py b/hesabixAPI/adapters/api/v1/currencies.py new file mode 100644 index 0000000..244babd --- /dev/null +++ b/hesabixAPI/adapters/api/v1/currencies.py @@ -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) + + diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py index 53a9b85..e7fdf86 100644 --- a/hesabixAPI/adapters/api/v1/schemas.py +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -178,6 +178,8 @@ class BusinessCreateRequest(BaseModel): city: Optional[str] = Field(default=None, max_length=100, description="شهر") postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی") fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه") + default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض") + currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده") class BusinessUpdateRequest(BaseModel): @@ -214,6 +216,8 @@ class BusinessResponse(BaseModel): postal_code: Optional[str] = Field(default=None, description="کد پستی") created_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): diff --git a/hesabixAPI/adapters/db/models/business.py b/hesabixAPI/adapters/db/models/business.py index befab27..85253d9 100644 --- a/hesabixAPI/adapters/db/models/business.py +++ b/hesabixAPI/adapters/db/models/business.py @@ -36,6 +36,7 @@ class Business(Base): business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), 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) + 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) @@ -58,5 +59,6 @@ class Business(Base): 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") + default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False) documents = relationship("Document", back_populates="business", cascade="all, delete-orphan") accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan") diff --git a/hesabixAPI/adapters/db/repositories/business_repo.py b/hesabixAPI/adapters/db/repositories/business_repo.py index 53c9ed8..50c9af2 100644 --- a/hesabixAPI/adapters/db/repositories/business_repo.py +++ b/hesabixAPI/adapters/db/repositories/business_repo.py @@ -50,6 +50,7 @@ class BusinessRepository(BaseRepository[Business]): business_type: BusinessType, business_field: BusinessField, owner_id: int, + default_currency_id: int | None = None, address: str | None = None, phone: str | None = None, mobile: str | None = None, @@ -60,13 +61,14 @@ class BusinessRepository(BaseRepository[Business]): province: str | None = None, city: str | None = None, postal_code: str | None = None - ) -> Business: + ) -> Business: """ایجاد کسب و کار جدید""" business = Business( name=name, business_type=business_type, business_field=business_field, owner_id=owner_id, + default_currency_id=default_currency_id, address=address, phone=phone, mobile=mobile, diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 9ac253c..093bdec 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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.users import router as users_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_users import router as business_users_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(users_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_users_router, prefix=settings.api_v1_prefix) application.include_router(accounts_router, prefix=settings.api_v1_prefix) diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py index 42b3fb4..a8be166 100644 --- a/hesabixAPI/app/services/business_service.py +++ b/hesabixAPI/app/services/business_service.py @@ -6,6 +6,7 @@ from sqlalchemy import select, and_, func from adapters.db.repositories.business_repo import BusinessRepository from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository +from adapters.db.models.currency import Currency, BusinessCurrency from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository from adapters.db.models.business import Business, BusinessType, BusinessField 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_field=business_field_enum, owner_id=owner_id, + default_currency_id=getattr(business_data, "default_currency_id", None), address=business_data.address, phone=business_data.phone, 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) ) + # مدیریت ارزها + 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 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]: """تبدیل مدل کسب و کار به dictionary""" - return { + data = { "id": business.id, "name": business.name, "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 بماند "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 diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 9129a0c..7360a45 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -8,6 +8,7 @@ adapters/api/v1/auth.py adapters/api/v1/business_dashboard.py adapters/api/v1/business_users.py adapters/api/v1/businesses.py +adapters/api/v1/currencies.py adapters/api/v1/health.py adapters/api/v1/persons.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_000021_update_person_type_enum_to_persian.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/d3e84892c1c2_sync_person_type_enum_values_callable_.py migrations/versions/f876bfa36805_merge_multiple_heads.py diff --git a/hesabixAPI/migrations/env.py b/hesabixAPI/migrations/env.py index 1306c4e..ff4638e 100644 --- a/hesabixAPI/migrations/env.py +++ b/hesabixAPI/migrations/env.py @@ -51,6 +51,22 @@ def run_migrations_online() -> None: ) 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( connection=connection, target_metadata=target_metadata, diff --git a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py index e65145b..9420d8d 100644 --- a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py +++ b/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py @@ -12,7 +12,10 @@ 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() + # اگر جدول 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: if 'code' not in cols: batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) @@ -25,7 +28,20 @@ def upgrade() -> 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: - batch_op.drop_constraint('uq_persons_business_code', type_='unique') - batch_op.drop_column('person_types') - batch_op.drop_column('code') + try: + batch_op.drop_constraint('uq_persons_business_code', type_='unique') + except Exception: + pass + try: + batch_op.drop_column('person_types') + except Exception: + pass + try: + batch_op.drop_column('code') + except Exception: + pass diff --git a/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py b/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py index 2f6e488..1d8c3e1 100644 --- a/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py +++ b/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py @@ -1,5 +1,6 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = '20250926_000011_drop_person_is_active' @@ -9,20 +10,30 @@ 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 + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + if 'persons' in tables: + with op.batch_alter_table('persons') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + 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: - 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'))) + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + 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'))) diff --git a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py index e3cc8f0..baa60a2 100644 --- a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py +++ b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py @@ -9,7 +9,7 @@ from sqlalchemy import inspect revision = '20250927_000012_add_fiscal_years_table' down_revision = '20250926_000011_drop_person_is_active' branch_labels = None -depends_on = None +depends_on = ('20250117_000003',) def upgrade() -> None: diff --git a/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py index 12b61f5..934e96d 100644 --- a/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py +++ b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py @@ -43,6 +43,17 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id'), 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 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']) @@ -50,6 +61,20 @@ def upgrade() -> 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_business_id', table_name='business_currencies') op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique') diff --git a/hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py b/hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py new file mode 100644 index 0000000..a049ce7 --- /dev/null +++ b/hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py @@ -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 diff --git a/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py b/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py index 7ba9699..bc3ad76 100644 --- a/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py +++ b/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py @@ -8,6 +8,8 @@ Create Date: 2025-09-27 19:18:06.253391 from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql +from sqlalchemy import text +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = 'd3e84892c1c2' @@ -16,109 +18,156 @@ branch_labels = 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: - # ### 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', + # ### commands auto generated by Alembic - guarded for idempotency ### + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names()) + + if 'storage_configs' not in existing_tables: + 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') + ) + + if 'file_storage' not in existing_tables: + 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') + ) + + if 'file_verifications' not in existing_tables: + 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') + ) + # 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(), 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) + + if _table_exists(conn, 'persons'): + if _column_exists(conn, 'persons', 'business_id'): + op.alter_column('persons', 'business_id', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='شناسه کسب و کار', + existing_nullable=False) + if _column_exists(conn, 'persons', 'code'): + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + if _column_exists(conn, 'persons', 'person_types'): + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + 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 ### def downgrade() -> None: # ### 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(), comment=None, existing_comment='تعداد سهام (فقط برای سهامدار)', 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'), comment=None, existing_comment='لیست انواع شخص به صورت JSON', 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(), comment=None, existing_comment='کد یکتا در هر کسب و کار', 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(), comment='شناسه کسب و کار', 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(), comment='شناسه شخص', existing_nullable=False) diff --git a/hesabixUI/hesabix_ui/lib/models/business_models.dart b/hesabixUI/hesabix_ui/lib/models/business_models.dart index 1f431e64a00267a88f4b2ec19b5f80b23fdb33e9..5cc642fd311c252136d39afb70852ae363be1bd5 100644 GIT binary patch delta 747 zcmaJi$dzDgX(LfOR4}lE6oon!mO_eRXQ-;GBLYUgfTvIMGT5U=(bCRtx;l^PpB!B_Tq)H@9qLLg2D6@RJ@v3oszQ;{kP94+|d> z*T)E@%;<7+t{wh$5YnamB-Ci5 tpRKuwsNlnjc!08iM`potOXO0Cs_13=dqZ1`?stxNe*w#{{pJ7w delta 46 zcmeB5JRQBkn`Lu9iyb3Vt>$D7HlxiMZ1b2HYd2?btzg{j!LyiYb1MHM_RSvRPLcq0 CvkylA diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index 618e02d..a92df5b 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -23,6 +23,7 @@ class _NewBusinessPageState extends State { bool _isLoading = false; int _fiscalTabIndex = 0; late TextEditingController _fiscalTitleController; + List> _currencies = []; @override void initState() { @@ -32,6 +33,7 @@ class _NewBusinessPageState extends State { // Set default selections for business type and field _businessData.businessType ??= BusinessType.shop; _businessData.businessField ??= BusinessField.commercial; + _loadCurrencies(); } @override @@ -58,6 +60,27 @@ class _NewBusinessPageState extends State { } } + Future _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, + ); + 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() { if (_businessData.fiscalYears.isEmpty) { _businessData.fiscalYears.add(FiscalYearData(isLast: true)); @@ -238,7 +261,7 @@ class _NewBusinessPageState extends State { case 2: return t.businessLegalInfo; case 3: - return 'سال مالی'; + return 'ارز و سال مالی'; case 4: return t.businessConfirmation; default: @@ -379,7 +402,7 @@ class _NewBusinessPageState extends State { _buildStepIndicator(0, t.businessBasicInfo), _buildStepIndicator(1, t.businessContactInfo), _buildStepIndicator(2, t.businessLegalInfo), - _buildStepIndicator(3, 'سال مالی'), + _buildStepIndicator(3, 'ارز و سال مالی'), _buildStepIndicator(4, t.businessConfirmation), ], ), @@ -435,26 +458,21 @@ class _NewBusinessPageState extends State { // Form content Expanded( - child: SingleChildScrollView( - child: SizedBox( - height: MediaQuery.of(context).size.height - 200, // ارتفاع مناسب برای اسکرول - child: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - onPageChanged: (index) { - setState(() { - _currentStep = index; - }); - }, - children: [ - _buildStep1(), - _buildStep2(), - _buildStep3(), - _buildFiscalStep(), - _buildStep4(), - ], - ), - ), + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (index) { + setState(() { + _currentStep = index; + }); + }, + children: [ + SingleChildScrollView(child: _buildStep1()), + SingleChildScrollView(child: _buildStep2()), + SingleChildScrollView(child: _buildStep3()), + SingleChildScrollView(child: _buildCurrencyAndFiscalStep()), + SingleChildScrollView(child: _buildStep4()), + ], ), ), @@ -1479,6 +1497,79 @@ class _NewBusinessPageState extends State { ); } + 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( + value: _businessData.defaultCurrencyId, + items: _currencies.map((c) { + return DropdownMenuItem( + 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() { final t = Localizations.of(context, AppLocalizations)!; @@ -1603,4 +1694,160 @@ class _NewBusinessPageState extends State { ), ); } +} + +class _CurrencyMultiSelect extends StatefulWidget { + final List> currencies; + final List selectedIds; + final int? defaultId; + final ValueChanged> 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 _selected; + final TextEditingController _searchCtrl = TextEditingController(); + bool _panelOpen = false; + + @override + void initState() { + super.initState(); + _selected = List.from(widget.selectedIds); + } + + @override + void didUpdateWidget(covariant _CurrencyMultiSelect oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedIds != widget.selectedIds) { + _selected = List.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.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, + ); + }, + ), + ), + ), + ], + ], + ); + } } \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/services/business_api_service.dart b/hesabixUI/hesabix_ui/lib/services/business_api_service.dart index b36a354..92a2dd0 100644 --- a/hesabixUI/hesabix_ui/lib/services/business_api_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/business_api_service.dart @@ -4,6 +4,7 @@ import '../models/business_models.dart'; class BusinessApiService { static const String _basePath = '/api/v1/businesses'; static final ApiClient _apiClient = ApiClient(); + static const String _currencyPath = '/api/v1/currencies'; // ایجاد کسب و کار جدید static Future createBusiness(BusinessData businessData) async { @@ -19,6 +20,17 @@ class BusinessApiService { } } + // دریافت فهرست ارزها + static Future>> getCurrencies() async { + final response = await _apiClient.get(_currencyPath); + if (response.data['success'] == true) { + final List items = response.data['data']; + return items.cast>(); + } else { + throw Exception(response.data['message'] ?? 'خطا در دریافت فهرست ارزها'); + } + } + // دریافت لیست کسب و کارها static Future> getBusinesses({ int page = 1,