progress in money and persons
This commit is contained in:
parent
af7aac7657
commit
a409202f6f
30
hesabixAPI/adapters/api/v1/currencies.py
Normal file
30
hesabixAPI/adapters/api/v1/currencies.py
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -67,6 +68,7 @@ class BusinessRepository(BaseRepository[Business]):
|
|||
business_type=business_type,
|
||||
business_field=business_field,
|
||||
owner_id=owner_id,
|
||||
default_currency_id=default_currency_id,
|
||||
address=address,
|
||||
phone=phone,
|
||||
mobile=mobile,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,11 +10,16 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
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')
|
||||
|
|
@ -22,7 +28,12 @@ def upgrade() -> None:
|
|||
|
||||
|
||||
def downgrade() -> None:
|
||||
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')))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
29
hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py
Normal file
29
hesabixAPI/migrations/versions/4b2ea782bcb3_merge_heads.py
Normal 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
|
||||
|
|
@ -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,8 +18,27 @@ 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! ###
|
||||
# ### 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),
|
||||
|
|
@ -31,6 +52,8 @@ def upgrade() -> None:
|
|||
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),
|
||||
|
|
@ -58,6 +81,8 @@ def upgrade() -> None:
|
|||
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),
|
||||
|
|
@ -71,25 +96,43 @@ def upgrade() -> None:
|
|||
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)
|
||||
|
||||
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='تعداد سهام (فقط برای سهامدار)',
|
||||
|
|
@ -99,25 +142,31 @@ def upgrade() -> None:
|
|||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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='شناسه شخص',
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -23,6 +23,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
bool _isLoading = false;
|
||||
int _fiscalTabIndex = 0;
|
||||
late TextEditingController _fiscalTitleController;
|
||||
List<Map<String, dynamic>> _currencies = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -32,6 +33,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
// 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<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() {
|
||||
if (_businessData.fiscalYears.isEmpty) {
|
||||
_businessData.fiscalYears.add(FiscalYearData(isLast: true));
|
||||
|
|
@ -238,7 +261,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
case 2:
|
||||
return t.businessLegalInfo;
|
||||
case 3:
|
||||
return 'سال مالی';
|
||||
return 'ارز و سال مالی';
|
||||
case 4:
|
||||
return t.businessConfirmation;
|
||||
default:
|
||||
|
|
@ -379,7 +402,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
_buildStepIndicator(0, t.businessBasicInfo),
|
||||
_buildStepIndicator(1, t.businessContactInfo),
|
||||
_buildStepIndicator(2, t.businessLegalInfo),
|
||||
_buildStepIndicator(3, 'سال مالی'),
|
||||
_buildStepIndicator(3, 'ارز و سال مالی'),
|
||||
_buildStepIndicator(4, t.businessConfirmation),
|
||||
],
|
||||
),
|
||||
|
|
@ -435,9 +458,6 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
|
||||
// Form content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height - 200, // ارتفاع مناسب برای اسکرول
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
|
|
@ -447,16 +467,14 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
|
|||
});
|
||||
},
|
||||
children: [
|
||||
_buildStep1(),
|
||||
_buildStep2(),
|
||||
_buildStep3(),
|
||||
_buildFiscalStep(),
|
||||
_buildStep4(),
|
||||
SingleChildScrollView(child: _buildStep1()),
|
||||
SingleChildScrollView(child: _buildStep2()),
|
||||
SingleChildScrollView(child: _buildStep3()),
|
||||
SingleChildScrollView(child: _buildCurrencyAndFiscalStep()),
|
||||
SingleChildScrollView(child: _buildStep4()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation buttons
|
||||
Container(
|
||||
|
|
@ -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() {
|
||||
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
|
|
@ -1604,3 +1695,159 @@ 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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({
|
||||
int page = 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue