From a8e5c3d14c5f699e1a0b8676a7973a290373ce73 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sat, 27 Sep 2025 21:19:00 +0330 Subject: [PATCH] progress in persons --- hesabixAPI/adapters/api/v1/accounts.py | 57 +++ hesabixAPI/adapters/api/v1/persons.py | 331 ++++++++++++++++- .../adapters/api/v1/schema_models/account.py | 19 + .../adapters/api/v1/schema_models/person.py | 75 ++++ hesabixAPI/adapters/api/v1/schemas.py | 11 +- hesabixAPI/adapters/db/models/__init__.py | 10 + hesabixAPI/adapters/db/models/account.py | 32 ++ hesabixAPI/adapters/db/models/business.py | 4 + hesabixAPI/adapters/db/models/currency.py | 43 +++ hesabixAPI/adapters/db/models/document.py | 37 ++ .../adapters/db/models/document_line.py | 30 ++ hesabixAPI/adapters/db/models/fiscal_year.py | 26 ++ hesabixAPI/adapters/db/models/person.py | 20 +- .../db/repositories/fiscal_year_repo.py | 37 ++ hesabixAPI/app/main.py | 2 + hesabixAPI/app/services/business_service.py | 18 + hesabixAPI/app/services/person_service.py | 69 +++- hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 21 ++ .../20250117_000003_add_business_table.py | 40 +- ...250926_000010_add_person_code_and_types.py | 15 +- .../20250927_000012_add_fiscal_years_table.py | 48 +++ ..._add_currencies_and_business_currencies.py | 63 ++++ .../20250927_000014_add_documents_table.py | 56 +++ ...0250927_000015_add_document_lines_table.py | 38 ++ .../20250927_000016_add_accounts_table.py | 44 +++ ...000017_add_account_id_to_document_lines.py | 27 ++ .../20250927_000018_seed_currencies.py | 125 +++++++ .../20250927_000019_seed_accounts_chart.py | 253 +++++++++++++ ...20_add_share_count_and_shareholder_type.py | 45 +++ ...0021_update_person_type_enum_to_persian.py | 59 +++ ...927_000022_add_person_commission_fields.py | 43 +++ ..._sync_person_type_enum_values_callable_.py | 129 +++++++ .../f876bfa36805_merge_multiple_heads.py | 24 ++ hesabixUI/hesabix_ui/lib/core/api_client.dart | 33 +- hesabixUI/hesabix_ui/lib/main.dart | 46 ++- .../lib/models/business_models.dart | 103 +++++- .../lib/models/business_user_model.dart | 2 +- .../hesabix_ui/lib/models/person_model.dart | 84 ++++- .../lib/pages/business/accounts_page.dart | 67 ++++ .../lib/pages/business/business_shell.dart | 8 +- .../lib/pages/business/persons_page.dart | 138 ++++++- .../business/users_permissions_page.dart | 76 ++-- .../lib/pages/profile/new_business_page.dart | 180 ++++++++- .../lib/services/person_service.dart | 1 - .../widgets/data_table/data_table_config.dart | 29 +- .../widgets/data_table/data_table_widget.dart | 81 +++-- .../helpers/column_settings_service.dart | 11 +- .../data_table/helpers/file_saver.dart | 4 + .../data_table/helpers/file_saver_io.dart | 16 + .../data_table/helpers/file_saver_web.dart | 15 + .../lib/widgets/jalali_date_picker.dart | 26 +- .../widgets/person/person_form_dialog.dart | 341 +++++++++++++++--- .../hesabix_ui/lib/widgets/url_tracker.dart | 3 +- 53 files changed, 2926 insertions(+), 189 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/accounts.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/account.py create mode 100644 hesabixAPI/adapters/db/models/account.py create mode 100644 hesabixAPI/adapters/db/models/currency.py create mode 100644 hesabixAPI/adapters/db/models/document.py create mode 100644 hesabixAPI/adapters/db/models/document_line.py create mode 100644 hesabixAPI/adapters/db/models/fiscal_year.py create mode 100644 hesabixAPI/adapters/db/repositories/fiscal_year_repo.py create mode 100644 hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py create mode 100644 hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py create mode 100644 hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py create mode 100644 hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py create mode 100644 hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py create mode 100644 hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py create mode 100644 hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py create mode 100644 hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py create mode 100644 hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py create mode 100644 hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py create mode 100644 hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py create mode 100644 hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py create mode 100644 hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py new file mode 100644 index 0000000..4f8f62e --- /dev/null +++ b/hesabixAPI/adapters/api/v1/accounts.py @@ -0,0 +1,57 @@ +from typing import List, Dict, Any + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.api.v1.schemas import SuccessResponse +from adapters.api.v1.schema_models.account import AccountTreeNode +from app.core.responses import success_response +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from adapters.db.models.account import Account + + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]: + by_id: dict[int, AccountTreeNode] = {} + roots: list[AccountTreeNode] = [] + for n in nodes: + node = AccountTreeNode( + id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id') + ) + by_id[node.id] = node + for node in list(by_id.values()): + pid = node.parent_id + if pid and pid in by_id: + by_id[pid].children.append(node) + else: + roots.append(node) + return roots + + +@router.get("/business/{business_id}/tree", + summary="دریافت درخت حساب‌ها برای یک کسب و کار", + description="لیست حساب‌های عمومی و حساب‌های اختصاصی کسب و کار به صورت درختی", +) +@require_business_access("business_id") +def get_accounts_tree( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + # دریافت حساب‌های عمومی (business_id IS NULL) و حساب‌های مختص این کسب و کار + rows = db.query(Account).filter( + (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 + ).order_by(Account.code.asc()).all() + flat = [ + {"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id} + for r in rows + ] + tree = _build_tree(flat) + return success_response({"items": [n.model_dump() for n in tree]}, request) + + diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index 5a7783a..1e5645a 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body from sqlalchemy.orm import Session -from typing import Dict, Any +from typing import Dict, Any, List, Optional from adapters.db.session import get_db from adapters.api.v1.schema_models.person import ( @@ -16,6 +16,7 @@ from app.services.person_service import ( update_person, delete_person, get_person_summary ) from adapters.db.models.person import Person +from adapters.db.models.business import Business router = APIRouter(prefix="/persons", tags=["persons"]) @@ -132,6 +133,332 @@ async def get_persons_endpoint( ) +@router.post("/businesses/{business_id}/persons/export/excel", + summary="خروجی Excel لیست اشخاص", + description="خروجی Excel لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها", +) +async def export_persons_excel( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import json + import datetime + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + from fastapi.responses import Response + + # Build query dict similar to list endpoint from flat body + query_dict = { + "take": int(body.get("take", 20)), + "skip": int(body.get("skip", 0)), + "sort_by": body.get("sort_by"), + "sort_desc": bool(body.get("sort_desc", False)), + "search": body.get("search"), + "search_fields": body.get("search_fields"), + "filters": body.get("filters"), + } + + result = get_persons_by_business(db, business_id, query_dict) + + items = result.get('items', []) + # Format date/time fields using existing helper + items = [format_datetime_fields(item, request) for item in items] + + # Apply selected indices filter if requested + selected_only = bool(body.get('selected_only', False)) + selected_indices = body.get('selected_indices') + if selected_only and selected_indices is not None: + indices = None + if isinstance(selected_indices, str): + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + elif isinstance(selected_indices, list): + indices = selected_indices + if isinstance(indices, list): + items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)] + + # Prepare headers based on export_columns (order + visibility) + headers: List[str] = [] + keys: List[str] = [] + export_columns = body.get('export_columns') + if export_columns: + for col in export_columns: + key = col.get('key') + label = col.get('label', key) + if key: + keys.append(str(key)) + headers.append(str(label)) + else: + # Fallback to item keys if no columns provided + if items: + keys = list(items[0].keys()) + headers = keys + + # Create workbook + wb = Workbook() + ws = wb.active + ws.title = "Persons" + + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + + # Write header row + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border + + # Write data rows + for row_idx, item in enumerate(items, 2): + for col_idx, key in enumerate(keys, 1): + value = item.get(key, "") + if isinstance(value, list): + value = ", ".join(str(v) for v in value) + ws.cell(row=row_idx, column=col_idx, value=value).border = border + + # Auto-width columns + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except Exception: + pass + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + # Save to bytes + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + filename = f"persons_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + content = buffer.getvalue() + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(content)), + }, + ) + + +@router.post("/businesses/{business_id}/persons/export/pdf", + summary="خروجی PDF لیست اشخاص", + description="خروجی PDF لیست اشخاص با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها", +) +async def export_persons_pdf( + business_id: int, + request: Request, + body: Dict[str, Any] = Body(...), + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import json + import datetime + from fastapi.responses import Response + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + + # Build query dict from flat body + query_dict = { + "take": int(body.get("take", 20)), + "skip": int(body.get("skip", 0)), + "sort_by": body.get("sort_by"), + "sort_desc": bool(body.get("sort_desc", False)), + "search": body.get("search"), + "search_fields": body.get("search_fields"), + "filters": body.get("filters"), + } + + result = get_persons_by_business(db, business_id, query_dict) + items = result.get('items', []) + items = [format_datetime_fields(item, request) for item in items] + + selected_only = bool(body.get('selected_only', False)) + selected_indices = body.get('selected_indices') + if selected_only and selected_indices is not None: + indices = None + if isinstance(selected_indices, str): + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + elif isinstance(selected_indices, list): + indices = selected_indices + if isinstance(indices, list): + items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)] + + headers: List[str] = [] + keys: List[str] = [] + export_columns = body.get('export_columns') + if export_columns: + for col in export_columns: + key = col.get('key') + label = col.get('label', key) + if key: + keys.append(str(key)) + headers.append(str(label)) + else: + if items: + keys = list(items[0].keys()) + headers = keys + + # Load business info for header + business_name = "" + try: + biz = db.query(Business).filter(Business.id == business_id).first() + if biz is not None: + business_name = biz.name + except Exception: + business_name = "" + + # Styled HTML (A4 landscape, RTL) + def escape(s: Any) -> str: + try: + return str(s).replace('&', '&').replace('<', '<').replace('>', '>') + except Exception: + return str(s) + + rows_html = [] + for item in items: + tds = [] + for key in keys: + value = item.get(key) + if value is None: + value = "" + elif isinstance(value, list): + value = ", ".join(str(v) for v in value) + tds.append(f"{escape(value)}") + rows_html.append(f"{''.join(tds)}") + + headers_html = ''.join(f"{escape(h)}" for h in headers) + # Format report datetime based on X-Calendar-Type header + calendar_header = request.headers.get("X-Calendar-Type", "jalali").lower() + try: + from app.core.calendar import CalendarConverter + formatted_now = CalendarConverter.format_datetime(datetime.datetime.now(), + "jalali" if calendar_header in ["jalali", "persian", "shamsi"] else "gregorian") + now = formatted_now.get('formatted', formatted_now.get('date_time', '')) + except Exception: + now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + table_html = f""" + + + + + + +
+
+
گزارش لیست اشخاص
+
نام کسب‌وکار: {escape(business_name)}
+
+
تاریخ گزارش: {escape(now)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
تولید شده توسط Hesabix
+ + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config) + + filename = f"persons_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + }, + ) + + @router.get("/persons/{person_id}", summary="جزئیات شخص", description="دریافت جزئیات یک شخص", diff --git a/hesabixAPI/adapters/api/v1/schema_models/account.py b/hesabixAPI/adapters/api/v1/schema_models/account.py new file mode 100644 index 0000000..581b091 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/account.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import List, Optional +from pydantic import BaseModel, Field + + +class AccountTreeNode(BaseModel): + id: int = Field(..., description="ID حساب") + code: str = Field(..., description="کد حساب") + name: str = Field(..., description="نام حساب") + account_type: Optional[str] = Field(default=None, description="نوع حساب") + parent_id: Optional[int] = Field(default=None, description="شناسه والد") + level: Optional[int] = Field(default=None, description="سطح حساب در درخت") + children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان") + + class Config: + from_attributes = True + + diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py index cffb79d..31ca7b4 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/person.py +++ b/hesabixAPI/adapters/api/v1/schema_models/person.py @@ -12,6 +12,7 @@ class PersonType(str, Enum): SUPPLIER = "تامین‌کننده" PARTNER = "همکار" SELLER = "فروشنده" + SHAREHOLDER = "سهامدار" class PersonBankAccountCreateRequest(BaseModel): @@ -78,6 +79,38 @@ class PersonCreateRequest(BaseModel): # حساب‌های بانکی bank_accounts: Optional[List[PersonBankAccountCreateRequest]] = Field(default=[], description="حساب‌های بانکی") + # سهام + share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار، اجباری و حداقل 1)") + # پورسانت (برای بازاریاب/فروشنده) + commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش") + commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش") + commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا") + commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا") + commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف") + commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات") + commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور") + + @classmethod + def __get_validators__(cls): + yield from super().__get_validators__() + + @staticmethod + def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool: + if person_type == PersonType.SHAREHOLDER: + return True + if person_types: + return PersonType.SHAREHOLDER in person_types + return False + + @classmethod + def validate(cls, value): # type: ignore[override] + obj = super().validate(value) + # اعتبارسنجی شرطی سهامدار + if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)): + sc = getattr(obj, 'share_count', None) + if sc is None or (isinstance(sc, int) and sc <= 0): + raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد") + return obj class PersonUpdateRequest(BaseModel): @@ -111,6 +144,38 @@ class PersonUpdateRequest(BaseModel): # وضعیت is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن") + # سهام + share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)") + # پورسانت + commission_sale_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از فروش") + commission_sales_return_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد پورسانت از برگشت از فروش") + commission_sales_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ فروش مبنا") + commission_sales_return_amount: Optional[float] = Field(default=None, ge=0, description="مبلغ برگشت از فروش مبنا") + commission_exclude_discounts: Optional[bool] = Field(default=None, description="عدم محاسبه تخفیف") + commission_exclude_additions_deductions: Optional[bool] = Field(default=None, description="عدم محاسبه اضافات و کسورات") + commission_post_in_invoice_document: Optional[bool] = Field(default=None, description="ثبت پورسانت در سند فاکتور") + + @classmethod + def __get_validators__(cls): + yield from super().__get_validators__() + + @staticmethod + def _has_shareholder(person_type: Optional[PersonType], person_types: Optional[List[PersonType]]) -> bool: + if person_type == PersonType.SHAREHOLDER: + return True + if person_types: + return PersonType.SHAREHOLDER in person_types + return False + + @classmethod + def validate(cls, value): # type: ignore[override] + obj = super().validate(value) + # اگر ورودی‌ها مشخصاً به سهامدار اشاره دارند، share_count باید معتبر باشد + if cls._has_shareholder(getattr(obj, 'person_type', None), getattr(obj, 'person_types', None)): + sc = getattr(obj, 'share_count', None) + if sc is None or (isinstance(sc, int) and sc <= 0): + raise ValueError("برای سهامدار، مقدار تعداد سهام الزامی و باید بزرگتر از صفر باشد") + return obj class PersonResponse(BaseModel): @@ -154,6 +219,16 @@ class PersonResponse(BaseModel): # حساب‌های بانکی bank_accounts: List[PersonBankAccountResponse] = Field(default=[], description="حساب‌های بانکی") + # سهام + share_count: Optional[int] = Field(default=None, description="تعداد سهام") + # پورسانت + commission_sale_percent: Optional[float] = Field(default=None, description="درصد پورسانت از فروش") + commission_sales_return_percent: Optional[float] = Field(default=None, description="درصد پورسانت از برگشت از فروش") + commission_sales_amount: Optional[float] = Field(default=None, description="مبلغ فروش مبنا") + commission_sales_return_amount: Optional[float] = Field(default=None, description="مبلغ برگشت از فروش مبنا") + commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف") + commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات") + commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور") class Config: from_attributes = True diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py index 2532062..53a9b85 100644 --- a/hesabixAPI/adapters/api/v1/schemas.py +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -1,7 +1,7 @@ from typing import Any, List, Optional, Union, Generic, TypeVar from pydantic import BaseModel, EmailStr, Field from enum import Enum -from datetime import datetime +from datetime import datetime, date T = TypeVar('T') @@ -177,6 +177,7 @@ class BusinessCreateRequest(BaseModel): province: 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="کد پستی") + fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه") class BusinessUpdateRequest(BaseModel): @@ -248,6 +249,14 @@ class PaginatedResponse(BaseModel, Generic[T]): ) +# Fiscal Year Schemas +class FiscalYearCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی") + start_date: date = Field(..., description="تاریخ شروع سال مالی") + end_date: date = Field(..., description="تاریخ پایان سال مالی") + is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟") + + # Business User Schemas class BusinessUserSchema(BaseModel): id: int diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index b505ba6..610b9e5 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -20,3 +20,13 @@ from .file_storage import * from .email_config import EmailConfig # noqa: F401, F403 +# Accounting / Fiscal models +from .fiscal_year import FiscalYear # noqa: F401 + +# Currency models +from .currency import Currency, BusinessCurrency # noqa: F401 + +# Documents +from .document import Document # noqa: F401 +from .document_line import DocumentLine # noqa: F401 +from .account import Account # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/account.py b/hesabixAPI/adapters/db/models/account.py new file mode 100644 index 0000000..a5e38a6 --- /dev/null +++ b/hesabixAPI/adapters/db/models/account.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Account(Base): + __tablename__ = "accounts" + __table_args__ = ( + UniqueConstraint('business_id', 'code', name='uq_accounts_business_code'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + business_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=True, index=True) + account_type: Mapped[str] = mapped_column(String(50), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + business = relationship("Business", back_populates="accounts") + parent = relationship("Account", remote_side="Account.id", back_populates="children") + children = relationship("Account", back_populates="parent", cascade="all, delete-orphan") + document_lines = relationship("DocumentLine", back_populates="account") + + diff --git a/hesabixAPI/adapters/db/models/business.py b/hesabixAPI/adapters/db/models/business.py index b3813e2..befab27 100644 --- a/hesabixAPI/adapters/db/models/business.py +++ b/hesabixAPI/adapters/db/models/business.py @@ -56,3 +56,7 @@ class Business(Base): # Relationships persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan") + fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan") + currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses") + documents = relationship("Document", back_populates="business", cascade="all, delete-orphan") + accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan") diff --git a/hesabixAPI/adapters/db/models/currency.py b/hesabixAPI/adapters/db/models/currency.py new file mode 100644 index 0000000..776fd05 --- /dev/null +++ b/hesabixAPI/adapters/db/models/currency.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Currency(Base): + __tablename__ = "currencies" + __table_args__ = ( + UniqueConstraint('name', name='uq_currencies_name'), + UniqueConstraint('code', name='uq_currencies_code'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + symbol: Mapped[str] = mapped_column(String(16), nullable=False) + code: Mapped[str] = mapped_column(String(16), nullable=False) # نام کوتاه + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + businesses = relationship("Business", secondary="business_currencies", back_populates="currencies") + documents = relationship("Document", back_populates="currency") + + +class BusinessCurrency(Base): + __tablename__ = "business_currencies" + __table_args__ = ( + UniqueConstraint('business_id', 'currency_id', name='uq_business_currencies_business_currency'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="CASCADE"), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/adapters/db/models/document.py b/hesabixAPI/adapters/db/models/document.py new file mode 100644 index 0000000..6d290cd --- /dev/null +++ b/hesabixAPI/adapters/db/models/document.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Document(Base): + __tablename__ = "documents" + __table_args__ = ( + UniqueConstraint('business_id', 'code', name='uq_documents_business_code'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) + created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True) + registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + document_date: Mapped[date] = mapped_column(Date, nullable=False) + document_type: Mapped[str] = mapped_column(String(50), nullable=False) + is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True) + developer_settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + business = relationship("Business", back_populates="documents") + currency = relationship("Currency", back_populates="documents") + created_by = relationship("User", foreign_keys=[created_by_user_id]) + lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan") + + diff --git a/hesabixAPI/adapters/db/models/document_line.py b/hesabixAPI/adapters/db/models/document_line.py new file mode 100644 index 0000000..494012b --- /dev/null +++ b/hesabixAPI/adapters/db/models/document_line.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import Integer, DateTime, ForeignKey, JSON, Text, Numeric +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class DocumentLine(Base): + __tablename__ = "document_lines" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True) + debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0) + credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True) + developer_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + document = relationship("Document", back_populates="lines") + account = relationship("Account", back_populates="document_lines") + + diff --git a/hesabixAPI/adapters/db/models/fiscal_year.py b/hesabixAPI/adapters/db/models/fiscal_year.py new file mode 100644 index 0000000..a3026b7 --- /dev/null +++ b/hesabixAPI/adapters/db/models/fiscal_year.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import String, Date, DateTime, Integer, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class FiscalYear(Base): + __tablename__ = "fiscal_years" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + start_date: Mapped[date] = mapped_column(Date, nullable=False) + end_date: Mapped[date] = mapped_column(Date, nullable=False) + is_last: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + business = relationship("Business", back_populates="fiscal_years") + + diff --git a/hesabixAPI/adapters/db/models/person.py b/hesabixAPI/adapters/db/models/person.py index 0a3cfea..f130c07 100644 --- a/hesabixAPI/adapters/db/models/person.py +++ b/hesabixAPI/adapters/db/models/person.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint +from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text, UniqueConstraint, Numeric, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship from adapters.db.session import Base @@ -17,6 +17,7 @@ class PersonType(str, Enum): SUPPLIER = "تامین‌کننده" # تامین‌کننده PARTNER = "همکار" # همکار SELLER = "فروشنده" # فروشنده + SHAREHOLDER = "سهامدار" # سهامدار class Person(Base): @@ -33,10 +34,25 @@ class Person(Base): alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)") first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام") last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی") - person_type: Mapped[PersonType] = mapped_column(SQLEnum(PersonType), nullable=False, comment="نوع شخص") + person_type: Mapped[PersonType] = mapped_column( + SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"), + nullable=False, + comment="نوع شخص" + ) person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON") company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت") payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت") + # سهام + share_count: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="تعداد سهام (فقط برای سهامدار)") + + # تنظیمات پورسانت برای بازاریاب/فروشنده + commission_sale_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از فروش") + commission_sales_return_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="درصد پورسانت از برگشت از فروش") + commission_sales_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ فروش مبنا برای پورسانت") + commission_sales_return_amount: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True, comment="مبلغ برگشت از فروش مبنا برای پورسانت") + commission_exclude_discounts: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه تخفیف در پورسانت") + commission_exclude_additions_deductions: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="عدم محاسبه اضافات و کسورات فاکتور در پورسانت") + commission_post_in_invoice_document: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", comment="ثبت پورسانت در سند حسابداری فاکتور") # اطلاعات اقتصادی national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True, comment="شناسه ملی") diff --git a/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py new file mode 100644 index 0000000..b4cf059 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import date +from sqlalchemy.orm import Session + +from .base_repo import BaseRepository +from ..models.fiscal_year import FiscalYear + + +class FiscalYearRepository(BaseRepository[FiscalYear]): + """Repository برای مدیریت سال‌های مالی""" + + def __init__(self, db: Session) -> None: + super().__init__(db, FiscalYear) + + def create_fiscal_year( + self, + *, + business_id: int, + title: str, + start_date: date, + end_date: date, + is_last: bool = True, + ) -> FiscalYear: + fiscal_year = FiscalYear( + business_id=business_id, + title=title, + start_date=start_date, + end_date=end_date, + is_last=is_last, + ) + self.db.add(fiscal_year) + self.db.commit() + self.db.refresh(fiscal_year) + return fiscal_year + + diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 7c6b342..9ac253c 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -9,6 +9,7 @@ from adapters.api.v1.users import router as users_router from adapters.api.v1.businesses import router as businesses_router from adapters.api.v1.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 from adapters.api.v1.persons import router as persons_router from adapters.api.v1.support.tickets import router as support_tickets_router from adapters.api.v1.support.operator import router as support_operator_router @@ -274,6 +275,7 @@ def create_app() -> FastAPI: application.include_router(businesses_router, prefix=settings.api_v1_prefix) application.include_router(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) application.include_router(persons_router, prefix=settings.api_v1_prefix) # Support endpoints diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py index 9fc62b1..42b3fb4 100644 --- a/hesabixAPI/app/services/business_service.py +++ b/hesabixAPI/app/services/business_service.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session 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.repositories.business_permission_repo import BusinessPermissionRepository from adapters.db.models.business import Business, BusinessType, BusinessField from adapters.api.v1.schemas import ( @@ -17,6 +18,7 @@ from app.core.responses import format_datetime_fields def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: int) -> Dict[str, Any]: """ایجاد کسب و کار جدید""" business_repo = BusinessRepository(db) + fiscal_repo = FiscalYearRepository(db) # تبدیل enum values به مقادیر فارسی # business_data.business_type و business_data.business_field قبلاً مقادیر فارسی هستند @@ -41,6 +43,22 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id: postal_code=business_data.postal_code ) + # ایجاد سال‌های مالی اولیه (در صورت ارسال) + if getattr(business_data, "fiscal_years", None): + # فقط یک سال با is_last=True نگه داریم (آخرین مورد True باشد) + last_true_index = None + for idx, fy in enumerate(business_data.fiscal_years or []): + if fy.is_last: + last_true_index = idx + for idx, fy in enumerate(business_data.fiscal_years or []): + fiscal_repo.create_fiscal_year( + business_id=created_business.id, + title=fy.title, + start_date=fy.start_date, + end_date=fy.end_date, + is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1) + ) + # تبدیل به response format return _business_to_dict(created_business) diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py index 4d27c3d..6c26dba 100644 --- a/hesabixAPI/app/services/person_service.py +++ b/hesabixAPI/app/services/person_service.py @@ -34,14 +34,39 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques t = person_data.person_type types_list = [t.value if hasattr(t, 'value') else str(t)] + # اعتبارسنجی سهام برای سهامدار + is_shareholder = False + if types_list: + is_shareholder = 'سهامدار' in types_list + if not is_shareholder and incoming_single_type is not None: + try: + is_shareholder = (getattr(incoming_single_type, 'value', str(incoming_single_type)) == 'سهامدار') + except Exception: + is_shareholder = False + if is_shareholder: + sc_val = getattr(person_data, 'share_count', None) + if sc_val is None or not isinstance(sc_val, int) or sc_val <= 0: + raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400) + # ایجاد شخص + # نگاشت person_type دریافتی از اسکیما به Enum مدل + incoming_single_type = getattr(person_data, 'person_type', None) + mapped_single_type = None + if incoming_single_type is not None: + try: + # incoming_single_type.value مقدار فارسی مانند "سهامدار" + mapped_single_type = PersonType(getattr(incoming_single_type, 'value', str(incoming_single_type))) + except Exception: + mapped_single_type = None + person = Person( business_id=business_id, code=code, alias_name=person_data.alias_name, first_name=person_data.first_name, last_name=person_data.last_name, - person_type=person_data.person_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER), + # ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را می‌نویسد) + person_type=(mapped_single_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER)), person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None, company_name=person_data.company_name, payment_id=person_data.payment_id, @@ -58,6 +83,14 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques fax=person_data.fax, email=person_data.email, website=person_data.website, + share_count=getattr(person_data, 'share_count', None), + commission_sale_percent=getattr(person_data, 'commission_sale_percent', None), + commission_sales_return_percent=getattr(person_data, 'commission_sales_return_percent', None), + commission_sales_amount=getattr(person_data, 'commission_sales_amount', None), + commission_sales_return_amount=getattr(person_data, 'commission_sales_return_amount', None), + commission_exclude_discounts=bool(getattr(person_data, 'commission_exclude_discounts', False)), + commission_exclude_additions_deductions=bool(getattr(person_data, 'commission_exclude_additions_deductions', False)), + commission_post_in_invoice_document=bool(getattr(person_data, 'commission_post_in_invoice_document', False)), ) db.add(person) @@ -333,11 +366,37 @@ def update_person( person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None # همگام کردن person_type تکی برای سازگاری if types_list: + # مقدار Enum را با مقدار فارسی ست می‌کنیم try: person.person_type = PersonType(types_list[0]) except Exception: pass + # مدیریت person_type تکی از اسکیما + if 'person_type' in update_data and update_data['person_type'] is not None: + single_type = update_data['person_type'] + # نگاشت به Enum (مقدار فارسی) + try: + person.person_type = PersonType(getattr(single_type, 'value', str(single_type))) + except Exception: + pass + # پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود + update_data.pop('person_type', None) + + # اگر شخص سهامدار شد، share_count معتبر باشد + resulting_types: List[str] = [] + if person.person_types: + try: + tmp = json.loads(person.person_types) + if isinstance(tmp, list): + resulting_types = [str(x) for x in tmp] + except Exception: + resulting_types = [] + if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types): + sc_val2 = update_data.get('share_count', person.share_count) + if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0): + raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400) + # سایر فیلدها for field in list(update_data.keys()): if field in {'code', 'person_types'}: @@ -416,6 +475,14 @@ def _person_to_dict(person: Person) -> Dict[str, Any]: 'person_types': types_list, 'company_name': person.company_name, 'payment_id': person.payment_id, + 'share_count': person.share_count, + 'commission_sale_percent': float(person.commission_sale_percent) if getattr(person, 'commission_sale_percent', None) is not None else None, + 'commission_sales_return_percent': float(person.commission_sales_return_percent) if getattr(person, 'commission_sales_return_percent', None) is not None else None, + 'commission_sales_amount': float(person.commission_sales_amount) if getattr(person, 'commission_sales_amount', None) is not None else None, + 'commission_sales_return_amount': float(person.commission_sales_return_amount) if getattr(person, 'commission_sales_return_amount', None) is not None else None, + 'commission_exclude_discounts': bool(person.commission_exclude_discounts), + 'commission_exclude_additions_deductions': bool(person.commission_exclude_additions_deductions), + 'commission_post_in_invoice_document': bool(person.commission_post_in_invoice_document), 'national_id': person.national_id, 'registration_number': person.registration_number, 'economic_id': person.economic_id, diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 2740258..9129a0c 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -3,6 +3,7 @@ pyproject.toml adapters/__init__.py adapters/api/__init__.py adapters/api/v1/__init__.py +adapters/api/v1/accounts.py adapters/api/v1/auth.py adapters/api/v1/business_dashboard.py adapters/api/v1/business_users.py @@ -14,6 +15,7 @@ adapters/api/v1/users.py adapters/api/v1/admin/email_config.py adapters/api/v1/admin/file_storage.py adapters/api/v1/schema_models/__init__.py +adapters/api/v1/schema_models/account.py adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/person.py @@ -27,12 +29,17 @@ adapters/api/v1/support/tickets.py adapters/db/__init__.py adapters/db/session.py adapters/db/models/__init__.py +adapters/db/models/account.py adapters/db/models/api_key.py adapters/db/models/business.py adapters/db/models/business_permission.py adapters/db/models/captcha.py +adapters/db/models/currency.py +adapters/db/models/document.py +adapters/db/models/document_line.py adapters/db/models/email_config.py adapters/db/models/file_storage.py +adapters/db/models/fiscal_year.py adapters/db/models/password_reset.py adapters/db/models/person.py adapters/db/models/user.py @@ -48,6 +55,7 @@ adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_repo.py adapters/db/repositories/email_config_repository.py adapters/db/repositories/file_storage_repository.py +adapters/db/repositories/fiscal_year_repo.py adapters/db/repositories/password_reset_repo.py adapters/db/repositories/user_repo.py adapters/db/repositories/support/__init__.py @@ -104,7 +112,20 @@ migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250916_000002_add_referral_fields.py migrations/versions/20250926_000010_add_person_code_and_types.py migrations/versions/20250926_000011_drop_person_is_active.py +migrations/versions/20250927_000012_add_fiscal_years_table.py +migrations/versions/20250927_000013_add_currencies_and_business_currencies.py +migrations/versions/20250927_000014_add_documents_table.py +migrations/versions/20250927_000015_add_document_lines_table.py +migrations/versions/20250927_000016_add_accounts_table.py +migrations/versions/20250927_000017_add_account_id_to_document_lines.py +migrations/versions/20250927_000018_seed_currencies.py +migrations/versions/20250927_000019_seed_accounts_chart.py +migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py +migrations/versions/20250927_000021_update_person_type_enum_to_persian.py +migrations/versions/20250927_000022_add_person_commission_fields.py migrations/versions/5553f8745c6e_add_support_tables.py +migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py +migrations/versions/f876bfa36805_merge_multiple_heads.py tests/__init__.py tests/test_health.py tests/test_permissions.py \ No newline at end of file diff --git a/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py b/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py index 0531336..739c141 100644 --- a/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py +++ b/hesabixAPI/migrations/versions/20250117_000003_add_business_table.py @@ -2,6 +2,7 @@ from __future__ import annotations from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect from sqlalchemy.dialects import mysql # revision identifiers, used by Alembic. @@ -12,23 +13,30 @@ depends_on = None def upgrade() -> None: - # Create businesses table - op.create_table( - 'businesses', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False), - sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False), - sa.Column('owner_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) + bind = op.get_bind() + inspector = inspect(bind) + + # Create businesses table if not exists + if 'businesses' not in inspector.get_table_names(): + op.create_table( + 'businesses', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('business_type', mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', name='businesstype'), nullable=False), + sa.Column('business_field', mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', name='businessfield'), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) - # Create indexes - op.create_index('ix_businesses_name', 'businesses', ['name']) - op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id']) + # Create indexes if not exists + existing_indexes = {idx['name'] for idx in inspector.get_indexes('businesses')} if 'businesses' in inspector.get_table_names() else set() + if 'ix_businesses_name' not in existing_indexes: + op.create_index('ix_businesses_name', 'businesses', ['name']) + if 'ix_businesses_owner_id' not in existing_indexes: + op.create_index('ix_businesses_owner_id', 'businesses', ['owner_id']) def downgrade() -> None: 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 2badcdf..e65145b 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 @@ -1,5 +1,6 @@ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = '20250926_000010_add_person_code_and_types' @@ -9,10 +10,18 @@ depends_on = None def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set() with op.batch_alter_table('persons') as batch_op: - batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True)) - batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code']) + if 'code' not in cols: + batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) + if 'person_types' not in cols: + batch_op.add_column(sa.Column('person_types', sa.Text(), nullable=True)) + # unique constraint if not exists + existing_uniques = {uc['name'] for uc in inspector.get_unique_constraints('persons')} + if 'uq_persons_business_code' not in existing_uniques: + batch_op.create_unique_constraint('uq_persons_business_code', ['business_id', 'code']) def downgrade() -> None: diff --git a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py new file mode 100644 index 0000000..e3cc8f0 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = '20250927_000012_add_fiscal_years_table' +down_revision = '20250926_000011_drop_person_is_active' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + # Create fiscal_years table if not exists + if 'fiscal_years' not in inspector.get_table_names(): + op.create_table( + 'fiscal_years', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('start_date', sa.Date(), nullable=False), + sa.Column('end_date', sa.Date(), nullable=False), + sa.Column('is_last', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Indexes if not exists + existing_indexes = {idx['name'] for idx in inspector.get_indexes('fiscal_years')} if 'fiscal_years' in inspector.get_table_names() else set() + if 'ix_fiscal_years_business_id' not in existing_indexes: + op.create_index('ix_fiscal_years_business_id', 'fiscal_years', ['business_id']) + if 'ix_fiscal_years_title' not in existing_indexes: + op.create_index('ix_fiscal_years_title', 'fiscal_years', ['title']) + + +def downgrade() -> None: + op.drop_index('ix_fiscal_years_title', table_name='fiscal_years') + op.drop_index('ix_fiscal_years_business_id', table_name='fiscal_years') + op.drop_table('fiscal_years') + + 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 new file mode 100644 index 0000000..12b61f5 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000013_add_currencies_and_business_currencies' +down_revision = '20250927_000012_add_fiscal_years_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create currencies table + op.create_table( + 'currencies', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('symbol', sa.String(length=16), nullable=False), + sa.Column('code', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + # Unique constraints and indexes + op.create_unique_constraint('uq_currencies_name', 'currencies', ['name']) + op.create_unique_constraint('uq_currencies_code', 'currencies', ['code']) + op.create_index('ix_currencies_name', 'currencies', ['name']) + + # Create business_currencies association table + op.create_table( + 'business_currencies', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('currency_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + # Unique and indexes for association + op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id']) + op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id']) + op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id']) + + +def downgrade() -> None: + op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies') + op.drop_index('ix_business_currencies_business_id', table_name='business_currencies') + op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique') + op.drop_table('business_currencies') + + op.drop_index('ix_currencies_name', table_name='currencies') + op.drop_constraint('uq_currencies_code', 'currencies', type_='unique') + op.drop_constraint('uq_currencies_name', 'currencies', type_='unique') + op.drop_table('currencies') + + diff --git a/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py b/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py new file mode 100644 index 0000000..8f94d86 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000014_add_documents_table' +down_revision = '20250927_000013_add_currencies_and_business_currencies' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create documents table + op.create_table( + 'documents', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('code', sa.String(length=50), nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('currency_id', sa.Integer(), nullable=False), + sa.Column('created_by_user_id', sa.Integer(), nullable=False), + sa.Column('registered_at', sa.DateTime(), nullable=False), + sa.Column('document_date', sa.Date(), nullable=False), + sa.Column('document_type', sa.String(length=50), nullable=False), + sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('extra_info', sa.JSON(), nullable=True), + sa.Column('developer_settings', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + # Unique per business code + op.create_unique_constraint('uq_documents_business_code', 'documents', ['business_id', 'code']) + + # Indexes + op.create_index('ix_documents_code', 'documents', ['code']) + op.create_index('ix_documents_business_id', 'documents', ['business_id']) + op.create_index('ix_documents_currency_id', 'documents', ['currency_id']) + op.create_index('ix_documents_created_by_user_id', 'documents', ['created_by_user_id']) + + +def downgrade() -> None: + op.drop_index('ix_documents_created_by_user_id', table_name='documents') + op.drop_index('ix_documents_currency_id', table_name='documents') + op.drop_index('ix_documents_business_id', table_name='documents') + op.drop_index('ix_documents_code', table_name='documents') + op.drop_constraint('uq_documents_business_code', 'documents', type_='unique') + op.drop_table('documents') + + diff --git a/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py b/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py new file mode 100644 index 0000000..be3bbc8 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000015_add_document_lines_table' +down_revision = '20250927_000014_add_documents_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'document_lines', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('document_id', sa.Integer(), nullable=False), + sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')), + sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('extra_info', sa.JSON(), nullable=True), + sa.Column('developer_data', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id']) + + +def downgrade() -> None: + op.drop_index('ix_document_lines_document_id', table_name='document_lines') + op.drop_table('document_lines') + + diff --git a/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py new file mode 100644 index 0000000..236159f --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000016_add_accounts_table' +down_revision = '20250927_000015_add_document_lines_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'accounts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('business_id', sa.Integer(), nullable=True), + sa.Column('account_type', sa.String(length=50), nullable=False), + sa.Column('code', sa.String(length=50), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_id'], ['accounts.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + op.create_unique_constraint('uq_accounts_business_code', 'accounts', ['business_id', 'code']) + op.create_index('ix_accounts_name', 'accounts', ['name']) + op.create_index('ix_accounts_business_id', 'accounts', ['business_id']) + op.create_index('ix_accounts_parent_id', 'accounts', ['parent_id']) + + +def downgrade() -> None: + op.drop_index('ix_accounts_parent_id', table_name='accounts') + op.drop_index('ix_accounts_business_id', table_name='accounts') + op.drop_index('ix_accounts_name', table_name='accounts') + op.drop_constraint('uq_accounts_business_code', 'accounts', type_='unique') + op.drop_table('accounts') + + diff --git a/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py b/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py new file mode 100644 index 0000000..687e483 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000017_add_account_id_to_document_lines.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000017_add_account_id_to_document_lines' +down_revision = '20250927_000016_add_accounts_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.add_column(sa.Column('account_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_document_lines_account_id_accounts', 'accounts', ['account_id'], ['id'], ondelete='RESTRICT') + batch_op.create_index('ix_document_lines_account_id', ['account_id']) + + +def downgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.drop_index('ix_document_lines_account_id') + batch_op.drop_constraint('fk_document_lines_account_id_accounts', type_='foreignkey') + batch_op.drop_column('account_id') + + diff --git a/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py b/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py new file mode 100644 index 0000000..24ee953 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000018_seed_currencies.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000018_seed_currencies' +down_revision = 'f876bfa36805' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + insert_sql = sa.text( + """ + INSERT INTO currencies (name, title, symbol, code, created_at, updated_at) + VALUES (:name, :title, :symbol, :code, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + symbol = VALUES(symbol), + updated_at = VALUES(updated_at) + """ + ) + + currencies = [ + {"name": "Iranian Rial", "title": "ریال ایران", "symbol": "﷼", "code": "IRR"}, + {"name": "United States Dollar", "title": "US Dollar", "symbol": "$", "code": "USD"}, + {"name": "Euro", "title": "Euro", "symbol": "€", "code": "EUR"}, + {"name": "British Pound", "title": "Pound Sterling", "symbol": "£", "code": "GBP"}, + {"name": "Japanese Yen", "title": "Yen", "symbol": "¥", "code": "JPY"}, + {"name": "Chinese Yuan", "title": "Yuan", "symbol": "¥", "code": "CNY"}, + {"name": "Swiss Franc", "title": "Swiss Franc", "symbol": "CHF", "code": "CHF"}, + {"name": "Canadian Dollar", "title": "Canadian Dollar", "symbol": "$", "code": "CAD"}, + {"name": "Australian Dollar", "title": "Australian Dollar", "symbol": "$", "code": "AUD"}, + {"name": "New Zealand Dollar", "title": "New Zealand Dollar", "symbol": "$", "code": "NZD"}, + {"name": "Russian Ruble", "title": "Ruble", "symbol": "₽", "code": "RUB"}, + {"name": "Turkish Lira", "title": "Lira", "symbol": "₺", "code": "TRY"}, + {"name": "UAE Dirham", "title": "Dirham", "symbol": "د.إ", "code": "AED"}, + {"name": "Saudi Riyal", "title": "Riyal", "symbol": "﷼", "code": "SAR"}, + {"name": "Qatari Riyal", "title": "Qatari Riyal", "symbol": "﷼", "code": "QAR"}, + {"name": "Kuwaiti Dinar", "title": "Kuwaiti Dinar", "symbol": "د.ك", "code": "KWD"}, + {"name": "Omani Rial", "title": "Omani Rial", "symbol": "﷼", "code": "OMR"}, + {"name": "Bahraini Dinar", "title": "Bahraini Dinar", "symbol": ".د.ب", "code": "BHD"}, + {"name": "Iraqi Dinar", "title": "Iraqi Dinar", "symbol": "ع.د", "code": "IQD"}, + {"name": "Afghan Afghani", "title": "Afghani", "symbol": "؋", "code": "AFN"}, + {"name": "Pakistani Rupee", "title": "Rupee", "symbol": "₨", "code": "PKR"}, + {"name": "Indian Rupee", "title": "Rupee", "symbol": "₹", "code": "INR"}, + {"name": "Armenian Dram", "title": "Dram", "symbol": "֏", "code": "AMD"}, + {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"}, + {"name": "Georgian Lari", "title": "Lari", "symbol": "₾", "code": "GEL"}, + {"name": "Kazakhstani Tenge", "title": "Tenge", "symbol": "₸", "code": "KZT"}, + {"name": "Uzbekistani Som", "title": "Som", "symbol": "so'm", "code": "UZS"}, + {"name": "Tajikistani Somoni", "title": "Somoni", "symbol": "ЅМ", "code": "TJS"}, + {"name": "Turkmenistani Manat", "title": "Manat", "symbol": "m", "code": "TMT"}, + {"name": "Afgani Lek", "title": "Lek", "symbol": "L", "code": "ALL"}, + {"name": "Bulgarian Lev", "title": "Lev", "symbol": "лв", "code": "BGN"}, + {"name": "Romanian Leu", "title": "Leu", "symbol": "lei", "code": "RON"}, + {"name": "Polish Złoty", "title": "Zloty", "symbol": "zł", "code": "PLN"}, + {"name": "Czech Koruna", "title": "Koruna", "symbol": "Kč", "code": "CZK"}, + {"name": "Hungarian Forint", "title": "Forint", "symbol": "Ft", "code": "HUF"}, + {"name": "Danish Krone", "title": "Krone", "symbol": "kr", "code": "DKK"}, + {"name": "Norwegian Krone", "title": "Krone", "symbol": "kr", "code": "NOK"}, + {"name": "Swedish Krona", "title": "Krona", "symbol": "kr", "code": "SEK"}, + {"name": "Icelandic Króna", "title": "Krona", "symbol": "kr", "code": "ISK"}, + {"name": "Croatian Kuna", "title": "Kuna", "symbol": "kn", "code": "HRK"}, + {"name": "Serbian Dinar", "title": "Dinar", "symbol": "дин.", "code": "RSD"}, + {"name": "Bosnia and Herzegovina Mark", "title": "Mark", "symbol": "KM", "code": "BAM"}, + {"name": "Ukrainian Hryvnia", "title": "Hryvnia", "symbol": "₴", "code": "UAH"}, + {"name": "Belarusian Ruble", "title": "Ruble", "symbol": "Br", "code": "BYN"}, + {"name": "Egyptian Pound", "title": "Pound", "symbol": "£", "code": "EGP"}, + {"name": "South African Rand", "title": "Rand", "symbol": "R", "code": "ZAR"}, + {"name": "Nigerian Naira", "title": "Naira", "symbol": "₦", "code": "NGN"}, + {"name": "Kenyan Shilling", "title": "Shilling", "symbol": "Sh", "code": "KES"}, + {"name": "Ethiopian Birr", "title": "Birr", "symbol": "Br", "code": "ETB"}, + {"name": "Moroccan Dirham", "title": "Dirham", "symbol": "د.م.", "code": "MAD"}, + {"name": "Tunisian Dinar", "title": "Dinar", "symbol": "د.ت", "code": "TND"}, + {"name": "Algerian Dinar", "title": "Dinar", "symbol": "د.ج", "code": "DZD"}, + {"name": "Israeli New Shekel", "title": "Shekel", "symbol": "₪", "code": "ILS"}, + {"name": "Jordanian Dinar", "title": "Dinar", "symbol": "د.ا", "code": "JOD"}, + {"name": "Lebanese Pound", "title": "Pound", "symbol": "ل.ل", "code": "LBP"}, + {"name": "Syrian Pound", "title": "Pound", "symbol": "£", "code": "SYP"}, + {"name": "Azerbaijani Manat", "title": "Manat", "symbol": "₼", "code": "AZN"}, + {"name": "Singapore Dollar", "title": "Singapore Dollar", "symbol": "$", "code": "SGD"}, + {"name": "Hong Kong Dollar", "title": "Hong Kong Dollar", "symbol": "$", "code": "HKD"}, + {"name": "Thai Baht", "title": "Baht", "symbol": "฿", "code": "THB"}, + {"name": "Malaysian Ringgit", "title": "Ringgit", "symbol": "RM", "code": "MYR"}, + {"name": "Indonesian Rupiah", "title": "Rupiah", "symbol": "Rp", "code": "IDR"}, + {"name": "Philippine Peso", "title": "Peso", "symbol": "₱", "code": "PHP"}, + {"name": "Vietnamese Dong", "title": "Dong", "symbol": "₫", "code": "VND"}, + {"name": "South Korean Won", "title": "Won", "symbol": "₩", "code": "KRW"}, + {"name": "Taiwan New Dollar", "title": "New Dollar", "symbol": "$", "code": "TWD"}, + {"name": "Mexican Peso", "title": "Peso", "symbol": "$", "code": "MXN"}, + {"name": "Brazilian Real", "title": "Real", "symbol": "R$", "code": "BRL"}, + {"name": "Argentine Peso", "title": "Peso", "symbol": "$", "code": "ARS"}, + {"name": "Chilean Peso", "title": "Peso", "symbol": "$", "code": "CLP"}, + {"name": "Colombian Peso", "title": "Peso", "symbol": "$", "code": "COP"}, + {"name": "Peruvian Sol", "title": "Sol", "symbol": "S/.", "code": "PEN"}, + {"name": "Uruguayan Peso", "title": "Peso", "symbol": "$U", "code": "UYU"}, + {"name": "Paraguayan Guarani", "title": "Guarani", "symbol": "₲", "code": "PYG"}, + {"name": "Bolivian Boliviano", "title": "Boliviano", "symbol": "Bs.", "code": "BOB"}, + {"name": "Dominican Peso", "title": "Peso", "symbol": "RD$", "code": "DOP"}, + {"name": "Cuban Peso", "title": "Peso", "symbol": "$", "code": "CUP"}, + {"name": "Costa Rican Colon", "title": "Colon", "symbol": "₡", "code": "CRC"}, + {"name": "Guatemalan Quetzal", "title": "Quetzal", "symbol": "Q", "code": "GTQ"}, + {"name": "Honduran Lempira", "title": "Lempira", "symbol": "L", "code": "HNL"}, + {"name": "Nicaraguan Córdoba", "title": "Cordoba", "symbol": "C$", "code": "NIO"}, + {"name": "Panamanian Balboa", "title": "Balboa", "symbol": "B/.", "code": "PAB"}, + {"name": "Venezuelan Bolívar", "title": "Bolivar", "symbol": "Bs.", "code": "VES"}, + ] + + for row in currencies: + conn.execute(insert_sql, row) + + +def downgrade() -> None: + conn = op.get_bind() + codes = [ + 'IRR','USD','EUR','GBP','JPY','CNY','CHF','CAD','AUD','NZD','RUB','TRY','AED','SAR','QAR','KWD','OMR','BHD','IQD','AFN','PKR','INR','AMD','AZN','GEL','KZT','UZS','TJS','TMT','ALL','BGN','RON','PLN','CZK','HUF','DKK','NOK','SEK','ISK','HRK','RSD','BAM','UAH','BYN','EGP','ZAR','NGN','KES','ETB','MAD','TND','DZD','ILS','JOD','LBP','SYP','SGD','HKD','THB','MYR','IDR','PHP','VND','KRW','TWD','MXN','BRL','ARS','CLP','COP','PEN','UYU','PYG','BOB','DOP','CUP','CRC','GTQ','HNL','NIO','PAB','VES' + ] + delete_sql = sa.text("DELETE FROM currencies WHERE code IN :codes") + conn.execute(delete_sql, {"codes": tuple(codes)}) + + diff --git a/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py b/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py new file mode 100644 index 0000000..333a1d7 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000019_seed_accounts_chart.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250927_000019_seed_accounts_chart' +down_revision = '20250927_000018_seed_currencies' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # داده‌ها (خلاصه‌شده برای خوانایی؛ از JSON کاربر) + accounts = [ + {"id":2452,"level":1,"code":"1","name":"دارایی ها","parentId":0,"accountType":0}, + {"id":2453,"level":2,"code":"101","name":"دارایی های جاری","parentId":2452,"accountType":0}, + {"id":2454,"level":3,"code":"102","name":"موجودی نقد و بانک","parentId":2453,"accountType":0}, + {"id":2455,"level":4,"code":"10201","name":"تنخواه گردان","parentId":2454,"accountType":2}, + {"id":2456,"level":4,"code":"10202","name":"صندوق","parentId":2454,"accountType":1}, + {"id":2457,"level":4,"code":"10203","name":"بانک","parentId":2454,"accountType":3}, + {"id":2458,"level":4,"code":"10204","name":"وجوه در راه","parentId":2454,"accountType":0}, + {"id":2459,"level":3,"code":"103","name":"سپرده های کوتاه مدت","parentId":2453,"accountType":0}, + {"id":2460,"level":4,"code":"10301","name":"سپرده شرکت در مناقصه و مزایده","parentId":2459,"accountType":0}, + {"id":2461,"level":4,"code":"10302","name":"ضمانت نامه بانکی","parentId":2459,"accountType":0}, + {"id":2462,"level":4,"code":"10303","name":"سایر سپرده ها","parentId":2459,"accountType":0}, + {"id":2463,"level":3,"code":"104","name":"حساب های دریافتنی","parentId":2453,"accountType":0}, + {"id":2464,"level":4,"code":"10401","name":"حساب های دریافتنی","parentId":2463,"accountType":4}, + {"id":2465,"level":4,"code":"10402","name":"ذخیره مطالبات مشکوک الوصول","parentId":2463,"accountType":0}, + {"id":2466,"level":4,"code":"10403","name":"اسناد دریافتنی","parentId":2463,"accountType":5}, + {"id":2467,"level":4,"code":"10404","name":"اسناد در جریان وصول","parentId":2463,"accountType":6}, + {"id":2468,"level":3,"code":"105","name":"سایر حساب های دریافتنی","parentId":2453,"accountType":0}, + {"id":2469,"level":4,"code":"10501","name":"وام کارکنان","parentId":2468,"accountType":0}, + {"id":2470,"level":4,"code":"10502","name":"سایر حساب های دریافتنی","parentId":2468,"accountType":0}, + {"id":2471,"level":3,"code":"10101","name":"پیش پرداخت ها","parentId":2453,"accountType":0}, + {"id":2472,"level":3,"code":"10102","name":"موجودی کالا","parentId":2453,"accountType":7}, + {"id":2473,"level":3,"code":"10103","name":"ملزومات","parentId":2453,"accountType":0}, + {"id":2474,"level":3,"code":"10104","name":"مالیات بر ارزش افزوده خرید","parentId":2453,"accountType":8}, + {"id":2475,"level":2,"code":"106","name":"دارایی های غیر جاری","parentId":2452,"accountType":0}, + {"id":2476,"level":3,"code":"107","name":"دارایی های ثابت","parentId":2475,"accountType":0}, + {"id":2477,"level":4,"code":"10701","name":"زمین","parentId":2476,"accountType":0}, + {"id":2478,"level":4,"code":"10702","name":"ساختمان","parentId":2476,"accountType":0}, + {"id":2479,"level":4,"code":"10703","name":"وسائط نقلیه","parentId":2476,"accountType":0}, + {"id":2480,"level":4,"code":"10704","name":"اثاثیه اداری","parentId":2476,"accountType":0}, + {"id":2481,"level":3,"code":"108","name":"استهلاک انباشته","parentId":2475,"accountType":0}, + {"id":2482,"level":4,"code":"10801","name":"استهلاک انباشته ساختمان","parentId":2481,"accountType":0}, + {"id":2483,"level":4,"code":"10802","name":"استهلاک انباشته وسائط نقلیه","parentId":2481,"accountType":0}, + {"id":2484,"level":4,"code":"10803","name":"استهلاک انباشته اثاثیه اداری","parentId":2481,"accountType":0}, + {"id":2485,"level":3,"code":"109","name":"سپرده های بلندمدت","parentId":2475,"accountType":0}, + {"id":2486,"level":3,"code":"110","name":"سایر دارائی ها","parentId":2475,"accountType":0}, + {"id":2487,"level":4,"code":"11001","name":"حق الامتیازها","parentId":2486,"accountType":0}, + {"id":2488,"level":4,"code":"11002","name":"نرم افزارها","parentId":2486,"accountType":0}, + {"id":2489,"level":4,"code":"11003","name":"سایر دارایی های نامشهود","parentId":2486,"accountType":0}, + {"id":2490,"level":1,"code":"2","name":"بدهی ها","parentId":0,"accountType":0}, + {"id":2491,"level":2,"code":"201","name":"بدهیهای جاری","parentId":2490,"accountType":0}, + {"id":2492,"level":3,"code":"202","name":"حساب ها و اسناد پرداختنی","parentId":2491,"accountType":0}, + {"id":2493,"level":4,"code":"20201","name":"حساب های پرداختنی","parentId":2492,"accountType":9}, + {"id":2494,"level":4,"code":"20202","name":"اسناد پرداختنی","parentId":2492,"accountType":10}, + {"id":2495,"level":3,"code":"203","name":"سایر حساب های پرداختنی","parentId":2491,"accountType":0}, + {"id":2496,"level":4,"code":"20301","name":"ذخیره مالیات بر درآمد پرداختنی","parentId":2495,"accountType":40}, + {"id":2497,"level":4,"code":"20302","name":"مالیات بر درآمد پرداختنی","parentId":2495,"accountType":12}, + {"id":2498,"level":4,"code":"20303","name":"مالیات حقوق و دستمزد پرداختنی","parentId":2495,"accountType":0}, + {"id":2499,"level":4,"code":"20304","name":"حق بیمه پرداختنی","parentId":2495,"accountType":0}, + {"id":2500,"level":4,"code":"20305","name":"حقوق و دستمزد پرداختنی","parentId":2495,"accountType":42}, + {"id":2501,"level":4,"code":"20306","name":"عیدی و پاداش پرداختنی","parentId":2495,"accountType":0}, + {"id":2502,"level":4,"code":"20307","name":"سایر هزینه های پرداختنی","parentId":2495,"accountType":0}, + {"id":2503,"level":3,"code":"204","name":"پیش دریافت ها","parentId":2491,"accountType":0}, + {"id":2504,"level":4,"code":"20401","name":"پیش دریافت فروش","parentId":2503,"accountType":0}, + {"id":2505,"level":4,"code":"20402","name":"سایر پیش دریافت ها","parentId":2503,"accountType":0}, + {"id":2506,"level":3,"code":"20101","name":"مالیات بر ارزش افزوده فروش","parentId":2491,"accountType":11}, + {"id":2507,"level":2,"code":"205","name":"بدهیهای غیر جاری","parentId":2490,"accountType":0}, + {"id":2508,"level":3,"code":"206","name":"حساب ها و اسناد پرداختنی بلندمدت","parentId":2507,"accountType":0}, + {"id":2509,"level":4,"code":"20601","name":"حساب های پرداختنی بلندمدت","parentId":2508,"accountType":0}, + {"id":2510,"level":4,"code":"20602","name":"اسناد پرداختنی بلندمدت","parentId":2508,"accountType":0}, + {"id":2511,"level":3,"code":"20501","name":"وام پرداختنی","parentId":2507,"accountType":0}, + {"id":2512,"level":3,"code":"20502","name":"ذخیره مزایای پایان خدمت کارکنان","parentId":2507,"accountType":0}, + {"id":2513,"level":1,"code":"3","name":"حقوق صاحبان سهام","parentId":0,"accountType":0}, + {"id":2514,"level":2,"code":"301","name":"سرمایه","parentId":2513,"accountType":0}, + {"id":2515,"level":3,"code":"30101","name":"سرمایه اولیه","parentId":2514,"accountType":13}, + {"id":2516,"level":3,"code":"30102","name":"افزایش یا کاهش سرمایه","parentId":2514,"accountType":14}, + {"id":2517,"level":3,"code":"30103","name":"اندوخته قانونی","parentId":2514,"accountType":15}, + {"id":2518,"level":3,"code":"30104","name":"برداشت ها","parentId":2514,"accountType":16}, + {"id":2519,"level":3,"code":"30105","name":"سهم سود و زیان","parentId":2514,"accountType":17}, + {"id":2520,"level":3,"code":"30106","name":"سود یا زیان انباشته (سنواتی)","parentId":2514,"accountType":18}, + {"id":2521,"level":1,"code":"4","name":"بهای تمام شده کالای فروخته شده","parentId":0,"accountType":0}, + {"id":2522,"level":2,"code":"40001","name":"بهای تمام شده کالای فروخته شده","parentId":2521,"accountType":19}, + {"id":2523,"level":2,"code":"40002","name":"برگشت از خرید","parentId":2521,"accountType":20}, + {"id":2524,"level":2,"code":"40003","name":"تخفیفات نقدی خرید","parentId":2521,"accountType":21}, + {"id":2525,"level":1,"code":"5","name":"فروش","parentId":0,"accountType":0}, + {"id":2526,"level":2,"code":"50001","name":"فروش کالا","parentId":2525,"accountType":22}, + {"id":2527,"level":2,"code":"50002","name":"برگشت از فروش","parentId":2525,"accountType":23}, + {"id":2528,"level":2,"code":"50003","name":"تخفیفات نقدی فروش","parentId":2525,"accountType":24}, + {"id":2529,"level":1,"code":"6","name":"درآمد","parentId":0,"accountType":0}, + {"id":2530,"level":2,"code":"601","name":"درآمد های عملیاتی","parentId":2529,"accountType":0}, + {"id":2531,"level":3,"code":"60101","name":"درآمد حاصل از فروش خدمات","parentId":2530,"accountType":25}, + {"id":2532,"level":3,"code":"60102","name":"برگشت از خرید خدمات","parentId":2530,"accountType":26}, + {"id":2533,"level":3,"code":"60103","name":"درآمد اضافه کالا","parentId":2530,"accountType":27}, + {"id":2534,"level":3,"code":"60104","name":"درآمد حمل کالا","parentId":2530,"accountType":28}, + {"id":2535,"level":2,"code":"602","name":"درآمد های غیر عملیاتی","parentId":2529,"accountType":0}, + {"id":2536,"level":3,"code":"60201","name":"درآمد حاصل از سرمایه گذاری","parentId":2535,"accountType":0}, + {"id":2537,"level":3,"code":"60202","name":"درآمد سود سپرده ها","parentId":2535,"accountType":0}, + {"id":2538,"level":3,"code":"60203","name":"سایر درآمد ها","parentId":2535,"accountType":0}, + {"id":2539,"level":3,"code":"60204","name":"درآمد تسعیر ارز","parentId":2535,"accountType":36}, + {"id":2540,"level":1,"code":"7","name":"هزینه ها","parentId":0,"accountType":0}, + {"id":2541,"level":2,"code":"701","name":"هزینه های پرسنلی","parentId":2540,"accountType":0}, + {"id":2542,"level":3,"code":"702","name":"هزینه حقوق و دستمزد","parentId":2541,"accountType":0}, + {"id":2543,"level":4,"code":"70201","name":"حقوق پایه","parentId":2542,"accountType":0}, + {"id":2544,"level":4,"code":"70202","name":"اضافه کار","parentId":2542,"accountType":0}, + {"id":2545,"level":4,"code":"70203","name":"حق شیفت و شب کاری","parentId":2542,"accountType":0}, + {"id":2546,"level":4,"code":"70204","name":"حق نوبت کاری","parentId":2542,"accountType":0}, + {"id":2547,"level":4,"code":"70205","name":"حق ماموریت","parentId":2542,"accountType":0}, + {"id":2548,"level":4,"code":"70206","name":"فوق العاده مسکن و خاروبار","parentId":2542,"accountType":0}, + {"id":2549,"level":4,"code":"70207","name":"حق اولاد","parentId":2542,"accountType":0}, + {"id":2550,"level":4,"code":"70208","name":"عیدی و پاداش","parentId":2542,"accountType":0}, + {"id":2551,"level":4,"code":"70209","name":"بازخرید سنوات خدمت کارکنان","parentId":2542,"accountType":0}, + {"id":2552,"level":4,"code":"70210","name":"بازخرید مرخصی","parentId":2542,"accountType":0}, + {"id":2553,"level":4,"code":"70211","name":"بیمه سهم کارفرما","parentId":2542,"accountType":0}, + {"id":2554,"level":4,"code":"70212","name":"بیمه بیکاری","parentId":2542,"accountType":0}, + {"id":2555,"level":4,"code":"70213","name":"حقوق مزایای متفرقه","parentId":2542,"accountType":0}, + {"id":2556,"level":3,"code":"703","name":"سایر هزینه های کارکنان","parentId":2541,"accountType":0}, + {"id":2557,"level":4,"code":"70301","name":"سفر و ماموریت","parentId":2556,"accountType":0}, + {"id":2558,"level":4,"code":"70302","name":"ایاب و ذهاب","parentId":2556,"accountType":0}, + {"id":2559,"level":4,"code":"70303","name":"سایر هزینه های کارکنان","parentId":2556,"accountType":0}, + {"id":2560,"level":2,"code":"704","name":"هزینه های عملیاتی","parentId":2540,"accountType":0}, + {"id":2561,"level":3,"code":"70401","name":"خرید خدمات","parentId":2560,"accountType":30}, + {"id":2562,"level":3,"code":"70402","name":"برگشت از فروش خدمات","parentId":2560,"accountType":29}, + {"id":2563,"level":3,"code":"70403","name":"هزینه حمل کالا","parentId":2560,"accountType":31}, + {"id":2564,"level":3,"code":"70404","name":"تعمیر و نگهداری اموال و اثاثیه","parentId":2560,"accountType":0}, + {"id":2565,"level":3,"code":"70405","name":"هزینه اجاره محل","parentId":2560,"accountType":0}, + {"id":2566,"level":2,"code":"705","name":"هزینه های عمومی","parentId":2540,"accountType":0}, + {"id":2567,"level":4,"code":"70501","name":"هزینه آب و برق و گاز و تلفن","parentId":2566,"accountType":0}, + {"id":2568,"level":4,"code":"70502","name":"هزینه پذیرایی و آبدارخانه","parentId":2566,"accountType":0}, + {"id":2569,"level":3,"code":"70406","name":"هزینه ملزومات مصرفی","parentId":2560,"accountType":0}, + {"id":2570,"level":3,"code":"70407","name":"هزینه کسری و ضایعات کالا","parentId":2560,"accountType":32}, + {"id":2571,"level":3,"code":"70408","name":"بیمه دارایی های ثابت","parentId":2560,"accountType":0}, + {"id":2572,"level":2,"code":"706","name":"هزینه های استهلاک","parentId":2540,"accountType":0}, + {"id":2573,"level":3,"code":"70601","name":"هزینه استهلاک ساختمان","parentId":2572,"accountType":0}, + {"id":2574,"level":3,"code":"70602","name":"هزینه استهلاک وسائط نقلیه","parentId":2572,"accountType":0}, + {"id":2575,"level":3,"code":"70603","name":"هزینه استهلاک اثاثیه","parentId":2572,"accountType":0}, + {"id":2576,"level":2,"code":"707","name":"هزینه های بازاریابی و توزیع و فروش","parentId":2540,"accountType":0}, + {"id":2577,"level":3,"code":"70701","name":"هزینه آگهی و تبلیغات","parentId":2576,"accountType":0}, + {"id":2578,"level":3,"code":"70702","name":"هزینه بازاریابی و پورسانت","parentId":2576,"accountType":0}, + {"id":2579,"level":3,"code":"70703","name":"سایر هزینه های توزیع و فروش","parentId":2576,"accountType":0}, + {"id":2580,"level":2,"code":"708","name":"هزینه های غیرعملیاتی","parentId":2540,"accountType":0}, + {"id":2581,"level":3,"code":"709","name":"هزینه های بانکی","parentId":2580,"accountType":0}, + {"id":2582,"level":4,"code":"70901","name":"سود و کارمزد وامها","parentId":2581,"accountType":0}, + {"id":2583,"level":4,"code":"70902","name":"کارمزد خدمات بانکی","parentId":2581,"accountType":33}, + {"id":2584,"level":4,"code":"70903","name":"جرائم دیرکرد بانکی","parentId":2581,"accountType":0}, + {"id":2585,"level":3,"code":"70801","name":"هزینه تسعیر ارز","parentId":2580,"accountType":37}, + {"id":2586,"level":3,"code":"70802","name":"هزینه مطالبات سوخت شده","parentId":2580,"accountType":0}, + {"id":2587,"level":1,"code":"8","name":"سایر حساب ها","parentId":0,"accountType":0}, + {"id":2588,"level":2,"code":"801","name":"حساب های انتظامی","parentId":2587,"accountType":0}, + {"id":2589,"level":3,"code":"80101","name":"حساب های انتظامی","parentId":2588,"accountType":0}, + {"id":2590,"level":3,"code":"80102","name":"طرف حساب های انتظامی","parentId":2588,"accountType":0}, + {"id":2591,"level":2,"code":"802","name":"حساب های کنترلی","parentId":2587,"accountType":0}, + {"id":2592,"level":3,"code":"80201","name":"کنترل کسری و اضافه کالا","parentId":2591,"accountType":34}, + {"id":2593,"level":2,"code":"803","name":"حساب خلاصه سود و زیان","parentId":2587,"accountType":0}, + {"id":2594,"level":3,"code":"80301","name":"خلاصه سود و زیان","parentId":2593,"accountType":35}, + {"id":2595,"level":5,"code":"70503","name":"هزینه آب","parentId":2567,"accountType":0}, + {"id":2596,"level":5,"code":"70504","name":"هزینه برق","parentId":2567,"accountType":0}, + {"id":2597,"level":5,"code":"70505","name":"هزینه گاز","parentId":2567,"accountType":0}, + {"id":2598,"level":5,"code":"70506","name":"هزینه تلفن","parentId":2567,"accountType":0}, + {"id":2600,"level":4,"code":"20503","name":"وام از بانک ملت","parentId":2511,"accountType":0}, + {"id":2601,"level":4,"code":"10405","name":"سود تحقق نیافته فروش اقساطی","parentId":2463,"accountType":39}, + {"id":2602,"level":3,"code":"60205","name":"سود فروش اقساطی","parentId":2535,"accountType":38}, + {"id":2603,"level":4,"code":"70214","name":"حق تاهل","parentId":2542,"accountType":0}, + {"id":2604,"level":4,"code":"20504","name":"وام از بانک پارسیان","parentId":2511,"accountType":0}, + {"id":2605,"level":3,"code":"10105","name":"مساعده","parentId":2453,"accountType":0}, + {"id":2606,"level":3,"code":"60105","name":"تعمیرات لوازم آشپزخانه","parentId":2530,"accountType":0}, + {"id":2607,"level":4,"code":"10705","name":"کامپیوتر","parentId":2476,"accountType":0}, + {"id":2608,"level":3,"code":"60206","name":"درامد حاصل از فروش ضایعات","parentId":2535,"accountType":0}, + {"id":2609,"level":3,"code":"60207","name":"سود فروش دارایی","parentId":2535,"accountType":0}, + {"id":2610,"level":3,"code":"70803","name":"زیان فروش دارایی","parentId":2580,"accountType":0}, + {"id":2611,"level":3,"code":"10106","name":"موجودی کالای در جریان ساخت","parentId":2453,"accountType":41}, + {"id":2612,"level":3,"code":"20102","name":"سربار تولید پرداختنی","parentId":2491,"accountType":43}, + ] + + # نقشه id خارجی به id داخلی + ext_to_internal: dict[int, int] = {} + + # کوئری‌ها + select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1") + insert_q = sa.text( + """ + INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at) + VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW()) + """ + ) + update_q = sa.text( + """ + UPDATE accounts + SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW() + WHERE id = :id + """ + ) + + for item in accounts: + parent_internal = None + if item.get("parentId") and item["parentId"] in ext_to_internal: + parent_internal = ext_to_internal[item["parentId"]] + + # وجودی؟ + res = conn.execute(select_existing, {"code": item["code"]}) + row = res.fetchone() + if row is None: + result = conn.execute( + insert_q, + { + "name": item["name"], + "account_type": str(item.get("accountType", 0)), + "code": item["code"], + "parent_id": parent_internal, + }, + ) + new_id = result.lastrowid if hasattr(result, "lastrowid") else None + if new_id is None: + # fallback: انتخاب بر اساس code + res2 = conn.execute(select_existing, {"code": item["code"]}) + row2 = res2.fetchone() + if row2: + new_id = row2[0] + else: + pass + if new_id is not None: + ext_to_internal[item["id"]] = int(new_id) + else: + acc_id = int(row[0]) + conn.execute( + update_q, + { + "id": acc_id, + "name": item["name"], + "account_type": str(item.get("accountType", 0)), + "parent_id": parent_internal, + }, + ) + ext_to_internal[item["id"]] = acc_id + + +def downgrade() -> None: + conn = op.get_bind() + # حذف بر اساس کدها (فقط حساب‌های عمومی یعنی business_id IS NULL) + codes = [ + "1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102" + ] + delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code") + for code in codes: + conn.execute(delete_q, {"code": code}) + + diff --git a/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py b/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py new file mode 100644 index 0000000..23f423b --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py @@ -0,0 +1,45 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '20250927_000020_add_share_count_and_shareholder_type' +down_revision = '20250927_000019_seed_accounts_chart' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + b = op.get_bind() + inspector = inspect(b) + cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set() + with op.batch_alter_table('persons') as batch_op: + if 'share_count' not in cols: + batch_op.add_column(sa.Column('share_count', sa.Integer(), nullable=True)) + + # افزودن مقدار جدید به ENUM ستون person_type (برای MySQL) + # مقادیر فارسی مطابق Enum مدل: 'مشتری','بازاریاب','کارمند','تامین‌کننده','همکار','فروشنده' + # مقدار جدید: 'سهامدار' + op.execute( + """ + ALTER TABLE persons + MODIFY COLUMN person_type + ENUM('مشتری','بازاریاب','کارمند','تامین‌کننده','همکار','فروشنده','سهامدار') NOT NULL + """ + ) + + +def downgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + batch_op.drop_column('share_count') + + # بازگردانی ENUM بدون مقدار سهامدار + op.execute( + """ + ALTER TABLE persons + MODIFY COLUMN person_type + ENUM('مشتری','بازاریاب','کارمند','تامین‌کننده','همکار','فروشنده') NOT NULL + """ + ) + + diff --git a/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py b/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py new file mode 100644 index 0000000..a2c1be3 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py @@ -0,0 +1,59 @@ +from alembic import op + +# revision identifiers, used by Alembic. +revision = '20250927_000021_update_person_type_enum_to_persian' +down_revision = 'd3e84892c1c2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1) Allow both English and Persian, plus new 'سهامدار' + op.execute( + """ + ALTER TABLE persons + MODIFY COLUMN person_type + ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER', + 'مشتری','بازاریاب','کارمند','تامین‌کننده','همکار','فروشنده','سهامدار') NOT NULL + """ + ) + + # 2) Migrate existing data from English to Persian + op.execute("UPDATE persons SET person_type = 'مشتری' WHERE person_type = 'CUSTOMER'") + op.execute("UPDATE persons SET person_type = 'بازاریاب' WHERE person_type = 'MARKETER'") + op.execute("UPDATE persons SET person_type = 'کارمند' WHERE person_type = 'EMPLOYEE'") + op.execute("UPDATE persons SET person_type = 'تامین‌کننده' WHERE person_type = 'SUPPLIER'") + op.execute("UPDATE persons SET person_type = 'همکار' WHERE person_type = 'PARTNER'") + op.execute("UPDATE persons SET person_type = 'فروشنده' WHERE person_type = 'SELLER'") + + # 3) Restrict enum to Persian only (including 'سهامدار') + op.execute( + """ + ALTER TABLE persons + MODIFY COLUMN person_type + ENUM('مشتری','بازاریاب','کارمند','تامین‌کننده','همکار','فروشنده','سهامدار') NOT NULL + """ + ) + + +def downgrade() -> None: + # Revert to English-only (without shareholder) + op.execute( + """ + ALTER TABLE persons + MODIFY COLUMN person_type + ENUM('CUSTOMER','MARKETER','EMPLOYEE','SUPPLIER','PARTNER','SELLER') NOT NULL + """ + ) + + # Convert data back from Persian to English + reverse_mapping = { + 'مشتری': 'CUSTOMER', + 'بازاریاب': 'MARKETER', + 'کارمند': 'EMPLOYEE', + 'تامین‌کننده': 'SUPPLIER', + 'همکار': 'PARTNER', + 'فروشنده': 'SELLER', + } + for fa, en in reverse_mapping.items(): + op.execute(text("UPDATE persons SET person_type = :en WHERE person_type = :fa"), {"fa": fa, "en": en}) diff --git a/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py b/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py new file mode 100644 index 0000000..da6e07e --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000022_add_person_commission_fields.py @@ -0,0 +1,43 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '20250927_000022_add_person_commission_fields' +down_revision = '20250927_000021_update_person_type_enum_to_persian' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set() + with op.batch_alter_table('persons') as batch_op: + if 'commission_sale_percent' not in cols: + batch_op.add_column(sa.Column('commission_sale_percent', sa.Numeric(5, 2), nullable=True)) + if 'commission_sales_return_percent' not in cols: + batch_op.add_column(sa.Column('commission_sales_return_percent', sa.Numeric(5, 2), nullable=True)) + if 'commission_sales_amount' not in cols: + batch_op.add_column(sa.Column('commission_sales_amount', sa.Numeric(12, 2), nullable=True)) + if 'commission_sales_return_amount' not in cols: + batch_op.add_column(sa.Column('commission_sales_return_amount', sa.Numeric(12, 2), nullable=True)) + if 'commission_exclude_discounts' not in cols: + batch_op.add_column(sa.Column('commission_exclude_discounts', sa.Boolean(), server_default=sa.text('0'), nullable=False)) + if 'commission_exclude_additions_deductions' not in cols: + batch_op.add_column(sa.Column('commission_exclude_additions_deductions', sa.Boolean(), server_default=sa.text('0'), nullable=False)) + if 'commission_post_in_invoice_document' not in cols: + batch_op.add_column(sa.Column('commission_post_in_invoice_document', sa.Boolean(), server_default=sa.text('0'), nullable=False)) + + +def downgrade() -> None: + with op.batch_alter_table('persons') as batch_op: + batch_op.drop_column('commission_post_in_invoice_document') + batch_op.drop_column('commission_exclude_additions_deductions') + batch_op.drop_column('commission_exclude_discounts') + batch_op.drop_column('commission_sales_return_amount') + batch_op.drop_column('commission_sales_amount') + batch_op.drop_column('commission_sales_return_percent') + batch_op.drop_column('commission_sale_percent') + + 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 new file mode 100644 index 0000000..7ba9699 --- /dev/null +++ b/hesabixAPI/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py @@ -0,0 +1,129 @@ +"""sync person_type enum values_callable to persian + +Revision ID: d3e84892c1c2 +Revises: 20250927_000020_add_share_count_and_shareholder_type +Create Date: 2025-09-27 19:18:06.253391 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'd3e84892c1c2' +down_revision = '20250927_000020_add_share_count_and_shareholder_type' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('storage_configs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('storage_type', sa.String(length=20), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('config_data', sa.JSON(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('file_storage', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('original_name', sa.String(length=255), nullable=False), + sa.Column('stored_name', sa.String(length=255), nullable=False), + sa.Column('file_path', sa.String(length=500), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('storage_type', sa.String(length=20), nullable=False), + sa.Column('storage_config_id', sa.String(length=36), nullable=True), + sa.Column('uploaded_by', sa.Integer(), nullable=False), + sa.Column('module_context', sa.String(length=50), nullable=False), + sa.Column('context_id', sa.String(length=36), nullable=True), + sa.Column('developer_data', sa.JSON(), nullable=True), + sa.Column('checksum', sa.String(length=64), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_temporary', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.Column('verification_token', sa.String(length=100), nullable=True), + sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('file_verifications', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('file_id', sa.String(length=36), nullable=False), + sa.Column('module_name', sa.String(length=50), nullable=False), + sa.Column('verification_token', sa.String(length=100), nullable=False), + sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('verified_by', sa.Integer(), nullable=True), + sa.Column('verification_data', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ), + sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years') + op.alter_column('person_bank_accounts', 'person_id', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='شناسه شخص', + existing_nullable=False) + op.alter_column('persons', 'business_id', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='شناسه کسب و کار', + existing_nullable=False) + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + op.alter_column('persons', 'share_count', + existing_type=mysql.INTEGER(), + comment='تعداد سهام (فقط برای سهامدار)', + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('persons', 'share_count', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='تعداد سهام (فقط برای سهامدار)', + existing_nullable=True) + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment=None, + existing_comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + op.alter_column('persons', 'business_id', + existing_type=mysql.INTEGER(), + comment='شناسه کسب و کار', + existing_nullable=False) + op.alter_column('person_bank_accounts', 'person_id', + existing_type=mysql.INTEGER(), + comment='شناسه شخص', + existing_nullable=False) + op.create_index(op.f('ix_fiscal_years_title'), 'fiscal_years', ['title'], unique=False) + op.drop_table('file_verifications') + op.drop_table('file_storage') + op.drop_table('storage_configs') + # ### end Alembic commands ### diff --git a/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py b/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py new file mode 100644 index 0000000..d318963 --- /dev/null +++ b/hesabixAPI/migrations/versions/f876bfa36805_merge_multiple_heads.py @@ -0,0 +1,24 @@ +"""merge multiple heads + +Revision ID: f876bfa36805 +Revises: 20250117_000009, 20250120_000002, 20250927_000017_add_account_id_to_document_lines +Create Date: 2025-09-27 12:29:57.080003 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f876bfa36805' +down_revision = ('20250117_000009', '20250120_000002', '20250927_000017_add_account_id_to_document_lines') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index 7da7fd1..f8eb32f 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -71,7 +71,7 @@ class ApiClient { if (calendarType != null && calendarType.isNotEmpty) { options.headers['X-Calendar-Type'] = calendarType; } - // Inject X-Business-ID header when path targets a specific business + // Inject X-Business-ID header when request targets a specific business try { final uri = options.uri; final path = uri.path; @@ -80,7 +80,8 @@ class ApiClient { int? resolvedBusinessId = currentBusinessId; // Fallback: detect business_id from URL like /api/v1/business/{id}/... if (resolvedBusinessId == null) { - final match = RegExp(r"/api/v1/business/(\d+)/").firstMatch(path); + // Match any occurrence of /business/{id} in the path + final match = RegExp(r"/business/(\d+)(/|$)").firstMatch(path); if (match != null) { final idStr = match.group(1); if (idStr != null) { @@ -88,6 +89,14 @@ class ApiClient { } } } + // Fallback: query parameter business_id or businessId + if (resolvedBusinessId == null && uri.queryParameters.isNotEmpty) { + final qp = uri.queryParameters; + final idStr = qp['business_id'] ?? qp['businessId']; + if (idStr != null && idStr.isNotEmpty) { + resolvedBusinessId = int.tryParse(idStr); + } + } if (resolvedBusinessId != null) { options.headers['X-Business-ID'] = resolvedBusinessId.toString(); } @@ -121,6 +130,7 @@ class ApiClient { } Future> get(String path, {Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) { + path = _resolveApiPath(path); final requestOptions = options ?? Options(); if (responseType != null) { requestOptions.responseType = responseType; @@ -129,6 +139,7 @@ class ApiClient { } Future> post(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) { + path = _resolveApiPath(path); final requestOptions = options ?? Options(); if (responseType != null) { requestOptions.responseType = responseType; @@ -137,14 +148,17 @@ class ApiClient { } Future> put(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) { + path = _resolveApiPath(path); return _dio.put(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); } Future> patch(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) { + path = _resolveApiPath(path); return _dio.patch(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); } Future> delete(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) { + path = _resolveApiPath(path); return _dio.delete(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); } @@ -165,4 +179,19 @@ class ApiClient { } } +// Utilities +String _resolveApiPath(String path) { + // Absolute URL → leave as is + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + // Ensure leading slash + final p = path.startsWith('/') ? path : '/$path'; + // If already versioned, keep + if (p.startsWith('/api/')) { + return p; + } + // Auto-prefix with api version + return '/api/v1$p'.replaceAll(RegExp(r'//+'), '/'); +} diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 0eb0819..e82cfd5 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -22,6 +22,7 @@ import 'pages/admin/email_settings_page.dart'; import 'pages/business/business_shell.dart'; import 'pages/business/dashboard/business_dashboard_page.dart'; import 'pages/business/users_permissions_page.dart'; +import 'pages/business/accounts_page.dart'; import 'pages/business/settings_page.dart'; import 'pages/business/persons_page.dart'; import 'pages/error_404_page.dart'; @@ -324,6 +325,19 @@ class _MyAppState extends State { // برای سایر صفحات (شامل صفحات profile و business)، redirect نکن (بماند) // این مهم است: اگر کاربر در صفحات profile یا business است، بماند print('🔍 REDIRECT DEBUG: On other page ($currentPath), staying on current path'); + // ذخیره مسیر فعلی به عنوان آخرین URL معتبر + if (currentPath.isNotEmpty && + currentPath != '/' && + currentPath != '/login' && + (currentPath.startsWith('/user/profile/') || currentPath.startsWith('/business/'))) { + try { + await _authStore!.saveLastUrl(currentPath); + print('🔍 REDIRECT DEBUG: Saved last URL: $currentPath'); + } catch (e) { + // صرفاً لاگ برای خطای غیر بحرانی ذخیره آدرس + print('🔍 REDIRECT DEBUG: Error saving last URL: $e'); + } + } return null; }, routes: [ @@ -354,7 +368,7 @@ class _MyAppState extends State { GoRoute( path: '/user/profile/new-business', name: 'profile_new_business', - builder: (context, state) => const NewBusinessPage(), + builder: (context, state) => NewBusinessPage(calendarController: _calendarController!), ), GoRoute( path: '/user/profile/businesses', @@ -509,6 +523,36 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: 'chart-of-accounts', + name: 'business_chart_of_accounts', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: AccountsPage(businessId: businessId), + ); + }, + ), + GoRoute( + path: 'accounts', + name: 'business_accounts', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: AccountsPage(businessId: businessId), + ); + }, + ), GoRoute( path: 'settings', name: 'business_settings', diff --git a/hesabixUI/hesabix_ui/lib/models/business_models.dart b/hesabixUI/hesabix_ui/lib/models/business_models.dart index cae500d..1f431e6 100644 --- a/hesabixUI/hesabix_ui/lib/models/business_models.dart +++ b/hesabixUI/hesabix_ui/lib/models/business_models.dart @@ -1,3 +1,4 @@ +import 'package:shamsi_date/shamsi_date.dart'; enum BusinessType { company('شرکت'), shop('مغازه'), @@ -43,6 +44,9 @@ class BusinessData { String? province; String? city; + // مرحله 5: سال(های) مالی + List fiscalYears; + BusinessData({ this.name = '', this.businessType, @@ -57,14 +61,16 @@ class BusinessData { this.country, this.province, this.city, - }); + List? fiscalYears, + }) : fiscalYears = fiscalYears ?? []; // تبدیل به Map برای ارسال به API Map toJson() { return { 'name': name, - 'business_type': businessType?.name, - 'business_field': businessField?.name, + // بک‌اند انتظار مقادیر فارسی enum را دارد + 'business_type': businessType?.displayName, + 'business_field': businessField?.displayName, 'address': address, 'phone': phone, 'mobile': mobile, @@ -75,6 +81,7 @@ class BusinessData { 'country': country, 'province': province, 'city': city, + 'fiscal_years': fiscalYears.map((e) => e.toJson()).toList(), }; } @@ -93,6 +100,7 @@ class BusinessData { String? country, String? province, String? city, + List? fiscalYears, }) { return BusinessData( name: name ?? this.name, @@ -108,6 +116,7 @@ class BusinessData { country: country ?? this.country, province: province ?? this.province, city: city ?? this.city, + fiscalYears: fiscalYears ?? this.fiscalYears, ); } @@ -147,14 +156,23 @@ class BusinessData { return true; } - // بررسی اعتبار مرحله 4 (اختیاری) + // بررسی اعتبار مرحله 4 (اطلاعات جغرافیایی - اختیاری) bool isStep4Valid() { - return true; // همه فیلدها اختیاری هستند + return true; + } + + // بررسی اعتبار مرحله 5 (سال مالی - اجباری) + bool isFiscalStepValid() { + if (fiscalYears.isEmpty) return false; + final fy = fiscalYears.first; + if (fy.title.trim().isEmpty || fy.startDate == null || fy.endDate == null) return false; + if (fy.startDate!.isAfter(fy.endDate!)) return false; + return true; } // بررسی اعتبار کل فرم bool isFormValid() { - return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid(); + return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid() && isFiscalStepValid(); } // اعتبارسنجی شماره موبایل ایرانی @@ -251,6 +269,29 @@ class BusinessData { } } +class FiscalYearData { + String title; + DateTime? startDate; + DateTime? endDate; + bool isLast; + + FiscalYearData({ + this.title = '', + this.startDate, + this.endDate, + this.isLast = true, + }); + + Map toJson() { + return { + 'title': title, + 'start_date': startDate?.toIso8601String().split('T').first, + 'end_date': endDate?.toIso8601String().split('T').first, + 'is_last': isLast, + }; + } +} + class BusinessResponse { final int id; final String name; @@ -307,8 +348,54 @@ class BusinessResponse { province: json['province'], city: json['city'], postalCode: json['postal_code'], - createdAt: DateTime.parse(json['created_at']), - updatedAt: DateTime.parse(json['updated_at']), + createdAt: _parseDateTime(json['created_at'] ?? json['created_at_raw']), + updatedAt: _parseDateTime(json['updated_at'] ?? json['updated_at_raw']), ); } + + static DateTime _parseDateTime(dynamic value) { + if (value == null) return DateTime.now(); + if (value is DateTime) return value; + if (value is int) { + // epoch ms + return DateTime.fromMillisecondsSinceEpoch(value); + } + if (value is String) { + // Jalali format: YYYY/MM/DD [HH:MM:SS] + if (value.contains('/') && !value.contains('-')) { + try { + final parts = value.split(' '); + final dateParts = parts[0].split('/'); + if (dateParts.length == 3) { + final year = int.parse(dateParts[0]); + final month = int.parse(dateParts[1]); + final day = int.parse(dateParts[2]); + int hour = 0, minute = 0, second = 0; + if (parts.length > 1) { + final timeParts = parts[1].split(':'); + if (timeParts.length >= 2) { + hour = int.parse(timeParts[0]); + minute = int.parse(timeParts[1]); + if (timeParts.length >= 3) { + second = int.parse(timeParts[2]); + } + } + } + final j = Jalali(year, month, day); + final dt = j.toDateTime(); + return DateTime(dt.year, dt.month, dt.day, hour, minute, second); + } + } catch (_) { + // fallthrough + } + } + // ISO or other parseable formats + try { + return DateTime.parse(value); + } catch (_) { + return DateTime.now(); + } + } + return DateTime.now(); + } } diff --git a/hesabixUI/hesabix_ui/lib/models/business_user_model.dart b/hesabixUI/hesabix_ui/lib/models/business_user_model.dart index f7753c8..7ec97aa 100644 --- a/hesabixUI/hesabix_ui/lib/models/business_user_model.dart +++ b/hesabixUI/hesabix_ui/lib/models/business_user_model.dart @@ -54,7 +54,7 @@ class BusinessUser { try { // Parse Jalali date format: YYYY/MM/DD HH:MM:SS final parts = dateValue.split(' '); - if (parts.length >= 1) { + if (parts.isNotEmpty) { final dateParts = parts[0].split('/'); if (dateParts.length == 3) { final year = int.parse(dateParts[0]); diff --git a/hesabixUI/hesabix_ui/lib/models/person_model.dart b/hesabixUI/hesabix_ui/lib/models/person_model.dart index 87e35c8..5b4b986 100644 --- a/hesabixUI/hesabix_ui/lib/models/person_model.dart +++ b/hesabixUI/hesabix_ui/lib/models/person_model.dart @@ -80,7 +80,8 @@ enum PersonType { employee('کارمند', 'Employee'), supplier('تامین‌کننده', 'Supplier'), partner('همکار', 'Partner'), - seller('فروشنده', 'Seller'); + seller('فروشنده', 'Seller'), + shareholder('سهامدار', 'Shareholder'); const PersonType(this.persianName, this.englishName); final String persianName; @@ -122,6 +123,15 @@ class Person { final DateTime createdAt; final DateTime updatedAt; final List bankAccounts; + final int? shareCount; + // پورسانت + final double? commissionSalePercent; + final double? commissionSalesReturnPercent; + final double? commissionSalesAmount; + final double? commissionSalesReturnAmount; + final bool commissionExcludeDiscounts; + final bool commissionExcludeAdditionsDeductions; + final bool commissionPostInInvoiceDocument; Person({ this.id, @@ -151,6 +161,14 @@ class Person { required this.createdAt, required this.updatedAt, this.bankAccounts = const [], + this.shareCount, + this.commissionSalePercent, + this.commissionSalesReturnPercent, + this.commissionSalesAmount, + this.commissionSalesReturnAmount, + this.commissionExcludeDiscounts = false, + this.commissionExcludeAdditionsDeductions = false, + this.commissionPostInInvoiceDocument = false, }); factory Person.fromJson(Map json) { @@ -191,6 +209,14 @@ class Person { bankAccounts: (json['bank_accounts'] as List?) ?.map((ba) => PersonBankAccount.fromJson(ba)) .toList() ?? [], + shareCount: json['share_count'], + commissionSalePercent: (json['commission_sale_percent'] as num?)?.toDouble(), + commissionSalesReturnPercent: (json['commission_sales_return_percent'] as num?)?.toDouble(), + commissionSalesAmount: (json['commission_sales_amount'] as num?)?.toDouble(), + commissionSalesReturnAmount: (json['commission_sales_return_amount'] as num?)?.toDouble(), + commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false, + commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false, + commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false, ); } @@ -223,6 +249,14 @@ class Person { 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), 'bank_accounts': bankAccounts.map((ba) => ba.toJson()).toList(), + 'share_count': shareCount, + 'commission_sale_percent': commissionSalePercent, + 'commission_sales_return_percent': commissionSalesReturnPercent, + 'commission_sales_amount': commissionSalesAmount, + 'commission_sales_return_amount': commissionSalesReturnAmount, + 'commission_exclude_discounts': commissionExcludeDiscounts, + 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions, + 'commission_post_in_invoice_document': commissionPostInInvoiceDocument, }; } @@ -320,6 +354,14 @@ class PersonCreateRequest { final String? email; final String? website; final List bankAccounts; + final int? shareCount; + final double? commissionSalePercent; + final double? commissionSalesReturnPercent; + final double? commissionSalesAmount; + final double? commissionSalesReturnAmount; + final bool? commissionExcludeDiscounts; + final bool? commissionExcludeAdditionsDeductions; + final bool? commissionPostInInvoiceDocument; PersonCreateRequest({ required this.aliasName, @@ -343,6 +385,14 @@ class PersonCreateRequest { this.email, this.website, this.bankAccounts = const [], + this.shareCount, + this.commissionSalePercent, + this.commissionSalesReturnPercent, + this.commissionSalesAmount, + this.commissionSalesReturnAmount, + this.commissionExcludeDiscounts, + this.commissionExcludeAdditionsDeductions, + this.commissionPostInInvoiceDocument, }); Map toJson() { @@ -377,6 +427,14 @@ class PersonCreateRequest { 'sheba_number': ba.shebaNumber, }) .toList(), + if (shareCount != null) 'share_count': shareCount, + if (commissionSalePercent != null) 'commission_sale_percent': commissionSalePercent, + if (commissionSalesReturnPercent != null) 'commission_sales_return_percent': commissionSalesReturnPercent, + if (commissionSalesAmount != null) 'commission_sales_amount': commissionSalesAmount, + if (commissionSalesReturnAmount != null) 'commission_sales_return_amount': commissionSalesReturnAmount, + if (commissionExcludeDiscounts != null) 'commission_exclude_discounts': commissionExcludeDiscounts, + if (commissionExcludeAdditionsDeductions != null) 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions, + if (commissionPostInInvoiceDocument != null) 'commission_post_in_invoice_document': commissionPostInInvoiceDocument, }; } } @@ -404,6 +462,14 @@ class PersonUpdateRequest { final String? email; final String? website; final bool? isActive; + final int? shareCount; + final double? commissionSalePercent; + final double? commissionSalesReturnPercent; + final double? commissionSalesAmount; + final double? commissionSalesReturnAmount; + final bool? commissionExcludeDiscounts; + final bool? commissionExcludeAdditionsDeductions; + final bool? commissionPostInInvoiceDocument; PersonUpdateRequest({ this.code, @@ -428,6 +494,14 @@ class PersonUpdateRequest { this.email, this.website, this.isActive, + this.shareCount, + this.commissionSalePercent, + this.commissionSalesReturnPercent, + this.commissionSalesAmount, + this.commissionSalesReturnAmount, + this.commissionExcludeDiscounts, + this.commissionExcludeAdditionsDeductions, + this.commissionPostInInvoiceDocument, }); Map toJson() { @@ -455,6 +529,14 @@ class PersonUpdateRequest { if (email != null) json['email'] = email; if (website != null) json['website'] = website; if (isActive != null) json['is_active'] = isActive; + if (shareCount != null) json['share_count'] = shareCount; + if (commissionSalePercent != null) json['commission_sale_percent'] = commissionSalePercent; + if (commissionSalesReturnPercent != null) json['commission_sales_return_percent'] = commissionSalesReturnPercent; + if (commissionSalesAmount != null) json['commission_sales_amount'] = commissionSalesAmount; + if (commissionSalesReturnAmount != null) json['commission_sales_return_amount'] = commissionSalesReturnAmount; + if (commissionExcludeDiscounts != null) json['commission_exclude_discounts'] = commissionExcludeDiscounts; + if (commissionExcludeAdditionsDeductions != null) json['commission_exclude_additions_deductions'] = commissionExcludeAdditionsDeductions; + if (commissionPostInInvoiceDocument != null) json['commission_post_in_invoice_document'] = commissionPostInInvoiceDocument; return json; } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart new file mode 100644 index 0000000..2171f62 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/api_client.dart'; + +class AccountsPage extends StatefulWidget { + final int businessId; + const AccountsPage({super.key, required this.businessId}); + + @override + State createState() => _AccountsPageState(); +} + +class _AccountsPageState extends State { + bool _loading = true; + String? _error; + List _tree = const []; + + @override + void initState() { + super.initState(); + _fetch(); + } + + Future _fetch() async { + setState(() { _loading = true; _error = null; }); + try { + final api = ApiClient(); + final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree'); + setState(() { _tree = res.data['data']['items'] ?? []; }); + } catch (e) { + setState(() { _error = e.toString(); }); + } finally { + setState(() { _loading = false; }); + } + } + + Widget _buildNode(Map node) { + final children = (node['children'] as List?) ?? const []; + if (children.isEmpty) { + return ListTile( + title: Text('${node['code']} - ${node['name']}'), + ); + } + return ExpansionTile( + title: Text('${node['code']} - ${node['name']}'), + children: children.map((c) => _buildNode(Map.from(c))).toList(), + ); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return Center(child: Text(_error!)); + return Scaffold( + appBar: AppBar(title: Text(t.chartOfAccounts)), + body: RefreshIndicator( + onRefresh: _fetch, + child: ListView( + children: _tree.map((n) => _buildNode(Map.from(n))).toList(), + ), + ), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index fdc1b86..ba88ea0 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -460,7 +460,7 @@ class _BusinessShellState extends State { context.go('/login'); } - Future _showAddPersonDialog() async { + Future showAddPersonDialog() async { final result = await showDialog( context: context, builder: (context) => PersonFormDialog( @@ -647,7 +647,7 @@ class _BusinessShellState extends State { // Navigate to add new item if (child.label == t.personsList) { // Navigate to add person - _showAddPersonDialog(); + showAddPersonDialog(); } else if (child.label == t.products) { // Navigate to add product } else if (child.label == t.priceLists) { @@ -802,7 +802,7 @@ class _BusinessShellState extends State { return GestureDetector( onTap: () { if (item.label == t.people) { - _showAddPersonDialog(); + showAddPersonDialog(); } // سایر مسیرهای افزودن در آینده متصل می‌شوند }, @@ -898,7 +898,7 @@ class _BusinessShellState extends State { context.pop(); // در حال حاضر فقط اشخاص پشتیبانی می‌شود if (item.label == t.people) { - _showAddPersonDialog(); + showAddPersonDialog(); } }, child: Container( diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index b347138..61ebb33 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -38,22 +38,6 @@ class _PersonsPageState extends State { } return Scaffold( - appBar: AppBar( - title: Text(t.personsList), - actions: [ - // دکمه اضافه کردن فقط در صورت داشتن دسترسی - PermissionButton( - section: 'people', - action: 'add', - authStore: widget.authStore, - child: IconButton( - onPressed: _addPerson, - icon: const Icon(Icons.add), - tooltip: t.addPerson, - ), - ), - ], - ), body: DataTableWidget( key: _personsTableKey, config: _buildDataTableConfig(t), @@ -66,6 +50,14 @@ class _PersonsPageState extends State { return DataTableConfig( endpoint: '/api/v1/persons/businesses/${widget.businessId}/persons', title: t.personsList, + excelEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/excel', + pdfEndpoint: '/api/v1/persons/businesses/${widget.businessId}/persons/export/pdf', + getExportParams: () => { + 'business_id': widget.businessId, + }, + showBackButton: true, + onBack: () => Navigator.of(context).maybePop(), + showTableIcon: false, showRowNumbers: true, enableRowSelection: true, columns: [ @@ -131,6 +123,105 @@ class _PersonsPageState extends State { 'تاریخ ایجاد', width: ColumnWidth.medium, ), + NumberColumn( + 'share_count', + 'تعداد سهام', + width: ColumnWidth.small, + textAlign: TextAlign.center, + decimalPlaces: 0, + ), + NumberColumn( + 'commission_sale_percent', + 'درصد پورسانت فروش', + width: ColumnWidth.medium, + decimalPlaces: 2, + suffix: '٪', + ), + NumberColumn( + 'commission_sales_return_percent', + 'درصد پورسانت برگشت از فروش', + width: ColumnWidth.medium, + decimalPlaces: 2, + suffix: '٪', + ), + NumberColumn( + 'commission_sales_amount', + 'مبلغ پورسانت فروش', + width: ColumnWidth.large, + decimalPlaces: 0, + ), + NumberColumn( + 'commission_sales_return_amount', + 'مبلغ پورسانت برگشت از فروش', + width: ColumnWidth.large, + decimalPlaces: 0, + ), + TextColumn( + 'payment_id', + t.personPaymentId, + width: ColumnWidth.medium, + formatter: (person) => person.paymentId ?? '-', + ), + TextColumn( + 'registration_number', + t.personRegistrationNumber, + width: ColumnWidth.medium, + formatter: (person) => person.registrationNumber ?? '-', + ), + TextColumn( + 'economic_id', + t.personEconomicId, + width: ColumnWidth.medium, + formatter: (person) => person.economicId ?? '-', + ), + TextColumn( + 'country', + t.personCountry, + width: ColumnWidth.medium, + formatter: (person) => person.country ?? '-', + ), + TextColumn( + 'province', + t.personProvince, + width: ColumnWidth.medium, + formatter: (person) => person.province ?? '-', + ), + TextColumn( + 'city', + t.personCity, + width: ColumnWidth.medium, + formatter: (person) => person.city ?? '-', + ), + TextColumn( + 'address', + t.personAddress, + width: ColumnWidth.extraLarge, + formatter: (person) => person.address ?? '-', + ), + TextColumn( + 'postal_code', + t.personPostalCode, + width: ColumnWidth.medium, + formatter: (person) => person.postalCode ?? '-', + ), + TextColumn( + 'phone', + t.personPhone, + width: ColumnWidth.medium, + formatter: (person) => person.phone ?? '-', + ), + TextColumn( + 'fax', + t.personFax, + width: ColumnWidth.medium, + formatter: (person) => person.fax ?? '-', + ), + TextColumn( + 'website', + t.personWebsite, + width: ColumnWidth.large, + formatter: (person) => person.website ?? '-', + ), ActionColumn( 'actions', 'عملیات', @@ -167,6 +258,21 @@ class _PersonsPageState extends State { 'province', ], defaultPageSize: 20, + // انتقال دکمه افزودن به اکشن‌های هدر جدول با کنترل دسترسی + customHeaderActions: [ + PermissionButton( + section: 'people', + action: 'add', + authStore: widget.authStore, + child: Tooltip( + message: t.addPerson, + child: IconButton( + onPressed: _addPerson, + icon: const Icon(Icons.add), + ), + ), + ), + ], ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart index 49a6f84..2ab360b 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/users_permissions_page.dart @@ -416,7 +416,7 @@ class _UsersPermissionsPageState extends State { Widget _buildUsersList(AppLocalizations t, ThemeData theme, ColorScheme colorScheme) { if (_loading) { - return Container( + return SizedBox( height: 200, child: Center( child: Column( @@ -439,7 +439,7 @@ class _UsersPermissionsPageState extends State { } if (_error != null) { - return Container( + return SizedBox( height: 200, child: Center( child: Column( @@ -475,7 +475,7 @@ class _UsersPermissionsPageState extends State { } if (_filteredUsers.isEmpty) { - return Container( + return SizedBox( height: 200, child: Center( child: Column( @@ -881,23 +881,23 @@ class _PermissionsDialogState extends State<_PermissionsDialog> { 'draft': '${t.draft} ${t.warehouseTransfers}', }, 'settings': { - 'business': '${t.businessSettings}', - 'print': '${t.printSettings}', - 'history': '${t.eventHistory}', - 'users': '${t.usersAndPermissions}', + 'business': t.businessSettings, + 'print': t.printSettings, + 'history': t.eventHistory, + 'users': t.usersAndPermissions, }, 'storage': { 'view': '${t.view} ${t.storageSpace}', 'delete': '${t.delete} ${t.deleteFiles}', }, 'sms': { - 'history': '${t.viewSmsHistory}', - 'templates': '${t.manageSmsTemplates}', + 'history': t.viewSmsHistory, + 'templates': t.manageSmsTemplates, }, 'marketplace': { - 'view': '${t.viewMarketplace}', - 'buy': '${t.buyPlugins}', - 'invoices': '${t.viewInvoices}', + 'view': t.viewMarketplace, + 'buy': t.buyPlugins, + 'invoices': t.viewInvoices, }, }; } @@ -1235,36 +1235,36 @@ class _PermissionsDialogState extends State<_PermissionsDialog> { String _inferCurrentSectionKey(String title, String description) { // جستجو بر اساس کلمات کلیدی ساده final pairs = >{ - 'people': ['${AppLocalizations.of(context).people}'], + 'people': [(AppLocalizations.of(context).people)], 'people_transactions': [ - '${AppLocalizations.of(context).receiptsAndPayments}', - '${AppLocalizations.of(context).receipts}', - '${AppLocalizations.of(context).payments}', + (AppLocalizations.of(context).receiptsAndPayments), + (AppLocalizations.of(context).receipts), + (AppLocalizations.of(context).payments), ], - 'products': ['${AppLocalizations.of(context).products}'], - 'price_lists': ['${AppLocalizations.of(context).priceLists}'], - 'categories': ['${AppLocalizations.of(context).categories}'], - 'product_attributes': ['${AppLocalizations.of(context).productAttributes}'], - 'bank_accounts': ['${AppLocalizations.of(context).bankAccounts}'], - 'cash': ['${AppLocalizations.of(context).cash}'], - 'petty_cash': ['${AppLocalizations.of(context).pettyCash}'], - 'checks': ['${AppLocalizations.of(context).checks}'], - 'wallet': ['${AppLocalizations.of(context).wallet}'], - 'transfers': ['${AppLocalizations.of(context).transfers}'], - 'invoices': ['${AppLocalizations.of(context).invoices}'], - 'expenses_income': ['${AppLocalizations.of(context).expensesIncome}'], - 'accounting_documents': ['${AppLocalizations.of(context).accountingDocuments}'], - 'chart_of_accounts': ['${AppLocalizations.of(context).chartOfAccounts}'], - 'opening_balance': ['${AppLocalizations.of(context).openingBalance}'], - 'warehouses': ['${AppLocalizations.of(context).warehouses}'], - 'warehouse_transfers': ['${AppLocalizations.of(context).warehouseTransfers}'], - 'settings': ['${AppLocalizations.of(context).businessSettings}'], - 'storage': ['${AppLocalizations.of(context).storageSpace}'], - 'sms': ['${AppLocalizations.of(context).smsPanel}'], - 'marketplace': ['${AppLocalizations.of(context).marketplace}'], + 'products': [(AppLocalizations.of(context).products)], + 'price_lists': [(AppLocalizations.of(context).priceLists)], + 'categories': [(AppLocalizations.of(context).categories)], + 'product_attributes': [(AppLocalizations.of(context).productAttributes)], + 'bank_accounts': [(AppLocalizations.of(context).bankAccounts)], + 'cash': [(AppLocalizations.of(context).cash)], + 'petty_cash': [(AppLocalizations.of(context).pettyCash)], + 'checks': [(AppLocalizations.of(context).checks)], + 'wallet': [(AppLocalizations.of(context).wallet)], + 'transfers': [(AppLocalizations.of(context).transfers)], + 'invoices': [(AppLocalizations.of(context).invoices)], + 'expenses_income': [(AppLocalizations.of(context).expensesIncome)], + 'accounting_documents': [(AppLocalizations.of(context).accountingDocuments)], + 'chart_of_accounts': [(AppLocalizations.of(context).chartOfAccounts)], + 'opening_balance': [(AppLocalizations.of(context).openingBalance)], + 'warehouses': [(AppLocalizations.of(context).warehouses)], + 'warehouse_transfers': [(AppLocalizations.of(context).warehouseTransfers)], + 'settings': [(AppLocalizations.of(context).businessSettings)], + 'storage': [(AppLocalizations.of(context).storageSpace)], + 'sms': [(AppLocalizations.of(context).smsPanel)], + 'marketplace': [(AppLocalizations.of(context).marketplace)], }; - final hay = (title + ' ' + description).toLowerCase(); + final hay = ('$title $description').toLowerCase(); for (final entry in pairs.entries) { for (final token in entry.value) { if (hay.contains(token.toLowerCase())) { 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 99c13be..618e02d 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:shamsi_date/shamsi_date.dart'; import '../../models/business_models.dart'; import '../../services/business_api_service.dart'; +import '../../core/calendar_controller.dart'; +import '../../widgets/date_input_field.dart'; +import '../../core/date_utils.dart'; class NewBusinessPage extends StatefulWidget { - const NewBusinessPage({super.key}); + final CalendarController calendarController; + const NewBusinessPage({super.key, required this.calendarController}); @override State createState() => _NewBusinessPageState(); @@ -16,15 +21,162 @@ class _NewBusinessPageState extends State { final BusinessData _businessData = BusinessData(); int _currentStep = 0; bool _isLoading = false; + int _fiscalTabIndex = 0; + late TextEditingController _fiscalTitleController; + + @override + void initState() { + super.initState(); + widget.calendarController.addListener(_onCalendarChanged); + _fiscalTitleController = TextEditingController(); + // Set default selections for business type and field + _businessData.businessType ??= BusinessType.shop; + _businessData.businessField ??= BusinessField.commercial; + } @override void dispose() { + widget.calendarController.removeListener(_onCalendarChanged); _pageController.dispose(); + _fiscalTitleController.dispose(); super.dispose(); } + void _onCalendarChanged() { + if (_businessData.fiscalYears.isEmpty) return; + final fiscal = _businessData.fiscalYears[_fiscalTabIndex]; + if (fiscal.endDate != null) { + const autoPrefix = 'سال مالی منتهی به'; + if (fiscal.title.trim().isEmpty || fiscal.title.trim().startsWith(autoPrefix)) { + setState(() { + final isJalali = widget.calendarController.isJalali; + final endStr = HesabixDateUtils.formatForDisplay(fiscal.endDate, isJalali); + fiscal.title = '$autoPrefix $endStr'; + _fiscalTitleController.text = fiscal.title; + }); + } + } + } + + Widget _buildFiscalStep() { + if (_businessData.fiscalYears.isEmpty) { + _businessData.fiscalYears.add(FiscalYearData(isLast: true)); + } + final fiscal = _businessData.fiscalYears[_fiscalTabIndex]; + + String _autoTitle() { + final isJalali = widget.calendarController.isJalali; + final end = fiscal.endDate; + if (end == null) return fiscal.title; + final endStr = HesabixDateUtils.formatForDisplay(end, isJalali); + return 'سال مالی منتهی به $endStr'; + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'سال مالی', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).dividerColor.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: DateInputField( + value: fiscal.startDate, + labelText: 'تاریخ شروع *', + lastDate: fiscal.endDate, + calendarController: widget.calendarController, + onChanged: (d) { + setState(() { + fiscal.startDate = d; + if (fiscal.startDate != null) { + if (widget.calendarController.isJalali) { + final j = Jalali.fromDateTime(fiscal.startDate!); + final jNext = Jalali(j.year + 1, j.month, j.day); + fiscal.endDate = jNext.toDateTime(); + } else { + final s = fiscal.startDate!; + fiscal.endDate = DateTime(s.year + 1, s.month, s.day); + } + fiscal.title = _autoTitle(); + _fiscalTitleController.text = fiscal.title; + } + }); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DateInputField( + value: fiscal.endDate, + labelText: 'تاریخ پایان *', + firstDate: fiscal.startDate, + calendarController: widget.calendarController, + onChanged: (d) { + setState(() { + fiscal.endDate = d; + if (fiscal.title.trim().isEmpty || fiscal.title.startsWith('سال مالی منتهی به')) { + fiscal.title = _autoTitle(); + _fiscalTitleController.text = fiscal.title; + } + }); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _fiscalTitleController, + decoration: const InputDecoration( + labelText: 'عنوان سال مالی *', + border: OutlineInputBorder(), + ), + onChanged: (v) { + setState(() { + fiscal.title = v; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Text( + 'پرکردن عنوان، تاریخ شروع و پایان الزامی است.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ) + ], + ), + ), + ), + ); + } void _nextStep() { - if (_currentStep < 3) { + if (_currentStep < 4) { setState(() { _currentStep++; }); @@ -66,6 +218,8 @@ class _NewBusinessPageState extends State { return _businessData.isStep2Valid(); case 2: return _businessData.isStep3Valid(); + case 3: + return _businessData.isFiscalStepValid(); default: return false; } @@ -84,6 +238,8 @@ class _NewBusinessPageState extends State { case 2: return t.businessLegalInfo; case 3: + return 'سال مالی'; + case 4: return t.businessConfirmation; default: return ''; @@ -122,7 +278,7 @@ class _NewBusinessPageState extends State { duration: const Duration(seconds: 2), ), ); - context.pop(); + context.goNamed('profile_businesses'); } } catch (e) { if (mounted) { @@ -170,7 +326,7 @@ class _NewBusinessPageState extends State { children: [ // Progress bar Row( - children: List.generate(4, (index) { + children: List.generate(5, (index) { final isActive = index <= _currentStep; final isCurrent = index == _currentStep; @@ -203,7 +359,7 @@ class _NewBusinessPageState extends State { const SizedBox(height: 8), // Progress text Text( - '${t.step} ${_currentStep + 1} ${t.ofText} 4', + '${t.step} ${_currentStep + 1} ${t.ofText} 5', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -223,7 +379,8 @@ class _NewBusinessPageState extends State { _buildStepIndicator(0, t.businessBasicInfo), _buildStepIndicator(1, t.businessContactInfo), _buildStepIndicator(2, t.businessLegalInfo), - _buildStepIndicator(3, t.businessConfirmation), + _buildStepIndicator(3, 'سال مالی'), + _buildStepIndicator(4, t.businessConfirmation), ], ), ), @@ -293,6 +450,7 @@ class _NewBusinessPageState extends State { _buildStep1(), _buildStep2(), _buildStep3(), + _buildFiscalStep(), _buildStep4(), ], ), @@ -326,9 +484,9 @@ class _NewBusinessPageState extends State { SizedBox( width: double.infinity, child: _buildNavigationButton( - text: _currentStep < 3 ? t.next : t.createBusiness, - icon: _currentStep < 3 ? Icons.arrow_forward_ios : Icons.check, - onPressed: _currentStep < 3 + text: _currentStep < 4 ? t.next : t.createBusiness, + icon: _currentStep < 4 ? Icons.arrow_forward_ios : Icons.check, + onPressed: _currentStep < 4 ? (_canGoToNextStep() ? _nextStep : null) : (_isLoading ? null : _submitBusiness), isPrimary: true, @@ -361,7 +519,7 @@ class _NewBusinessPageState extends State { ), Row( children: [ - if (_currentStep < 3) ...[ + if (_currentStep < 4) ...[ _buildNavigationButton( text: t.next, icon: Icons.arrow_forward_ios, @@ -1381,6 +1539,8 @@ class _NewBusinessPageState extends State { _buildSummaryItem(t.city, _businessData.city!), if (_businessData.postalCode?.isNotEmpty == true) _buildSummaryItem(t.postalCode, _businessData.postalCode!), + if (_businessData.fiscalYears.isNotEmpty) + _buildSummaryItem('سال مالی', _businessData.fiscalYears.first.title), ], ), ), diff --git a/hesabixUI/hesabix_ui/lib/services/person_service.dart b/hesabixUI/hesabix_ui/lib/services/person_service.dart index 8149b40..079175b 100644 --- a/hesabixUI/hesabix_ui/lib/services/person_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/person_service.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import '../core/api_client.dart'; import '../models/person_model.dart'; diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart index 829c4ad..4133600 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -193,6 +193,10 @@ class DataTableConfig { final String? dateRangeField; final String? title; final String? subtitle; + // Header controls + final bool showBackButton; + final VoidCallback? onBack; + final bool showTableIcon; final bool showSearch; final bool showFilters; final bool showPagination; @@ -267,6 +271,9 @@ class DataTableConfig { this.dateRangeField, this.title, this.subtitle, + this.showBackButton = false, + this.onBack, + this.showTableIcon = true, this.showSearch = true, this.showFilters = false, this.showPagination = true, @@ -395,13 +402,27 @@ class DataTableResponse { ) { final data = json['data'] as Map; final itemsList = data['items'] as List? ?? []; + // Support both old and new pagination shapes + final pagination = data['pagination'] as Map?; + final total = pagination != null + ? (pagination['total'] as num?)?.toInt() ?? 0 + : (data['total'] as num?)?.toInt() ?? 0; + final page = pagination != null + ? (pagination['page'] as num?)?.toInt() ?? 1 + : (data['page'] as num?)?.toInt() ?? 1; + final limit = pagination != null + ? (pagination['per_page'] as num?)?.toInt() ?? 20 + : (data['limit'] as num?)?.toInt() ?? 20; + final totalPages = pagination != null + ? (pagination['total_pages'] as num?)?.toInt() ?? 0 + : (data['total_pages'] as num?)?.toInt() ?? 0; return DataTableResponse( items: itemsList.map((item) => fromJsonT(item as Map)).toList(), - total: (data['total'] as num?)?.toInt() ?? 0, - page: (data['page'] as num?)?.toInt() ?? 1, - limit: (data['limit'] as num?)?.toInt() ?? 20, - totalPages: (data['total_pages'] as num?)?.toInt() ?? 0, + total: total, + page: page, + limit: limit, + totalPages: totalPages, ); } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index ac42160..af453ad 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'helpers/file_saver.dart'; // import 'dart:html' as html; // Not available on Linux import 'package:flutter/material.dart'; import 'package:data_table_2/data_table_2.dart'; @@ -561,6 +563,16 @@ class _DataTableWidgetState extends State> { params['selected_indices'] = _selectedRows.toList(); } + // Add export columns in current visible order (excluding ActionColumn) + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + params['export_columns'] = dataColumnsToShow.map((c) => { + 'key': c.key, + 'label': c.label, + }).toList(); + // Add custom export parameters if provided if (widget.config.getExportParams != null) { final customParams = widget.config.getExportParams!(); @@ -620,17 +632,25 @@ class _DataTableWidgetState extends State> { } } - // Platform-specific download functions for Linux + // Cross-platform save using conditional FileSaver + Future _saveBytesToDownloads(dynamic data, String filename) async { + List bytes; + if (data is List) { + bytes = data; + } else if (data is Uint8List) { + bytes = data.toList(); + } else { + throw Exception('Unsupported binary data type: ${data.runtimeType}'); + } + await FileSaver.saveBytes(bytes, filename); + } + Future _downloadPdf(dynamic data, String filename) async { - // For Linux desktop, we'll save to Downloads folder - debugPrint('Download PDF: $filename (Linux desktop - save to Downloads folder)'); - // TODO: Implement proper file saving for Linux + await _saveBytesToDownloads(data, filename); } Future _downloadExcel(dynamic data, String filename) async { - // For Linux desktop, we'll save to Downloads folder - debugPrint('Download Excel: $filename (Linux desktop - save to Downloads folder)'); - // TODO: Implement proper file saving for Linux + await _saveBytesToDownloads(data, filename); } @@ -641,15 +661,16 @@ class _DataTableWidgetState extends State> { return Card( elevation: widget.config.boxShadow != null ? 2 : 0, - shape: widget.config.borderRadius != null - ? RoundedRectangleBorder(borderRadius: widget.config.borderRadius!) - : null, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), + ), child: Container( padding: widget.config.padding ?? const EdgeInsets.all(16), margin: widget.config.margin, decoration: BoxDecoration( color: widget.config.backgroundColor, - borderRadius: widget.config.borderRadius, + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), border: widget.config.showBorder ? Border.all( color: widget.config.borderColor ?? theme.dividerColor, @@ -719,19 +740,35 @@ class _DataTableWidgetState extends State> { Widget _buildHeader(AppLocalizations t, ThemeData theme) { return Row( children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(6), + if (widget.config.showBackButton) ...[ + Tooltip( + message: MaterialLocalizations.of(context).backButtonTooltip, + child: IconButton( + onPressed: widget.config.onBack ?? () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.arrow_back), + ), ), - child: Icon( - Icons.table_chart, - color: theme.colorScheme.onPrimaryContainer, - size: 18, + const SizedBox(width: 8), + ], + if (widget.config.showTableIcon) ...[ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.table_chart, + color: theme.colorScheme.onPrimaryContainer, + size: 18, + ), ), - ), - const SizedBox(width: 12), + const SizedBox(width: 12), + ], Text( widget.config.title!, style: theme.textTheme.titleMedium?.copyWith( diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart index a706659..2da036d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart @@ -105,9 +105,14 @@ class ColumnSettingsService { } // Ensure all default columns are present in visible columns + // If new columns are added (not in user settings), include them by default final visibleColumns = []; + final userVisible = Set.from(userSettings.visibleColumns); for (final key in defaultColumnKeys) { - if (userSettings.visibleColumns.contains(key)) { + if (userVisible.contains(key)) { + visibleColumns.add(key); + } else { + // New column introduced → show by default visibleColumns.add(key); } } @@ -117,15 +122,13 @@ class ColumnSettingsService { visibleColumns.add(defaultColumnKeys.first); } - // Ensure all visible columns are in the correct order + // Build columnOrder: keep user's order for known columns, append new ones at the end final columnOrder = []; for (final key in userSettings.columnOrder) { if (visibleColumns.contains(key)) { columnOrder.add(key); } } - - // Add any missing visible columns to the end for (final key in visibleColumns) { if (!columnOrder.contains(key)) { columnOrder.add(key); diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart new file mode 100644 index 0000000..e7bf083 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver.dart @@ -0,0 +1,4 @@ +// Conditional export of platform-specific implementations +export 'file_saver_io.dart' if (dart.library.html) 'file_saver_web.dart'; + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart new file mode 100644 index 0000000..75a2f93 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_io.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +class FileSaver { + static Future saveBytes(List bytes, String filename) async { + final homeDir = Platform.environment['HOME'] ?? Directory.current.path; + final downloadsDir = Directory('$homeDir/Downloads'); + if (!await downloadsDir.exists()) { + await downloadsDir.create(recursive: true); + } + final file = File('${downloadsDir.path}/$filename'); + await file.writeAsBytes(bytes, flush: true); + return file.path; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart new file mode 100644 index 0000000..d002fdc --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/file_saver_web.dart @@ -0,0 +1,15 @@ +import 'dart:html' as html; + +class FileSaver { + static Future saveBytes(List bytes, String filename) async { + final blob = html.Blob([bytes]); + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..setAttribute('download', filename) + ..click(); + html.Url.revokeObjectUrl(url); + return null; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart index 5db1b42..57a344f 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart @@ -31,6 +31,13 @@ class _JalaliDatePickerState extends State { void initState() { super.initState(); _selectedDate = widget.initialDate ?? DateTime.now(); + // Clamp initial within range if provided + if (widget.firstDate != null && _selectedDate.isBefore(widget.firstDate!)) { + _selectedDate = widget.firstDate!; + } + if (widget.lastDate != null && _selectedDate.isAfter(widget.lastDate!)) { + _selectedDate = widget.lastDate!; + } _selectedJalali = Jalali.fromDateTime(_selectedDate); } @@ -218,6 +225,10 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> { } void _selectDate(Jalali date) { + // Enforce range limits + if (date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0) { + return; + } setState(() { _selectedDate = date; }); @@ -305,18 +316,23 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> { date.month == Jalali.now().month && date.day == Jalali.now().day; + final isDisabled = date.compareTo(widget.firstDate) < 0 || date.compareTo(widget.lastDate) > 0; return GestureDetector( - onTap: () => _selectDate(date), + onTap: isDisabled ? null : () => _selectDate(date), child: Container( margin: const EdgeInsets.all(2), decoration: BoxDecoration( - color: isSelected + color: isDisabled + ? theme.disabledColor.withValues(alpha: 0.1) + : isSelected ? theme.colorScheme.primary : isToday ? theme.colorScheme.primary.withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(8), - border: isToday && !isSelected + border: isDisabled + ? Border.all(color: theme.disabledColor.withValues(alpha: 0.3), width: 1) + : isToday && !isSelected ? Border.all(color: theme.colorScheme.primary, width: 1) : null, ), @@ -324,7 +340,9 @@ class _CustomPersianCalendarState extends State<_CustomPersianCalendar> { child: Text( day.toString(), style: theme.textTheme.bodyMedium?.copyWith( - color: isSelected + color: isDisabled + ? theme.disabledColor + : isSelected ? theme.colorScheme.onPrimary : isToday ? theme.colorScheme.primary diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart index c51557c..dd4880b 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:flutter/services.dart'; import '../../models/person_model.dart'; import '../../services/person_service.dart'; @@ -51,6 +52,15 @@ class _PersonFormDialogState extends State { final _faxController = TextEditingController(); final _emailController = TextEditingController(); final _websiteController = TextEditingController(); + final _shareCountController = TextEditingController(); + // Commission controllers & state + final _commissionSalePercentController = TextEditingController(); + final _commissionSalesReturnPercentController = TextEditingController(); + final _commissionSalesAmountController = TextEditingController(); + final _commissionSalesReturnAmountController = TextEditingController(); + bool _commissionExcludeDiscounts = false; + bool _commissionExcludeAdditionsDeductions = false; + bool _commissionPostInInvoiceDocument = false; PersonType _selectedPersonType = PersonType.customer; // legacy single select (for compatibility) final Set _selectedPersonTypes = {}; @@ -96,6 +106,26 @@ class _PersonFormDialogState extends State { ..addAll(person.personTypes.isNotEmpty ? person.personTypes : [person.personType]); _isActive = person.isActive; _bankAccounts = List.from(person.bankAccounts); + // مقدار اولیه سهام + if (person.personTypes.contains(PersonType.shareholder) && person.shareCount != null) { + _shareCountController.text = person.shareCount!.toString(); + } + // مقدار اولیه پورسانت + if (person.commissionSalePercent != null) { + _commissionSalePercentController.text = person.commissionSalePercent!.toString(); + } + if (person.commissionSalesReturnPercent != null) { + _commissionSalesReturnPercentController.text = person.commissionSalesReturnPercent!.toString(); + } + if (person.commissionSalesAmount != null) { + _commissionSalesAmountController.text = person.commissionSalesAmount!.toString(); + } + if (person.commissionSalesReturnAmount != null) { + _commissionSalesReturnAmountController.text = person.commissionSalesReturnAmount!.toString(); + } + _commissionExcludeDiscounts = person.commissionExcludeDiscounts; + _commissionExcludeAdditionsDeductions = person.commissionExcludeAdditionsDeductions; + _commissionPostInInvoiceDocument = person.commissionPostInInvoiceDocument; } } @@ -120,6 +150,11 @@ class _PersonFormDialogState extends State { _faxController.dispose(); _emailController.dispose(); _websiteController.dispose(); + _shareCountController.dispose(); + _commissionSalePercentController.dispose(); + _commissionSalesReturnPercentController.dispose(); + _commissionSalesAmountController.dispose(); + _commissionSalesReturnAmountController.dispose(); super.dispose(); } @@ -136,7 +171,7 @@ class _PersonFormDialogState extends State { final personData = PersonCreateRequest( code: _autoGenerateCode ? null - : (int.tryParse(_codeController.text.trim()) ?? null), + : (int.tryParse(_codeController.text.trim())), aliasName: _aliasNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), @@ -157,6 +192,31 @@ class _PersonFormDialogState extends State { email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(), bankAccounts: _bankAccounts, + shareCount: _selectedPersonTypes.contains(PersonType.shareholder) + ? int.tryParse(_shareCountController.text.trim()) + : null, + // commission fields only if marketer or seller + commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalePercentController.text.trim()) + : null, + commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesReturnPercentController.text.trim()) + : null, + commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesAmountController.text.trim()) + : null, + commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesReturnAmountController.text.trim()) + : null, + commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionExcludeDiscounts + : null, + commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionExcludeAdditionsDeductions + : null, + commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionPostInInvoiceDocument + : null, ); await _personService.createPerson( @@ -166,7 +226,7 @@ class _PersonFormDialogState extends State { } else { // Update existing person final personData = PersonUpdateRequest( - code: (int.tryParse(_codeController.text.trim()) ?? null), + code: (int.tryParse(_codeController.text.trim())), aliasName: _aliasNameController.text.trim(), firstName: _firstNameController.text.trim().isEmpty ? null : _firstNameController.text.trim(), lastName: _lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(), @@ -188,6 +248,30 @@ class _PersonFormDialogState extends State { email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(), isActive: _isActive, + shareCount: _selectedPersonTypes.contains(PersonType.shareholder) + ? int.tryParse(_shareCountController.text.trim()) + : null, + commissionSalePercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalePercentController.text.trim()) + : null, + commissionSalesReturnPercent: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesReturnPercentController.text.trim()) + : null, + commissionSalesAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesAmountController.text.trim()) + : null, + commissionSalesReturnAmount: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? double.tryParse(_commissionSalesReturnAmountController.text.trim()) + : null, + commissionExcludeDiscounts: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionExcludeDiscounts + : null, + commissionExcludeAdditionsDeductions: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionExcludeAdditionsDeductions + : null, + commissionPostInInvoiceDocument: (_selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller)) + ? _commissionPostInInvoiceDocument + : null, ); await _personService.updatePerson( @@ -288,53 +372,68 @@ class _PersonFormDialogState extends State { Expanded( child: Form( key: _formKey, - child: DefaultTabController( - length: 4, - child: Column( - children: [ - TabBar( - isScrollable: true, - tabs: [ - Tab(text: t.personBasicInfo), - Tab(text: t.personEconomicInfo), - Tab(text: t.personContactInfo), - Tab(text: t.personBankInfo), - ], + child: Builder(builder: (context) { + final hasCommissionTab = _selectedPersonTypes.contains(PersonType.marketer) || _selectedPersonTypes.contains(PersonType.seller); + final tabs = [ + Tab(text: t.personBasicInfo), + Tab(text: t.personEconomicInfo), + Tab(text: t.personContactInfo), + Tab(text: t.personBankInfo), + ]; + final views = [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBasicInfoFields(t), ), - const SizedBox(height: 12), - Expanded( - child: TabBarView( - children: [ - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: _buildBasicInfoFields(t), - ), - ), - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: _buildEconomicInfoFields(t), - ), - ), - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: _buildContactInfoFields(t), - ), - ), - SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: _buildBankAccountsSection(t), - ), - ), - ], + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildEconomicInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildContactInfoFields(t), + ), + ), + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildBankAccountsSection(t), + ), + ), + ]; + if (hasCommissionTab) { + tabs.add(const Tab(text: 'پورسانت')); + views.add( + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: _buildCommissionTab(), ), ), - ], - ), - ), + ); + } + return DefaultTabController( + key: ValueKey(tabs.length), + length: tabs.length, + child: Column( + children: [ + TabBar( + isScrollable: true, + tabs: tabs, + ), + const SizedBox(height: 12), + Expanded( + child: TabBarView(children: views), + ), + ], + ), + ); + }), ), ), @@ -367,6 +466,128 @@ class _PersonFormDialogState extends State { ); } + Widget _buildCommissionTab() { + final isMarketer = _selectedPersonTypes.contains(PersonType.marketer); + final isSeller = _selectedPersonTypes.contains(PersonType.seller); + if (!isMarketer && !isSeller) { + return Center( + child: Text('این بخش فقط برای بازاریاب/فروشنده نمایش داده می‌شود'), + ); + } + + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _commissionSalePercentController, + decoration: const InputDecoration( + labelText: 'درصد از فروش', + suffixText: '%', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + validator: (v) { + if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) { + final num? val = num.tryParse(v); + if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _commissionSalesReturnPercentController, + decoration: const InputDecoration( + labelText: 'درصد از برگشت از فروش', + suffixText: '%', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + validator: (v) { + if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) { + final num? val = num.tryParse(v); + if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _commissionSalesAmountController, + decoration: const InputDecoration( + labelText: 'مبلغ فروش', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + validator: (v) { + if (v != null && v.isNotEmpty) { + final num? val = num.tryParse(v); + if (val == null || val < 0) return 'باید عدد مثبت باشد'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _commissionSalesReturnAmountController, + decoration: const InputDecoration( + labelText: 'مبلغ برگشت از فروش', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))], + validator: (v) { + if (v != null && v.isNotEmpty) { + final num? val = num.tryParse(v); + if (val == null || val < 0) return 'باید عدد مثبت باشد'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SwitchListTile( + title: const Text('عدم محاسبه تخفیف'), + value: _commissionExcludeDiscounts, + onChanged: (v) { setState(() { _commissionExcludeDiscounts = v; }); }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: SwitchListTile( + title: const Text('عدم محاسبه اضافات و کسورات فاکتور'), + value: _commissionExcludeAdditionsDeductions, + onChanged: (v) { setState(() { _commissionExcludeAdditionsDeductions = v; }); }, + ), + ), + ], + ), + const SizedBox(height: 12), + SwitchListTile( + title: const Text('ثبت پورسانت در سند حسابداری فاکتور'), + value: _commissionPostInInvoiceDocument, + onChanged: (v) { setState(() { _commissionPostInInvoiceDocument = v; }); }, + ), + ], + ); + } + Widget _buildSectionHeader(String title) { return Text( title, @@ -457,6 +678,34 @@ class _PersonFormDialogState extends State { ], ), const SizedBox(height: 8), + if (_selectedPersonTypes.contains(PersonType.shareholder)) + Row( + children: [ + Expanded( + child: TextFormField( + controller: _shareCountController, + decoration: const InputDecoration( + labelText: 'تعداد سهام', + hintText: 'عدد صحیح بدون اعشار', + ), + keyboardType: TextInputType.number, + validator: (value) { + if (_selectedPersonTypes.contains(PersonType.shareholder)) { + if (value == null || value.trim().isEmpty) { + return 'برای سهامدار، تعداد سهام الزامی است'; + } + final parsed = int.tryParse(value.trim()); + if (parsed == null || parsed <= 0) { + return 'تعداد سهام باید عدد صحیح بزرگتر از صفر باشد'; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 8), Row( children: [ Expanded( diff --git a/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart b/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart index 2888de5..41dfa59 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/url_tracker.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import '../core/auth_store.dart'; class UrlTracker extends StatefulWidget { @@ -29,7 +28,7 @@ class _UrlTrackerState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { try { - final currentUrl = GoRouterState.of(context).uri.path; + final currentUrl = Uri.base.path; if (currentUrl != _lastTrackedUrl && currentUrl.isNotEmpty && currentUrl != '/' &&