From 13632704453a7a9c0811ffeebe11fec65b7a9fd7 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Thu, 2 Oct 2025 03:21:43 +0330 Subject: [PATCH] progress in products --- hesabixAPI/adapters/api/v1/categories.py | 36 + hesabixAPI/adapters/api/v1/currencies.py | 63 +- hesabixAPI/adapters/api/v1/persons.py | 45 +- hesabixAPI/adapters/api/v1/price_lists.py | 4 +- hesabixAPI/adapters/api/v1/products.py | 593 ++++++++++- .../api/v1/schema_models/price_list.py | 12 +- .../adapters/api/v1/schema_models/product.py | 56 ++ hesabixAPI/adapters/api/v1/tax_units.py | 5 + hesabixAPI/adapters/db/models/price_list.py | 6 +- .../repositories/business_permission_repo.py | 27 +- .../db/repositories/category_repository.py | 54 +- .../db/repositories/price_list_repository.py | 18 +- .../db/repositories/product_repository.py | 34 +- hesabixAPI/app/core/auth_dependency.py | 24 +- hesabixAPI/app/core/permissions.py | 8 +- hesabixAPI/app/core/responses.py | 10 +- hesabixAPI/app/main.py | 2 + .../app/services/bulk_price_update_service.py | 233 +++++ hesabixAPI/app/services/business_service.py | 23 +- hesabixAPI/app/services/price_list_service.py | 20 +- hesabixAPI/app/services/product_service.py | 4 +- hesabixAPI/build/lib/adapters/__init__.py | 0 hesabixAPI/build/lib/adapters/api/__init__.py | 0 .../build/lib/adapters/api/v1/__init__.py | 5 + .../build/lib/adapters/api/v1/accounts.py | 57 ++ .../lib/adapters/api/v1/admin/email_config.py | 349 +++++++ .../lib/adapters/api/v1/admin/file_storage.py | 725 ++++++++++++++ hesabixAPI/build/lib/adapters/api/v1/auth.py | 936 +++++++++++++++++ .../lib/adapters/api/v1/business_dashboard.py | 293 ++++++ .../lib/adapters/api/v1/business_users.py | 564 +++++++++++ .../build/lib/adapters/api/v1/businesses.py | 320 ++++++ .../build/lib/adapters/api/v1/categories.py | 148 +++ .../build/lib/adapters/api/v1/currencies.py | 30 + .../build/lib/adapters/api/v1/health.py | 30 + .../build/lib/adapters/api/v1/persons.py | 945 ++++++++++++++++++ .../build/lib/adapters/api/v1/price_lists.py | 165 +++ .../lib/adapters/api/v1/product_attributes.py | 124 +++ .../build/lib/adapters/api/v1/products.py | 509 ++++++++++ .../adapters/api/v1/schema_models/__init__.py | 10 + .../adapters/api/v1/schema_models/account.py | 19 + .../adapters/api/v1/schema_models/email.py | 59 ++ .../api/v1/schema_models/file_storage.py | 80 ++ .../adapters/api/v1/schema_models/person.py | 242 +++++ .../api/v1/schema_models/price_list.py | 54 + .../adapters/api/v1/schema_models/product.py | 110 ++ .../api/v1/schema_models/product_attribute.py | 30 + .../build/lib/adapters/api/v1/schemas.py | 339 +++++++ .../lib/adapters/api/v1/support/__init__.py | 1 + .../lib/adapters/api/v1/support/categories.py | 29 + .../lib/adapters/api/v1/support/operator.py | 296 ++++++ .../lib/adapters/api/v1/support/priorities.py | 29 + .../lib/adapters/api/v1/support/schemas.py | 134 +++ .../lib/adapters/api/v1/support/statuses.py | 29 + .../lib/adapters/api/v1/support/tickets.py | 256 +++++ .../build/lib/adapters/api/v1/tax_types.py | 49 + .../build/lib/adapters/api/v1/tax_units.py | 387 +++++++ hesabixAPI/build/lib/adapters/api/v1/users.py | 362 +++++++ hesabixAPI/build/lib/adapters/db/__init__.py | 0 .../build/lib/adapters/db/models/__init__.py | 38 + .../build/lib/adapters/db/models/account.py | 32 + .../build/lib/adapters/db/models/api_key.py | 28 + .../build/lib/adapters/db/models/business.py | 64 ++ .../adapters/db/models/business_permission.py | 19 + .../build/lib/adapters/db/models/captcha.py | 20 + .../build/lib/adapters/db/models/category.py | 32 + .../build/lib/adapters/db/models/currency.py | 43 + .../build/lib/adapters/db/models/document.py | 37 + .../lib/adapters/db/models/document_line.py | 30 + .../lib/adapters/db/models/email_config.py | 27 + .../lib/adapters/db/models/file_storage.py | 71 ++ .../lib/adapters/db/models/fiscal_year.py | 26 + .../lib/adapters/db/models/password_reset.py | 21 + .../build/lib/adapters/db/models/person.py | 100 ++ .../lib/adapters/db/models/price_list.py | 51 + .../build/lib/adapters/db/models/product.py | 87 ++ .../adapters/db/models/product_attribute.py | 30 + .../db/models/product_attribute_link.py | 24 + .../adapters/db/models/support/__init__.py | 8 + .../adapters/db/models/support/category.py | 23 + .../lib/adapters/db/models/support/message.py | 35 + .../adapters/db/models/support/priority.py | 24 + .../lib/adapters/db/models/support/status.py | 24 + .../lib/adapters/db/models/support/ticket.py | 40 + .../build/lib/adapters/db/models/tax_unit.py | 24 + .../build/lib/adapters/db/models/user.py | 35 + .../adapters/db/repositories/api_key_repo.py | 27 + .../lib/adapters/db/repositories/base_repo.py | 64 ++ .../repositories/business_permission_repo.py | 120 +++ .../adapters/db/repositories/business_repo.py | 145 +++ .../db/repositories/category_repository.py | 92 ++ .../repositories/email_config_repository.py | 85 ++ .../repositories/file_storage_repository.py | 301 ++++++ .../db/repositories/fiscal_year_repo.py | 37 + .../db/repositories/password_reset_repo.py | 30 + .../db/repositories/price_list_repository.py | 158 +++ .../product_attribute_repository.py | 96 ++ .../db/repositories/product_repository.py | 111 ++ .../db/repositories/support/__init__.py | 1 + .../support/category_repository.py | 19 + .../support/message_repository.py | 78 ++ .../support/priority_repository.py | 18 + .../repositories/support/status_repository.py | 25 + .../repositories/support/ticket_repository.py | 262 +++++ .../lib/adapters/db/repositories/user_repo.py | 111 ++ hesabixAPI/build/lib/adapters/db/session.py | 23 + hesabixAPI/build/lib/app/__init__.py | 0 hesabixAPI/build/lib/app/core/__init__.py | 0 .../build/lib/app/core/auth_dependency.py | 431 ++++++++ hesabixAPI/build/lib/app/core/calendar.py | 91 ++ .../build/lib/app/core/calendar_middleware.py | 14 + .../build/lib/app/core/error_handlers.py | 100 ++ hesabixAPI/build/lib/app/core/i18n.py | 59 ++ hesabixAPI/build/lib/app/core/i18n_catalog.py | 17 + hesabixAPI/build/lib/app/core/logging.py | 29 + hesabixAPI/build/lib/app/core/permissions.py | 201 ++++ hesabixAPI/build/lib/app/core/responses.py | 77 ++ hesabixAPI/build/lib/app/core/security.py | 46 + hesabixAPI/build/lib/app/core/settings.py | 48 + .../build/lib/app/core/smart_normalizer.py | 200 ++++ hesabixAPI/build/lib/app/main.py | 361 +++++++ .../build/lib/app/services/api_key_service.py | 51 + .../build/lib/app/services/auth_service.py | 289 ++++++ .../services/business_dashboard_service.py | 194 ++++ .../lib/app/services/business_service.py | 360 +++++++ .../build/lib/app/services/captcha_service.py | 97 ++ .../build/lib/app/services/email_service.py | 143 +++ .../lib/app/services/file_storage_service.py | 227 +++++ .../build/lib/app/services/pdf/__init__.py | 6 + .../lib/app/services/pdf/base_pdf_service.py | 135 +++ .../lib/app/services/pdf/modules/__init__.py | 3 + .../pdf/modules/marketing/__init__.py | 6 + .../pdf/modules/marketing/marketing_module.py | 441 ++++++++ .../build/lib/app/services/person_service.py | 516 ++++++++++ .../lib/app/services/price_list_service.py | 139 +++ .../app/services/product_attribute_service.py | 109 ++ .../build/lib/app/services/product_service.py | 223 +++++ .../build/lib/app/services/query_service.py | 162 +++ hesabixAPI/build/lib/migrations/env.py | 83 ++ .../20250117_000003_add_business_table.py | 48 + ...0117_000004_add_business_contact_fields.py | 40 + ...7_000005_add_business_geographic_fields.py | 26 + ...117_000006_add_app_permissions_to_users.py | 28 + ...00007_create_business_permissions_table.py | 42 + .../20250117_000008_add_email_config_table.py | 45 + ...7_000009_add_is_default_to_email_config.py | 26 + .../20250120_000001_add_persons_tables.py | 82 ++ .../20250120_000002_add_join_permission.py | 30 + .../20250915_000001_init_auth_tables.py | 86 ++ .../20250916_000002_add_referral_fields.py | 53 + ...250926_000010_add_person_code_and_types.py | 47 + .../20250926_000011_drop_person_is_active.py | 39 + .../20250927_000012_add_fiscal_years_table.py | 48 + ..._add_currencies_and_business_currencies.py | 88 ++ .../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 + ...28_000023_remove_person_is_active_force.py | 56 ++ .../20250929_000101_add_categories_table.py | 36 + ...250929_000201_drop_type_from_categories.py | 41 + ...929_000301_add_product_attributes_table.py | 34 + ..._drop_is_active_from_product_attributes.py | 31 + ...0250929_000501_add_products_and_pricing.py | 244 +++++ ...te_price_items_currency_unique_not_null.py | 82 ++ ...1_drop_price_list_currency_default_unit.py | 102 ++ .../versions/4b2ea782bcb3_merge_heads.py | 29 + .../5553f8745c6e_add_support_tables.py | 132 +++ .../9f9786ae7191_create_tax_units_table.py | 50 + .../caf3f4ef4b76_add_tax_units_table.py | 169 ++++ ..._sync_person_type_enum_values_callable_.py | 178 ++++ .../f876bfa36805_merge_multiple_heads.py | 24 + hesabixAPI/build/lib/tests/__init__.py | 0 hesabixAPI/build/lib/tests/test_health.py | 14 + .../build/lib/tests/test_permissions.py | 226 +++++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 3 + hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 5915 -> 6527 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 7457 -> 8667 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 21 + ...te_price_items_currency_unique_not_null.py | 99 ++ ...1_drop_price_list_currency_default_unit.py | 87 ++ ...201_merge_heads_drop_currency_tax_units.py | 26 + .../9f9786ae7191_create_tax_units_table.py | 48 +- .../controllers/product_form_controller.dart | 139 ++- hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 96 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 96 +- .../lib/l10n/app_localizations.dart | 492 +++++++++ .../lib/l10n/app_localizations_en.dart | 254 +++++ .../lib/l10n/app_localizations_fa.dart | 253 +++++ .../lib/models/bulk_price_update_data.dart | 147 +++ .../lib/models/product_form_data.dart | 33 +- .../lib/pages/business/business_shell.dart | 24 +- .../pages/business/price_list_items_page.dart | 190 ++-- .../lib/pages/business/price_lists_page.dart | 463 ++++++++- .../lib/pages/business/products_page.dart | 141 ++- .../services/bulk_price_update_service.dart | 31 + .../lib/services/category_service.dart | 24 + .../lib/services/currency_service.dart | 33 + .../lib/services/price_list_service.dart | 43 +- .../services/product_attribute_service.dart | 1 - .../lib/services/product_service.dart | 15 +- .../hesabix_ui/lib/utils/date_formatters.dart | 69 ++ .../lib/utils/number_formatters.dart | 61 ++ .../lib/utils/product_form_validator.dart | 2 +- .../category/category_picker_field.dart | 321 ++++++ .../widgets/data_table/data_table_widget.dart | 15 +- .../data_table/helpers/data_table_utils.dart | 18 +- .../product/bulk_price_update_dialog.dart | 903 +++++++++++++++++ .../widgets/product/product_form_dialog.dart | 45 +- .../product/product_import_dialog.dart | 333 ++++++ .../sections/product_basic_info_section.dart | 63 +- .../product_pricing_inventory_section.dart | 264 ++++- .../product/sections/product_tax_section.dart | 59 +- .../hesabix_ui/test/product_form_test.dart | 1 - 218 files changed, 24064 insertions(+), 361 deletions(-) create mode 100644 hesabixAPI/app/services/bulk_price_update_service.py create mode 100644 hesabixAPI/build/lib/adapters/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/api/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/accounts.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/auth.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/business_users.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/businesses.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/categories.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/currencies.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/health.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/persons.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/price_lists.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/product_attributes.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/products.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/schemas.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/categories.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/operator.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/priorities.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/schemas.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/statuses.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/support/tickets.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/tax_types.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/tax_units.py create mode 100644 hesabixAPI/build/lib/adapters/api/v1/users.py create mode 100644 hesabixAPI/build/lib/adapters/db/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/account.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/api_key.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/business.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/business_permission.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/captcha.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/category.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/currency.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/document.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/document_line.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/email_config.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/file_storage.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/fiscal_year.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/password_reset.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/person.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/price_list.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/product.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/product_attribute.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/category.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/message.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/priority.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/status.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/support/ticket.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/tax_unit.py create mode 100644 hesabixAPI/build/lib/adapters/db/models/user.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/base_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/business_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/category_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/product_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py create mode 100644 hesabixAPI/build/lib/adapters/db/repositories/user_repo.py create mode 100644 hesabixAPI/build/lib/adapters/db/session.py create mode 100644 hesabixAPI/build/lib/app/__init__.py create mode 100644 hesabixAPI/build/lib/app/core/__init__.py create mode 100644 hesabixAPI/build/lib/app/core/auth_dependency.py create mode 100644 hesabixAPI/build/lib/app/core/calendar.py create mode 100644 hesabixAPI/build/lib/app/core/calendar_middleware.py create mode 100644 hesabixAPI/build/lib/app/core/error_handlers.py create mode 100644 hesabixAPI/build/lib/app/core/i18n.py create mode 100644 hesabixAPI/build/lib/app/core/i18n_catalog.py create mode 100644 hesabixAPI/build/lib/app/core/logging.py create mode 100644 hesabixAPI/build/lib/app/core/permissions.py create mode 100644 hesabixAPI/build/lib/app/core/responses.py create mode 100644 hesabixAPI/build/lib/app/core/security.py create mode 100644 hesabixAPI/build/lib/app/core/settings.py create mode 100644 hesabixAPI/build/lib/app/core/smart_normalizer.py create mode 100644 hesabixAPI/build/lib/app/main.py create mode 100644 hesabixAPI/build/lib/app/services/api_key_service.py create mode 100644 hesabixAPI/build/lib/app/services/auth_service.py create mode 100644 hesabixAPI/build/lib/app/services/business_dashboard_service.py create mode 100644 hesabixAPI/build/lib/app/services/business_service.py create mode 100644 hesabixAPI/build/lib/app/services/captcha_service.py create mode 100644 hesabixAPI/build/lib/app/services/email_service.py create mode 100644 hesabixAPI/build/lib/app/services/file_storage_service.py create mode 100644 hesabixAPI/build/lib/app/services/pdf/__init__.py create mode 100644 hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py create mode 100644 hesabixAPI/build/lib/app/services/pdf/modules/__init__.py create mode 100644 hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py create mode 100644 hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py create mode 100644 hesabixAPI/build/lib/app/services/person_service.py create mode 100644 hesabixAPI/build/lib/app/services/price_list_service.py create mode 100644 hesabixAPI/build/lib/app/services/product_attribute_service.py create mode 100644 hesabixAPI/build/lib/app/services/product_service.py create mode 100644 hesabixAPI/build/lib/app/services/query_service.py create mode 100644 hesabixAPI/build/lib/migrations/env.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000014_add_documents_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000016_add_accounts_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000018_seed_currencies.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py create mode 100644 hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py create mode 100644 hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py create mode 100644 hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py create mode 100644 hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py create mode 100644 hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py create mode 100644 hesabixAPI/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py create mode 100644 hesabixAPI/build/lib/tests/__init__.py create mode 100644 hesabixAPI/build/lib/tests/test_health.py create mode 100644 hesabixAPI/build/lib/tests/test_permissions.py create mode 100644 hesabixAPI/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py create mode 100644 hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py create mode 100644 hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py create mode 100644 hesabixUI/hesabix_ui/lib/models/bulk_price_update_data.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/bulk_price_update_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/currency_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/utils/date_formatters.dart create mode 100644 hesabixUI/hesabix_ui/lib/utils/number_formatters.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart diff --git a/hesabixAPI/adapters/api/v1/categories.py b/hesabixAPI/adapters/api/v1/categories.py index 6290dbc..ddfec4b 100644 --- a/hesabixAPI/adapters/api/v1/categories.py +++ b/hesabixAPI/adapters/api/v1/categories.py @@ -146,3 +146,39 @@ def delete_category( return success_response({"deleted": ok}, request) +# Server-side search categories with breadcrumb path +@router.post("/business/{business_id}/search") +@require_business_access("business_id") +def search_categories( + request: Request, + business_id: int, + body: Dict[str, Any] | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("categories"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403) + q = (body or {}).get("query") if isinstance(body, dict) else None + limit = (body or {}).get("limit") if isinstance(body, dict) else None + if not isinstance(q, str) or not q.strip(): + return success_response({"items": []}, request) + try: + limit_int = int(limit) if isinstance(limit, int) or (isinstance(limit, str) and str(limit).isdigit()) else 50 + limit_int = max(1, min(limit_int, 200)) + except Exception: + limit_int = 50 + repo = CategoryRepository(db) + items = repo.search_with_paths(business_id=business_id, query=q.strip(), limit=limit_int) + # map label consistently + mapped = [ + { + "id": it.get("id"), + "parent_id": it.get("parent_id"), + "label": it.get("title") or "", + "translations": it.get("translations") or {}, + "path": it.get("path") or [], + } + for it in items + ] + return success_response({"items": mapped}, request) + diff --git a/hesabixAPI/adapters/api/v1/currencies.py b/hesabixAPI/adapters/api/v1/currencies.py index 244babd..0429b23 100644 --- a/hesabixAPI/adapters/api/v1/currencies.py +++ b/hesabixAPI/adapters/api/v1/currencies.py @@ -1,9 +1,13 @@ from fastapi import APIRouter, Depends, Request -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from adapters.db.session import get_db from adapters.db.models.currency import Currency from app.core.responses import success_response +from app.core.responses import ApiError +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from adapters.db.models.business import Business router = APIRouter(prefix="/currencies", tags=["currencies"]) @@ -28,3 +32,60 @@ def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict: return success_response(items, request) +@router.get( + "/business/{business_id}", + summary="فهرست ارزهای کسب‌وکار", + description="دریافت ارز پیش‌فرض کسب‌وکار به‌علاوه ارزهای فعال آن کسب‌وکار (بدون تکرار)", +) +@require_business_access() +def list_business_currencies( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + business = ( + db.query(Business) + .options( + joinedload(Business.default_currency), + joinedload(Business.currencies), + ) + .filter(Business.id == business_id) + .first() + ) + if not business: + raise ApiError("NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404) + + seen_ids = set() + result = [] + + # Add default currency first if exists + if business.default_currency: + c = business.default_currency + result.append({ + "id": c.id, + "name": c.name, + "title": c.title, + "symbol": c.symbol, + "code": c.code, + "is_default": True, + }) + seen_ids.add(c.id) + + # Add active business currencies (excluding duplicates) + for c in business.currencies or []: + if c.id in seen_ids: + continue + result.append({ + "id": c.id, + "name": c.name, + "title": c.title, + "symbol": c.symbol, + "code": c.code, + "is_default": False, + }) + seen_ids.add(c.id) + + # If nothing found, return empty list + return success_response(result, request) + diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index ac23487..2185f0c 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -12,6 +12,7 @@ from adapters.api.v1.schemas import QueryInfo, SuccessResponse from app.core.responses import success_response, format_datetime_fields from app.core.auth_dependency import get_current_user, AuthContext from app.core.permissions import require_business_management_dep +from app.core.i18n import negotiate_locale from app.services.person_service import ( create_person, get_person_by_id, get_persons_by_business, update_person, delete_person, get_person_summary @@ -207,6 +208,14 @@ async def export_persons_excel( ws = wb.active ws.title = "Persons" + # Locale and RTL/LTR handling + locale = negotiate_locale(request.headers.get("Accept-Language")) + if locale == 'fa': + try: + ws.sheet_view.rightToLeft = True + except Exception: + pass + 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") @@ -226,7 +235,10 @@ async def export_persons_excel( 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 + cell = ws.cell(row=row_idx, column=col_idx, value=value) + cell.border = border + if locale == 'fa': + cell.alignment = Alignment(horizontal="right") # Auto-width columns for column in ws.columns: @@ -344,7 +356,12 @@ async def export_persons_pdf( except Exception: business_name = "" - # Styled HTML (A4 landscape, RTL) + # Styled HTML with dynamic direction/locale + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = (locale == 'fa') + html_lang = 'fa' if is_fa else 'en' + html_dir = 'rtl' if is_fa else 'ltr' + def escape(s: Any) -> str: try: return str(s).replace('&', '&').replace('<', '<').replace('>', '>') @@ -373,16 +390,24 @@ async def export_persons_pdf( now = formatted_now.get('formatted', formatted_now.get('date_time', '')) except Exception: now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + + title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report" + label_biz = "نام کسب‌وکار" if is_fa else "Business Name" + label_date = "تاریخ گزارش" if is_fa else "Report Date" + footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix" + page_label_left = "صفحه " if is_fa else "Page " + page_label_of = " از " if is_fa else " of " + table_html = f""" - +
-
گزارش لیست اشخاص
-
نام کسب‌وکار: {escape(business_name)}
+
{title_text}
+
{label_biz}: {escape(business_name)}
-
تاریخ گزارش: {escape(now)}
+
{label_date}: {escape(now)}
@@ -459,7 +484,7 @@ async def export_persons_pdf(
-
تولید شده توسط Hesabix
+
{footer_text}
""" diff --git a/hesabixAPI/adapters/api/v1/price_lists.py b/hesabixAPI/adapters/api/v1/price_lists.py index 958331b..8cfa5df 100644 --- a/hesabixAPI/adapters/api/v1/price_lists.py +++ b/hesabixAPI/adapters/api/v1/price_lists.py @@ -137,12 +137,14 @@ def list_price_items_endpoint( request: Request, business_id: int, price_list_id: int, + product_id: int | None = None, + currency_id: int | None = None, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ) -> Dict[str, Any]: if not ctx.can_read_section("inventory"): raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) - result = list_price_items(db, business_id, price_list_id) + result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id) return success_response(data=format_datetime_fields(result, request), request=request) diff --git a/hesabixAPI/adapters/api/v1/products.py b/hesabixAPI/adapters/api/v1/products.py index 79d9fc7..d4ceda1 100644 --- a/hesabixAPI/adapters/api/v1/products.py +++ b/hesabixAPI/adapters/api/v1/products.py @@ -12,6 +12,8 @@ from adapters.api.v1.schemas import QueryInfo from adapters.api.v1.schema_models.product import ( ProductCreateRequest, ProductUpdateRequest, + BulkPriceUpdateRequest, + BulkPriceUpdatePreviewResponse, ) from app.services.product_service import ( create_product, @@ -20,6 +22,13 @@ from app.services.product_service import ( update_product, delete_product, ) +from app.services.bulk_price_update_service import ( + preview_bulk_price_update, + apply_bulk_price_update, +) +from adapters.db.models.business import Business +from app.core.i18n import negotiate_locale +from fastapi import UploadFile, File, Form router = APIRouter(prefix="/products", tags=["products"]) @@ -125,6 +134,8 @@ async def export_products_excel( db: Session = Depends(get_db), ): import io + import re + import datetime from fastapi.responses import Response from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side @@ -145,6 +156,22 @@ async def export_products_excel( items = result.get("items", []) if isinstance(result, dict) else result.get("items", []) 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 and isinstance(items, list): + indices = None + if isinstance(selected_indices, str): + try: + import json as _json + indices = _json.loads(selected_indices) + except Exception: + 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)] + export_columns = body.get("export_columns") if export_columns and isinstance(export_columns, list): headers = [col.get("label") or col.get("key") for col in export_columns] @@ -169,6 +196,14 @@ async def export_products_excel( ws = wb.active ws.title = "Products" + # Locale and RTL/LTR handling for Excel + locale = negotiate_locale(request.headers.get("Accept-Language")) + if locale == 'fa': + try: + ws.sheet_view.rightToLeft = True + except Exception: + pass + # Header style header_font = Font(bold=True) header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid") @@ -188,21 +223,336 @@ async def export_products_excel( ws.append(row) for cell in ws[ws.max_row]: cell.border = thin_border + # Align data cells based on locale + if locale == 'fa': + cell.alignment = Alignment(horizontal="right") + + # Auto width columns + try: + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if cell.value is not None and 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) + except Exception: + pass output = io.BytesIO() wb.save(output) data = output.getvalue() + # Build meaningful filename + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "products" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return Response( content=data, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ - "Content-Disposition": "attachment; filename=products.xlsx", + "Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(data)), + "Access-Control-Expose-Headers": "Content-Disposition", }, ) +@router.post("/business/{business_id}/import/template", + summary="دانلود تمپلیت ایمپورت محصولات", + description="فایل Excel تمپلیت برای ایمپورت کالا/خدمت را برمی‌گرداند", +) +@require_business_access("business_id") +async def download_products_import_template( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import datetime + from fastapi.responses import Response + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment + + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + + wb = Workbook() + ws = wb.active + ws.title = "Template" + + headers = [ + "code","name","item_type","description","category_id", + "main_unit_id","secondary_unit_id","unit_conversion_factor", + "base_sales_price","base_purchase_price","track_inventory", + "reorder_point","min_order_qty","lead_time_days", + "is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate", + "tax_type_id","tax_code","tax_unit_id", + # attribute_ids can be comma-separated ids + "attribute_ids", + ] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal="center") + + sample = [ + "P1001","نمونه کالا","کالا","توضیح اختیاری", "", + "", "", "", + "150000", "120000", "TRUE", + "0", "0", "", + "FALSE", "FALSE", "", "", + "", "", "", + "1,2,3", + ] + for col, val in enumerate(sample, 1): + ws.cell(row=2, column=col, value=val) + + # Auto width + for column in ws.columns: + try: + letter = column[0].column_letter + max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column) + ws.column_dimensions[letter].width = min(max_len + 2, 50) + except Exception: + pass + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + + filename = f"products_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return Response( + content=buf.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.post("/business/{business_id}/import/excel", + summary="ایمپورت محصولات از فایل Excel", + description="فایل اکسل را دریافت می‌کند و به‌صورت dry-run یا واقعی پردازش می‌کند", +) +@require_business_access("business_id") +async def import_products_excel( + request: Request, + business_id: int, + file: UploadFile = File(...), + dry_run: str = Form(default="true"), + match_by: str = Form(default="code"), + conflict_policy: str = Form(default="upsert"), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import json + import logging + import re + import zipfile + from decimal import Decimal + from typing import Optional + from openpyxl import load_workbook + + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + + logger = logging.getLogger(__name__) + + def _validate_excel_signature(content: bytes) -> bool: + try: + if not content.startswith(b'PK'): + return False + with zipfile.ZipFile(io.BytesIO(content), 'r') as zf: + return any(n.startswith('xl/') for n in zf.namelist()) + except Exception: + return False + + try: + is_dry_run = str(dry_run).lower() in ("true","1","yes","on") + + if not file.filename or not file.filename.lower().endswith('.xlsx'): + raise ApiError("INVALID_FILE", "فرمت فایل معتبر نیست. تنها xlsx پشتیبانی می‌شود", http_status=400) + + content = await file.read() + if len(content) < 100 or not _validate_excel_signature(content): + raise ApiError("INVALID_FILE", "فایل Excel معتبر نیست یا خالی است", http_status=400) + + try: + wb = load_workbook(filename=io.BytesIO(content), data_only=True) + except zipfile.BadZipFile: + raise ApiError("INVALID_FILE", "فایل Excel خراب است یا فرمت آن معتبر نیست", http_status=400) + + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + return success_response(data={"summary": {"total": 0}}, request=request, message="EMPTY_FILE") + + headers = [str(h).strip() if h is not None else "" for h in rows[0]] + data_rows = rows[1:] + + def _parse_bool(v: object) -> Optional[bool]: + if v is None: return None + s = str(v).strip().lower() + if s in ("true","1","yes","on","بله","هست"): + return True + if s in ("false","0","no","off","خیر","نیست"): + return False + return None + + def _parse_decimal(v: object) -> Optional[Decimal]: + if v is None or str(v).strip() == "": + return None + try: + return Decimal(str(v).replace(",","")) + except Exception: + return None + + def _parse_int(v: object) -> Optional[int]: + if v is None or str(v).strip() == "": + return None + try: + return int(str(v).split(".")[0]) + except Exception: + return None + + def _normalize_item_type(v: object) -> Optional[str]: + if v is None: return None + s = str(v).strip() + mapping = {"product": "کالا", "service": "خدمت"} + low = s.lower() + if low in mapping: return mapping[low] + if s in ("کالا","خدمت"): return s + return None + + errors: list[dict] = [] + valid_items: list[dict] = [] + + for idx, row in enumerate(data_rows, start=2): + item: dict[str, Any] = {} + row_errors: list[str] = [] + + for ci, key in enumerate(headers): + if not key: + continue + val = row[ci] if ci < len(row) else None + if isinstance(val, str): + val = val.strip() + item[key] = val + + # normalize & cast + if 'item_type' in item: + item['item_type'] = _normalize_item_type(item.get('item_type')) or 'کالا' + for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']: + if k in item: + item[k] = _parse_decimal(item.get(k)) + for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']: + if k in item: + item[k] = _parse_int(item.get(k)) + for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']: + if k in item: + item[k] = _parse_bool(item.get(k)) if item.get(k) is not None else None + + # attribute_ids: comma-separated + if 'attribute_ids' in item and item['attribute_ids']: + try: + parts = [p.strip() for p in str(item['attribute_ids']).split(',') if p and p.strip()] + item['attribute_ids'] = [int(p) for p in parts if p.isdigit()] + except Exception: + item['attribute_ids'] = [] + + # validations + name = item.get('name') + if not name or str(name).strip() == "": + row_errors.append('name الزامی است') + + # if code is empty, it will be auto-generated in service + code = item.get('code') + if code is not None and str(code).strip() == "": + item['code'] = None + + if row_errors: + errors.append({"row": idx, "errors": row_errors}) + continue + + valid_items.append(item) + + inserted = 0 + updated = 0 + skipped = 0 + + if not is_dry_run and valid_items: + from sqlalchemy import and_ as _and + from adapters.db.models.product import Product + from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest + from app.services.product_service import create_product, update_product + + def _find_existing(session: Session, data: dict) -> Optional[Product]: + if match_by == 'code' and data.get('code'): + return session.query(Product).filter(_and(Product.business_id == business_id, Product.code == str(data['code']).strip())).first() + if match_by == 'name' and data.get('name'): + return session.query(Product).filter(_and(Product.business_id == business_id, Product.name == str(data['name']).strip())).first() + return None + + for data in valid_items: + existing = _find_existing(db, data) + if existing is None: + try: + create_product(db, business_id, ProductCreateRequest(**data)) + inserted += 1 + except Exception as e: + logger.error(f"Create product failed: {e}") + skipped += 1 + else: + if conflict_policy == 'insert': + skipped += 1 + elif conflict_policy in ('update','upsert'): + try: + update_product(db, existing.id, business_id, ProductUpdateRequest(**data)) + updated += 1 + except Exception as e: + logger.error(f"Update product failed: {e}") + skipped += 1 + + summary = { + "total": len(data_rows), + "valid": len(valid_items), + "invalid": len(errors), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "dry_run": is_dry_run, + } + + return success_response( + data={"summary": summary, "errors": errors}, + request=request, + message="PRODUCTS_IMPORT_RESULT", + ) + except ApiError: + raise + except Exception as e: + logger.error(f"Import error: {e}", exc_info=True) + raise ApiError("IMPORT_ERROR", f"خطا در پردازش فایل: {e}", http_status=500) @router.post("/business/{business_id}/export/pdf", summary="خروجی PDF لیست محصولات", description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستون‌ها", @@ -215,8 +565,9 @@ async def export_products_pdf( ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), ): - import io + import json import datetime + import re from fastapi.responses import Response from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration @@ -237,6 +588,21 @@ async def export_products_pdf( items = result.get("items", []) 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)] + export_columns = body.get("export_columns") if export_columns and isinstance(export_columns, list): headers = [col.get("label") or col.get("key") for col in export_columns] @@ -257,44 +623,211 @@ async def export_products_pdf( keys = [k for k, _ in default_cols] headers = [v for _, v in default_cols] - # Build simple HTML table - head_html = """ - - """ - title = "گزارش فهرست محصولات" - now = datetime.datetime.utcnow().isoformat() - header_row = "".join([f"{h}" for h in headers]) - body_rows = "".join([ - "" + "".join([f"{(it.get(k) if it.get(k) is not None else '')}" for k in keys]) + "" - for it in items - ]) - html = f""" - {head_html} -

{title}

-
زمان تولید: {now}
- - {header_row} - {body_rows} -
- + # Locale and direction + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = (locale == 'fa') + html_lang = 'fa' if is_fa else 'en' + html_dir = 'rtl' if is_fa else 'ltr' + + # 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 or "" + except Exception: + business_name = "" + + # Escape helper + def escape(s: Any) -> str: + try: + return str(s).replace('&', '&').replace('<', '<').replace('>', '>') + except Exception: + return str(s) + + # Build rows + 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_str = formatted_now.get('formatted', formatted_now.get('date_time', '')) + except Exception: + now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + + title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report" + label_biz = "نام کسب‌وکار" if is_fa else "Business Name" + label_date = "تاریخ گزارش" if is_fa else "Report Date" + footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix" + page_label_left = "صفحه " if is_fa else "Page " + page_label_of = " از " if is_fa else " of " + + table_html = f""" + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now_str)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
{footer_text}
+ + """ font_config = FontConfiguration() - pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 landscape; margin: 10mm; }")], font_config=font_config) + pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config) + + # Build meaningful filename + biz_name = business_name + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "products" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={ - "Content-Disposition": "attachment; filename=products.pdf", + "Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes)), + "Access-Control-Expose-Headers": "Content-Disposition", }, ) +@router.post("/business/{business_id}/bulk-price-update/preview", + summary="پیش‌نمایش تغییر قیمت‌های گروهی", + description="پیش‌نمایش تغییرات قیمت قبل از اعمال", +) +@require_business_access("business_id") +def preview_bulk_price_update_endpoint( + request: Request, + business_id: int, + payload: BulkPriceUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + + result = preview_bulk_price_update(db, business_id, payload) + return success_response(data=result.dict(), request=request) + + +@router.post("/business/{business_id}/bulk-price-update/apply", + summary="اعمال تغییر قیمت‌های گروهی", + description="اعمال تغییرات قیمت بر روی کالاهای انتخاب شده", +) +@require_business_access("business_id") +def apply_bulk_price_update_endpoint( + request: Request, + business_id: int, + payload: BulkPriceUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + + result = apply_bulk_price_update(db, business_id, payload) + return success_response(data=result, request=request) + + diff --git a/hesabixAPI/adapters/api/v1/schema_models/price_list.py b/hesabixAPI/adapters/api/v1/schema_models/price_list.py index f89a40e..7849770 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/price_list.py +++ b/hesabixAPI/adapters/api/v1/schema_models/price_list.py @@ -7,23 +7,19 @@ from pydantic import BaseModel, Field class PriceListCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) - currency_id: Optional[int] = None - default_unit_id: Optional[int] = None is_active: bool = True class PriceListUpdateRequest(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=255) - currency_id: Optional[int] = None - default_unit_id: Optional[int] = None is_active: Optional[bool] = None class PriceItemUpsertRequest(BaseModel): product_id: int unit_id: Optional[int] = None - currency_id: Optional[int] = None - tier_name: str = Field(..., min_length=1, max_length=64) + currency_id: int + tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64) min_qty: Decimal = Field(default=0) price: Decimal @@ -32,8 +28,6 @@ class PriceListResponse(BaseModel): id: int business_id: int name: str - currency_id: Optional[int] = None - default_unit_id: Optional[int] = None is_active: bool created_at: str updated_at: str @@ -47,7 +41,7 @@ class PriceItemResponse(BaseModel): price_list_id: int product_id: int unit_id: Optional[int] = None - currency_id: Optional[int] = None + currency_id: int tier_name: str min_qty: Decimal price: Decimal diff --git a/hesabixAPI/adapters/api/v1/schema_models/product.py b/hesabixAPI/adapters/api/v1/schema_models/product.py index 129bf46..80535a7 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/product.py +++ b/hesabixAPI/adapters/api/v1/schema_models/product.py @@ -108,3 +108,59 @@ class ProductResponse(BaseModel): from_attributes = True +class BulkPriceUpdateType(str, Enum): + PERCENTAGE = "percentage" + AMOUNT = "amount" + + +class BulkPriceUpdateDirection(str, Enum): + INCREASE = "increase" + DECREASE = "decrease" + + +class BulkPriceUpdateTarget(str, Enum): + SALES_PRICE = "sales_price" + PURCHASE_PRICE = "purchase_price" + BOTH = "both" + + +class BulkPriceUpdateRequest(BaseModel): + """درخواست تغییر قیمت‌های گروهی""" + update_type: BulkPriceUpdateType = Field(..., description="نوع تغییر: درصدی یا مقداری") + direction: BulkPriceUpdateDirection = Field(default=BulkPriceUpdateDirection.INCREASE, description="جهت تغییر: افزایش یا کاهش") + target: BulkPriceUpdateTarget = Field(..., description="هدف تغییر: قیمت فروش، خرید یا هر دو") + value: Decimal = Field(..., description="مقدار تغییر (درصد یا مبلغ)") + + # فیلترهای انتخاب کالاها + category_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های دسته‌بندی") + currency_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های ارز") + price_list_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های لیست قیمت") + item_types: Optional[List[ProductItemType]] = Field(default=None, description="نوع آیتم‌ها") + product_ids: Optional[List[int]] = Field(default=None, description="شناسه‌های کالاهای خاص") + + # گزینه‌های اضافی + only_products_with_inventory: Optional[bool] = Field(default=None, description="فقط کالاهای با موجودی") + only_products_with_base_price: Optional[bool] = Field(default=True, description="فقط کالاهای با قیمت پایه") + + +class BulkPriceUpdatePreview(BaseModel): + """پیش‌نمایش تغییرات قیمت""" + product_id: int + product_name: str + product_code: str + category_name: Optional[str] = None + current_sales_price: Optional[Decimal] = None + current_purchase_price: Optional[Decimal] = None + new_sales_price: Optional[Decimal] = None + new_purchase_price: Optional[Decimal] = None + sales_price_change: Optional[Decimal] = None + purchase_price_change: Optional[Decimal] = None + + +class BulkPriceUpdatePreviewResponse(BaseModel): + """پاسخ پیش‌نمایش تغییرات قیمت""" + total_products: int + affected_products: List[BulkPriceUpdatePreview] + summary: dict = Field(..., description="خلاصه تغییرات") + + diff --git a/hesabixAPI/adapters/api/v1/tax_units.py b/hesabixAPI/adapters/api/v1/tax_units.py index 56dceb7..77392cc 100644 --- a/hesabixAPI/adapters/api/v1/tax_units.py +++ b/hesabixAPI/adapters/api/v1/tax_units.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, Field router = APIRouter(prefix="/tax-units", tags=["tax-units"]) +alias_router = APIRouter(prefix="/units", tags=["units"]) class TaxUnitCreateRequest(BaseModel): @@ -86,6 +87,7 @@ class TaxUnitResponse(BaseModel): } } ) +@alias_router.get("/business/{business_id}") @require_business_access() def get_tax_units( request: Request, @@ -160,6 +162,7 @@ def get_tax_units( } } ) +@alias_router.post("/business/{business_id}") @require_business_access() def create_tax_unit( request: Request, @@ -255,6 +258,7 @@ def create_tax_unit( } } ) +@alias_router.put("/{tax_unit_id}") @require_business_access() def update_tax_unit( request: Request, @@ -345,6 +349,7 @@ def update_tax_unit( } } ) +@alias_router.delete("/{tax_unit_id}") @require_business_access() def delete_tax_unit( request: Request, diff --git a/hesabixAPI/adapters/db/models/price_list.py b/hesabixAPI/adapters/db/models/price_list.py index 88944b7..7f95dd4 100644 --- a/hesabixAPI/adapters/db/models/price_list.py +++ b/hesabixAPI/adapters/db/models/price_list.py @@ -26,8 +26,6 @@ class PriceList(Base): 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) name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) - currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True) - default_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, 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) @@ -36,14 +34,14 @@ class PriceList(Base): class PriceItem(Base): __tablename__ = "price_items" __table_args__ = ( - UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", name="uq_price_items_unique_tier"), + UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"), ) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True) product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) - currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True) + currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" ) min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0) price: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False) diff --git a/hesabixAPI/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/adapters/db/repositories/business_permission_repo.py index f4ac3e9..b364fa2 100644 --- a/hesabixAPI/adapters/db/repositories/business_permission_repo.py +++ b/hesabixAPI/adapters/db/repositories/business_permission_repo.py @@ -89,7 +89,32 @@ class BusinessPermissionRepository(BaseRepository[BusinessPermission]): # سپس فیلتر می‌کنیم member_permissions = [] for perm in all_permissions: - if perm.business_permissions and perm.business_permissions.get('join') == True: + # Normalize legacy/non-dict JSON values to dict before access + raw = perm.business_permissions + normalized = {} + if isinstance(raw, dict): + normalized = raw + elif isinstance(raw, list): + # If legacy stored as list, try to coerce to dict if it looks like key-value pairs + try: + # e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...] + if all(isinstance(item, list) and len(item) == 2 for item in raw): + normalized = {k: v for k, v in raw if isinstance(k, str)} + elif all(isinstance(item, dict) for item in raw): + # Merge list of dicts + merged: dict = {} + for item in raw: + merged.update({k: v for k, v in item.items()}) + normalized = merged + except Exception: + normalized = {} + elif raw is None: + normalized = {} + else: + # Unsupported type, skip safely + normalized = {} + + if normalized.get('join') == True: member_permissions.append(perm) return member_permissions \ No newline at end of file diff --git a/hesabixAPI/adapters/db/repositories/category_repository.py b/hesabixAPI/adapters/db/repositories/category_repository.py index 43ad61e..a84d944 100644 --- a/hesabixAPI/adapters/db/repositories/category_repository.py +++ b/hesabixAPI/adapters/db/repositories/category_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import List, Dict, Any from sqlalchemy.orm import Session -from sqlalchemy import select, and_, or_ +from sqlalchemy import select, and_, or_, func from .base_repo import BaseRepository from ..models.category import BusinessCategory @@ -90,3 +90,55 @@ class CategoryRepository(BaseRepository[BusinessCategory]): return True + def search_with_paths(self, *, business_id: int, query: str, limit: int = 50) -> list[Dict[str, Any]]: + q = (query or "").strip() + if not q: + return [] + # Basic ILIKE search over fa/en translations by JSON string casting + # Note: For performance, consider a materialized path or FTS in future + stmt = ( + select(BusinessCategory) + .where(BusinessCategory.business_id == business_id) + ) + rows = list(self.db.execute(stmt).scalars().all()) + # Build in-memory tree index + by_id: dict[int, BusinessCategory] = {r.id: r for r in rows} + def get_title(r: BusinessCategory) -> str: + trans = r.title_translations or {} + return (trans.get("fa") or trans.get("en") or "").strip() + # Filter by query + q_lower = q.lower() + matched: list[BusinessCategory] = [] + for r in rows: + if q_lower in get_title(r).lower(): + matched.append(r) + matched = matched[: max(1, min(limit, 200))] + # Build path for each match + def build_path(r: BusinessCategory) -> list[Dict[str, Any]]: + path: list[Dict[str, Any]] = [] + current = r + seen: set[int] = set() + while current is not None and current.id not in seen: + seen.add(current.id) + title = get_title(current) + path.append({ + "id": current.id, + "parent_id": current.parent_id, + "title": title, + "translations": current.title_translations or {}, + }) + pid = current.parent_id + current = by_id.get(pid) if pid else None + path.reverse() + return path + result: list[Dict[str, Any]] = [] + for r in matched: + result.append({ + "id": r.id, + "parent_id": r.parent_id, + "title": get_title(r), + "translations": r.title_translations or {}, + "path": build_path(r), + }) + return result + diff --git a/hesabixAPI/adapters/db/repositories/price_list_repository.py b/hesabixAPI/adapters/db/repositories/price_list_repository.py index 0c11600..6d7c500 100644 --- a/hesabixAPI/adapters/db/repositories/price_list_repository.py +++ b/hesabixAPI/adapters/db/repositories/price_list_repository.py @@ -69,8 +69,6 @@ class PriceListRepository(BaseRepository[PriceList]): "id": pl.id, "business_id": pl.business_id, "name": pl.name, - "currency_id": pl.currency_id, - "default_unit_id": pl.default_unit_id, "is_active": pl.is_active, "created_at": pl.created_at, "updated_at": pl.updated_at, @@ -81,8 +79,12 @@ class PriceItemRepository(BaseRepository[PriceItem]): def __init__(self, db: Session) -> None: super().__init__(db, PriceItem) - def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0) -> dict[str, Any]: + def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]: stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id) + if product_id is not None: + stmt = stmt.where(PriceItem.product_id == product_id) + if currency_id is not None: + stmt = stmt.where(PriceItem.currency_id == currency_id) total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all()) items = [self._to_dict(pi) for pi in rows] @@ -98,22 +100,22 @@ class PriceItemRepository(BaseRepository[PriceItem]): }, } - def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int | None, tier_name: str, min_qty, price) -> PriceItem: + def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem: # Try find existing unique combination stmt = select(PriceItem).where( and_( PriceItem.price_list_id == price_list_id, PriceItem.product_id == product_id, PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id, - PriceItem.tier_name == tier_name, + PriceItem.tier_name == (tier_name or 'پیش‌فرض'), PriceItem.min_qty == min_qty, + PriceItem.currency_id == currency_id, ) ) existing = self.db.execute(stmt).scalars().first() if existing: existing.price = price - if currency_id is not None: - existing.currency_id = currency_id + existing.currency_id = currency_id self.db.commit() self.db.refresh(existing) return existing @@ -122,7 +124,7 @@ class PriceItemRepository(BaseRepository[PriceItem]): product_id=product_id, unit_id=unit_id, currency_id=currency_id, - tier_name=tier_name, + tier_name=(tier_name or 'پیش‌فرض'), min_qty=min_qty, price=price, ) diff --git a/hesabixAPI/adapters/db/repositories/product_repository.py b/hesabixAPI/adapters/db/repositories/product_repository.py index db63dc2..e28deba 100644 --- a/hesabixAPI/adapters/db/repositories/product_repository.py +++ b/hesabixAPI/adapters/db/repositories/product_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Dict, Optional from sqlalchemy.orm import Session -from sqlalchemy import select, and_, func +from sqlalchemy import select, and_, or_, func from .base_repo import BaseRepository from ..models.product import Product @@ -25,6 +25,38 @@ class ProductRepository(BaseRepository[Product]): ) ) + # Apply filters (supports minimal set used by clients) + if filters: + for f in filters: + # Support both dict and pydantic-like objects + if isinstance(f, dict): + field = f.get("property") + operator = f.get("operator") + value = f.get("value") + else: + field = getattr(f, "property", None) + operator = getattr(f, "operator", None) + value = getattr(f, "value", None) + + if not field or not operator: + continue + + # Code filters + if field == "code": + if operator == "=": + stmt = stmt.where(Product.code == value) + elif operator == "in" and isinstance(value, (list, tuple)): + stmt = stmt.where(Product.code.in_(list(value))) + continue + + # Name contains + if field == "name": + if operator in {"contains", "ilike"} and isinstance(value, str): + stmt = stmt.where(Product.name.ilike(f"%{value}%")) + elif operator == "=": + stmt = stmt.where(Product.name == value) + continue + total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 # Sorting diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index ed68fc4..c38b5ac 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/app/core/auth_dependency.py @@ -45,6 +45,26 @@ class AuthContext: # ایجاد translator برای زبان تشخیص داده شده self._translator = Translator(language) + @staticmethod + def _normalize_permissions_value(value) -> dict: + """نرمال‌سازی مقدار JSON دسترسی‌ها به dict برای سازگاری با داده‌های legacy""" + if isinstance(value, dict): + return value + if isinstance(value, list): + try: + # لیست جفت‌ها مانند [["join", true], ["sales", {..}]] + if all(isinstance(item, list) and len(item) == 2 for item in value): + return {k: v for k, v in value if isinstance(k, str)} + # لیست دیکشنری‌ها مانند [{"join": true}, {"sales": {...}}] + if all(isinstance(item, dict) for item in value): + merged = {} + for item in value: + merged.update({k: v for k, v in item.items()}) + return merged + except Exception: + return {} + return {} + def get_translator(self) -> Translator: """دریافت translator برای ترجمه""" return self._translator @@ -89,7 +109,7 @@ class AuthContext: permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id) if permission_obj and permission_obj.business_permissions: - return permission_obj.business_permissions + return AuthContext._normalize_permissions_value(permission_obj.business_permissions) return {} # بررسی دسترسی‌های اپلیکیشن @@ -278,7 +298,7 @@ class AuthContext: return False # بررسی دسترسی join - business_perms = permission_obj.business_permissions or {} + business_perms = AuthContext._normalize_permissions_value(permission_obj.business_permissions) has_join_access = business_perms.get('join', False) logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}") return has_join_access diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index 1805506..ce5d65f 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -74,7 +74,7 @@ def require_business_access(business_id_param: str = "business_id"): """ def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(*args, **kwargs) -> Any: + async def wrapper(*args, **kwargs) -> Any: import logging from fastapi import Request logger = logging.getLogger(__name__) @@ -108,7 +108,11 @@ def require_business_access(business_id_param: str = "business_id"): logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}") raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) - return func(*args, **kwargs) + # فراخوانی تابع اصلی و await در صورت نیاز + result = func(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result # Preserve original signature so FastAPI sees correct parameters (including Request) wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] return wrapper diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py index c44d1f1..2c2730d 100644 --- a/hesabixAPI/app/core/responses.py +++ b/hesabixAPI/app/core/responses.py @@ -14,9 +14,15 @@ def success_response(data: Any, request: Request = None, message: str = None) -> if data is not None: response["data"] = data - # Add message if provided + # Add message if provided (translate if translator exists) if message is not None: - response["message"] = message + translated = message + try: + if request is not None and hasattr(request.state, 'translator') and request.state.translator is not None: + translated = request.state.translator.t(message, default=message) + except Exception: + translated = message + response["message"] = translated # Add calendar type information if request is available if request and hasattr(request.state, 'calendar_type'): diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index c706a6a..b478cab 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -17,6 +17,7 @@ from adapters.api.v1.products import router as products_router from adapters.api.v1.price_lists import router as price_lists_router from adapters.api.v1.persons import router as persons_router from adapters.api.v1.tax_units import router as tax_units_router +from adapters.api.v1.tax_units import alias_router as units_alias_router from adapters.api.v1.tax_types import router as tax_types_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 @@ -292,6 +293,7 @@ def create_app() -> FastAPI: application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(tax_units_router, prefix=settings.api_v1_prefix) + application.include_router(units_alias_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix) # Support endpoints diff --git a/hesabixAPI/app/services/bulk_price_update_service.py b/hesabixAPI/app/services/bulk_price_update_service.py new file mode 100644 index 0000000..056a8dd --- /dev/null +++ b/hesabixAPI/app/services/bulk_price_update_service.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import Dict, Any, List, Optional +from decimal import Decimal +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from adapters.db.models.product import Product +from adapters.db.models.price_list import PriceItem +from adapters.db.models.category import BusinessCategory +from adapters.db.models.currency import Currency +from adapters.api.v1.schema_models.product import ( + BulkPriceUpdateRequest, + BulkPriceUpdatePreview, + BulkPriceUpdatePreviewResponse, + BulkPriceUpdateType, + BulkPriceUpdateTarget, + BulkPriceUpdateDirection, + ProductItemType +) + + +def _quantize_non_negative_integer(value: Decimal) -> Decimal: + """رُند کردن به عدد صحیح غیرمنفی (بدون اعشار).""" + # حذف اعشار: round-half-up به نزدیک‌ترین عدد صحیح + quantized = value.quantize(Decimal('1')) + if quantized < 0: + return Decimal('0') + return quantized + +def _quantize_integer_keep_sign(value: Decimal) -> Decimal: + """رُند کردن به عدد صحیح با حفظ علامت (بدون اعشار).""" + return value.quantize(Decimal('1')) + + +def calculate_new_price(current_price: Optional[Decimal], update_type: BulkPriceUpdateType, direction: BulkPriceUpdateDirection, value: Decimal) -> Optional[Decimal]: + """محاسبه قیمت جدید بر اساس نوع تغییر با جهت، سپس رُند و کلَمپ به صفر""" + if current_price is None: + return None + + delta = Decimal('0') + if update_type == BulkPriceUpdateType.PERCENTAGE: + sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1') + multiplier = Decimal('1') + (sign * (value / Decimal('100'))) + new_value = current_price * multiplier + else: + sign = Decimal('1') if direction == BulkPriceUpdateDirection.INCREASE else Decimal('-1') + delta = sign * value + new_value = current_price + delta + + # رُند به عدد صحیح و کلَمپ به صفر + return _quantize_non_negative_integer(new_value) + + +def get_filtered_products(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> List[Product]: + """دریافت کالاهای فیلتر شده بر اساس معیارهای درخواست""" + query = db.query(Product).filter(Product.business_id == business_id) + + # فیلتر بر اساس دسته‌بندی + if request.category_ids: + query = query.filter(Product.category_id.in_(request.category_ids)) + + # فیلتر بر اساس نوع آیتم + if request.item_types: + query = query.filter(Product.item_type.in_([t.value for t in request.item_types])) + + # فیلتر بر اساس ارز: محصولی که قیمت‌های لیست مرتبط با ارزهای انتخابی دارد + if request.currency_ids: + query = query.filter( + db.query(PriceItem.id) + .filter( + PriceItem.product_id == Product.id, + PriceItem.currency_id.in_(request.currency_ids) + ).exists() + ) + + # فیلتر بر اساس لیست قیمت: محصولی که در هر یک از لیست‌های انتخابی آیتم قیمت دارد + if request.price_list_ids: + query = query.filter( + db.query(PriceItem.id) + .filter( + PriceItem.product_id == Product.id, + PriceItem.price_list_id.in_(request.price_list_ids) + ).exists() + ) + + # فیلتر بر اساس شناسه‌های کالاهای خاص + if request.product_ids: + query = query.filter(Product.id.in_(request.product_ids)) + + # فیلتر بر اساس موجودی + if request.only_products_with_inventory is not None: + if request.only_products_with_inventory: + query = query.filter(Product.track_inventory == True) + else: + query = query.filter(Product.track_inventory == False) + + # فیلتر بر اساس وجود قیمت پایه + if request.only_products_with_base_price: + if request.target == BulkPriceUpdateTarget.SALES_PRICE: + query = query.filter(Product.base_sales_price.isnot(None)) + elif request.target == BulkPriceUpdateTarget.PURCHASE_PRICE: + query = query.filter(Product.base_purchase_price.isnot(None)) + else: + # در حالت هر دو، حداقل یکی موجود باشد + query = query.filter(or_(Product.base_sales_price.isnot(None), Product.base_purchase_price.isnot(None))) + + return query.all() + + +def preview_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> BulkPriceUpdatePreviewResponse: + """پیش‌نمایش تغییرات قیمت گروهی""" + products = get_filtered_products(db, business_id, request) + + # کش نام دسته‌ها برای کاهش کوئری + category_titles: Dict[int, str] = {} + def _resolve_category_name(cid: Optional[int]) -> Optional[str]: + if cid is None: + return None + if cid in category_titles: + return category_titles[cid] + try: + cat = db.query(BusinessCategory).filter(BusinessCategory.id == cid, BusinessCategory.business_id == business_id).first() + if cat and isinstance(cat.title_translations, dict): + title = cat.title_translations.get('fa') or cat.title_translations.get('default') or '' + category_titles[cid] = title + return title + except Exception: + return None + return None + + affected_products = [] + total_sales_change = Decimal('0') + total_purchase_change = Decimal('0') + products_with_sales_change = 0 + products_with_purchase_change = 0 + + for product in products: + preview = BulkPriceUpdatePreview( + product_id=product.id, + product_name=product.name or "بدون نام", + product_code=product.code or "بدون کد", + category_name=_resolve_category_name(product.category_id), + current_sales_price=product.base_sales_price, + current_purchase_price=product.base_purchase_price, + new_sales_price=None, + new_purchase_price=None, + sales_price_change=None, + purchase_price_change=None + ) + + # محاسبه تغییرات قیمت فروش + if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None: + new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value) + preview.new_sales_price = new_sales_price + preview.sales_price_change = (new_sales_price - product.base_sales_price) if new_sales_price is not None else None + total_sales_change += (preview.sales_price_change or Decimal('0')) + products_with_sales_change += 1 + + # محاسبه تغییرات قیمت خرید + if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None: + new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value) + preview.new_purchase_price = new_purchase_price + preview.purchase_price_change = (new_purchase_price - product.base_purchase_price) if new_purchase_price is not None else None + total_purchase_change += (preview.purchase_price_change or Decimal('0')) + products_with_purchase_change += 1 + + affected_products.append(preview) + + summary = { + "total_products": len(products), + "affected_products": len(affected_products), + "products_with_sales_change": products_with_sales_change, + "products_with_purchase_change": products_with_purchase_change, + "total_sales_change": float(_quantize_integer_keep_sign(total_sales_change)), + "total_purchase_change": float(_quantize_integer_keep_sign(total_purchase_change)), + "update_type": request.update_type.value, + "direction": request.direction.value, + "target": request.target.value, + "value": float(_quantize_non_negative_integer(request.value)) if request.update_type == BulkPriceUpdateType.AMOUNT else float(request.value) + } + + return BulkPriceUpdatePreviewResponse( + total_products=len(products), + affected_products=affected_products, + summary=summary + ) + + +def apply_bulk_price_update(db: Session, business_id: int, request: BulkPriceUpdateRequest) -> Dict[str, Any]: + """اعمال تغییرات قیمت گروهی""" + products = get_filtered_products(db, business_id, request) + + updated_count = 0 + errors = [] + + # اگر price_list_ids مشخص شده باشد، هم قیمت پایه و هم PriceItemها باید به روزرسانی شوند + for product in products: + try: + # بروزرسانی قیمت فروش + if request.target in [BulkPriceUpdateTarget.SALES_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_sales_price is not None: + new_sales_price = calculate_new_price(product.base_sales_price, request.update_type, request.direction, request.value) + product.base_sales_price = new_sales_price + + # بروزرسانی قیمت خرید + if request.target in [BulkPriceUpdateTarget.PURCHASE_PRICE, BulkPriceUpdateTarget.BOTH] and product.base_purchase_price is not None: + new_purchase_price = calculate_new_price(product.base_purchase_price, request.update_type, request.direction, request.value) + product.base_purchase_price = new_purchase_price + + # بروزرسانی آیتم‌های لیست قیمت مرتبط (در صورت مشخص بودن فیلترها) + q = db.query(PriceItem).filter(PriceItem.product_id == product.id) + if request.currency_ids: + q = q.filter(PriceItem.currency_id.in_(request.currency_ids)) + if request.price_list_ids: + q = q.filter(PriceItem.price_list_id.in_(request.price_list_ids)) + # اگر هدف فقط فروش/خرید نیست چون PriceItem فقط یک فیلد price دارد، همان price را تغییر می‌دهیم + for pi in q.all(): + new_pi_price = calculate_new_price(Decimal(pi.price), request.update_type, request.direction, request.value) + pi.price = new_pi_price + + updated_count += 1 + + except Exception as e: + errors.append(f"خطا در بروزرسانی کالای {product.name}: {str(e)}") + + db.commit() + + return { + "message": f"تغییرات قیمت برای {updated_count} کالا اعمال شد", + "updated_count": updated_count, + "total_products": len(products), + "errors": errors + } diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py index a8be166..2ff1927 100644 --- a/hesabixAPI/app/services/business_service.py +++ b/hesabixAPI/app/services/business_service.py @@ -192,7 +192,28 @@ def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) - business_dict['role'] = 'عضو' # دریافت دسترسی‌های کاربر برای این کسب و کار permission_obj = permission_repo.get_by_user_and_business(user_id, business.id) - business_dict['permissions'] = permission_obj.business_permissions if permission_obj else {} + if permission_obj and permission_obj.business_permissions: + perms = permission_obj.business_permissions + # Normalize to dict to avoid legacy list format + if isinstance(perms, dict): + business_dict['permissions'] = perms + elif isinstance(perms, list): + try: + if all(isinstance(item, list) and len(item) == 2 for item in perms): + business_dict['permissions'] = {k: v for k, v in perms if isinstance(k, str)} + elif all(isinstance(item, dict) for item in perms): + merged = {} + for it in perms: + merged.update({k: v for k, v in it.items()}) + business_dict['permissions'] = merged + else: + business_dict['permissions'] = {} + except Exception: + business_dict['permissions'] = {} + else: + business_dict['permissions'] = {} + else: + business_dict['permissions'] = {} all_businesses.append(business_dict) # اعمال فیلترها diff --git a/hesabixAPI/app/services/price_list_service.py b/hesabixAPI/app/services/price_list_service.py index 805d50a..0a40588 100644 --- a/hesabixAPI/app/services/price_list_service.py +++ b/hesabixAPI/app/services/price_list_service.py @@ -20,11 +20,9 @@ def create_price_list(db: Session, business_id: int, payload: PriceListCreateReq obj = repo.create( business_id=business_id, name=payload.name.strip(), - currency_id=payload.currency_id, - default_unit_id=payload.default_unit_id, is_active=payload.is_active, ) - return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)} + return {"message": "PRICE_LIST_CREATED", "data": _pl_to_dict(obj)} def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: @@ -53,10 +51,10 @@ def update_price_list(db: Session, business_id: int, id: int, payload: PriceList dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first() if dup: raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400) - updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, currency_id=payload.currency_id, default_unit_id=payload.default_unit_id, is_active=payload.is_active) + updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active) if not updated: return None - return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)} + return {"message": "PRICE_LIST_UPDATED", "data": _pl_to_dict(updated)} def delete_price_list(db: Session, business_id: int, id: int) -> bool: @@ -67,13 +65,13 @@ def delete_price_list(db: Session, business_id: int, id: int) -> bool: return repo.delete(id) -def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0) -> Dict[str, Any]: +def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> Dict[str, Any]: # مالکیت را از روی price_list بررسی می‌کنیم pl = db.get(PriceList, price_list_id) if not pl or pl.business_id != business_id: raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404) repo = PriceItemRepository(db) - return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip) + return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip, product_id=product_id, currency_id=currency_id) def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]: @@ -93,12 +91,12 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload price_list_id=price_list_id, product_id=payload.product_id, unit_id=payload.unit_id, - currency_id=payload.currency_id or pl.currency_id, - tier_name=payload.tier_name.strip(), + currency_id=payload.currency_id, + tier_name=(payload.tier_name.strip() if isinstance(payload.tier_name, str) and payload.tier_name.strip() else 'پیش‌فرض'), min_qty=payload.min_qty, price=payload.price, ) - return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)} + return {"message": "PRICE_ITEM_UPSERTED", "data": _pi_to_dict(obj)} def delete_price_item(db: Session, business_id: int, id: int) -> bool: @@ -118,8 +116,6 @@ def _pl_to_dict(obj: PriceList) -> Dict[str, Any]: "id": obj.id, "business_id": obj.business_id, "name": obj.name, - "currency_id": obj.currency_id, - "default_unit_id": obj.default_unit_id, "is_active": obj.is_active, "created_at": obj.created_at, "updated_at": obj.updated_at, diff --git a/hesabixAPI/app/services/product_service.py b/hesabixAPI/app/services/product_service.py index 71f7666..6d09190 100644 --- a/hesabixAPI/app/services/product_service.py +++ b/hesabixAPI/app/services/product_service.py @@ -103,7 +103,7 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest) _upsert_attributes(db, obj.id, business_id, payload.attribute_ids) - return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)} + return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)} def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: @@ -178,7 +178,7 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod return None _upsert_attributes(db, product_id, business_id, payload.attribute_ids) - return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)} + return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)} def delete_product(db: Session, product_id: int, business_id: int) -> bool: diff --git a/hesabixAPI/build/lib/adapters/__init__.py b/hesabixAPI/build/lib/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/adapters/api/__init__.py b/hesabixAPI/build/lib/adapters/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/adapters/api/v1/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/__init__.py new file mode 100644 index 0000000..d8d30c8 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/__init__.py @@ -0,0 +1,5 @@ +from .health import router as health # noqa: F401 +from .categories import router as categories # noqa: F401 +from .products import router as products # noqa: F401 +from .price_lists import router as price_lists # noqa: F401 + diff --git a/hesabixAPI/build/lib/adapters/api/v1/accounts.py b/hesabixAPI/build/lib/adapters/api/v1/accounts.py new file mode 100644 index 0000000..4f8f62e --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/api/v1/admin/email_config.py b/hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py new file mode 100644 index 0000000..fe79901 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/admin/email_config.py @@ -0,0 +1,349 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import List + +from adapters.db.session import get_db +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.email_config_repository import EmailConfigRepository +from adapters.api.v1.schema_models.email import ( + EmailConfigCreate, + EmailConfigUpdate, + EmailConfigResponse, + SendEmailRequest, + TestConnectionRequest +) +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.permissions import require_app_permission +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.i18n import gettext, negotiate_locale + +router = APIRouter(prefix="/admin/email", tags=["Email Configuration"]) + + +@router.get("/configs", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def get_email_configs( + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Get all email configurations""" + try: + email_repo = EmailConfigRepository(db) + configs = email_repo.get_all_configs() + + config_responses = [ + EmailConfigResponse.model_validate(config) for config in configs + ] + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_responses, request) + + return success_response( + data=formatted_data, + request=request + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def get_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Get specific email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + if not config: + locale = negotiate_locale(request.headers.get("Accept-Language")) + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def create_email_config( + request_data: EmailConfigCreate, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Create new email configuration""" + try: + email_repo = EmailConfigRepository(db) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + # Check if name already exists + existing_config = email_repo.get_by_name(request_data.name) + if existing_config: + raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale)) + + # Create new config + config = EmailConfig(**request_data.model_dump()) + email_repo.db.add(config) + email_repo.db.commit() + email_repo.db.refresh(config) + + # If this is the first config, set it as default + if not email_repo.get_default_config(): + email_repo.set_default_config(config.id) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def update_email_config( + config_id: int, + request_data: EmailConfigUpdate, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Update email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + # Check name uniqueness if name is being updated + if request_data.name and request_data.name != config.name: + existing_config = email_repo.get_by_name(request_data.name) + if existing_config: + raise HTTPException(status_code=400, detail=gettext("Configuration name already exists", locale)) + + # Update config + update_data = request_data.model_dump(exclude_unset=True) + + # Prevent changing is_default through update - use set-default endpoint instead + if 'is_default' in update_data: + del update_data['is_default'] + + for field, value in update_data.items(): + setattr(config, field, value) + + email_repo.update(config) + + config_response = EmailConfigResponse.model_validate(config) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(config_response.model_dump(), request) + + return success_response( + data=formatted_data, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/configs/{config_id}", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def delete_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Delete email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + # Prevent deletion of default config + if config.is_default: + raise HTTPException(status_code=400, detail=gettext("Cannot delete default configuration", locale)) + + email_repo.delete(config) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/test", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def test_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Test email configuration connection""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + is_connected = email_repo.test_connection(config) + + return success_response( + data={"connected": is_connected}, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/activate", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def activate_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Activate email configuration""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + success = email_repo.set_active_config(config_id) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to activate configuration", locale)) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/configs/{config_id}/set-default", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def set_default_email_config( + config_id: int, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Set email configuration as default""" + try: + email_repo = EmailConfigRepository(db) + config = email_repo.get_by_id(config_id) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not config: + raise HTTPException(status_code=404, detail=gettext("Email configuration not found", locale)) + + success = email_repo.set_default_config(config_id) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to set default configuration", locale)) + + return success_response( + data=None, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/send", response_model=SuccessResponse) +@require_app_permission("superadmin") +async def send_email( + request_data: SendEmailRequest, + request: Request, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user) +): + """Send email using configured SMTP""" + try: + from app.services.email_service import EmailService + + email_service = EmailService(db) + success = email_service.send_email( + to=request_data.to, + subject=request_data.subject, + body=request_data.body, + html_body=request_data.html_body, + config_id=request_data.config_id + ) + + # Get locale from request + locale = negotiate_locale(request.headers.get("Accept-Language")) + + if not success: + raise HTTPException(status_code=500, detail=gettext("Failed to send email", locale)) + + return success_response( + data={"sent": True}, + request=request + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py b/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py new file mode 100644 index 0000000..906242c --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/admin/file_storage.py @@ -0,0 +1,725 @@ +from typing import List, Optional +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Request +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_app_permission +from app.core.responses import success_response +from app.core.responses import ApiError +from app.core.i18n import locale_dependency +from app.services.file_storage_service import FileStorageService +from adapters.db.repositories.file_storage_repository import StorageConfigRepository, FileStorageRepository +from adapters.db.models.user import User +from adapters.db.models.file_storage import StorageConfig, FileStorage +from adapters.api.v1.schema_models.file_storage import ( + StorageConfigCreateRequest, + StorageConfigUpdateRequest, + FileUploadRequest, + FileVerificationRequest, + FileInfo, + FileUploadResponse, + StorageConfigResponse, + FileStatisticsResponse, + CleanupResponse +) + +router = APIRouter(prefix="/admin/files", tags=["Admin File Management"]) + + +@router.get("/", response_model=dict) +@require_app_permission("superadmin") +async def list_all_files( + request: Request, + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=100), + module_context: Optional[str] = Query(None), + is_temporary: Optional[bool] = Query(None), + is_verified: Optional[bool] = Query(None), + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """لیست تمام فایل‌ها با فیلتر""" + try: + file_repo = FileStorageRepository(db) + + # محاسبه offset برای pagination + offset = (page - 1) * size + + # ساخت فیلترها + filters = [] + if module_context: + filters.append(FileStorage.module_context == module_context) + if is_temporary is not None: + filters.append(FileStorage.is_temporary == is_temporary) + if is_verified is not None: + filters.append(FileStorage.is_verified == is_verified) + + # اضافه کردن فیلتر حذف نشده + filters.append(FileStorage.deleted_at.is_(None)) + + # دریافت فایل‌ها با فیلتر و pagination + files_query = db.query(FileStorage).filter(and_(*filters)) + total_count = files_query.count() + + files = files_query.order_by(FileStorage.created_at.desc()).offset(offset).limit(size).all() + + # تبدیل به فرمت مناسب + files_data = [] + for file in files: + files_data.append({ + "id": str(file.id), + "original_name": file.original_name, + "stored_name": file.stored_name, + "file_size": file.file_size, + "mime_type": file.mime_type, + "storage_type": file.storage_type, + "module_context": file.module_context, + "context_id": str(file.context_id) if file.context_id else None, + "is_temporary": file.is_temporary, + "is_verified": file.is_verified, + "is_active": file.is_active, + "created_at": file.created_at.isoformat(), + "updated_at": file.updated_at.isoformat(), + "expires_at": file.expires_at.isoformat() if file.expires_at else None, + "uploaded_by": file.uploaded_by, + "checksum": file.checksum + }) + + # محاسبه pagination info + total_pages = (total_count + size - 1) // size + has_next = page < total_pages + has_prev = page > 1 + + data = { + "files": files_data, + "pagination": { + "page": page, + "size": size, + "total_count": total_count, + "total_pages": total_pages, + "has_next": has_next, + "has_prev": has_prev + }, + "filters": { + "module_context": module_context, + "is_temporary": is_temporary, + "is_verified": is_verified + } + } + + return success_response(data, request) + except Exception as e: + raise ApiError( + code="FILE_LIST_ERROR", + message=translator.t("FILE_LIST_ERROR", f"خطا در دریافت لیست فایل‌ها: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.get("/unverified", response_model=dict) +@require_app_permission("superadmin") +async def get_unverified_files( + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """فایل‌های تایید نشده""" + try: + file_service = FileStorageService(db) + unverified_files = await file_service.file_repo.get_unverified_temporary_files() + + data = { + "unverified_files": [ + { + "file_id": str(file.id), + "original_name": file.original_name, + "file_size": file.file_size, + "module_context": file.module_context, + "created_at": file.created_at.isoformat(), + "expires_at": file.expires_at.isoformat() if file.expires_at else None + } + for file in unverified_files + ], + "count": len(unverified_files) + } + + return success_response(data, request) + except Exception as e: + raise ApiError( + code="UNVERIFIED_FILES_ERROR", + message=translator.t("UNVERIFIED_FILES_ERROR", f"خطا در دریافت فایل‌های تایید نشده: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.post("/cleanup-temporary", response_model=dict) +@require_app_permission("superadmin") +async def cleanup_temporary_files( + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """پاکسازی فایل‌های موقت""" + try: + file_service = FileStorageService(db) + cleanup_result = await file_service.cleanup_unverified_files() + + data = { + "message": translator.t("CLEANUP_COMPLETED", "Temporary files cleanup completed"), + "result": cleanup_result + } + + return success_response(data, request) + except Exception as e: + raise ApiError( + code="CLEANUP_ERROR", + message=translator.t("CLEANUP_ERROR", f"خطا در پاکسازی فایل‌های موقت: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.delete("/{file_id}", response_model=dict) +@require_app_permission("superadmin") +async def force_delete_file( + file_id: UUID, + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """حذف اجباری فایل""" + try: + file_service = FileStorageService(db) + success = await file_service.delete_file(file_id) + + if not success: + raise ApiError( + code="FILE_NOT_FOUND", + message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"), + http_status=404, + translator=translator + ) + + data = {"message": translator.t("FILE_DELETED_SUCCESS", "File deleted successfully")} + return success_response(data, request) + except ApiError: + raise + except Exception as e: + raise ApiError( + code="DELETE_FILE_ERROR", + message=translator.t("DELETE_FILE_ERROR", f"خطا در حذف فایل: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.put("/{file_id}/restore", response_model=dict) +@require_app_permission("superadmin") +async def restore_file( + file_id: UUID, + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """بازیابی فایل حذف شده""" + try: + file_repo = FileStorageRepository(db) + success = await file_repo.restore_file(file_id) + + if not success: + raise ApiError( + code="FILE_NOT_FOUND", + message=translator.t("FILE_NOT_FOUND", "فایل یافت نشد"), + http_status=404, + translator=translator + ) + + data = {"message": translator.t("FILE_RESTORED_SUCCESS", "File restored successfully")} + return success_response(data, request) + except ApiError: + raise + except Exception as e: + raise ApiError( + code="RESTORE_FILE_ERROR", + message=translator.t("RESTORE_FILE_ERROR", f"خطا در بازیابی فایل: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.get("/statistics", response_model=dict) +@require_app_permission("superadmin") +async def get_file_statistics( + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """آمار استفاده از فضای ذخیره‌سازی""" + try: + file_service = FileStorageService(db) + statistics = await file_service.get_storage_statistics() + + return success_response(statistics, request) + except Exception as e: + raise ApiError( + code="STATISTICS_ERROR", + message=translator.t("STATISTICS_ERROR", f"خطا در دریافت آمار: {str(e)}"), + http_status=500, + translator=translator + ) + + +# Storage Configuration Management +@router.get("/storage-configs/", response_model=dict) +@require_app_permission("superadmin") +async def get_storage_configs( + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """لیست تنظیمات ذخیره‌سازی""" + try: + config_repo = StorageConfigRepository(db) + configs = config_repo.get_all_configs() + + data = { + "configs": [ + { + "id": str(config.id), + "name": config.name, + "storage_type": config.storage_type, + "is_default": config.is_default, + "is_active": config.is_active, + "config_data": config.config_data, + "created_at": config.created_at.isoformat() + } + for config in configs + ] + } + + return success_response(data, request) + except Exception as e: + raise ApiError( + code="STORAGE_CONFIGS_ERROR", + message=translator.t("STORAGE_CONFIGS_ERROR", f"خطا در دریافت تنظیمات ذخیره‌سازی: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.post("/storage-configs/", response_model=dict) +@require_app_permission("superadmin") +async def create_storage_config( + request: Request, + config_request: StorageConfigCreateRequest, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """ایجاد تنظیمات ذخیره‌سازی جدید""" + try: + config_repo = StorageConfigRepository(db) + + config = await config_repo.create_config( + name=config_request.name, + storage_type=config_request.storage_type, + config_data=config_request.config_data, + created_by=current_user.get_user_id(), + is_default=config_request.is_default, + is_active=config_request.is_active + ) + + data = { + "message": translator.t("STORAGE_CONFIG_CREATED", "Storage configuration created successfully"), + "config_id": str(config.id) + } + + return success_response(data, request) + except Exception as e: + raise ApiError( + code="CREATE_STORAGE_CONFIG_ERROR", + message=translator.t("CREATE_STORAGE_CONFIG_ERROR", f"خطا در ایجاد تنظیمات ذخیره‌سازی: {str(e)}"), + http_status=400, + translator=translator + ) + + +@router.put("/storage-configs/{config_id}", response_model=dict) +@require_app_permission("superadmin") +async def update_storage_config( + config_id: UUID, + request: Request, + config_request: StorageConfigUpdateRequest, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """بروزرسانی تنظیمات ذخیره‌سازی""" + try: + config_repo = StorageConfigRepository(db) + + # TODO: پیاده‌سازی بروزرسانی + data = {"message": translator.t("STORAGE_CONFIG_UPDATE_NOT_IMPLEMENTED", "Storage configuration update - to be implemented")} + return success_response(data, request) + except Exception as e: + raise ApiError( + code="UPDATE_STORAGE_CONFIG_ERROR", + message=translator.t("UPDATE_STORAGE_CONFIG_ERROR", f"خطا در بروزرسانی تنظیمات ذخیره‌سازی: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.put("/storage-configs/{config_id}/set-default", response_model=dict) +@require_app_permission("superadmin") +async def set_default_storage_config( + config_id: UUID, + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """تنظیم به عنوان پیش‌فرض""" + try: + config_repo = StorageConfigRepository(db) + success = await config_repo.set_default_config(config_id) + + if not success: + raise ApiError( + code="STORAGE_CONFIG_NOT_FOUND", + message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"), + http_status=404, + translator=translator + ) + + data = {"message": translator.t("DEFAULT_STORAGE_CONFIG_UPDATED", "Default storage configuration updated successfully")} + return success_response(data, request) + except ApiError: + raise + except Exception as e: + raise ApiError( + code="SET_DEFAULT_STORAGE_CONFIG_ERROR", + message=translator.t("SET_DEFAULT_STORAGE_CONFIG_ERROR", f"خطا در تنظیم پیش‌فرض: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.delete("/storage-configs/{config_id}", response_model=dict) +@require_app_permission("superadmin") +async def delete_storage_config( + config_id: str, + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """حذف تنظیمات ذخیره‌سازی""" + try: + config_repo = StorageConfigRepository(db) + + # بررسی وجود فایل‌ها قبل از حذف + file_count = config_repo.count_files_by_storage_config(config_id) + if file_count > 0: + raise ApiError( + code="STORAGE_CONFIG_HAS_FILES", + message=translator.t("STORAGE_CONFIG_HAS_FILES", f"این تنظیمات ذخیره‌سازی دارای {file_count} فایل است و قابل حذف نیست"), + http_status=400, + translator=translator + ) + + success = config_repo.delete_config(config_id) + + if not success: + raise ApiError( + code="STORAGE_CONFIG_NOT_FOUND", + message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"), + http_status=404, + translator=translator + ) + + data = {"message": translator.t("STORAGE_CONFIG_DELETED", "Storage configuration deleted successfully")} + return success_response(data, request) + except ApiError: + raise + except Exception as e: + raise ApiError( + code="DELETE_STORAGE_CONFIG_ERROR", + message=translator.t("DELETE_STORAGE_CONFIG_ERROR", f"خطا در حذف تنظیمات ذخیره‌سازی: {str(e)}"), + http_status=500, + translator=translator + ) + + +@router.post("/storage-configs/{config_id}/test", response_model=dict) +@require_app_permission("superadmin") +async def test_storage_config( + config_id: str, + request: Request, + db: Session = Depends(get_db), + current_user: AuthContext = Depends(get_current_user), + translator = Depends(locale_dependency) +): + """تست اتصال به storage""" + try: + config_repo = StorageConfigRepository(db) + config = db.query(StorageConfig).filter(StorageConfig.id == config_id).first() + + if not config: + raise ApiError( + code="STORAGE_CONFIG_NOT_FOUND", + message=translator.t("STORAGE_CONFIG_NOT_FOUND", "تنظیمات ذخیره‌سازی یافت نشد"), + http_status=404, + translator=translator + ) + + # تست اتصال بر اساس نوع storage + test_result = await _test_storage_connection(config) + + if test_result["success"]: + data = { + "message": translator.t("STORAGE_CONNECTION_SUCCESS", "اتصال به storage موفقیت‌آمیز بود"), + "test_result": test_result + } + else: + data = { + "message": translator.t("STORAGE_CONNECTION_FAILED", "اتصال به storage ناموفق بود"), + "test_result": test_result + } + + return success_response(data, request) + except ApiError: + raise + except Exception as e: + raise ApiError( + code="TEST_STORAGE_CONFIG_ERROR", + message=translator.t("TEST_STORAGE_CONFIG_ERROR", f"خطا در تست اتصال: {str(e)}"), + http_status=500, + translator=translator + ) + + +# Helper function for testing storage connections +async def _test_storage_connection(config: StorageConfig) -> dict: + """تست اتصال به storage بر اساس نوع آن""" + import os + import tempfile + from datetime import datetime + + try: + if config.storage_type == "local": + return await _test_local_storage(config) + elif config.storage_type == "ftp": + return await _test_ftp_storage(config) + else: + return { + "success": False, + "error": f"نوع storage پشتیبانی نشده: {config.storage_type}", + "tested_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "success": False, + "error": str(e), + "tested_at": datetime.utcnow().isoformat() + } + + +async def _test_local_storage(config: StorageConfig) -> dict: + """تست اتصال به local storage""" + import os + from datetime import datetime + + try: + base_path = config.config_data.get("base_path", "/tmp/hesabix_files") + + # بررسی وجود مسیر + if not os.path.exists(base_path): + # تلاش برای ایجاد مسیر + os.makedirs(base_path, exist_ok=True) + + # بررسی دسترسی نوشتن + test_file_path = os.path.join(base_path, f"test_connection_{datetime.utcnow().timestamp()}.txt") + + # نوشتن فایل تست + with open(test_file_path, "w") as f: + f.write("Test connection file") + + # خواندن فایل تست + with open(test_file_path, "r") as f: + content = f.read() + + # حذف فایل تست + os.remove(test_file_path) + + if content == "Test connection file": + return { + "success": True, + "message": "اتصال به local storage موفقیت‌آمیز بود", + "storage_type": "local", + "base_path": base_path, + "tested_at": datetime.utcnow().isoformat() + } + else: + return { + "success": False, + "error": "خطا در خواندن فایل تست", + "tested_at": datetime.utcnow().isoformat() + } + + except PermissionError: + return { + "success": False, + "error": "دسترسی به مسیر ذخیره‌سازی وجود ندارد", + "tested_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "success": False, + "error": f"خطا در تست local storage: {str(e)}", + "tested_at": datetime.utcnow().isoformat() + } + + +async def _test_ftp_storage(config: StorageConfig) -> dict: + """تست اتصال به FTP storage""" + import ftplib + import tempfile + import os + from datetime import datetime + + try: + # دریافت تنظیمات FTP + config_data = config.config_data + host = config_data.get("host") + port = int(config_data.get("port", 21)) + username = config_data.get("username") + password = config_data.get("password") + directory = config_data.get("directory", "/") + use_tls = config_data.get("use_tls", False) + + # بررسی وجود پارامترهای ضروری + if not all([host, username, password]): + return { + "success": False, + "error": "پارامترهای ضروری FTP (host, username, password) موجود نیست", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + + # اتصال به FTP + if use_tls: + ftp = ftplib.FTP_TLS() + else: + ftp = ftplib.FTP() + + # تنظیم timeout + ftp.connect(host, port, timeout=10) + ftp.login(username, password) + + # تغییر به دایرکتوری مورد نظر + if directory and directory != "/": + try: + ftp.cwd(directory) + except ftplib.error_perm: + return { + "success": False, + "error": f"دسترسی به دایرکتوری {directory} وجود ندارد", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + + # تست نوشتن فایل + test_filename = f"test_connection_{datetime.utcnow().timestamp()}.txt" + test_content = "Test FTP connection file" + + # ایجاد فایل موقت + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as temp_file: + temp_file.write(test_content) + temp_file_path = temp_file.name + + try: + # آپلود فایل + with open(temp_file_path, 'rb') as file: + ftp.storbinary(f'STOR {test_filename}', file) + + # بررسی وجود فایل + file_list = [] + ftp.retrlines('LIST', file_list.append) + file_exists = any(test_filename in line for line in file_list) + + if not file_exists: + return { + "success": False, + "error": "فایل تست آپلود نشد", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + + # حذف فایل تست + try: + ftp.delete(test_filename) + except ftplib.error_perm: + pass # اگر نتوانست حذف کند، مهم نیست + + # بستن اتصال + ftp.quit() + + return { + "success": True, + "message": "اتصال به FTP server موفقیت‌آمیز بود", + "storage_type": "ftp", + "host": host, + "port": port, + "directory": directory, + "use_tls": use_tls, + "tested_at": datetime.utcnow().isoformat() + } + + finally: + # حذف فایل موقت + try: + os.unlink(temp_file_path) + except: + pass + + except ftplib.error_perm as e: + return { + "success": False, + "error": f"خطا در احراز هویت FTP: {str(e)}", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + except ftplib.error_temp as e: + return { + "success": False, + "error": f"خطای موقت FTP: {str(e)}", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + except ConnectionRefusedError: + return { + "success": False, + "error": "اتصال به سرور FTP رد شد. بررسی کنید که سرور در حال اجرا باشد", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "success": False, + "error": f"خطا در تست FTP storage: {str(e)}", + "storage_type": "ftp", + "tested_at": datetime.utcnow().isoformat() + } diff --git a/hesabixAPI/build/lib/adapters/api/v1/auth.py b/hesabixAPI/build/lib/adapters/api/v1/auth.py new file mode 100644 index 0000000..2854aca --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/auth.py @@ -0,0 +1,936 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +import datetime +from fastapi import APIRouter, Depends, Request, Query +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.responses import success_response, format_datetime_fields +from app.services.captcha_service import create_captcha +from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats +from app.services.pdf import PDFService +from .schemas import ( + RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, + ChangePasswordRequest, CreateApiKeyRequest, QueryInfo, FilterItem, + SuccessResponse, CaptchaResponse, LoginResponse, ApiKeyResponse, + ReferralStatsResponse, UserResponse +) +from app.core.auth_dependency import get_current_user, AuthContext +from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/captcha", + summary="تولید کپچای عددی", + description="تولید کپچای عددی برای تأیید هویت در عملیات حساس", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کپچا با موفقیت تولید شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کپچا تولید شد", + "data": { + "captcha_id": "abc123def456", + "image_base64": "iVBORw0KGgoAAAANSUhEUgAA...", + "ttl_seconds": 180 + } + } + } + } + } + } +) +def generate_captcha(db: Session = Depends(get_db)) -> dict: + captcha_id, image_base64, ttl = create_captcha(db) + return success_response({ + "captcha_id": captcha_id, + "image_base64": image_base64, + "ttl_seconds": ttl, + }) + + +@router.get("/me", + summary="دریافت اطلاعات کاربر کنونی", + description="دریافت اطلاعات کامل کاربری که در حال حاضر وارد سیستم شده است", + response_model=SuccessResponse, + responses={ + 200: { + "description": "اطلاعات کاربر با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "اطلاعات کاربر دریافت شد", + "data": { + "id": 1, + "email": "user@example.com", + "mobile": "09123456789", + "first_name": "احمد", + "last_name": "احمدی", + "is_active": True, + "referral_code": "ABC123", + "referred_by_user_id": None, + "app_permissions": {"admin": True}, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است", + "content": { + "application/json": { + "example": { + "success": False, + "message": "احراز هویت مورد نیاز است", + "error_code": "UNAUTHORIZED" + } + } + } + } + } +) +def get_current_user_info( + request: Request, + ctx: AuthContext = Depends(get_current_user) +) -> dict: + """دریافت اطلاعات کاربر کنونی""" + return success_response(ctx.to_dict(), request) + + +@router.post("/register", + summary="ثبت‌نام کاربر جدید", + description="ثبت‌نام کاربر جدید در سیستم با تأیید کپچا", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کاربر با موفقیت ثبت‌نام شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "ثبت‌نام با موفقیت انجام شد", + "data": { + "api_key": "sk_1234567890abcdef", + "expires_at": None, + "user": { + "id": 1, + "first_name": "احمد", + "last_name": "احمدی", + "email": "ahmad@example.com", + "mobile": "09123456789", + "referral_code": "ABC123", + "app_permissions": None + } + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کپچا نامعتبر است", + "error_code": "INVALID_CAPTCHA" + } + } + } + }, + 409: { + "description": "کاربر با این ایمیل یا موبایل قبلاً ثبت‌نام کرده است", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کاربر با این ایمیل قبلاً ثبت‌نام کرده است", + "error_code": "USER_EXISTS" + } + } + } + } + } +) +def register(request: Request, payload: RegisterRequest, db: Session = Depends(get_db)) -> dict: + user_id = register_user( + db=db, + first_name=payload.first_name, + last_name=payload.last_name, + email=payload.email, + mobile=payload.mobile, + password=payload.password, + captcha_id=payload.captcha_id, + captcha_code=payload.captcha_code, + referrer_code=payload.referrer_code, + ) + # Create a session api key similar to login + user_agent = request.headers.get("User-Agent") + ip = request.client.host if request.client else None + from app.core.security import generate_api_key + from adapters.db.repositories.api_key_repo import ApiKeyRepository + api_key, key_hash = generate_api_key() + api_repo = ApiKeyRepository(db) + api_repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=payload.device_id, user_agent=user_agent, ip=ip, expires_at=None) + from adapters.db.models.user import User + user_obj = db.get(User, user_id) + user = {"id": user_id, "first_name": payload.first_name, "last_name": payload.last_name, "email": payload.email, "mobile": payload.mobile, "referral_code": getattr(user_obj, "referral_code", None), "app_permissions": getattr(user_obj, "app_permissions", None)} + response_data = {"api_key": api_key, "expires_at": None, "user": user} + formatted_data = format_datetime_fields(response_data, request) + return success_response(formatted_data, request) + + +@router.post("/login", + summary="ورود با ایمیل یا موبایل", + description="ورود کاربر به سیستم با استفاده از ایمیل یا شماره موبایل و رمز عبور", + response_model=SuccessResponse, + responses={ + 200: { + "description": "ورود با موفقیت انجام شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "ورود با موفقیت انجام شد", + "data": { + "api_key": "sk_1234567890abcdef", + "expires_at": "2024-01-02T00:00:00Z", + "user": { + "id": 1, + "first_name": "احمد", + "last_name": "احمدی", + "email": "ahmad@example.com", + "mobile": "09123456789", + "referral_code": "ABC123", + "app_permissions": {"admin": True} + } + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کپچا نامعتبر است", + "error_code": "INVALID_CAPTCHA" + } + } + } + }, + 401: { + "description": "اطلاعات ورود نامعتبر است", + "content": { + "application/json": { + "example": { + "success": False, + "message": "ایمیل یا رمز عبور اشتباه است", + "error_code": "INVALID_CREDENTIALS" + } + } + } + } + } +) +def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)) -> dict: + user_agent = request.headers.get("User-Agent") + ip = request.client.host if request.client else None + api_key, expires_at, user = login_user( + db=db, + identifier=payload.identifier, + password=payload.password, + captcha_id=payload.captcha_id, + captcha_code=payload.captcha_code, + device_id=payload.device_id, + user_agent=user_agent, + ip=ip, + ) + # Ensure referral_code is included + from adapters.db.repositories.user_repo import UserRepository + repo = UserRepository(db) + from adapters.db.models.user import User + user_obj = None + if 'id' in user and user['id']: + user_obj = repo.db.get(User, user['id']) + if user_obj is not None: + user["referral_code"] = getattr(user_obj, "referral_code", None) + response_data = {"api_key": api_key, "expires_at": expires_at, "user": user} + formatted_data = format_datetime_fields(response_data, request) + return success_response(formatted_data, request) + + +@router.post("/forgot-password", + summary="ایجاد توکن بازنشانی رمز عبور", + description="ایجاد توکن برای بازنشانی رمز عبور کاربر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "توکن بازنشانی با موفقیت ایجاد شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "توکن بازنشانی ارسال شد", + "data": { + "ok": True, + "token": "reset_token_1234567890abcdef" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کپچا نامعتبر است", + "error_code": "INVALID_CAPTCHA" + } + } + } + }, + 404: { + "description": "کاربر یافت نشد", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کاربر با این ایمیل یا موبایل یافت نشد", + "error_code": "USER_NOT_FOUND" + } + } + } + } + } +) +def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)) -> dict: + # In production do not return token; send via email/SMS. Here we return for dev/testing. + token = create_password_reset(db=db, identifier=payload.identifier, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code) + return success_response({"ok": True, "token": token if token else None}) + + +@router.post("/reset-password", + summary="بازنشانی رمز عبور با توکن", + description="بازنشانی رمز عبور کاربر با استفاده از توکن دریافتی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "رمز عبور با موفقیت بازنشانی شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "رمز عبور با موفقیت تغییر کرد", + "data": { + "ok": True + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کپچا نامعتبر است", + "error_code": "INVALID_CAPTCHA" + } + } + } + }, + 404: { + "description": "توکن نامعتبر یا منقضی شده است", + "content": { + "application/json": { + "example": { + "success": False, + "message": "توکن نامعتبر یا منقضی شده است", + "error_code": "INVALID_TOKEN" + } + } + } + } + } +) +def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict: + reset_password(db=db, token=payload.token, new_password=payload.new_password, captcha_id=payload.captcha_id, captcha_code=payload.captcha_code) + return success_response({"ok": True}) + + +@router.get("/api-keys", + summary="لیست کلیدهای API شخصی", + description="دریافت لیست کلیدهای API شخصی کاربر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست کلیدهای API با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست کلیدهای API دریافت شد", + "data": [ + { + "id": 1, + "name": "کلید اصلی", + "scopes": "read,write", + "device_id": "device123", + "user_agent": "Mozilla/5.0...", + "ip": "192.168.1.1", + "expires_at": None, + "last_used_at": "2024-01-01T12:00:00Z", + "created_at": "2024-01-01T00:00:00Z" + } + ] + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + items = list_personal_keys(db, ctx.user.id) + return success_response(items) + + +@router.post("/api-keys", + summary="ایجاد کلید API شخصی", + description="ایجاد کلید API جدید برای کاربر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کلید API با موفقیت ایجاد شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کلید API ایجاد شد", + "data": { + "id": 1, + "api_key": "sk_1234567890abcdef" + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None) + return success_response({"id": id_, "api_key": api_key}) + + +@router.post("/change-password", + summary="تغییر رمز عبور", + description="تغییر رمز عبور کاربر با تأیید رمز عبور فعلی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "رمز عبور با موفقیت تغییر کرد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "رمز عبور با موفقیت تغییر کرد", + "data": { + "ok": True + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها", + "content": { + "application/json": { + "example": { + "success": False, + "message": "رمز عبور فعلی اشتباه است", + "error_code": "INVALID_CURRENT_PASSWORD" + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def change_password_endpoint(request: Request, payload: ChangePasswordRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + # دریافت translator از request state + translator = getattr(request.state, "translator", None) + + change_password( + db=db, + user_id=ctx.user.id, + current_password=payload.current_password, + new_password=payload.new_password, + confirm_password=payload.confirm_password, + translator=translator + ) + return success_response({"ok": True}) + + +@router.delete("/api-keys/{key_id}", + summary="حذف کلید API", + description="حذف کلید API مشخص شده", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کلید API با موفقیت حذف شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کلید API حذف شد", + "data": { + "ok": True + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 404: { + "description": "کلید API یافت نشد", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کلید API یافت نشد", + "error_code": "API_KEY_NOT_FOUND" + } + } + } + } + } +) +def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: + revoke_key(db, ctx.user.id, key_id) + return success_response({"ok": True}) + + +@router.get("/referrals/stats", + summary="آمار معرفی‌ها", + description="دریافت آمار معرفی‌های کاربر فعلی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "آمار معرفی‌ها با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "آمار معرفی‌ها دریافت شد", + "data": { + "total_referrals": 25, + "active_referrals": 20, + "recent_referrals": 5, + "referral_rate": 0.8 + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str = Query(None, description="تاریخ شروع (ISO format)"), end: str = Query(None, description="تاریخ پایان (ISO format)")): + from datetime import datetime + start_dt = datetime.fromisoformat(start) if start else None + end_dt = datetime.fromisoformat(end) if end else None + stats = referral_stats(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt) + return success_response(stats) + + +@router.post("/referrals/list", + summary="لیست معرفی‌ها با فیلتر پیشرفته", + description="دریافت لیست معرفی‌ها با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست معرفی‌ها با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست معرفی‌ها دریافت شد", + "data": { + "items": [ + { + "id": 1, + "first_name": "علی", + "last_name": "احمدی", + "email": "ali@example.com", + "mobile": "09123456789", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "limit": 10, + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def get_referral_list_advanced( + request: Request, + query_info: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """ + دریافت لیست معرفی‌ها با قابلیت فیلتر پیشرفته + + پارامترهای QueryInfo: + - sort_by: فیلد مرتب‌سازی (مثال: created_at, first_name, last_name, email) + - sort_desc: ترتیب نزولی (true/false) + - take: تعداد رکورد در هر صفحه (پیش‌فرض: 10) + - skip: تعداد رکورد صرف‌نظر شده (پیش‌فرض: 0) + - search: عبارت جستجو + - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"]) + - filters: آرایه فیلترها با ساختار: + [ + { + "property": "created_at", + "operator": ">=", + "value": "2024-01-01T00:00:00" + }, + { + "property": "first_name", + "operator": "*", + "value": "احمد" + } + ] + """ + from adapters.db.repositories.user_repo import UserRepository + from adapters.db.models.user import User + from datetime import datetime + + # Create a custom query for referrals + repo = UserRepository(db) + + # Add filter for referrals only (users with referred_by_user_id = current user) + referral_filter = FilterItem( + property="referred_by_user_id", + operator="=", + value=ctx.user.id + ) + + # Add referral filter to existing filters + if query_info.filters is None: + query_info.filters = [referral_filter] + else: + query_info.filters.append(referral_filter) + + # Set default search fields for referrals + if query_info.search_fields is None: + query_info.search_fields = ["first_name", "last_name", "email"] + + # Execute query with filters + referrals, total = repo.query_with_filters(query_info) + + # Convert to dictionary format + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + # Format datetime fields + formatted_referrals = format_datetime_fields(referral_dicts, request) + + # Calculate pagination info + page = (query_info.skip // query_info.take) + 1 + total_pages = (total + query_info.take - 1) // query_info.take + + return success_response({ + "items": formatted_referrals, + "total": total, + "page": page, + "limit": query_info.take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1 + }, request) + + +@router.post("/referrals/export/pdf", + summary="خروجی PDF لیست معرفی‌ها", + description="خروجی PDF لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص", + responses={ + 200: { + "description": "فایل PDF با موفقیت تولید شد", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def export_referrals_pdf( + request: Request, + query_info: QueryInfo, + selected_only: bool = False, + selected_indices: str | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Response: + """ + خروجی PDF لیست معرفی‌ها + + پارامترها: + - selected_only: آیا فقط سطرهای انتخاب شده export شوند + - selected_indices: لیست ایندکس‌های انتخاب شده (JSON string) + - سایر پارامترهای QueryInfo برای فیلتر + """ + from app.services.pdf import PDFService + from app.services.auth_service import referral_stats + import json + + # Parse selected indices if provided + indices = None + if selected_only and selected_indices: + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + + # Get stats for the report + stats = None + try: + # Extract date range from filters if available + start_date = None + end_date = None + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == 'created_at': + if filter_item.operator == '>=': + start_date = filter_item.value + elif filter_item.operator == '<': + end_date = filter_item.value + + stats = referral_stats( + db=db, + user_id=ctx.user.id, + start=start_date, + end=end_date + ) + except Exception: + pass # Continue without stats + + # Get calendar type from request headers + calendar_header = request.headers.get("X-Calendar-Type", "jalali") + calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian" + + # Generate PDF using new modular service + pdf_service = PDFService() + + # Get locale from request headers + locale_header = request.headers.get("Accept-Language", "fa") + locale = "fa" if locale_header.startswith("fa") else "en" + + pdf_bytes = pdf_service.generate_pdf( + module_name='marketing', + data={}, # Empty data - module will fetch its own data + calendar_type=calendar_type, + locale=locale, + db=db, + user_id=ctx.user.id, + query_info=query_info, + selected_indices=indices, + stats=stats + ) + + # Return PDF response + from fastapi.responses import Response + import datetime + + filename = f"referrals_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.post("/referrals/export/excel", + summary="خروجی Excel لیست معرفی‌ها", + description="خروجی Excel لیست معرفی‌ها با قابلیت فیلتر و انتخاب سطرهای خاص", + responses={ + 200: { + "description": "فایل Excel با موفقیت تولید شد", + "content": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def export_referrals_excel( + request: Request, + query_info: QueryInfo, + selected_only: bool = False, + selected_indices: str | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Response: + """ + خروجی Excel لیست معرفی‌ها (فایل Excel واقعی برای دانلود) + + پارامترها: + - selected_only: آیا فقط سطرهای انتخاب شده export شوند + - selected_indices: لیست ایندکس‌های انتخاب شده (JSON string) + - سایر پارامترهای QueryInfo برای فیلتر + """ + from app.services.pdf import PDFService + import json + import io + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + + # Parse selected indices if provided + indices = None + if selected_only and selected_indices: + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + + # Get calendar type from request headers + calendar_header = request.headers.get("X-Calendar-Type", "jalali") + calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian" + + # Generate Excel data using new modular service + pdf_service = PDFService() + + # Get locale from request headers + locale_header = request.headers.get("Accept-Language", "fa") + locale = "fa" if locale_header.startswith("fa") else "en" + + excel_data = pdf_service.generate_excel_data( + module_name='marketing', + data={}, # Empty data - module will fetch its own data + calendar_type=calendar_type, + locale=locale, + db=db, + user_id=ctx.user.id, + query_info=query_info, + selected_indices=indices + ) + + # Create Excel workbook + wb = Workbook() + ws = wb.active + ws.title = "Referrals" + + # Define styles + 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') + ) + + # Add headers + if excel_data: + headers = list(excel_data[0].keys()) + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border + + # Add data rows + for row, data in enumerate(excel_data, 2): + for col, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col, value=data.get(header, "")) + cell.border = border + # Center align for numbers and dates + if header in ["ردیف", "Row", "تاریخ ثبت", "Registration Date"]: + cell.alignment = Alignment(horizontal="center") + + # Auto-adjust column widths + 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: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Save to BytesIO + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + # Generate filename + filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + # Return Excel file as response + return Response( + content=excel_buffer.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ) + diff --git a/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py new file mode 100644 index 0000000..da722d2 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/business_dashboard.py @@ -0,0 +1,293 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from fastapi import APIRouter, Depends, Request, HTTPException +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.services.business_dashboard_service import ( + get_business_dashboard_data, get_business_members, get_business_statistics +) + +router = APIRouter(prefix="/business", tags=["business-dashboard"]) + + +@router.post("/{business_id}/dashboard", + summary="دریافت داشبورد کسب و کار", + description="دریافت اطلاعات کلی و آمار کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "داشبورد کسب و کار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "داشبورد کسب و کار دریافت شد", + "data": { + "business_info": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "created_at": "1403/01/01 00:00:00", + "member_count": 5 + }, + "statistics": { + "total_sales": 1000000.0, + "total_purchases": 500000.0, + "active_members": 5, + "recent_transactions": 25 + }, + "recent_activities": [ + { + "id": 1, + "title": "فروش جدید", + "description": "فروش محصول A به مبلغ 100,000 تومان", + "icon": "sell", + "time_ago": "2 ساعت پیش" + } + ] + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +@require_business_access("business_id") +def get_business_dashboard( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت داشبورد کسب و کار""" + dashboard_data = get_business_dashboard_data(db, business_id, ctx) + formatted_data = format_datetime_fields(dashboard_data, request) + return success_response(formatted_data, request) + + +@router.post("/{business_id}/members", + summary="لیست اعضای کسب و کار", + description="دریافت لیست اعضای کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست اعضا با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست اعضا دریافت شد", + "data": { + "items": [ + { + "id": 1, + "user_id": 2, + "first_name": "احمد", + "last_name": "احمدی", + "email": "ahmad@example.com", + "role": "مدیر فروش", + "permissions": { + "sales": {"write": True, "delete": True}, + "reports": {"export": True} + }, + "joined_at": "1403/01/01 00:00:00" + } + ], + "pagination": { + "total": 1, + "page": 1, + "per_page": 10, + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + } + } +) +@require_business_access("business_id") +def get_business_members( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """لیست اعضای کسب و کار""" + members_data = get_business_members(db, business_id, ctx) + formatted_data = format_datetime_fields(members_data, request) + return success_response(formatted_data, request) + + +@router.post("/{business_id}/statistics", + summary="آمار کسب و کار", + description="دریافت آمار تفصیلی کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "آمار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "آمار دریافت شد", + "data": { + "sales_by_month": [ + {"month": "1403/01", "amount": 500000}, + {"month": "1403/02", "amount": 750000} + ], + "top_products": [ + {"name": "محصول A", "sales_count": 100, "revenue": 500000} + ], + "member_activity": { + "active_today": 3, + "active_this_week": 5, + "total_members": 8 + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + } + } +) +@require_business_access("business_id") +def get_business_statistics( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """آمار کسب و کار""" + stats_data = get_business_statistics(db, business_id, ctx) + formatted_data = format_datetime_fields(stats_data, request) + return success_response(formatted_data, request) + + +@router.post("/{business_id}/info-with-permissions", + summary="دریافت اطلاعات کسب و کار و دسترسی‌ها", + description="دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "اطلاعات کسب و کار و دسترسی‌ها با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "اطلاعات کسب و کار و دسترسی‌ها دریافت شد", + "data": { + "business_info": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "address": "تهران، خیابان ولیعصر", + "phone": "02112345678", + "mobile": "09123456789", + "created_at": "1403/01/01 00:00:00" + }, + "user_permissions": { + "people": {"add": True, "view": True, "edit": True, "delete": False}, + "products": {"add": True, "view": True, "edit": False, "delete": False}, + "invoices": {"add": True, "view": True, "edit": True, "delete": True} + }, + "is_owner": False, + "role": "عضو", + "has_access": True + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +@require_business_access("business_id") +def get_business_info_with_permissions( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت اطلاعات کسب و کار همراه با دسترسی‌های کاربر""" + from adapters.db.models.business import Business + from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository + + # دریافت اطلاعات کسب و کار + business = db.get(Business, business_id) + if not business: + from app.core.responses import ApiError + raise ApiError("NOT_FOUND", "Business not found", http_status=404) + + # دریافت دسترسی‌های کاربر + permissions = {} + if not ctx.is_superadmin() and not ctx.is_business_owner(business_id): + # دریافت دسترسی‌های کسب و کار از business_permissions + permission_repo = BusinessPermissionRepository(db) + # ترتیب آرگومان‌ها: (user_id, business_id) + business_permission = permission_repo.get_by_user_and_business(ctx.get_user_id(), business_id) + if business_permission: + permissions = business_permission.business_permissions or {} + + business_info = { + "id": business.id, + "name": business.name, + "business_type": business.business_type.value, + "business_field": business.business_field.value, + "owner_id": business.owner_id, + "address": business.address, + "phone": business.phone, + "mobile": business.mobile, + "created_at": business.created_at.isoformat(), + } + + response_data = { + "business_info": business_info, + "user_permissions": permissions, + "is_owner": ctx.is_business_owner(business_id), + "role": "مالک" if ctx.is_business_owner(business_id) else "عضو", + "has_access": ctx.can_access_business(business_id) + } + + formatted_data = format_datetime_fields(response_data, request) + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/business_users.py b/hesabixAPI/build/lib/adapters/api/v1/business_users.py new file mode 100644 index 0000000..dfc9482 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/business_users.py @@ -0,0 +1,564 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from fastapi import APIRouter, Depends, Request, HTTPException +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.api.v1.schemas import ( + BusinessUsersListResponse, AddUserRequest, AddUserResponse, + UpdatePermissionsRequest, UpdatePermissionsResponse, RemoveUserResponse +) +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository +from adapters.db.models.user import User +from adapters.db.models.business import Business + +router = APIRouter(prefix="/business", tags=["business-users"]) + + +@router.get("/{business_id}/users/{user_id}", + summary="دریافت جزئیات کاربر", + description="دریافت جزئیات کاربر و دسترسی‌هایش در کسب و کار", + responses={ + 200: { + "description": "جزئیات کاربر با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "جزئیات کاربر دریافت شد", + "user": { + "id": 1, + "business_id": 1, + "user_id": 2, + "user_name": "علی احمدی", + "user_email": "ali@example.com", + "user_phone": "09123456789", + "role": "member", + "status": "active", + "added_at": "2024-01-01T00:00:00Z", + "last_active": "2024-01-01T12:00:00Z", + "permissions": { + "people": { + "add": True, + "view": True, + "edit": False, + "delete": False + } + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + }, + 404: { + "description": "کاربر یافت نشد" + } + } +) +@require_business_access("business_id") +def get_user_details( + request: Request, + business_id: int, + user_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت جزئیات کاربر و دسترسی‌هایش""" + import logging + logger = logging.getLogger(__name__) + + current_user_id = ctx.get_user_id() + logger.info(f"Getting user details for user {user_id} in business {business_id}, current user: {current_user_id}") + + # Check if user is business owner or has permission to manage users + business = db.get(Business, business_id) + if not business: + logger.error(f"Business {business_id} not found") + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + is_owner = business.owner_id == current_user_id + can_manage = ctx.can_manage_business_users() + + logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}") + + if not is_owner and not can_manage: + logger.warning(f"User {current_user_id} does not have permission to view user details for business {business_id}") + raise HTTPException(status_code=403, detail="شما مجوز مشاهده جزئیات کاربران ندارید") + + # Get user details + user = db.get(User, user_id) + if not user: + logger.warning(f"User {user_id} not found") + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + # Get user permissions for this business + permission_repo = BusinessPermissionRepository(db) + permission_obj = permission_repo.get_by_user_and_business(user_id, business_id) + + # Determine role and permissions + if business.owner_id == user_id: + role = "owner" + permissions = {} # Owner has all permissions + else: + role = "member" + permissions = permission_obj.business_permissions if permission_obj else {} + + # Format user data + user_data = { + "id": permission_obj.id if permission_obj else user_id, + "business_id": business_id, + "user_id": user_id, + "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(), + "user_email": user.email or "", + "user_phone": user.mobile, + "role": role, + "status": "active", + "added_at": permission_obj.created_at if permission_obj else business.created_at, + "last_active": permission_obj.updated_at if permission_obj else business.updated_at, + "permissions": permissions, + } + + logger.info(f"Returning user data: {user_data}") + + # Format datetime fields based on calendar type + formatted_user_data = format_datetime_fields(user_data, request) + + return success_response( + data={"user": formatted_user_data}, + request=request, + message="جزئیات کاربر دریافت شد" + ) + + +@router.get("/{business_id}/users", + summary="لیست کاربران کسب و کار", + description="دریافت لیست کاربران یک کسب و کار", + response_model=BusinessUsersListResponse, + responses={ + 200: { + "description": "لیست کاربران با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست کاربران دریافت شد", + "users": [ + { + "id": 1, + "business_id": 1, + "user_id": 2, + "user_name": "علی احمدی", + "user_email": "ali@example.com", + "user_phone": "09123456789", + "role": "member", + "status": "active", + "added_at": "2024-01-01T00:00:00Z", + "last_active": "2024-01-01T12:00:00Z", + "permissions": { + "sales": { + "read": True, + "write": True, + "delete": False + }, + "reports": { + "read": True, + "export": True + } + } + } + ], + "total_count": 1 + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب و کار" + } + } +) +@require_business_access("business_id") +def get_users( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت لیست کاربران کسب و کار""" + import logging + logger = logging.getLogger(__name__) + + current_user_id = ctx.get_user_id() + logger.info(f"Getting users for business {business_id}, current user: {current_user_id}") + + # Check if user is business owner or has permission to manage users + business = db.get(Business, business_id) + if not business: + logger.error(f"Business {business_id} not found") + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + is_owner = business.owner_id == current_user_id + can_manage = ctx.can_manage_business_users() + + logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}") + + if not is_owner and not can_manage: + logger.warning(f"User {current_user_id} does not have permission to manage users for business {business_id}") + raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید") + + # Get business permissions for this business + permission_repo = BusinessPermissionRepository(db) + business_permissions = permission_repo.get_business_users(business_id) + logger.info(f"Found {len(business_permissions)} business permissions for business {business_id}") + + # Format users data + formatted_users = [] + + # Add business owner first + owner = db.get(User, business.owner_id) + if owner: + logger.info(f"Adding business owner: {owner.id} - {owner.email}") + owner_data = { + "id": business.owner_id, # Use owner_id as id + "business_id": business_id, + "user_id": business.owner_id, + "user_name": f"{owner.first_name or ''} {owner.last_name or ''}".strip(), + "user_email": owner.email or "", + "user_phone": owner.mobile, + "role": "owner", + "status": "active", + "added_at": business.created_at, + "last_active": business.updated_at, + "permissions": {}, # Owner has all permissions + } + formatted_users.append(owner_data) + else: + logger.warning(f"Business owner {business.owner_id} not found in users table") + + # Add other users with permissions + for perm in business_permissions: + # Skip if this is the owner (already added) + if perm.user_id == business.owner_id: + logger.info(f"Skipping owner user {perm.user_id} as already added") + continue + + user = db.get(User, perm.user_id) + if user: + logger.info(f"Adding user with permissions: {user.id} - {user.email}") + user_data = { + "id": perm.id, + "business_id": perm.business_id, + "user_id": perm.user_id, + "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(), + "user_email": user.email or "", + "user_phone": user.mobile, + "role": "member", + "status": "active", + "added_at": perm.created_at, + "last_active": perm.updated_at, + "permissions": perm.business_permissions or {}, + } + formatted_users.append(user_data) + else: + logger.warning(f"User {perm.user_id} not found in users table") + + logger.info(f"Returning {len(formatted_users)} users for business {business_id}") + + # Format datetime fields based on calendar type + formatted_users = format_datetime_fields(formatted_users, request) + + return success_response( + data={ + "users": formatted_users, + "total_count": len(formatted_users) + }, + request=request, + message="لیست کاربران دریافت شد" + ) + + +@router.post("/{business_id}/users", + summary="افزودن کاربر به کسب و کار", + description="افزودن کاربر جدید به کسب و کار با ایمیل یا شماره تلفن", + response_model=AddUserResponse, + responses={ + 200: { + "description": "کاربر با موفقیت اضافه شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کاربر با موفقیت اضافه شد", + "user": { + "id": 1, + "business_id": 1, + "user_id": 2, + "user_name": "علی احمدی", + "user_email": "ali@example.com", + "user_phone": "09123456789", + "role": "member", + "status": "active", + "added_at": "2024-01-01T00:00:00Z", + "last_active": None, + "permissions": {} + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز یا مجوز کافی نیست" + }, + 404: { + "description": "کاربر یافت نشد" + } + } +) +@require_business_access("business_id") +def add_user( + request: Request, + business_id: int, + add_request: AddUserRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """افزودن کاربر به کسب و کار""" + import logging + logger = logging.getLogger(__name__) + + current_user_id = ctx.get_user_id() + logger.info(f"Adding user to business {business_id}, current user: {current_user_id}") + logger.info(f"Add request: {add_request.email_or_phone}") + + # Check if user is business owner or has permission to manage users + business = db.get(Business, business_id) + if not business: + logger.error(f"Business {business_id} not found") + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + is_owner = business.owner_id == current_user_id + can_manage = ctx.can_manage_business_users(business_id) + + logger.info(f"Business owner: {business.owner_id}, is_owner: {is_owner}, can_manage: {can_manage}") + logger.info(f"User {current_user_id} business_id from context: {ctx.business_id}") + logger.info(f"User {current_user_id} is superadmin: {ctx.is_superadmin()}") + + if not is_owner and not can_manage: + logger.warning(f"User {current_user_id} does not have permission to add users to business {business_id}") + raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید") + + # Find user by email or phone + logger.info(f"Searching for user with email/phone: {add_request.email_or_phone}") + user = db.query(User).filter( + (User.email == add_request.email_or_phone) | + (User.mobile == add_request.email_or_phone) + ).first() + + if not user: + logger.warning(f"User not found with email/phone: {add_request.email_or_phone}") + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + logger.info(f"Found user: {user.id} - {user.email}") + + # Check if user is already added to this business + permission_repo = BusinessPermissionRepository(db) + existing_permission = permission_repo.get_by_user_and_business(user.id, business_id) + + if existing_permission: + logger.warning(f"User {user.id} already exists in business {business_id}") + raise HTTPException(status_code=400, detail="کاربر قبلاً به این کسب و کار اضافه شده است") + + # Add user to business with default permissions + logger.info(f"Adding user {user.id} to business {business_id}") + permission_obj = permission_repo.create_or_update( + user_id=user.id, + business_id=business_id, + permissions={'join': True} # Default permissions with join access + ) + + logger.info(f"Created permission object: {permission_obj.id}") + + # Format user data + user_data = { + "id": permission_obj.id, + "business_id": permission_obj.business_id, + "user_id": permission_obj.user_id, + "user_name": f"{user.first_name or ''} {user.last_name or ''}".strip(), + "user_email": user.email or "", + "user_phone": user.mobile, + "role": "member", + "status": "active", + "added_at": permission_obj.created_at, + "last_active": None, + "permissions": permission_obj.business_permissions or {}, + } + + logger.info(f"Returning user data: {user_data}") + + # Format datetime fields based on calendar type + formatted_user_data = format_datetime_fields(user_data, request) + + return success_response( + data={"user": formatted_user_data}, + request=request, + message="کاربر با موفقیت اضافه شد" + ) + + +@router.put("/{business_id}/users/{user_id}/permissions", + summary="به‌روزرسانی دسترسی‌های کاربر", + description="به‌روزرسانی دسترسی‌های یک کاربر در کسب و کار", + response_model=UpdatePermissionsResponse, + responses={ + 200: { + "description": "دسترسی‌ها با موفقیت به‌روزرسانی شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "دسترسی‌ها با موفقیت به‌روزرسانی شد" + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز یا مجوز کافی نیست" + }, + 404: { + "description": "کاربر یافت نشد" + } + } +) +@require_business_access("business_id") +def update_permissions( + request: Request, + business_id: int, + user_id: int, + update_request: UpdatePermissionsRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """به‌روزرسانی دسترسی‌های کاربر""" + current_user_id = ctx.get_user_id() + + # Check if user is business owner or has permission to manage users + business = db.get(Business, business_id) + if not business: + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + is_owner = business.owner_id == current_user_id + can_manage = ctx.can_manage_business_users() + + if not is_owner and not can_manage: + raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید") + + # Check if target user exists + target_user = db.get(User, user_id) + if not target_user: + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + # Update permissions + permission_repo = BusinessPermissionRepository(db) + permission_obj = permission_repo.create_or_update( + user_id=user_id, + business_id=business_id, + permissions=update_request.permissions + ) + + return success_response( + data={}, + request=request, + message="دسترسی‌ها با موفقیت به‌روزرسانی شد" + ) + + +@router.delete("/{business_id}/users/{user_id}", + summary="حذف کاربر از کسب و کار", + description="حذف کاربر از کسب و کار", + response_model=RemoveUserResponse, + responses={ + 200: { + "description": "کاربر با موفقیت حذف شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کاربر با موفقیت حذف شد" + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز یا مجوز کافی نیست" + }, + 404: { + "description": "کاربر یافت نشد" + } + } +) +@require_business_access("business_id") +def remove_user( + request: Request, + business_id: int, + user_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """حذف کاربر از کسب و کار""" + current_user_id = ctx.get_user_id() + + # Check if user is business owner or has permission to manage users + business = db.get(Business, business_id) + if not business: + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + is_owner = business.owner_id == current_user_id + can_manage = ctx.can_manage_business_users() + + if not is_owner and not can_manage: + raise HTTPException(status_code=403, detail="شما مجوز مدیریت کاربران ندارید") + + # Check if target user is business owner + business = db.get(Business, business_id) + if business and business.owner_id == user_id: + raise HTTPException(status_code=400, detail="نمی‌توان مالک کسب و کار را حذف کرد") + + # Remove user permissions + permission_repo = BusinessPermissionRepository(db) + success = permission_repo.delete_by_user_and_business(user_id, business_id) + + if not success: + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + return success_response( + data={}, + request=request, + message="کاربر با موفقیت حذف شد" + ) diff --git a/hesabixAPI/build/lib/adapters/api/v1/businesses.py b/hesabixAPI/build/lib/adapters/api/v1/businesses.py new file mode 100644 index 0000000..8d93b81 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/businesses.py @@ -0,0 +1,320 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from fastapi import APIRouter, Depends, Request, Query, HTTPException +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.api.v1.schemas import ( + BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse, + BusinessListResponse, BusinessSummaryResponse, SuccessResponse +) +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_management +from app.services.business_service import ( + create_business, get_business_by_id, get_businesses_by_owner, get_user_businesses, + update_business, delete_business, get_business_summary +) + + +router = APIRouter(prefix="/businesses", tags=["businesses"]) + + +@router.post("", + summary="ایجاد کسب و کار جدید", + description="ایجاد کسب و کار جدید برای کاربر جاری", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کسب و کار با موفقیت ایجاد شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کسب و کار با موفقیت ایجاد شد", + "data": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "created_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def create_new_business( + request: Request, + business_data: BusinessCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """ایجاد کسب و کار جدید""" + owner_id = ctx.get_user_id() + business = create_business(db, business_data, owner_id) + formatted_data = format_datetime_fields(business, request) + return success_response(formatted_data, request) + + +@router.post("/list", + summary="لیست کسب و کارهای کاربر", + description="دریافت لیست کسب و کارهای کاربر جاری با قابلیت فیلتر و جستجو", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست کسب و کارها با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست کسب و کارها دریافت شد", + "data": { + "items": [ + { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "created_at": "1403/01/01 00:00:00" + } + ], + "pagination": { + "total": 1, + "page": 1, + "per_page": 10, + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def list_user_businesses( + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + take: int = 10, + skip: int = 0, + sort_by: str = "created_at", + sort_desc: bool = True, + search: str = None +) -> dict: + """لیست کسب و کارهای کاربر (مالک + عضو)""" + user_id = ctx.get_user_id() + query_dict = { + "take": take, + "skip": skip, + "sort_by": sort_by, + "sort_desc": sort_desc, + "search": search + } + businesses = get_user_businesses(db, user_id, query_dict) + formatted_data = format_datetime_fields(businesses, request) + + return success_response(formatted_data, request) + + +@router.post("/{business_id}/details", + summary="جزئیات کسب و کار", + description="دریافت جزئیات یک کسب و کار خاص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "جزئیات کسب و کار با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "جزئیات کسب و کار دریافت شد", + "data": { + "id": 1, + "name": "شرکت نمونه", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "address": "تهران، خیابان ولیعصر", + "phone": "02112345678", + "created_at": "1403/01/01 00:00:00" + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +def get_business( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت جزئیات کسب و کار""" + owner_id = ctx.get_user_id() + business = get_business_by_id(db, business_id, owner_id) + + if not business: + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + formatted_data = format_datetime_fields(business, request) + return success_response(formatted_data, request) + + +@router.put("/{business_id}", + summary="ویرایش کسب و کار", + description="ویرایش اطلاعات یک کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کسب و کار با موفقیت ویرایش شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کسب و کار با موفقیت ویرایش شد", + "data": { + "id": 1, + "name": "شرکت نمونه ویرایش شده", + "business_type": "شرکت", + "business_field": "تولیدی", + "owner_id": 1, + "updated_at": "2024-01-01T12:00:00Z" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +def update_business_info( + request: Request, + business_id: int, + business_data: BusinessUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """ویرایش کسب و کار""" + owner_id = ctx.get_user_id() + business = update_business(db, business_id, business_data, owner_id) + + if not business: + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + formatted_data = format_datetime_fields(business, request) + return success_response(formatted_data, request, "کسب و کار با موفقیت ویرایش شد") + + +@router.delete("/{business_id}", + summary="حذف کسب و کار", + description="حذف یک کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "کسب و کار با موفقیت حذف شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "کسب و کار با موفقیت حذف شد", + "data": {"ok": True} + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 404: { + "description": "کسب و کار یافت نشد" + } + } +) +def delete_business_info( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """حذف کسب و کار""" + owner_id = ctx.get_user_id() + success = delete_business(db, business_id, owner_id) + + if not success: + raise HTTPException(status_code=404, detail="کسب و کار یافت نشد") + + return success_response({"ok": True}, request, "کسب و کار با موفقیت حذف شد") + + +@router.post("/stats", + summary="آمار کسب و کارها", + description="دریافت آمار کلی کسب و کارهای کاربر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "آمار کسب و کارها با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "آمار کسب و کارها دریافت شد", + "data": { + "total_businesses": 5, + "by_type": { + "شرکت": 2, + "مغازه": 1, + "فروشگاه": 2 + }, + "by_field": { + "تولیدی": 3, + "خدماتی": 2 + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + } + } +) +def get_business_stats( + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """آمار کسب و کارها""" + owner_id = ctx.get_user_id() + stats = get_business_summary(db, owner_id) + return success_response(stats, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/categories.py b/hesabixAPI/build/lib/adapters/api/v1/categories.py new file mode 100644 index 0000000..6290dbc --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/categories.py @@ -0,0 +1,148 @@ +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError +from adapters.db.repositories.category_repository import CategoryRepository + + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.post("/business/{business_id}/tree") +@require_business_access("business_id") +def get_categories_tree( + request: Request, + business_id: int, + body: Dict[str, Any] | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + # اجازه مشاهده نیاز به view روی سکشن categories دارد + if not ctx.can_read_section("categories"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.view", http_status=403) + repo = CategoryRepository(db) + # درخت سراسری: بدون فیلتر نوع + tree = repo.get_tree(business_id, None) + # تبدیل کلید title به label به صورت بازگشتی + def _map_label(nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + mapped: list[Dict[str, Any]] = [] + for n in nodes: + children = n.get("children") or [] + mapped.append({ + "id": n.get("id"), + "parent_id": n.get("parent_id"), + "label": n.get("title", ""), + "translations": n.get("translations", {}), + "children": _map_label(children) if isinstance(children, list) else [], + }) + return mapped + items = _map_label(tree) + return success_response({"items": items}, request) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_category( + request: Request, + business_id: int, + body: Dict[str, Any], + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("categories", "add"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.add", http_status=403) + parent_id = body.get("parent_id") + label: str = (body.get("label") or "").strip() + # ساخت ترجمه‌ها از روی برچسب واحد + translations: Dict[str, str] = {"fa": label, "en": label} if label else {} + repo = CategoryRepository(db) + obj = repo.create_category(business_id=business_id, parent_id=parent_id, translations=translations) + item = { + "id": obj.id, + "parent_id": obj.parent_id, + "label": (obj.title_translations or {}).get(ctx.language) + or (obj.title_translations or {}).get("fa") + or (obj.title_translations or {}).get("en"), + "translations": obj.title_translations, + } + return success_response({"item": item}, request) + + +@router.post("/business/{business_id}/update") +@require_business_access("business_id") +def update_category( + request: Request, + business_id: int, + body: Dict[str, Any], + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("categories", "edit"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403) + category_id = body.get("category_id") + label = body.get("label") + translations = {"fa": label, "en": label} if isinstance(label, str) and label.strip() else None + repo = CategoryRepository(db) + obj = repo.update_category(category_id=category_id, translations=translations) + if not obj: + raise ApiError("NOT_FOUND", "Category not found", http_status=404) + item = { + "id": obj.id, + "parent_id": obj.parent_id, + "label": (obj.title_translations or {}).get(ctx.language) + or (obj.title_translations or {}).get("fa") + or (obj.title_translations or {}).get("en"), + "translations": obj.title_translations, + } + return success_response({"item": item}, request) + + +@router.post("/business/{business_id}/move") +@require_business_access("business_id") +def move_category( + request: Request, + business_id: int, + body: Dict[str, Any], + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("categories", "edit"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.edit", http_status=403) + category_id = body.get("category_id") + new_parent_id = body.get("new_parent_id") + repo = CategoryRepository(db) + obj = repo.move_category(category_id=category_id, new_parent_id=new_parent_id) + if not obj: + raise ApiError("NOT_FOUND", "Category not found", http_status=404) + item = { + "id": obj.id, + "parent_id": obj.parent_id, + "label": (obj.title_translations or {}).get(ctx.language) + or (obj.title_translations or {}).get("fa") + or (obj.title_translations or {}).get("en"), + "translations": obj.title_translations, + } + return success_response({"item": item}, request) + + +@router.post("/business/{business_id}/delete") +@require_business_access("business_id") +def delete_category( + request: Request, + business_id: int, + body: Dict[str, Any], + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("categories", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: categories.delete", http_status=403) + repo = CategoryRepository(db) + category_id = body.get("category_id") + ok = repo.delete_category(category_id=category_id) + return success_response({"deleted": ok}, request) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/currencies.py b/hesabixAPI/build/lib/adapters/api/v1/currencies.py new file mode 100644 index 0000000..244babd --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/currencies.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.models.currency import Currency +from app.core.responses import success_response + + +router = APIRouter(prefix="/currencies", tags=["currencies"]) + + +@router.get( + "", + summary="فهرست ارزها", + description="دریافت فهرست ارزهای قابل استفاده", +) +def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict: + items = [ + { + "id": c.id, + "name": c.name, + "title": c.title, + "symbol": c.symbol, + "code": c.code, + } + for c in db.query(Currency).order_by(Currency.title.asc()).all() + ] + return success_response(items, request) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/health.py b/hesabixAPI/build/lib/adapters/api/v1/health.py new file mode 100644 index 0000000..fc9c8c8 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/health.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from adapters.api.v1.schemas import SuccessResponse + +router = APIRouter(prefix="/health", tags=["health"]) + + +@router.get("", + summary="بررسی وضعیت سرویس", + description="بررسی وضعیت کلی سرویس و در دسترس بودن آن", + response_model=SuccessResponse, + responses={ + 200: { + "description": "سرویس در دسترس است", + "content": { + "application/json": { + "example": { + "success": True, + "message": "سرویس در دسترس است", + "data": { + "status": "ok", + "timestamp": "2024-01-01T00:00:00Z" + } + } + } + } + } + } +) +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/hesabixAPI/build/lib/adapters/api/v1/persons.py b/hesabixAPI/build/lib/adapters/api/v1/persons.py new file mode 100644 index 0000000..2185f0c --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/persons.py @@ -0,0 +1,945 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form +from fastapi import UploadFile, File +from sqlalchemy.orm import Session +from typing import Dict, Any, List, Optional + +from adapters.db.session import get_db +from adapters.api.v1.schema_models.person import ( + PersonCreateRequest, PersonUpdateRequest, PersonResponse, + PersonListResponse, PersonSummaryResponse, PersonBankAccountCreateRequest +) +from adapters.api.v1.schemas import QueryInfo, SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_management_dep +from app.core.i18n import negotiate_locale +from app.services.person_service import ( + create_person, get_person_by_id, get_persons_by_business, + 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"]) + + +@router.post("/businesses/{business_id}/persons/create", + summary="ایجاد شخص جدید", + description="ایجاد شخص جدید برای کسب و کار مشخص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "شخص با موفقیت ایجاد شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "شخص با موفقیت ایجاد شد", + "data": { + "id": 1, + "business_id": 1, + "alias_name": "علی احمدی", + "person_type": "مشتری", + "created_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "عدم احراز هویت" + }, + 403: { + "description": "عدم دسترسی به کسب و کار" + } + } +) +async def create_person_endpoint( + request: Request, + business_id: int, + person_data: PersonCreateRequest, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ایجاد شخص جدید برای کسب و کار""" + result = create_person(db, business_id, person_data) + return success_response( + data=format_datetime_fields(result['data'], request), + request=request, + message=result['message'], + ) + + +@router.post("/businesses/{business_id}/persons", + summary="لیست اشخاص کسب و کار", + description="دریافت لیست اشخاص یک کسب و کار با امکان جستجو و فیلتر", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست اشخاص با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست اشخاص با موفقیت دریافت شد", + "data": { + "items": [], + "pagination": { + "total": 0, + "page": 1, + "per_page": 20, + "total_pages": 0, + "has_next": False, + "has_prev": False + }, + "query_info": {} + } + } + } + } + } + } +) +async def get_persons_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), +): + """دریافت لیست اشخاص کسب و کار""" + query_dict = { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + "search_fields": query_info.search_fields, + "filters": query_info.filters, + } + result = get_persons_by_business(db, business_id, query_dict) + + # فرمت کردن تاریخ‌ها + result['items'] = [ + format_datetime_fields(item, request) for item in result['items'] + ] + + return success_response( + data=result, + request=request, + message="لیست اشخاص با موفقیت دریافت شد", + ) + + +@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 + import re + 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" + + # Locale and RTL/LTR handling + locale = negotiate_locale(request.headers.get("Accept-Language")) + if locale == 'fa': + try: + ws.sheet_view.rightToLeft = True + except Exception: + pass + + 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) + cell = ws.cell(row=row_idx, column=col_idx, value=value) + cell.border = border + if locale == 'fa': + cell.alignment = Alignment(horizontal="right") + + # 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) + + # Build meaningful filename + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "persons" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{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)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@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 + import re + 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 with dynamic direction/locale + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = (locale == 'fa') + html_lang = 'fa' if is_fa else 'en' + html_dir = 'rtl' if is_fa else 'ltr' + + 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') + + title_text = "گزارش لیست اشخاص" if is_fa else "Persons List Report" + label_biz = "نام کسب‌وکار" if is_fa else "Business Name" + label_date = "تاریخ گزارش" if is_fa else "Report Date" + footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix" + page_label_left = "صفحه " if is_fa else "Page " + page_label_of = " از " if is_fa else " of " + + table_html = f""" + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
{footer_text}
+ + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config) + + # Build meaningful filename + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "persons" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{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)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.post("/businesses/{business_id}/persons/import/template", + summary="دانلود تمپلیت ایمپورت اشخاص", + description="فایل Excel تمپلیت برای ایمپورت اشخاص را برمی‌گرداند", +) +async def download_persons_import_template( + business_id: int, + request: Request, + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import datetime + from fastapi.responses import Response + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment + + wb = Workbook() + ws = wb.active + ws.title = "Template" + + headers = [ + 'code','alias_name','first_name','last_name','person_type','person_types','company_name','payment_id', + 'national_id','registration_number','economic_id','country','province','city','address','postal_code', + 'phone','mobile','fax','email','website','share_count','commission_sale_percent','commission_sales_return_percent', + 'commission_sales_amount','commission_sales_return_amount' + ] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = Font(bold=True) + cell.alignment = Alignment(horizontal="center") + + # Sample row + sample = [ + '', 'نمونه نام مستعار', 'علی', 'احمدی', 'مشتری', 'مشتری, فروشنده', 'نمونه شرکت', 'PID123', + '0012345678', '12345', 'ECO-1', 'ایران', 'تهران', 'تهران', 'خیابان مثال ۱', '1234567890', + '02112345678', '09120000000', '', 'test@example.com', 'example.com', '', '5', '0', '0', '0' + ] + for col, val in enumerate(sample, 1): + ws.cell(row=2, column=col, value=val) + + # Auto width + for column in ws.columns: + try: + letter = column[0].column_letter + max_len = max(len(str(c.value)) if c.value is not None else 0 for c in column) + ws.column_dimensions[letter].width = min(max_len + 2, 50) + except Exception: + pass + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + + filename = f"persons_import_template_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + return Response( + content=buf.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.get("/persons/{person_id}", + summary="جزئیات شخص", + description="دریافت جزئیات یک شخص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "جزئیات شخص با موفقیت دریافت شد" + }, + 404: { + "description": "شخص یافت نشد" + } + } +) +async def get_person_endpoint( + request: Request, + person_id: int, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """دریافت جزئیات شخص""" + # ابتدا باید business_id را از person دریافت کنیم + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + result = get_person_by_id(db, person_id, person.business_id) + if not result: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + return success_response( + data=format_datetime_fields(result, request), + request=request, + message="جزئیات شخص با موفقیت دریافت شد", + ) + + +@router.put("/persons/{person_id}", + summary="ویرایش شخص", + description="ویرایش اطلاعات یک شخص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "شخص با موفقیت ویرایش شد" + }, + 404: { + "description": "شخص یافت نشد" + } + } +) +async def update_person_endpoint( + request: Request, + person_id: int, + person_data: PersonUpdateRequest, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ویرایش شخص""" + # ابتدا باید business_id را از person دریافت کنیم + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + result = update_person(db, person_id, person.business_id, person_data) + if not result: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + return success_response( + data=format_datetime_fields(result['data'], request), + request=request, + message=result['message'], + ) + + +@router.delete("/persons/{person_id}", + summary="حذف شخص", + description="حذف یک شخص", + response_model=SuccessResponse, + responses={ + 200: { + "description": "شخص با موفقیت حذف شد" + }, + 404: { + "description": "شخص یافت نشد" + } + } +) +async def delete_person_endpoint( + request: Request, + person_id: int, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """حذف شخص""" + # ابتدا باید business_id را از person دریافت کنیم + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + success = delete_person(db, person_id, person.business_id) + if not success: + raise HTTPException(status_code=404, detail="شخص یافت نشد") + + return success_response(message="شخص با موفقیت حذف شد", request=request) + + +@router.get("/businesses/{business_id}/persons/summary", + summary="خلاصه اشخاص کسب و کار", + description="دریافت خلاصه آماری اشخاص یک کسب و کار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "خلاصه اشخاص با موفقیت دریافت شد" + } + } +) +async def get_persons_summary_endpoint( + request: Request, + business_id: int, + db: Session = Depends(get_db), + auth_context: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """دریافت خلاصه اشخاص کسب و کار""" + result = get_person_summary(db, business_id) + + return success_response( + data=result, + request=request, + message="خلاصه اشخاص با موفقیت دریافت شد", + ) + + +@router.post("/businesses/{business_id}/persons/import/excel", + summary="ایمپورت اشخاص از فایل Excel", + description="فایل اکسل را دریافت می‌کند و به‌صورت dry-run یا واقعی پردازش می‌کند", +) +async def import_persons_excel( + business_id: int, + request: Request, + file: UploadFile = File(...), + dry_run: str = Form(default="true"), + match_by: str = Form(default="code"), + conflict_policy: str = Form(default="upsert"), + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import json + import re + from openpyxl import load_workbook + from fastapi import HTTPException + import logging + import zipfile + + logger = logging.getLogger(__name__) + + def validate_excel_file(content: bytes) -> bool: + """ + Validate if the content is a valid Excel file + """ + try: + # Check if it starts with PK signature (zip file) + if not content.startswith(b'PK'): + return False + + # Try to open as zip file + with zipfile.ZipFile(io.BytesIO(content), 'r') as zip_file: + file_list = zip_file.namelist() + # Check for Excel structure (xl/ folder for .xlsx files) + excel_structure = any(f.startswith('xl/') for f in file_list) + if excel_structure: + return True + + # Check for older Excel format (.xls) - this would be a different structure + # But since we only support .xlsx, we'll return False for .xls + return False + except zipfile.BadZipFile: + logger.error("File is not a valid zip file") + return False + except Exception as e: + logger.error(f"Error validating Excel file: {str(e)}") + return False + + try: + # Convert dry_run string to boolean + dry_run_bool = dry_run.lower() in ('true', '1', 'yes', 'on') + + logger.info(f"Import request: business_id={business_id}, dry_run={dry_run_bool}, match_by={match_by}, conflict_policy={conflict_policy}") + logger.info(f"File info: filename={file.filename}, content_type={file.content_type}") + + if not file.filename or not file.filename.lower().endswith('.xlsx'): + logger.error(f"Invalid file format: {file.filename}") + raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. تنها xlsx پشتیبانی می‌شود") + + content = await file.read() + logger.info(f"File content size: {len(content)} bytes") + + # Log first few bytes for debugging + logger.info(f"File header (first 20 bytes): {content[:20].hex()}") + logger.info(f"File header (first 20 bytes as text): {content[:20]}") + + # Check if content is empty or too small + if len(content) < 100: + logger.error(f"File too small: {len(content)} bytes") + raise HTTPException(status_code=400, detail="فایل خیلی کوچک است یا خالی است") + + # Validate Excel file format + if not validate_excel_file(content): + logger.error("File is not a valid Excel file") + raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. فایل Excel معتبر نیست") + + try: + # Try to load the workbook with additional error handling + wb = load_workbook(filename=io.BytesIO(content), data_only=True) + logger.info(f"Successfully loaded workbook with {len(wb.worksheets)} worksheets") + except zipfile.BadZipFile as e: + logger.error(f"Bad zip file error: {str(e)}") + raise HTTPException(status_code=400, detail="فایل Excel خراب است یا فرمت آن معتبر نیست") + except Exception as e: + logger.error(f"Error loading workbook: {str(e)}") + raise HTTPException(status_code=400, detail=f"امکان خواندن فایل وجود ندارد: {str(e)}") + + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + return success_response(data={"summary": {"total": 0}}, request=request, message="فایل خالی است") + + headers = [str(h).strip() if h is not None else "" for h in rows[0]] + data_rows = rows[1:] + + # helper to map enum strings (fa/en) to internal value + def normalize_person_type(value: str) -> Optional[str]: + if not value: + return None + value = str(value).strip() + mapping = { + 'customer': 'مشتری', 'marketer': 'بازاریاب', 'employee': 'کارمند', 'supplier': 'تامین‌کننده', + 'partner': 'همکار', 'seller': 'فروشنده', 'shareholder': 'سهامدار' + } + for en, fa in mapping.items(): + if value.lower() == en or value == fa: + return fa + return value # assume already fa + + errors: list[dict] = [] + valid_items: list[dict] = [] + + for idx, row in enumerate(data_rows, start=2): + item: dict[str, Any] = {} + row_errors: list[str] = [] + for ci, key in enumerate(headers): + if not key: + continue + val = row[ci] if ci < len(row) else None + if isinstance(val, str): + val = val.strip() + item[key] = val + # normalize types + if 'person_type' in item and item['person_type']: + item['person_type'] = normalize_person_type(item['person_type']) + if 'person_types' in item and item['person_types']: + # split by comma + parts = [normalize_person_type(p.strip()) for p in str(item['person_types']).split(',') if str(p).strip()] + item['person_types'] = parts + + # alias_name required + if not item.get('alias_name'): + row_errors.append('alias_name الزامی است') + + # shareholder rule + if (item.get('person_type') == 'سهامدار') or (isinstance(item.get('person_types'), list) and 'سهامدار' in item.get('person_types', [])): + sc = item.get('share_count') + try: + sc_val = int(sc) if sc is not None and str(sc).strip() != '' else None + except Exception: + sc_val = None + if sc_val is None or sc_val <= 0: + row_errors.append('برای سهامدار share_count باید > 0 باشد') + else: + item['share_count'] = sc_val + + if row_errors: + errors.append({"row": idx, "errors": row_errors}) + continue + + valid_items.append(item) + + inserted = 0 + updated = 0 + skipped = 0 + + if not dry_run_bool and valid_items: + # apply import with conflict policy + from adapters.db.models.person import Person + from sqlalchemy import and_ + + def find_existing(session: Session, data: dict) -> Optional[Person]: + if match_by == 'national_id' and data.get('national_id'): + return session.query(Person).filter(and_(Person.business_id == business_id, Person.national_id == data['national_id'])).first() + if match_by == 'email' and data.get('email'): + return session.query(Person).filter(and_(Person.business_id == business_id, Person.email == data['email'])).first() + if match_by == 'code' and data.get('code'): + try: + code_int = int(data['code']) + return session.query(Person).filter(and_(Person.business_id == business_id, Person.code == code_int)).first() + except Exception: + return None + return None + + for data in valid_items: + existing = find_existing(db, data) + match_value = None + try: + match_value = data.get(match_by) + except Exception: + match_value = None + if existing is None: + # create + try: + create_person(db, business_id, PersonCreateRequest(**data)) + inserted += 1 + except Exception as e: + logger.error(f"Create person failed for data={data}: {str(e)}") + skipped += 1 + else: + if conflict_policy == 'insert': + logger.info(f"Skipping existing person (match_by={match_by}, value={match_value}) due to conflict_policy=insert") + skipped += 1 + elif conflict_policy in ('update', 'upsert'): + try: + update_person(db, existing.id, business_id, PersonUpdateRequest(**data)) + updated += 1 + except Exception as e: + logger.error(f"Update person failed for id={existing.id}, data={data}: {str(e)}") + skipped += 1 + + summary = { + "total": len(data_rows), + "valid": len(valid_items), + "invalid": len(errors), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + "dry_run": dry_run_bool, + } + + return success_response( + data={ + "summary": summary, + "errors": errors, + }, + request=request, + message="نتیجه ایمپورت اشخاص", + ) + except Exception as e: + logger.error(f"Import error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"خطا در پردازش فایل: {str(e)}") diff --git a/hesabixAPI/build/lib/adapters/api/v1/price_lists.py b/hesabixAPI/build/lib/adapters/api/v1/price_lists.py new file mode 100644 index 0000000..8cfa5df --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/price_lists.py @@ -0,0 +1,165 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo +from adapters.api.v1.schema_models.price_list import ( + PriceListCreateRequest, + PriceListUpdateRequest, + PriceItemUpsertRequest, +) +from app.services.price_list_service import ( + create_price_list, + list_price_lists, + get_price_list, + update_price_list, + delete_price_list, + list_price_items, + upsert_price_item, + delete_price_item, +) + + +router = APIRouter(prefix="/price-lists", tags=["price-lists"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_price_list_endpoint( + request: Request, + business_id: int, + payload: PriceListCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = create_price_list(db, business_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.post("/business/{business_id}/search") +@require_business_access("business_id") +def search_price_lists_endpoint( + request: Request, + business_id: int, + query: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = list_price_lists(db, business_id, { + "take": query.take, + "skip": query.skip, + "sort_by": query.sort_by, + "sort_desc": query.sort_desc, + "search": query.search, + }) + return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.get("/business/{business_id}/{price_list_id}") +@require_business_access("business_id") +def get_price_list_endpoint( + request: Request, + business_id: int, + price_list_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + item = get_price_list(db, business_id, price_list_id) + if not item: + raise ApiError("NOT_FOUND", "Price list not found", http_status=404) + return success_response(data=format_datetime_fields({"item": item}, request), request=request) + + +@router.put("/business/{business_id}/{price_list_id}") +@require_business_access("business_id") +def update_price_list_endpoint( + request: Request, + business_id: int, + price_list_id: int, + payload: PriceListUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = update_price_list(db, business_id, price_list_id, payload) + if not result: + raise ApiError("NOT_FOUND", "Price list not found", http_status=404) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.delete("/business/{business_id}/{price_list_id}") +@require_business_access("business_id") +def delete_price_list_endpoint( + request: Request, + business_id: int, + price_list_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403) + ok = delete_price_list(db, business_id, price_list_id) + return success_response({"deleted": ok}, request) + + +@router.post("/business/{business_id}/{price_list_id}/items") +@require_business_access("business_id") +def upsert_price_item_endpoint( + request: Request, + business_id: int, + price_list_id: int, + payload: PriceItemUpsertRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = upsert_price_item(db, business_id, price_list_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.get("/business/{business_id}/{price_list_id}/items") +@require_business_access("business_id") +def list_price_items_endpoint( + request: Request, + business_id: int, + price_list_id: int, + product_id: int | None = None, + currency_id: int | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = list_price_items(db, business_id, price_list_id, product_id=product_id, currency_id=currency_id) + return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.delete("/business/{business_id}/items/{item_id}") +@require_business_access("business_id") +def delete_price_item_endpoint( + request: Request, + business_id: int, + item_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403) + ok = delete_price_item(db, business_id, item_id) + return success_response({"deleted": ok}, request) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py b/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py new file mode 100644 index 0000000..c87b9f8 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/product_attributes.py @@ -0,0 +1,124 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo +from adapters.api.v1.schema_models.product_attribute import ( + ProductAttributeCreateRequest, + ProductAttributeUpdateRequest, +) +from app.services.product_attribute_service import ( + create_attribute, + list_attributes, + get_attribute, + update_attribute, + delete_attribute, +) + + +router = APIRouter(prefix="/product-attributes", tags=["product-attributes"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_product_attribute( + request: Request, + business_id: int, + payload: ProductAttributeCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("product_attributes", "add"): + raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.add", http_status=403) + result = create_attribute(db, business_id, payload) + return success_response( + data=format_datetime_fields(result["data"], request), + request=request, + message=result.get("message"), + ) + + +@router.post("/business/{business_id}/search") +@require_business_access("business_id") +def search_product_attributes( + request: Request, + business_id: int, + query: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("product_attributes"): + raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403) + + result = list_attributes(db, business_id, { + "take": query.take, + "skip": query.skip, + "sort_by": query.sort_by, + "sort_desc": query.sort_desc, + "search": query.search, + "filters": query.filters, + }) + # Format all datetime fields in items/pagination + formatted = format_datetime_fields(result, request) + return success_response(data=formatted, request=request) + + +@router.get("/business/{business_id}/{attribute_id}") +@require_business_access("business_id") +def get_product_attribute( + request: Request, + business_id: int, + attribute_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("product_attributes"): + raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.view", http_status=403) + item = get_attribute(db, attribute_id, business_id) + if not item: + raise ApiError("NOT_FOUND", "Attribute not found", http_status=404) + return success_response(data=format_datetime_fields({"item": item}, request), request=request) + + +@router.put("/business/{business_id}/{attribute_id}") +@require_business_access("business_id") +def update_product_attribute( + request: Request, + business_id: int, + attribute_id: int, + payload: ProductAttributeUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("product_attributes", "edit"): + raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.edit", http_status=403) + result = update_attribute(db, attribute_id, business_id, payload) + if not result: + raise ApiError("NOT_FOUND", "Attribute not found", http_status=404) + return success_response( + data=format_datetime_fields(result["data"], request), + request=request, + message=result.get("message"), + ) + + +@router.delete("/business/{business_id}/{attribute_id}") +@require_business_access("business_id") +def delete_product_attribute( + request: Request, + business_id: int, + attribute_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("product_attributes", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: product_attributes.delete", http_status=403) + ok = delete_attribute(db, attribute_id, business_id) + return success_response({"deleted": ok}, request) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/products.py b/hesabixAPI/build/lib/adapters/api/v1/products.py new file mode 100644 index 0000000..a76bfdb --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/products.py @@ -0,0 +1,509 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schemas import QueryInfo +from adapters.api.v1.schema_models.product import ( + ProductCreateRequest, + ProductUpdateRequest, +) +from app.services.product_service import ( + create_product, + list_products, + get_product, + update_product, + delete_product, +) +from adapters.db.models.business import Business +from app.core.i18n import negotiate_locale + + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_product_endpoint( + request: Request, + business_id: int, + payload: ProductCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = create_product(db, business_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.post("/business/{business_id}/search") +@require_business_access("business_id") +def search_products_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = list_products(db, business_id, { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + "filters": query_info.filters, + }) + return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.get("/business/{business_id}/{product_id}") +@require_business_access("business_id") +def get_product_endpoint( + request: Request, + business_id: int, + product_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + item = get_product(db, product_id, business_id) + if not item: + raise ApiError("NOT_FOUND", "Product not found", http_status=404) + return success_response(data=format_datetime_fields({"item": item}, request), request=request) + + +@router.put("/business/{business_id}/{product_id}") +@require_business_access("business_id") +def update_product_endpoint( + request: Request, + business_id: int, + product_id: int, + payload: ProductUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = update_product(db, product_id, business_id, payload) + if not result: + raise ApiError("NOT_FOUND", "Product not found", http_status=404) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.delete("/business/{business_id}/{product_id}") +@require_business_access("business_id") +def delete_product_endpoint( + request: Request, + business_id: int, + product_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403) + ok = delete_product(db, product_id, business_id) + return success_response({"deleted": ok}, request) + + +@router.post("/business/{business_id}/export/excel", + summary="خروجی Excel لیست محصولات", + description="خروجی Excel لیست محصولات با قابلیت فیلتر، انتخاب ستون‌ها و ترتیب آن‌ها", +) +@require_business_access("business_id") +async def export_products_excel( + request: Request, + business_id: int, + body: dict, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import io + import re + import datetime + from fastapi.responses import Response + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + + query_dict = { + "take": int(body.get("take", 1000)), + "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 = list_products(db, business_id, query_dict) + items = result.get("items", []) if isinstance(result, dict) else result.get("items", []) + 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 and isinstance(items, list): + indices = None + if isinstance(selected_indices, str): + try: + import json as _json + indices = _json.loads(selected_indices) + except Exception: + 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)] + + export_columns = body.get("export_columns") + if export_columns and isinstance(export_columns, list): + headers = [col.get("label") or col.get("key") for col in export_columns] + keys = [col.get("key") for col in export_columns] + else: + default_cols = [ + ("code", "کد"), + ("name", "نام"), + ("item_type", "نوع"), + ("category_id", "دسته"), + ("base_sales_price", "قیمت فروش"), + ("base_purchase_price", "قیمت خرید"), + ("main_unit_id", "واحد اصلی"), + ("secondary_unit_id", "واحد فرعی"), + ("track_inventory", "کنترل موجودی"), + ("created_at_formatted", "ایجاد"), + ] + keys = [k for k, _ in default_cols] + headers = [v for _, v in default_cols] + + wb = Workbook() + ws = wb.active + ws.title = "Products" + + # Locale and RTL/LTR handling for Excel + locale = negotiate_locale(request.headers.get("Accept-Language")) + if locale == 'fa': + try: + ws.sheet_view.rightToLeft = True + except Exception: + pass + + # Header style + header_font = Font(bold=True) + header_fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid") + thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + + ws.append(headers) + for cell in ws[1]: + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center") + cell.border = thin_border + + for it in items: + row = [] + for k in keys: + row.append(it.get(k)) + ws.append(row) + for cell in ws[ws.max_row]: + cell.border = thin_border + # Align data cells based on locale + if locale == 'fa': + cell.alignment = Alignment(horizontal="right") + + # Auto width columns + try: + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if cell.value is not None and 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) + except Exception: + pass + + output = io.BytesIO() + wb.save(output) + data = output.getvalue() + + # Build meaningful filename + biz_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + biz_name = b.name or "" + except Exception: + biz_name = "" + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "products" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(data)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + +@router.post("/business/{business_id}/export/pdf", + summary="خروجی PDF لیست محصولات", + description="خروجی PDF لیست محصولات با قابلیت فیلتر و انتخاب ستون‌ها", +) +@require_business_access("business_id") +async def export_products_pdf( + request: Request, + business_id: int, + body: dict, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + import json + import datetime + import re + from fastapi.responses import Response + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + + query_dict = { + "take": int(body.get("take", 100)), + "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 = list_products(db, business_id, query_dict) + items = result.get("items", []) + 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)] + + export_columns = body.get("export_columns") + if export_columns and isinstance(export_columns, list): + headers = [col.get("label") or col.get("key") for col in export_columns] + keys = [col.get("key") for col in export_columns] + else: + default_cols = [ + ("code", "کد"), + ("name", "نام"), + ("item_type", "نوع"), + ("category_id", "دسته"), + ("base_sales_price", "قیمت فروش"), + ("base_purchase_price", "قیمت خرید"), + ("main_unit_id", "واحد اصلی"), + ("secondary_unit_id", "واحد فرعی"), + ("track_inventory", "کنترل موجودی"), + ("created_at_formatted", "ایجاد"), + ] + keys = [k for k, _ in default_cols] + headers = [v for _, v in default_cols] + + # Locale and direction + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = (locale == 'fa') + html_lang = 'fa' if is_fa else 'en' + html_dir = 'rtl' if is_fa else 'ltr' + + # 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 or "" + except Exception: + business_name = "" + + # Escape helper + def escape(s: Any) -> str: + try: + return str(s).replace('&', '&').replace('<', '<').replace('>', '>') + except Exception: + return str(s) + + # Build rows + 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_str = formatted_now.get('formatted', formatted_now.get('date_time', '')) + except Exception: + now_str = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + + title_text = "گزارش فهرست محصولات" if is_fa else "Products List Report" + label_biz = "نام کسب‌وکار" if is_fa else "Business Name" + label_date = "تاریخ گزارش" if is_fa else "Report Date" + footer_text = "تولید شده توسط Hesabix" if is_fa else "Generated by Hesabix" + page_label_left = "صفحه " if is_fa else "Page " + page_label_of = " از " if is_fa else " of " + + table_html = f""" + + + + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now_str)}
+
+
+ + + {headers_html} + + + {''.join(rows_html)} + +
+
+
{footer_text}
+ + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config) + + # Build meaningful filename + biz_name = business_name + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + base = "products" + if biz_name: + base += f"_{slugify(biz_name)}" + if selected_only: + base += "_selected" + filename = f"{base}_{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)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py new file mode 100644 index 0000000..03f73ed --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/__init__.py @@ -0,0 +1,10 @@ +# This file makes the directory a Python package + +# Import from file_storage module +from .file_storage import * + +# Re-export from parent schemas module +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from schemas import * diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/account.py new file mode 100644 index 0000000..581b091 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/api/v1/schema_models/email.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py new file mode 100644 index 0000000..633f6e7 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/email.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + + +class EmailConfigBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="Configuration name") + smtp_host: str = Field(..., min_length=1, max_length=255, description="SMTP host") + smtp_port: int = Field(..., ge=1, le=65535, description="SMTP port") + smtp_username: str = Field(..., min_length=1, max_length=255, description="SMTP username") + smtp_password: str = Field(..., min_length=1, max_length=255, description="SMTP password") + use_tls: bool = Field(default=True, description="Use TLS encryption") + use_ssl: bool = Field(default=False, description="Use SSL encryption") + from_email: EmailStr = Field(..., description="From email address") + from_name: str = Field(..., min_length=1, max_length=100, description="From name") + is_active: bool = Field(default=True, description="Is this configuration active") + is_default: bool = Field(default=False, description="Is this the default configuration") + + +class EmailConfigCreate(EmailConfigBase): + pass + + +class EmailConfigUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + smtp_host: Optional[str] = Field(None, min_length=1, max_length=255) + smtp_port: Optional[int] = Field(None, ge=1, le=65535) + smtp_username: Optional[str] = Field(None, min_length=1, max_length=255) + smtp_password: Optional[str] = Field(None, min_length=1, max_length=255) + use_tls: Optional[bool] = None + use_ssl: Optional[bool] = None + from_email: Optional[EmailStr] = None + from_name: Optional[str] = Field(None, min_length=1, max_length=100) + is_active: Optional[bool] = None + is_default: Optional[bool] = None + + +class EmailConfigResponse(EmailConfigBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SendEmailRequest(BaseModel): + to: EmailStr = Field(..., description="Recipient email address") + subject: str = Field(..., min_length=1, max_length=255, description="Email subject") + body: str = Field(..., min_length=1, description="Email body (plain text)") + html_body: Optional[str] = Field(None, description="Email body (HTML)") + config_id: Optional[int] = Field(None, description="Specific config ID to use") + + +class TestConnectionRequest(BaseModel): + config_id: int = Field(..., description="Configuration ID to test") + + +# These response models are no longer needed as we use SuccessResponse from schemas.py diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py new file mode 100644 index 0000000..ab6dc56 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/file_storage.py @@ -0,0 +1,80 @@ +from typing import Optional, Dict, Any, List +from uuid import UUID +from pydantic import BaseModel, Field +from datetime import datetime + + +# Request Models +class StorageConfigCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="نام پیکربندی") + storage_type: str = Field(..., description="نوع ذخیره‌سازی") + config_data: Dict[str, Any] = Field(..., description="داده‌های پیکربندی") + is_default: bool = Field(default=False, description="آیا پیش‌فرض است") + is_active: bool = Field(default=True, description="آیا فعال است") + + +class StorageConfigUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="نام پیکربندی") + config_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های پیکربندی") + is_active: Optional[bool] = Field(default=None, description="آیا فعال است") + + +class FileUploadRequest(BaseModel): + module_context: str = Field(..., description="زمینه ماژول") + context_id: Optional[UUID] = Field(default=None, description="شناسه زمینه") + developer_data: Optional[Dict[str, Any]] = Field(default=None, description="داده‌های توسعه‌دهنده") + is_temporary: bool = Field(default=False, description="آیا فایل موقت است") + expires_in_days: int = Field(default=30, ge=1, le=365, description="تعداد روزهای انقضا") + + +class FileVerificationRequest(BaseModel): + verification_data: Dict[str, Any] = Field(..., description="داده‌های تایید") + + +# Response Models +class FileInfo(BaseModel): + file_id: str = Field(..., description="شناسه فایل") + original_name: str = Field(..., description="نام اصلی فایل") + file_size: int = Field(..., description="حجم فایل") + mime_type: str = Field(..., description="نوع فایل") + is_temporary: bool = Field(..., description="آیا موقت است") + is_verified: bool = Field(..., description="آیا تایید شده است") + created_at: str = Field(..., description="تاریخ ایجاد") + expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا") + + class Config: + from_attributes = True + + +class FileUploadResponse(BaseModel): + file_id: str = Field(..., description="شناسه فایل") + original_name: str = Field(..., description="نام اصلی فایل") + file_size: int = Field(..., description="حجم فایل") + mime_type: str = Field(..., description="نوع فایل") + is_temporary: bool = Field(..., description="آیا موقت است") + verification_token: Optional[str] = Field(default=None, description="توکن تایید") + expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا") + + +class StorageConfigResponse(BaseModel): + id: str = Field(..., description="شناسه پیکربندی") + name: str = Field(..., description="نام پیکربندی") + storage_type: str = Field(..., description="نوع ذخیره‌سازی") + is_default: bool = Field(..., description="آیا پیش‌فرض است") + is_active: bool = Field(..., description="آیا فعال است") + created_at: str = Field(..., description="تاریخ ایجاد") + + class Config: + from_attributes = True + + +class FileStatisticsResponse(BaseModel): + total_files: int = Field(..., description="کل فایل‌ها") + total_size: int = Field(..., description="حجم کل") + temporary_files: int = Field(..., description="فایل‌های موقت") + unverified_files: int = Field(..., description="فایل‌های تایید نشده") + + +class CleanupResponse(BaseModel): + cleaned_files: int = Field(..., description="تعداد فایل‌های پاکسازی شده") + total_unverified: int = Field(..., description="کل فایل‌های تایید نشده") diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py new file mode 100644 index 0000000..3772a4b --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/person.py @@ -0,0 +1,242 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from enum import Enum +from datetime import datetime + + +class PersonType(str, Enum): + """نوع شخص""" + CUSTOMER = "مشتری" + MARKETER = "بازاریاب" + EMPLOYEE = "کارمند" + SUPPLIER = "تامین‌کننده" + PARTNER = "همکار" + SELLER = "فروشنده" + SHAREHOLDER = "سهامدار" + + +class PersonBankAccountCreateRequest(BaseModel): + """درخواست ایجاد حساب بانکی شخص""" + bank_name: str = Field(..., min_length=1, max_length=255, description="نام بانک") + account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب") + card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت") + sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا") + + +class PersonBankAccountUpdateRequest(BaseModel): + """درخواست ویرایش حساب بانکی شخص""" + bank_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام بانک") + account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب") + card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت") + sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا") + + +class PersonBankAccountResponse(BaseModel): + """پاسخ اطلاعات حساب بانکی شخص""" + id: int = Field(..., description="شناسه حساب بانکی") + person_id: int = Field(..., description="شناسه شخص") + bank_name: str = Field(..., description="نام بانک") + account_number: Optional[str] = Field(default=None, description="شماره حساب") + card_number: Optional[str] = Field(default=None, description="شماره کارت") + sheba_number: Optional[str] = Field(default=None, description="شماره شبا") + created_at: str = Field(..., description="تاریخ ایجاد") + updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی") + + class Config: + from_attributes = True + + +class PersonCreateRequest(BaseModel): + """درخواست ایجاد شخص جدید""" + # اطلاعات پایه + code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار (در صورت عدم ارسال، خودکار تولید می‌شود)") + alias_name: str = Field(..., min_length=1, max_length=255, description="نام مستعار (الزامی)") + first_name: Optional[str] = Field(default=None, max_length=100, description="نام") + last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی") + person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)") + person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)") + company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت") + payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت") + + # اطلاعات اقتصادی + national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی") + registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی") + + # اطلاعات تماس + country: Optional[str] = Field(default=None, max_length=100, description="کشور") + province: Optional[str] = Field(default=None, max_length=100, description="استان") + city: Optional[str] = Field(default=None, max_length=100, description="شهرستان") + address: Optional[str] = Field(default=None, description="آدرس") + postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی") + phone: Optional[str] = Field(default=None, max_length=20, description="تلفن") + mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل") + fax: Optional[str] = Field(default=None, max_length=20, description="فکس") + email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی") + website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت") + + # حساب‌های بانکی + 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): + """درخواست ویرایش شخص""" + # اطلاعات پایه + code: Optional[int] = Field(default=None, ge=1, description="کد یکتا در هر کسب و کار") + alias_name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام مستعار") + first_name: Optional[str] = Field(default=None, max_length=100, description="نام") + last_name: Optional[str] = Field(default=None, max_length=100, description="نام خانوادگی") + person_type: Optional[PersonType] = Field(default=None, description="نوع شخص (سازگاری قدیمی)") + person_types: Optional[List[PersonType]] = Field(default=None, description="انواع شخص (چندانتخابی)") + company_name: Optional[str] = Field(default=None, max_length=255, description="نام شرکت") + payment_id: Optional[str] = Field(default=None, max_length=100, description="شناسه پرداخت") + + # اطلاعات اقتصادی + national_id: Optional[str] = Field(default=None, max_length=20, description="شناسه ملی") + registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی") + + # اطلاعات تماس + country: Optional[str] = Field(default=None, max_length=100, description="کشور") + province: Optional[str] = Field(default=None, max_length=100, description="استان") + city: Optional[str] = Field(default=None, max_length=100, description="شهرستان") + address: Optional[str] = Field(default=None, description="آدرس") + postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی") + phone: Optional[str] = Field(default=None, max_length=20, description="تلفن") + mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل") + fax: Optional[str] = Field(default=None, max_length=20, description="فکس") + email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی") + website: Optional[str] = Field(default=None, max_length=255, 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): + """پاسخ اطلاعات شخص""" + id: int = Field(..., description="شناسه شخص") + business_id: int = Field(..., description="شناسه کسب و کار") + + # اطلاعات پایه + code: Optional[int] = Field(default=None, description="کد یکتا") + alias_name: str = Field(..., description="نام مستعار") + first_name: Optional[str] = Field(default=None, description="نام") + last_name: Optional[str] = Field(default=None, description="نام خانوادگی") + person_type: str = Field(..., description="نوع شخص") + person_types: List[str] = Field(default_factory=list, description="انواع شخص") + company_name: Optional[str] = Field(default=None, description="نام شرکت") + payment_id: Optional[str] = Field(default=None, description="شناسه پرداخت") + + # اطلاعات اقتصادی + national_id: Optional[str] = Field(default=None, description="شناسه ملی") + registration_number: Optional[str] = Field(default=None, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی") + + # اطلاعات تماس + country: Optional[str] = Field(default=None, description="کشور") + province: Optional[str] = Field(default=None, description="استان") + city: Optional[str] = Field(default=None, description="شهرستان") + address: Optional[str] = Field(default=None, description="آدرس") + postal_code: Optional[str] = Field(default=None, description="کد پستی") + phone: Optional[str] = Field(default=None, description="تلفن") + mobile: Optional[str] = Field(default=None, description="موبایل") + fax: Optional[str] = Field(default=None, description="فکس") + email: Optional[str] = Field(default=None, description="پست الکترونیکی") + website: Optional[str] = Field(default=None, description="وب‌سایت") + + # زمان‌بندی + created_at: str = Field(..., description="تاریخ ایجاد") + updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی") + + # حساب‌های بانکی + 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 + + +class PersonListResponse(BaseModel): + """پاسخ لیست اشخاص""" + items: List[PersonResponse] = Field(..., description="لیست اشخاص") + pagination: dict = Field(..., description="اطلاعات صفحه‌بندی") + query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر") + + +class PersonSummaryResponse(BaseModel): + """پاسخ خلاصه اشخاص""" + total_persons: int = Field(..., description="تعداد کل اشخاص") + by_type: dict = Field(..., description="تعداد بر اساس نوع") + active_persons: int = Field(..., description="تعداد اشخاص فعال") + inactive_persons: int = Field(..., description="تعداد اشخاص غیرفعال") diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py new file mode 100644 index 0000000..7849770 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/price_list.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Optional, List +from decimal import Decimal +from pydantic import BaseModel, Field + + +class PriceListCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + is_active: bool = True + + +class PriceListUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + is_active: Optional[bool] = None + + +class PriceItemUpsertRequest(BaseModel): + product_id: int + unit_id: Optional[int] = None + currency_id: int + tier_name: Optional[str] = Field(default=None, min_length=1, max_length=64) + min_qty: Decimal = Field(default=0) + price: Decimal + + +class PriceListResponse(BaseModel): + id: int + business_id: int + name: str + is_active: bool + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class PriceItemResponse(BaseModel): + id: int + price_list_id: int + product_id: int + unit_id: Optional[int] = None + currency_id: int + tier_name: str + min_qty: Decimal + price: Decimal + created_at: str + updated_at: str + + class Config: + from_attributes = True + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py new file mode 100644 index 0000000..129bf46 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import Optional, List +from decimal import Decimal +from pydantic import BaseModel, Field +from enum import Enum + + +class ProductItemType(str, Enum): + PRODUCT = "کالا" + SERVICE = "خدمت" + + +class ProductCreateRequest(BaseModel): + item_type: ProductItemType = Field(default=ProductItemType.PRODUCT) + code: Optional[str] = Field(default=None, max_length=64) + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + category_id: Optional[int] = None + + main_unit_id: Optional[int] = None + secondary_unit_id: Optional[int] = None + unit_conversion_factor: Optional[Decimal] = None + + base_sales_price: Optional[Decimal] = None + base_sales_note: Optional[str] = None + base_purchase_price: Optional[Decimal] = None + base_purchase_note: Optional[str] = None + + track_inventory: bool = Field(default=False) + reorder_point: Optional[int] = None + min_order_qty: Optional[int] = None + lead_time_days: Optional[int] = None + + is_sales_taxable: bool = Field(default=False) + is_purchase_taxable: bool = Field(default=False) + sales_tax_rate: Optional[Decimal] = None + purchase_tax_rate: Optional[Decimal] = None + tax_type_id: Optional[int] = None + tax_code: Optional[str] = Field(default=None, max_length=100) + tax_unit_id: Optional[int] = None + + attribute_ids: Optional[List[int]] = Field(default=None, description="ویژگی‌های انتخابی برای لینک شدن") + + +class ProductUpdateRequest(BaseModel): + item_type: Optional[ProductItemType] = None + code: Optional[str] = Field(default=None, max_length=64) + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + category_id: Optional[int] = None + + main_unit_id: Optional[int] = None + secondary_unit_id: Optional[int] = None + unit_conversion_factor: Optional[Decimal] = None + + base_sales_price: Optional[Decimal] = None + base_sales_note: Optional[str] = None + base_purchase_price: Optional[Decimal] = None + base_purchase_note: Optional[str] = None + + track_inventory: Optional[bool] = None + reorder_point: Optional[int] = None + min_order_qty: Optional[int] = None + lead_time_days: Optional[int] = None + + is_sales_taxable: Optional[bool] = None + is_purchase_taxable: Optional[bool] = None + sales_tax_rate: Optional[Decimal] = None + purchase_tax_rate: Optional[Decimal] = None + tax_type_id: Optional[int] = None + tax_code: Optional[str] = Field(default=None, max_length=100) + tax_unit_id: Optional[int] = None + + attribute_ids: Optional[List[int]] = None + + +class ProductResponse(BaseModel): + id: int + business_id: int + item_type: str + code: str + name: str + description: Optional[str] = None + category_id: Optional[int] = None + main_unit_id: Optional[int] = None + secondary_unit_id: Optional[int] = None + unit_conversion_factor: Optional[Decimal] = None + base_sales_price: Optional[Decimal] = None + base_sales_note: Optional[str] = None + base_purchase_price: Optional[Decimal] = None + base_purchase_note: Optional[str] = None + track_inventory: bool + reorder_point: Optional[int] = None + min_order_qty: Optional[int] = None + lead_time_days: Optional[int] = None + is_sales_taxable: bool + is_purchase_taxable: bool + sales_tax_rate: Optional[Decimal] = None + purchase_tax_rate: Optional[Decimal] = None + tax_type_id: Optional[int] = None + tax_code: Optional[str] = None + tax_unit_id: Optional[int] = None + created_at: str + updated_at: str + + class Config: + from_attributes = True + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py new file mode 100644 index 0000000..1af0a88 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schema_models/product_attribute.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Optional, List +from pydantic import BaseModel, Field + + +class ProductAttributeCreateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255, description="عنوان ویژگی") + description: Optional[str] = Field(default=None, description="توضیحات ویژگی") + + +class ProductAttributeUpdateRequest(BaseModel): + title: Optional[str] = Field(default=None, min_length=1, max_length=255, description="عنوان ویژگی") + description: Optional[str] = Field(default=None, description="توضیحات ویژگی") + + +class ProductAttributeResponse(BaseModel): + id: int + business_id: int + title: str + description: Optional[str] = None + created_at: str + updated_at: str + + +class ProductAttributeListResponse(BaseModel): + items: list[ProductAttributeResponse] + pagination: dict + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/schemas.py b/hesabixAPI/build/lib/adapters/api/v1/schemas.py new file mode 100644 index 0000000..e7fdf86 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/schemas.py @@ -0,0 +1,339 @@ +from typing import Any, List, Optional, Union, Generic, TypeVar +from pydantic import BaseModel, EmailStr, Field +from enum import Enum +from datetime import datetime, date + +T = TypeVar('T') + + +class FilterItem(BaseModel): + property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر") + operator: str = Field(..., description="نوع عملگر: =, >, >=, <, <=, !=, *, ?*, *?, in") + value: Any = Field(..., description="مقدار مورد نظر") + + +class QueryInfo(BaseModel): + sort_by: Optional[str] = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی") + sort_desc: bool = Field(default=False, description="false = مرتب سازی صعودی، true = مرتب سازی نزولی") + take: int = Field(default=10, ge=1, le=1000, description="حداکثر تعداد رکورد بازگشتی") + skip: int = Field(default=0, ge=0, description="تعداد رکوردی که از ابتدای لیست صرف نظر می شود") + search: Optional[str] = Field(default=None, description="عبارت جستجو") + search_fields: Optional[List[str]] = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد") + filters: Optional[List[FilterItem]] = Field(default=None, description="آرایه ای از اشیا برای اعمال فیلتر بر روی لیست") + + +class CaptchaSolve(BaseModel): + captcha_id: str = Field(..., min_length=8) + captcha_code: str = Field(..., min_length=3, max_length=8) + + +class RegisterRequest(CaptchaSolve): + first_name: Optional[str] = Field(default=None, max_length=100) + last_name: Optional[str] = Field(default=None, max_length=100) + email: Optional[EmailStr] = None + mobile: Optional[str] = Field(default=None, max_length=32) + password: str = Field(..., min_length=8, max_length=128) + device_id: Optional[str] = Field(default=None, max_length=100) + referrer_code: Optional[str] = Field(default=None, min_length=4, max_length=32) + + +class LoginRequest(CaptchaSolve): + identifier: str = Field(..., min_length=3, max_length=255) + password: str = Field(..., min_length=8, max_length=128) + device_id: Optional[str] = Field(default=None, max_length=100) + + +class ForgotPasswordRequest(CaptchaSolve): + identifier: str = Field(..., min_length=3, max_length=255) + + +class ResetPasswordRequest(CaptchaSolve): + token: str = Field(..., min_length=16) + new_password: str = Field(..., min_length=8, max_length=128) + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(..., min_length=8, max_length=128) + new_password: str = Field(..., min_length=8, max_length=128) + confirm_password: str = Field(..., min_length=8, max_length=128) + + +class CreateApiKeyRequest(BaseModel): + name: Optional[str] = Field(default=None, max_length=100) + scopes: Optional[str] = Field(default=None, max_length=500) + expires_at: Optional[str] = None # ISO string; parse server-side if provided + + +# Response Models +class SuccessResponse(BaseModel): + success: bool = Field(default=True, description="وضعیت موفقیت عملیات") + message: Optional[str] = Field(default=None, description="پیام توضیحی") + data: Optional[Union[dict, list]] = Field(default=None, description="داده‌های بازگشتی") + + +class ErrorResponse(BaseModel): + success: bool = Field(default=False, description="وضعیت موفقیت عملیات") + message: str = Field(..., description="پیام خطا") + error_code: Optional[str] = Field(default=None, description="کد خطا") + details: Optional[dict] = Field(default=None, description="جزئیات خطا") + + +class UserResponse(BaseModel): + id: int = Field(..., description="شناسه کاربر") + email: Optional[str] = Field(default=None, description="ایمیل کاربر") + mobile: Optional[str] = Field(default=None, description="شماره موبایل") + first_name: Optional[str] = Field(default=None, description="نام") + last_name: Optional[str] = Field(default=None, description="نام خانوادگی") + is_active: bool = Field(..., description="وضعیت فعال بودن") + referral_code: str = Field(..., description="کد معرفی") + referred_by_user_id: Optional[int] = Field(default=None, description="شناسه کاربر معرف") + app_permissions: Optional[dict] = Field(default=None, description="مجوزهای اپلیکیشن") + created_at: str = Field(..., description="تاریخ ایجاد") + updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی") + + +class CaptchaResponse(BaseModel): + captcha_id: str = Field(..., description="شناسه کپچا") + image_base64: str = Field(..., description="تصویر کپچا به صورت base64") + ttl_seconds: int = Field(..., description="زمان انقضا به ثانیه") + + +class LoginResponse(BaseModel): + api_key: str = Field(..., description="کلید API") + expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا") + user: UserResponse = Field(..., description="اطلاعات کاربر") + + +class ApiKeyResponse(BaseModel): + id: int = Field(..., description="شناسه کلید") + name: Optional[str] = Field(default=None, description="نام کلید") + scopes: Optional[str] = Field(default=None, description="محدوده دسترسی") + device_id: Optional[str] = Field(default=None, description="شناسه دستگاه") + user_agent: Optional[str] = Field(default=None, description="اطلاعات مرورگر") + ip: Optional[str] = Field(default=None, description="آدرس IP") + expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا") + last_used_at: Optional[str] = Field(default=None, description="آخرین استفاده") + created_at: str = Field(..., description="تاریخ ایجاد") + + +class ReferralStatsResponse(BaseModel): + total_referrals: int = Field(..., description="تعداد کل معرفی‌ها") + active_referrals: int = Field(..., description="تعداد معرفی‌های فعال") + recent_referrals: int = Field(..., description="تعداد معرفی‌های اخیر") + referral_rate: float = Field(..., description="نرخ معرفی") + + +class PaginationInfo(BaseModel): + total: int = Field(..., description="تعداد کل رکوردها") + page: int = Field(..., description="شماره صفحه فعلی") + per_page: int = Field(..., description="تعداد رکورد در هر صفحه") + total_pages: int = Field(..., description="تعداد کل صفحات") + has_next: bool = Field(..., description="آیا صفحه بعدی وجود دارد") + has_prev: bool = Field(..., description="آیا صفحه قبلی وجود دارد") + + +class UsersListResponse(BaseModel): + items: List[UserResponse] = Field(..., description="لیست کاربران") + pagination: PaginationInfo = Field(..., description="اطلاعات صفحه‌بندی") + query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر") + + +class UsersSummaryResponse(BaseModel): + total_users: int = Field(..., description="تعداد کل کاربران") + active_users: int = Field(..., description="تعداد کاربران فعال") + inactive_users: int = Field(..., description="تعداد کاربران غیرفعال") + active_percentage: float = Field(..., description="درصد کاربران فعال") + + +# Business Schemas +class BusinessType(str, Enum): + COMPANY = "شرکت" + SHOP = "مغازه" + STORE = "فروشگاه" + UNION = "اتحادیه" + CLUB = "باشگاه" + INSTITUTE = "موسسه" + INDIVIDUAL = "شخصی" + + +class BusinessField(str, Enum): + MANUFACTURING = "تولیدی" + COMMERCIAL = "بازرگانی" + SERVICE = "خدماتی" + OTHER = "سایر" + + +class BusinessCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255, description="نام کسب و کار") + business_type: BusinessType = Field(..., description="نوع کسب و کار") + business_field: BusinessField = Field(..., description="زمینه فعالیت") + address: Optional[str] = Field(default=None, max_length=1000, description="آدرس") + phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت") + mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل") + national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی") + registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی") + country: Optional[str] = Field(default=None, max_length=100, description="کشور") + 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="آرایه سال‌های مالی برای ایجاد اولیه") + default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض") + currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده") + + +class BusinessUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام کسب و کار") + business_type: Optional[BusinessType] = Field(default=None, description="نوع کسب و کار") + business_field: Optional[BusinessField] = Field(default=None, description="زمینه فعالیت") + address: Optional[str] = Field(default=None, max_length=1000, description="آدرس") + phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت") + mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل") + national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی") + registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی") + country: Optional[str] = Field(default=None, max_length=100, description="کشور") + 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="کد پستی") + + +class BusinessResponse(BaseModel): + id: int = Field(..., description="شناسه کسب و کار") + name: str = Field(..., description="نام کسب و کار") + business_type: str = Field(..., description="نوع کسب و کار") + business_field: str = Field(..., description="زمینه فعالیت") + owner_id: int = Field(..., description="شناسه مالک") + address: Optional[str] = Field(default=None, description="آدرس") + phone: Optional[str] = Field(default=None, description="تلفن ثابت") + mobile: Optional[str] = Field(default=None, description="موبایل") + national_id: Optional[str] = Field(default=None, description="کد ملی") + registration_number: Optional[str] = Field(default=None, description="شماره ثبت") + economic_id: Optional[str] = Field(default=None, description="شناسه اقتصادی") + country: Optional[str] = Field(default=None, description="کشور") + province: Optional[str] = Field(default=None, description="استان") + city: Optional[str] = Field(default=None, description="شهر") + postal_code: Optional[str] = Field(default=None, description="کد پستی") + created_at: str = Field(..., description="تاریخ ایجاد") + updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی") + default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض") + currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسب‌وکار") + + +class BusinessListResponse(BaseModel): + items: List[BusinessResponse] = Field(..., description="لیست کسب و کارها") + pagination: PaginationInfo = Field(..., description="اطلاعات صفحه‌بندی") + query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر") + + +class BusinessSummaryResponse(BaseModel): + total_businesses: int = Field(..., description="تعداد کل کسب و کارها") + by_type: dict = Field(..., description="تعداد بر اساس نوع") + by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت") + + +class PaginatedResponse(BaseModel, Generic[T]): + """پاسخ صفحه‌بندی شده برای لیست‌ها""" + items: List[T] = Field(..., description="آیتم‌های صفحه") + total: int = Field(..., description="تعداد کل آیتم‌ها") + page: int = Field(..., description="شماره صفحه فعلی") + limit: int = Field(..., description="تعداد آیتم در هر صفحه") + total_pages: int = Field(..., description="تعداد کل صفحات") + + @classmethod + def create(cls, items: List[T], total: int, page: int, limit: int) -> 'PaginatedResponse[T]': + """ایجاد پاسخ صفحه‌بندی شده""" + total_pages = (total + limit - 1) // limit + return cls( + items=items, + total=total, + page=page, + limit=limit, + total_pages=total_pages + ) + + +# 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 + business_id: int + user_id: int + user_name: str + user_email: str + user_phone: Optional[str] = None + role: str + status: str + added_at: datetime + last_active: Optional[datetime] = None + permissions: dict + + class Config: + from_attributes = True + + +class AddUserRequest(BaseModel): + email_or_phone: str + + class Config: + json_schema_extra = { + "example": { + "email_or_phone": "user@example.com" + } + } + + +class AddUserResponse(BaseModel): + success: bool + message: str + user: Optional[BusinessUserSchema] = None + + +class UpdatePermissionsRequest(BaseModel): + permissions: dict + + class Config: + json_schema_extra = { + "example": { + "permissions": { + "sales": { + "read": True, + "write": True, + "delete": False + }, + "reports": { + "read": True, + "export": True + }, + "settings": { + "manage_users": True + } + } + } + } + + +class UpdatePermissionsResponse(BaseModel): + success: bool + message: str + + +class RemoveUserResponse(BaseModel): + success: bool + message: str + + +class BusinessUsersListResponse(BaseModel): + success: bool + message: str + data: dict + calendar_type: Optional[str] = None + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py b/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py new file mode 100644 index 0000000..3c6872d --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/__init__.py @@ -0,0 +1 @@ +# Support API endpoints diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/categories.py b/hesabixAPI/build/lib/adapters/api/v1/support/categories.py new file mode 100644 index 0000000..47d4dcb --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/categories.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.category_repository import CategoryRepository +from adapters.api.v1.support.schemas import CategoryResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_categories( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست دسته‌بندی‌های فعال""" + category_repo = CategoryRepository(db) + categories = category_repo.get_active_categories() + + # Convert to dict and format datetime fields + categories_data = [CategoryResponse.from_orm(category).dict() for category in categories] + formatted_data = format_datetime_fields(categories_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/operator.py b/hesabixAPI/build/lib/adapters/api/v1/support/operator.py new file mode 100644 index 0000000..45ade04 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/operator.py @@ -0,0 +1,296 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Request, Body +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.ticket_repository import TicketRepository +from adapters.db.repositories.support.message_repository import MessageRepository +from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse +from adapters.api.v1.support.schemas import ( + CreateMessageRequest, + UpdateStatusRequest, + AssignTicketRequest, + TicketResponse, + MessageResponse +) +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_app_permission +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.post("/tickets/search", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def search_operator_tickets( + request: Request, + query_info: QueryInfo = Body(...), + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """جستجو در تمام تیکت‌ها برای اپراتور""" + ticket_repo = TicketRepository(db) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["title", "description", "user_email", "user_name"] + + tickets, total = ticket_repo.get_operator_tickets(query_info) + + # تبدیل به dict + ticket_dicts = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "title": ticket.title, + "description": ticket.description, + "user_id": ticket.user_id, + "category_id": ticket.category_id, + "priority_id": ticket.priority_id, + "status_id": ticket.status_id, + "assigned_operator_id": ticket.assigned_operator_id, + "is_internal": ticket.is_internal, + "closed_at": ticket.closed_at, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "user": { + "id": ticket.user.id, + "first_name": ticket.user.first_name, + "last_name": ticket.user.last_name, + "email": ticket.user.email + } if ticket.user else None, + "assigned_operator": { + "id": ticket.assigned_operator.id, + "first_name": ticket.assigned_operator.first_name, + "last_name": ticket.assigned_operator.last_name, + "email": ticket.assigned_operator.email + } if ticket.assigned_operator else None, + "category": { + "id": ticket.category.id, + "name": ticket.category.name, + "description": ticket.category.description, + "is_active": ticket.category.is_active, + "created_at": ticket.category.created_at, + "updated_at": ticket.category.updated_at + } if ticket.category else None, + "priority": { + "id": ticket.priority.id, + "name": ticket.priority.name, + "description": ticket.priority.description, + "color": ticket.priority.color, + "order": ticket.priority.order, + "created_at": ticket.priority.created_at, + "updated_at": ticket.priority.updated_at + } if ticket.priority else None, + "status": { + "id": ticket.status.id, + "name": ticket.status.name, + "description": ticket.status.description, + "color": ticket.status.color, + "is_final": ticket.status.is_final, + "created_at": ticket.status.created_at, + "updated_at": ticket.status.updated_at + } if ticket.status else None + } + ticket_dicts.append(ticket_dict) + + paginated_data = PaginatedResponse.create( + items=ticket_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) + + +@router.get("/tickets/{ticket_id}", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def get_operator_ticket( + request: Request, + ticket_id: int, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """مشاهده تیکت برای اپراتور""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.put("/tickets/{ticket_id}/status", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def update_ticket_status( + request: Request, + ticket_id: int, + status_request: UpdateStatusRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """تغییر وضعیت تیکت""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.update_ticket_status( + ticket_id=ticket_id, + status_id=status_request.status_id, + operator_id=status_request.assigned_operator_id or current_user.get_user_id() + ) + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/assign", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def assign_ticket( + request: Request, + ticket_id: int, + assign_request: AssignTicketRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """تخصیص تیکت به اپراتور""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.assign_ticket(ticket_id, assign_request.operator_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_operator_ticket_with_details(ticket_id) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/messages", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def send_operator_message( + request: Request, + ticket_id: int, + message_request: CreateMessageRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ارسال پیام اپراتور به تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # ایجاد پیام + message = message_repo.create_message( + ticket_id=ticket_id, + sender_id=current_user.get_user_id(), + sender_type="operator", + content=message_request.content, + is_internal=message_request.is_internal + ) + + # اگر تیکت هنوز به اپراتور تخصیص نشده، آن را تخصیص ده + if not ticket.assigned_operator_id: + ticket_repo.assign_ticket(ticket_id, current_user.get_user_id()) + + # Format datetime fields based on calendar type + message_data = MessageResponse.from_orm(message).dict() + formatted_data = format_datetime_fields(message_data, request) + + return success_response(formatted_data, request) + + +@router.post("/tickets/{ticket_id}/messages/search", response_model=SuccessResponse) +@require_app_permission("support_operator") +async def search_operator_ticket_messages( + request: Request, + ticket_id: int, + query_info: QueryInfo = Body(...), + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """جستجو در پیام‌های تیکت برای اپراتور""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_operator_ticket_with_details(ticket_id) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["content"] + + messages, total = message_repo.get_ticket_messages(ticket_id, query_info) + + # تبدیل به dict + message_dicts = [] + for message in messages: + message_dict = { + "id": message.id, + "ticket_id": message.ticket_id, + "sender_id": message.sender_id, + "sender_type": message.sender_type, + "content": message.content, + "is_internal": message.is_internal, + "created_at": message.created_at, + "sender": { + "id": message.sender.id, + "first_name": message.sender.first_name, + "last_name": message.sender.last_name, + "email": message.sender.email + } if message.sender else None + } + message_dicts.append(message_dict) + + paginated_data = PaginatedResponse.create( + items=message_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py b/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py new file mode 100644 index 0000000..47442c7 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/priorities.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.priority_repository import PriorityRepository +from adapters.api.v1.support.schemas import PriorityResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_priorities( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست اولویت‌ها""" + priority_repo = PriorityRepository(db) + priorities = priority_repo.get_priorities_ordered() + + # Convert to dict and format datetime fields + priorities_data = [PriorityResponse.from_orm(priority).dict() for priority in priorities] + formatted_data = format_datetime_fields(priorities_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py b/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py new file mode 100644 index 0000000..0f4bcc9 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/schemas.py @@ -0,0 +1,134 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from adapters.db.models.support.message import SenderType +from adapters.api.v1.schemas import PaginatedResponse + + +# Base schemas +class CategoryBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + is_active: bool = True + + +class PriorityBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + order: int = 0 + + +class StatusBase(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + description: Optional[str] = None + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + is_final: bool = False + + +class TicketBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: str = Field(..., min_length=1) + category_id: int + priority_id: int + + +class MessageBase(BaseModel): + content: str = Field(..., min_length=1) + is_internal: bool = False + + +# Response schemas +class CategoryResponse(CategoryBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PriorityResponse(PriorityBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class StatusResponse(StatusBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class UserInfo(BaseModel): + id: int + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + + class Config: + from_attributes = True + + +class MessageResponse(MessageBase): + id: int + ticket_id: int + sender_id: int + sender_type: SenderType + sender: Optional[UserInfo] = None + created_at: datetime + + class Config: + from_attributes = True + + +class TicketResponse(TicketBase): + id: int + user_id: int + status_id: int + assigned_operator_id: Optional[int] = None + is_internal: bool = False + closed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + # Related objects + user: Optional[UserInfo] = None + assigned_operator: Optional[UserInfo] = None + category: Optional[CategoryResponse] = None + priority: Optional[PriorityResponse] = None + status: Optional[StatusResponse] = None + messages: Optional[List[MessageResponse]] = None + + class Config: + from_attributes = True + + +# Request schemas +class CreateTicketRequest(TicketBase): + pass + + +class CreateMessageRequest(MessageBase): + pass + + +class UpdateStatusRequest(BaseModel): + status_id: int + assigned_operator_id: Optional[int] = None + + +class AssignTicketRequest(BaseModel): + operator_id: int + + +# PaginatedResponse is now imported from adapters.api.v1.schemas diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py b/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py new file mode 100644 index 0000000..e9a292b --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/statuses.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import List +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.status_repository import StatusRepository +from adapters.api.v1.support.schemas import StatusResponse +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.get("", response_model=SuccessResponse) +async def get_statuses( + request: Request, + db: Session = Depends(get_db) +): + """دریافت لیست وضعیت‌ها""" + status_repo = StatusRepository(db) + statuses = status_repo.get_all_statuses() + + # Convert to dict and format datetime fields + statuses_data = [StatusResponse.from_orm(status).dict() for status in statuses] + formatted_data = format_datetime_fields(statuses_data, request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py b/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py new file mode 100644 index 0000000..8a98e21 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/support/tickets.py @@ -0,0 +1,256 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.support.ticket_repository import TicketRepository +from adapters.db.repositories.support.message_repository import MessageRepository +from adapters.api.v1.schemas import QueryInfo, PaginatedResponse, SuccessResponse +from adapters.api.v1.support.schemas import ( + CreateTicketRequest, + CreateMessageRequest, + TicketResponse, + MessageResponse +) +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import success_response, format_datetime_fields + +router = APIRouter() + + +@router.post("/search", response_model=SuccessResponse) +async def search_user_tickets( + request: Request, + query_info: QueryInfo, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """جستجو در تیکت‌های کاربر""" + ticket_repo = TicketRepository(db) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["title", "description"] + + tickets, total = ticket_repo.get_user_tickets(current_user.get_user_id(), query_info) + + # تبدیل به dict + ticket_dicts = [] + for ticket in tickets: + ticket_dict = { + "id": ticket.id, + "title": ticket.title, + "description": ticket.description, + "user_id": ticket.user_id, + "category_id": ticket.category_id, + "priority_id": ticket.priority_id, + "status_id": ticket.status_id, + "assigned_operator_id": ticket.assigned_operator_id, + "is_internal": ticket.is_internal, + "closed_at": ticket.closed_at, + "created_at": ticket.created_at, + "updated_at": ticket.updated_at, + "category": { + "id": ticket.category.id, + "name": ticket.category.name, + "description": ticket.category.description, + "is_active": ticket.category.is_active, + "created_at": ticket.category.created_at, + "updated_at": ticket.category.updated_at + } if ticket.category else None, + "priority": { + "id": ticket.priority.id, + "name": ticket.priority.name, + "description": ticket.priority.description, + "color": ticket.priority.color, + "order": ticket.priority.order, + "created_at": ticket.priority.created_at, + "updated_at": ticket.priority.updated_at + } if ticket.priority else None, + "status": { + "id": ticket.status.id, + "name": ticket.status.name, + "description": ticket.status.description, + "color": ticket.status.color, + "is_final": ticket.status.is_final, + "created_at": ticket.status.created_at, + "updated_at": ticket.status.updated_at + } if ticket.status else None + } + ticket_dicts.append(ticket_dict) + + paginated_data = PaginatedResponse.create( + items=ticket_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) + + +@router.post("", response_model=SuccessResponse) +async def create_ticket( + request: Request, + ticket_request: CreateTicketRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ایجاد تیکت جدید""" + ticket_repo = TicketRepository(db) + + # ایجاد تیکت + ticket_data = { + "title": ticket_request.title, + "description": ticket_request.description, + "user_id": current_user.get_user_id(), + "category_id": ticket_request.category_id, + "priority_id": ticket_request.priority_id, + "status_id": 1, # وضعیت پیش‌فرض: باز + "is_internal": False + } + + ticket = ticket_repo.create(ticket_data) + + # ایجاد پیام اولیه + message_repo = MessageRepository(db) + message_repo.create_message( + ticket_id=ticket.id, + sender_id=current_user.get_user_id(), + sender_type="user", + content=ticket_request.description, + is_internal=False + ) + + # دریافت تیکت با جزئیات + ticket_with_details = ticket_repo.get_ticket_with_details(ticket.id, current_user.get_user_id()) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket_with_details).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.get("/{ticket_id}", response_model=SuccessResponse) +async def get_ticket( + request: Request, + ticket_id: int, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """مشاهده تیکت""" + ticket_repo = TicketRepository(db) + + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # Format datetime fields based on calendar type + ticket_data = TicketResponse.from_orm(ticket).dict() + formatted_data = format_datetime_fields(ticket_data, request) + + return success_response(formatted_data, request) + + +@router.post("/{ticket_id}/messages", response_model=SuccessResponse) +async def send_message( + request: Request, + ticket_id: int, + message_request: CreateMessageRequest, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ارسال پیام به تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # ایجاد پیام + message = message_repo.create_message( + ticket_id=ticket_id, + sender_id=current_user.get_user_id(), + sender_type="user", + content=message_request.content, + is_internal=message_request.is_internal + ) + + # Format datetime fields based on calendar type + message_data = MessageResponse.from_orm(message).dict() + formatted_data = format_datetime_fields(message_data, request) + + return success_response(formatted_data, request) + + +@router.post("/{ticket_id}/messages/search", response_model=SuccessResponse) +async def search_ticket_messages( + request: Request, + ticket_id: int, + query_info: QueryInfo, + current_user: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """جستجو در پیام‌های تیکت""" + ticket_repo = TicketRepository(db) + message_repo = MessageRepository(db) + + # بررسی وجود تیکت + ticket = ticket_repo.get_ticket_with_details(ticket_id, current_user.get_user_id()) + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="تیکت یافت نشد" + ) + + # تنظیم فیلدهای قابل جستجو + if not query_info.search_fields: + query_info.search_fields = ["content"] + + messages, total = message_repo.get_ticket_messages(ticket_id, query_info) + + # تبدیل به dict + message_dicts = [] + for message in messages: + message_dict = { + "id": message.id, + "ticket_id": message.ticket_id, + "sender_id": message.sender_id, + "sender_type": message.sender_type, + "content": message.content, + "is_internal": message.is_internal, + "created_at": message.created_at, + "sender": { + "id": message.sender.id, + "first_name": message.sender.first_name, + "last_name": message.sender.last_name, + "email": message.sender.email + } if message.sender else None + } + message_dicts.append(message_dict) + + paginated_data = PaginatedResponse.create( + items=message_dicts, + total=total, + page=(query_info.skip // query_info.take) + 1, + limit=query_info.take + ) + + # Format datetime fields based on calendar type + formatted_data = format_datetime_fields(paginated_data.dict(), request) + + return success_response(formatted_data, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/tax_types.py b/hesabixAPI/build/lib/adapters/api/v1/tax_types.py new file mode 100644 index 0000000..5360905 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/tax_types.py @@ -0,0 +1,49 @@ +from typing import Dict, Any, List +from fastapi import APIRouter, Depends, Request + +from adapters.api.v1.schemas import SuccessResponse +from adapters.db.session import get_db # noqa: F401 (kept for consistency/future use) +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 sqlalchemy.orm import Session # noqa: F401 + + +router = APIRouter(prefix="/tax-types", tags=["tax-types"]) + + +def _static_tax_types() -> List[Dict[str, Any]]: + titles = [ + "دارو", + "دخانیات", + "موبایل", + "لوازم خانگی برقی", + "قطعات مصرفی و یدکی وسایل نقلیه", + "فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی", + "طلا اعم از شمش، مسکوکات و مصنوعات زینتی", + "منسوجات و پوشاک", + "اسباب بازی", + "دام زنده، گوشت سفید و قرمز", + "محصولات اساسی کشاورزی", + "سایر کالا ها", + ] + return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)] + + +@router.get( + "/business/{business_id}", + summary="لیست نوع‌های مالیات", + description="دریافت لیست نوع‌های مالیات (ثابت)", + response_model=SuccessResponse, +) +@require_business_access() +def list_tax_types( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), +) -> Dict[str, Any]: + # Currently returns a static list; later can be sourced from DB if needed + items = _static_tax_types() + return success_response(items, request) + + diff --git a/hesabixAPI/build/lib/adapters/api/v1/tax_units.py b/hesabixAPI/build/lib/adapters/api/v1/tax_units.py new file mode 100644 index 0000000..77392cc --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/tax_units.py @@ -0,0 +1,387 @@ +from fastapi import APIRouter, Depends, Request, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from decimal import Decimal + +from adapters.db.session import get_db +from adapters.db.models.tax_unit import TaxUnit +from adapters.api.v1.schemas import SuccessResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from pydantic import BaseModel, Field + + +router = APIRouter(prefix="/tax-units", tags=["tax-units"]) +alias_router = APIRouter(prefix="/units", tags=["units"]) + + +class TaxUnitCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی") + code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی") + description: Optional[str] = Field(default=None, description="توضیحات") + tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)") + is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال") + + +class TaxUnitUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی") + code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی") + description: Optional[str] = Field(default=None, description="توضیحات") + tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)") + is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال") + + +class TaxUnitResponse(BaseModel): + id: int + business_id: int + name: str + code: str + description: Optional[str] = None + tax_rate: Optional[Decimal] = None + is_active: bool + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +@router.get("/business/{business_id}", + summary="لیست واحدهای مالیاتی کسب‌وکار", + description="دریافت لیست واحدهای مالیاتی یک کسب‌وکار", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست واحدهای مالیاتی با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست واحدهای مالیاتی دریافت شد", + "data": [ + { + "id": 1, + "business_id": 1, + "name": "مالیات بر ارزش افزوده", + "code": "VAT", + "description": "مالیات بر ارزش افزوده 9 درصد", + "tax_rate": 9.0, + "is_active": True, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ] + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب‌وکار" + }, + 404: { + "description": "کسب‌وکار یافت نشد" + } + } +) +@alias_router.get("/business/{business_id}") +@require_business_access() +def get_tax_units( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت لیست واحدهای مالیاتی یک کسب‌وکار""" + + # Query tax units for the business + tax_units = db.query(TaxUnit).filter( + TaxUnit.business_id == business_id + ).order_by(TaxUnit.name).all() + + # Convert to response format + tax_unit_dicts = [] + for tax_unit in tax_units: + tax_unit_dict = { + "id": tax_unit.id, + "business_id": tax_unit.business_id, + "name": tax_unit.name, + "code": tax_unit.code, + "description": tax_unit.description, + "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None, + "is_active": tax_unit.is_active, + "created_at": tax_unit.created_at.isoformat(), + "updated_at": tax_unit.updated_at.isoformat() + } + tax_unit_dicts.append(format_datetime_fields(tax_unit_dict, request)) + + return success_response(tax_unit_dicts, request) + + +@router.post("/business/{business_id}", + summary="ایجاد واحد مالیاتی جدید", + description="ایجاد یک واحد مالیاتی جدید برای کسب‌وکار", + response_model=SuccessResponse, + responses={ + 201: { + "description": "واحد مالیاتی با موفقیت ایجاد شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "واحد مالیاتی با موفقیت ایجاد شد", + "data": { + "id": 1, + "business_id": 1, + "name": "مالیات بر ارزش افزوده", + "code": "VAT", + "description": "مالیات بر ارزش افزوده 9 درصد", + "tax_rate": 9.0, + "is_active": True, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب‌وکار" + }, + 404: { + "description": "کسب‌وکار یافت نشد" + } + } +) +@alias_router.post("/business/{business_id}") +@require_business_access() +def create_tax_unit( + request: Request, + business_id: int, + tax_unit_data: TaxUnitCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """ایجاد واحد مالیاتی جدید""" + + # Check if code already exists for this business + existing_tax_unit = db.query(TaxUnit).filter( + TaxUnit.business_id == business_id, + TaxUnit.code == tax_unit_data.code + ).first() + + if existing_tax_unit: + raise HTTPException( + status_code=400, + detail="کد واحد مالیاتی قبلاً استفاده شده است" + ) + + # Create new tax unit + tax_unit = TaxUnit( + business_id=business_id, + name=tax_unit_data.name, + code=tax_unit_data.code, + description=tax_unit_data.description, + tax_rate=tax_unit_data.tax_rate, + is_active=tax_unit_data.is_active + ) + + db.add(tax_unit) + db.commit() + db.refresh(tax_unit) + + # Convert to response format + tax_unit_dict = { + "id": tax_unit.id, + "business_id": tax_unit.business_id, + "name": tax_unit.name, + "code": tax_unit.code, + "description": tax_unit.description, + "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None, + "is_active": tax_unit.is_active, + "created_at": tax_unit.created_at.isoformat(), + "updated_at": tax_unit.updated_at.isoformat() + } + + formatted_response = format_datetime_fields(tax_unit_dict, request) + + return success_response(formatted_response, request) + + +@router.put("/{tax_unit_id}", + summary="به‌روزرسانی واحد مالیاتی", + description="به‌روزرسانی اطلاعات یک واحد مالیاتی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "واحد مالیاتی با موفقیت به‌روزرسانی شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "واحد مالیاتی با موفقیت به‌روزرسانی شد", + "data": { + "id": 1, + "business_id": 1, + "name": "مالیات بر ارزش افزوده", + "code": "VAT", + "description": "مالیات بر ارزش افزوده 9 درصد", + "tax_rate": 9.0, + "is_active": True, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 400: { + "description": "خطا در اعتبارسنجی داده‌ها" + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب‌وکار" + }, + 404: { + "description": "واحد مالیاتی یافت نشد" + } + } +) +@alias_router.put("/{tax_unit_id}") +@require_business_access() +def update_tax_unit( + request: Request, + tax_unit_id: int, + tax_unit_data: TaxUnitUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """به‌روزرسانی واحد مالیاتی""" + + # Find the tax unit + tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first() + if not tax_unit: + raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد") + + # Check business access + if tax_unit.business_id not in ctx.business_ids: + raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسب‌وکار") + + # Check if new code conflicts with existing ones + if tax_unit_data.code and tax_unit_data.code != tax_unit.code: + existing_tax_unit = db.query(TaxUnit).filter( + TaxUnit.business_id == tax_unit.business_id, + TaxUnit.code == tax_unit_data.code, + TaxUnit.id != tax_unit_id + ).first() + + if existing_tax_unit: + raise HTTPException( + status_code=400, + detail="کد واحد مالیاتی قبلاً استفاده شده است" + ) + + # Update fields + update_data = tax_unit_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(tax_unit, field, value) + + db.commit() + db.refresh(tax_unit) + + # Convert to response format + tax_unit_dict = { + "id": tax_unit.id, + "business_id": tax_unit.business_id, + "name": tax_unit.name, + "code": tax_unit.code, + "description": tax_unit.description, + "tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None, + "is_active": tax_unit.is_active, + "created_at": tax_unit.created_at.isoformat(), + "updated_at": tax_unit.updated_at.isoformat() + } + + formatted_response = format_datetime_fields(tax_unit_dict, request) + + return success_response(formatted_response, request) + + +@router.delete("/{tax_unit_id}", + summary="حذف واحد مالیاتی", + description="حذف یک واحد مالیاتی", + response_model=SuccessResponse, + responses={ + 200: { + "description": "واحد مالیاتی با موفقیت حذف شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "واحد مالیاتی با موفقیت حذف شد", + "data": None + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز به کسب‌وکار" + }, + 404: { + "description": "واحد مالیاتی یافت نشد" + }, + 409: { + "description": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد" + } + } +) +@alias_router.delete("/{tax_unit_id}") +@require_business_access() +def delete_tax_unit( + request: Request, + tax_unit_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """حذف واحد مالیاتی""" + + # Find the tax unit + tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first() + if not tax_unit: + raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد") + + # Check business access + if tax_unit.business_id not in ctx.business_ids: + raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسب‌وکار") + + # Check if tax unit is used in products + from adapters.db.models.product import Product + products_using_tax_unit = db.query(Product).filter( + Product.tax_unit_id == tax_unit_id + ).count() + + if products_using_tax_unit > 0: + raise HTTPException( + status_code=409, + detail=f"امکان حذف واحد مالیاتی به دلیل استفاده در {products_using_tax_unit} محصول وجود ندارد" + ) + + # Delete the tax unit + db.delete(tax_unit) + db.commit() + + return success_response(None, request) diff --git a/hesabixAPI/build/lib/adapters/api/v1/users.py b/hesabixAPI/build/lib/adapters/api/v1/users.py new file mode 100644 index 0000000..06b9742 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/api/v1/users.py @@ -0,0 +1,362 @@ +# Removed __future__ annotations to fix OpenAPI schema generation + +from fastapi import APIRouter, Depends, Request, Query +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.user_repo import UserRepository +from adapters.api.v1.schemas import QueryInfo, SuccessResponse, UsersListResponse, UsersSummaryResponse, UserResponse +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_user_management + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("/search", + summary="لیست کاربران با فیلتر پیشرفته", + description="دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست کاربران با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست کاربران دریافت شد", + "data": { + "items": [ + { + "id": 1, + "email": "user@example.com", + "mobile": "09123456789", + "first_name": "احمد", + "last_name": "احمدی", + "is_active": True, + "referral_code": "ABC123", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "pagination": { + "total": 1, + "page": 1, + "per_page": 10, + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager", + "content": { + "application/json": { + "example": { + "success": False, + "message": "Missing app permission: user_management", + "error_code": "FORBIDDEN" + } + } + } + } + } +) +@require_user_management() +def list_users( + request: Request, + query_info: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی + + پارامترهای QueryInfo: + - sort_by: فیلد مرتب‌سازی (مثال: created_at, first_name) + - sort_desc: ترتیب نزولی (true/false) + - take: تعداد رکورد در هر صفحه (پیش‌فرض: 10) + - skip: تعداد رکورد صرف‌نظر شده (پیش‌فرض: 0) + - search: عبارت جستجو + - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"]) + - filters: آرایه فیلترها با ساختار: + [ + { + "property": "is_active", + "operator": "=", + "value": true + }, + { + "property": "first_name", + "operator": "*", + "value": "احمد" + } + ] + + عملگرهای پشتیبانی شده: + - = : برابر + - > : بزرگتر از + - >= : بزرگتر یا مساوی + - < : کوچکتر از + - <= : کوچکتر یا مساوی + - != : نامساوی + - * : شامل (contains) + - ?* : خاتمه یابد (ends with) + - *? : شروع شود (starts with) + - in : در بین مقادیر آرایه + """ + repo = UserRepository(db) + users, total = repo.query_with_filters(query_info) + + # تبدیل User objects به dictionary + user_dicts = [repo.to_dict(user) for user in users] + + # فرمت کردن تاریخ‌ها + formatted_users = [format_datetime_fields(user_dict, request) for user_dict in user_dicts] + + # محاسبه اطلاعات صفحه‌بندی + page = (query_info.skip // query_info.take) + 1 + total_pages = (total + query_info.take - 1) // query_info.take + + response_data = { + "items": formatted_users, + "pagination": { + "total": total, + "page": page, + "per_page": query_info.take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1 + }, + "query_info": { + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + "search_fields": query_info.search_fields, + "filters": [{"property": f.property, "operator": f.operator, "value": f.value} for f in (query_info.filters or [])] + } + } + + return success_response(response_data, request) + + +@router.get("", + summary="لیست ساده کاربران", + description="دریافت لیست ساده کاربران. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.", + response_model=SuccessResponse, + responses={ + 200: { + "description": "لیست کاربران با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "لیست کاربران دریافت شد", + "data": [ + { + "id": 1, + "email": "user@example.com", + "mobile": "09123456789", + "first_name": "احمد", + "last_name": "احمدی", + "is_active": True, + "referral_code": "ABC123", + "created_at": "2024-01-01T00:00:00Z" + } + ] + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager", + "content": { + "application/json": { + "example": { + "success": False, + "message": "Missing app permission: user_management", + "error_code": "FORBIDDEN" + } + } + } + } + } +) +@require_user_management() +def list_users_simple( + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + limit: int = Query(10, ge=1, le=100, description="تعداد رکورد در هر صفحه"), + offset: int = Query(0, ge=0, description="تعداد رکورد صرف‌نظر شده") +): + """دریافت لیست ساده کاربران""" + repo = UserRepository(db) + + # Create basic query info + query_info = QueryInfo(take=limit, skip=offset) + users, total = repo.query_with_filters(query_info) + + # تبدیل User objects به dictionary + user_dicts = [repo.to_dict(user) for user in users] + + # فرمت کردن تاریخ‌ها + formatted_users = [format_datetime_fields(user_dict, None) for user_dict in user_dicts] + + return success_response(formatted_users, None) + + +@router.get("/{user_id}", + summary="دریافت اطلاعات یک کاربر", + description="دریافت اطلاعات کامل یک کاربر بر اساس شناسه. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.", + response_model=SuccessResponse, + responses={ + 200: { + "description": "اطلاعات کاربر با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "اطلاعات کاربر دریافت شد", + "data": { + "id": 1, + "email": "user@example.com", + "mobile": "09123456789", + "first_name": "احمد", + "last_name": "احمدی", + "is_active": True, + "referral_code": "ABC123", + "created_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager", + "content": { + "application/json": { + "example": { + "success": False, + "message": "Missing app permission: user_management", + "error_code": "FORBIDDEN" + } + } + } + }, + 404: { + "description": "کاربر یافت نشد", + "content": { + "application/json": { + "example": { + "success": False, + "message": "کاربر یافت نشد", + "error_code": "USER_NOT_FOUND" + } + } + } + } + } +) +@require_user_management() +def get_user( + user_id: int, + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """دریافت اطلاعات یک کاربر بر اساس ID""" + repo = UserRepository(db) + user = repo.get_by_id(user_id) + + if not user: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + user_dict = repo.to_dict(user) + formatted_user = format_datetime_fields(user_dict, request) + + return success_response(formatted_user, request) + + +@router.get("/stats/summary", + summary="آمار کلی کاربران", + description="دریافت آمار کلی کاربران شامل تعداد کل، فعال و غیرفعال. نیاز به مجوز usermanager در سطح اپلیکیشن دارد.", + response_model=SuccessResponse, + responses={ + 200: { + "description": "آمار کاربران با موفقیت دریافت شد", + "content": { + "application/json": { + "example": { + "success": True, + "message": "آمار کاربران دریافت شد", + "data": { + "total_users": 100, + "active_users": 85, + "inactive_users": 15, + "active_percentage": 85.0 + } + } + } + } + }, + 401: { + "description": "کاربر احراز هویت نشده است" + }, + 403: { + "description": "دسترسی غیرمجاز - نیاز به مجوز usermanager", + "content": { + "application/json": { + "example": { + "success": False, + "message": "Missing app permission: user_management", + "error_code": "FORBIDDEN" + } + } + } + } + } +) +@require_user_management() +def get_users_summary( + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +): + """دریافت آمار کلی کاربران""" + repo = UserRepository(db) + + # تعداد کل کاربران + total_users = repo.count_all() + + # تعداد کاربران فعال + active_users = repo.query_with_filters(QueryInfo( + filters=[{"property": "is_active", "operator": "=", "value": True}] + ))[1] + + # تعداد کاربران غیرفعال + inactive_users = total_users - active_users + + response_data = { + "total_users": total_users, + "active_users": active_users, + "inactive_users": inactive_users, + "active_percentage": round((active_users / total_users * 100), 2) if total_users > 0 else 0 + } + + return success_response(response_data, request) + + diff --git a/hesabixAPI/build/lib/adapters/db/__init__.py b/hesabixAPI/build/lib/adapters/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/adapters/db/models/__init__.py b/hesabixAPI/build/lib/adapters/db/models/__init__.py new file mode 100644 index 0000000..a8d4d3c --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/__init__.py @@ -0,0 +1,38 @@ +from adapters.db.session import Base # re-export Base for Alembic + +# Import models to register with SQLAlchemy metadata +from .user import User # noqa: F401 +from .api_key import ApiKey # noqa: F401 +from .captcha import Captcha # noqa: F401 +from .password_reset import PasswordReset # noqa: F401 +from .business import Business # noqa: F401 +from .business_permission import BusinessPermission # noqa: F401 +from .person import Person, PersonBankAccount # noqa: F401 +# Business user models removed - using business_permissions instead + +# Import support models +from .support import * # noqa: F401, F403 + +# Import file storage models +from .file_storage import * + +# Import email config models +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 +from .category import BusinessCategory # noqa: F401 +from .product_attribute import ProductAttribute # noqa: F401 +from .product import Product # noqa: F401 +from .price_list import PriceList, PriceItem # noqa: F401 +from .product_attribute_link import ProductAttributeLink # noqa: F401 +from .tax_unit import TaxUnit # noqa: F401 diff --git a/hesabixAPI/build/lib/adapters/db/models/account.py b/hesabixAPI/build/lib/adapters/db/models/account.py new file mode 100644 index 0000000..a5e38a6 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/models/api_key.py b/hesabixAPI/build/lib/adapters/db/models/api_key.py new file mode 100644 index 0000000..991131d --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/api_key.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + key_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + key_type: Mapped[str] = mapped_column(String(16), nullable=False) # "session" | "personal" + name: Mapped[str | None] = mapped_column(String(100), nullable=True) + scopes: Mapped[str | None] = mapped_column(String(500), nullable=True) + device_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/business.py b/hesabixAPI/build/lib/adapters/db/models/business.py new file mode 100644 index 0000000..85253d9 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/business.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Enum as SQLEnum, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class BusinessType(str, Enum): + """نوع کسب و کار""" + COMPANY = "شرکت" # شرکت + SHOP = "مغازه" # مغازه + STORE = "فروشگاه" # فروشگاه + UNION = "اتحادیه" # اتحادیه + CLUB = "باشگاه" # باشگاه + INSTITUTE = "موسسه" # موسسه + INDIVIDUAL = "شخصی" # شخصی + + +class BusinessField(str, Enum): + """زمینه فعالیت کسب و کار""" + MANUFACTURING = "تولیدی" # تولیدی + TRADING = "بازرگانی" # بازرگانی + SERVICE = "خدماتی" # خدماتی + OTHER = "سایر" # سایر + + +class Business(Base): + __tablename__ = "businesses" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False) + business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False) + owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + default_currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True) + + # فیلدهای جدید + address: Mapped[str | None] = mapped_column(Text, nullable=True) + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True) + national_id: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + + # فیلدهای جغرافیایی + country: Mapped[str | None] = mapped_column(String(100), nullable=True) + province: Mapped[str | None] = mapped_column(String(100), nullable=True) + city: Mapped[str | None] = mapped_column(String(100), nullable=True) + postal_code: Mapped[str | None] = mapped_column(String(20), 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 + persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan") + fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan") + currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses") + default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False) + documents = relationship("Document", back_populates="business", cascade="all, delete-orphan") + accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan") diff --git a/hesabixAPI/build/lib/adapters/db/models/business_permission.py b/hesabixAPI/build/lib/adapters/db/models/business_permission.py new file mode 100644 index 0000000..fcac273 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/business_permission.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Integer, ForeignKey, JSON, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class BusinessPermission(Base): + __tablename__ = "business_permissions" + + 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) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + business_permissions: 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) diff --git a/hesabixAPI/build/lib/adapters/db/models/captcha.py b/hesabixAPI/build/lib/adapters/db/models/captcha.py new file mode 100644 index 0000000..cec072a --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/captcha.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class Captcha(Base): + __tablename__ = "captchas" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + code_hash: Mapped[str] = mapped_column(String(128), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + attempts: Mapped[int] = mapped_column(default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/category.py b/hesabixAPI/build/lib/adapters/db/models/category.py new file mode 100644 index 0000000..e9f4e73 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/category.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class BusinessCategory(Base): + """ + دسته‌بندی‌های کالا/خدمت برای هر کسب‌وکار با ساختار درختی + - عناوین چندزبانه در فیلد JSON `title_translations` نگهداری می‌شود + - نوع دسته‌بندی: product | service + """ + __tablename__ = "categories" + + 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) + parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True) + # فیلد type حذف شده است (در مهاجرت بعدی) + title_translations: Mapped[dict] = mapped_column(JSON, nullable=False, default={}) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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 + parent = relationship("BusinessCategory", remote_side=[id], backref="children") + + diff --git a/hesabixAPI/build/lib/adapters/db/models/currency.py b/hesabixAPI/build/lib/adapters/db/models/currency.py new file mode 100644 index 0000000..776fd05 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/models/document.py b/hesabixAPI/build/lib/adapters/db/models/document.py new file mode 100644 index 0000000..6d290cd --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/models/document_line.py b/hesabixAPI/build/lib/adapters/db/models/document_line.py new file mode 100644 index 0000000..494012b --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/models/email_config.py b/hesabixAPI/build/lib/adapters/db/models/email_config.py new file mode 100644 index 0000000..17aa98f --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/email_config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class EmailConfig(Base): + __tablename__ = "email_configs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + smtp_host: Mapped[str] = mapped_column(String(255), nullable=False) + smtp_port: Mapped[int] = mapped_column(Integer, nullable=False) + smtp_username: Mapped[str] = mapped_column(String(255), nullable=False) + smtp_password: Mapped[str] = mapped_column(String(255), nullable=False) # Should be encrypted + use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + use_ssl: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + from_email: Mapped[str] = mapped_column(String(255), nullable=False) + from_name: Mapped[str] = mapped_column(String(100), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, 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) diff --git a/hesabixAPI/build/lib/adapters/db/models/file_storage.py b/hesabixAPI/build/lib/adapters/db/models/file_storage.py new file mode 100644 index 0000000..e52ef85 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/file_storage.py @@ -0,0 +1,71 @@ +from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON, BigInteger +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid + +from adapters.db.session import Base + + +class FileStorage(Base): + __tablename__ = "file_storage" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + original_name = Column(String(255), nullable=False) + stored_name = Column(String(255), nullable=False) + file_path = Column(String(500), nullable=False) + file_size = Column(Integer, nullable=False) + mime_type = Column(String(100), nullable=False) + storage_type = Column(String(20), nullable=False) # local, ftp + storage_config_id = Column(String(36), ForeignKey("storage_configs.id"), nullable=True) + uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False) + module_context = Column(String(50), nullable=False) # tickets, accounting, business_logo, etc. + context_id = Column(String(36), nullable=True) # ticket_id, document_id, etc. + developer_data = Column(JSON, nullable=True) + checksum = Column(String(64), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + is_temporary = Column(Boolean, default=False, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + verification_token = Column(String(100), nullable=True) + last_verified_at = Column(DateTime(timezone=True), nullable=True) + expires_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + deleted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + uploader = relationship("User", foreign_keys=[uploaded_by]) + storage_config = relationship("StorageConfig", foreign_keys=[storage_config_id]) + + +class StorageConfig(Base): + __tablename__ = "storage_configs" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String(100), nullable=False) + storage_type = Column(String(20), nullable=False) # local, ftp + is_default = Column(Boolean, default=False, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + config_data = Column(JSON, nullable=False) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + # Relationships + creator = relationship("User", foreign_keys=[created_by]) + + +class FileVerification(Base): + __tablename__ = "file_verifications" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + file_id = Column(String(36), ForeignKey("file_storage.id"), nullable=False) + module_name = Column(String(50), nullable=False) + verification_token = Column(String(100), nullable=False) + verified_at = Column(DateTime(timezone=True), nullable=True) + verified_by = Column(Integer, ForeignKey("users.id"), nullable=True) + verification_data = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # Relationships + file = relationship("FileStorage", foreign_keys=[file_id]) + verifier = relationship("User", foreign_keys=[verified_by]) diff --git a/hesabixAPI/build/lib/adapters/db/models/fiscal_year.py b/hesabixAPI/build/lib/adapters/db/models/fiscal_year.py new file mode 100644 index 0000000..a3026b7 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/models/password_reset.py b/hesabixAPI/build/lib/adapters/db/models/password_reset.py new file mode 100644 index 0000000..d752ccb --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/password_reset.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class PasswordReset(Base): + __tablename__ = "password_resets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/person.py b/hesabixAPI/build/lib/adapters/db/models/person.py new file mode 100644 index 0000000..f130c07 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/person.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +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 + + +class PersonType(str, Enum): + """نوع شخص""" + CUSTOMER = "مشتری" # مشتری + MARKETER = "بازاریاب" # بازاریاب + EMPLOYEE = "کارمند" # کارمند + SUPPLIER = "تامین‌کننده" # تامین‌کننده + PARTNER = "همکار" # همکار + SELLER = "فروشنده" # فروشنده + SHAREHOLDER = "سهامدار" # سهامدار + + +class Person(Base): + __tablename__ = "persons" + __table_args__ = ( + UniqueConstraint('business_id', 'code', name='uq_persons_business_code'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + + # اطلاعات پایه + code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="کد یکتا در هر کسب و کار") + alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)") + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام") + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی") + person_type: Mapped[PersonType] = mapped_column( + SQLEnum(PersonType, 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="شناسه ملی") + registration_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره ثبت") + economic_id: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شناسه اقتصادی") + + # اطلاعات تماس + country: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="کشور") + province: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="استان") + city: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شهرستان") + address: Mapped[str | None] = mapped_column(Text, nullable=True, comment="آدرس") + postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="کد پستی") + phone: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="تلفن") + mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="موبایل") + fax: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="فکس") + email: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="پست الکترونیکی") + website: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="وب‌سایت") + + # زمان‌بندی + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + business: Mapped["Business"] = relationship("Business", back_populates="persons") + bank_accounts: Mapped[list["PersonBankAccount"]] = relationship("PersonBankAccount", back_populates="person", cascade="all, delete-orphan") + + +class PersonBankAccount(Base): + __tablename__ = "person_bank_accounts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + person_id: Mapped[int] = mapped_column(Integer, ForeignKey("persons.id", ondelete="CASCADE"), nullable=False, index=True) + + # اطلاعات حساب بانکی + bank_name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام بانک") + account_number: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="شماره حساب") + card_number: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="شماره کارت") + sheba_number: Mapped[str | None] = mapped_column(String(30), nullable=True, comment="شماره شبا") + + # زمان‌بندی + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + person: Mapped["Person"] = relationship("Person", back_populates="bank_accounts") diff --git a/hesabixAPI/build/lib/adapters/db/models/price_list.py b/hesabixAPI/build/lib/adapters/db/models/price_list.py new file mode 100644 index 0000000..7f95dd4 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/price_list.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ( + String, + Integer, + DateTime, + ForeignKey, + UniqueConstraint, + Boolean, + Numeric, +) +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class PriceList(Base): + __tablename__ = "price_lists" + __table_args__ = ( + UniqueConstraint("business_id", "name", name="uq_price_lists_business_name"), + ) + + 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) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, 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) + + +class PriceItem(Base): + __tablename__ = "price_items" + __table_args__ = ( + UniqueConstraint("price_list_id", "product_id", "unit_id", "tier_name", "min_qty", "currency_id", name="uq_price_items_unique_tier_currency"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + price_list_id: Mapped[int] = mapped_column(Integer, ForeignKey("price_lists.id", ondelete="CASCADE"), nullable=False, index=True) + product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) + unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) + tier_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="نام پله قیمت (تکی/عمده/همکار/...)" ) + min_qty: Mapped[Decimal] = mapped_column(Numeric(18, 3), nullable=False, default=0) + price: Mapped[Decimal] = mapped_column(Numeric(18, 2), 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) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/product.py b/hesabixAPI/build/lib/adapters/db/models/product.py new file mode 100644 index 0000000..6188a74 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/product.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import Enum + +from sqlalchemy import ( + String, + Integer, + DateTime, + Text, + ForeignKey, + UniqueConstraint, + Boolean, + Numeric, + Enum as SQLEnum, +) +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class ProductItemType(str, Enum): + PRODUCT = "کالا" + SERVICE = "خدمت" + + +class Product(Base): + """ + موجودیت کالا/خدمت در سطح هر کسب‌وکار + - کد دستی/اتوماتیک یکتا در هر کسب‌وکار + - پشتیبانی از مالیات فروش/خرید، کنترل موجودی و واحدها + - اتصال به دسته‌بندی‌ها و ویژگی‌ها (ویژگی‌ها از طریق جدول لینک) + """ + + __tablename__ = "products" + __table_args__ = ( + UniqueConstraint("business_id", "code", name="uq_products_business_code"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + + item_type: Mapped[ProductItemType] = mapped_column( + SQLEnum(ProductItemType, values_callable=lambda obj: [e.value for e in obj], name="product_item_type_enum"), + nullable=False, + default=ProductItemType.PRODUCT, + comment="نوع آیتم (کالا/خدمت)", + ) + + code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد یکتا در هر کسب‌وکار") + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + # دسته‌بندی (اختیاری) + category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True) + + # واحدها + main_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True) + + # قیمت‌های پایه (نمایشی) + base_sales_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) + base_sales_note: Mapped[str | None] = mapped_column(Text, nullable=True) + base_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) + base_purchase_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + # کنترل موجودی + track_inventory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + reorder_point: Mapped[int | None] = mapped_column(Integer, nullable=True) + min_order_qty: Mapped[int | None] = mapped_column(Integer, nullable=True) + lead_time_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # مالیات + is_sales_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_purchase_taxable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sales_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + purchase_tax_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + tax_type_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + tax_code: Mapped[str | None] = mapped_column(String(100), nullable=True) + tax_unit_id: Mapped[int | None] = mapped_column(Integer, 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) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/product_attribute.py b/hesabixAPI/build/lib/adapters/db/models/product_attribute.py new file mode 100644 index 0000000..2fec70d --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/product_attribute.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class ProductAttribute(Base): + """ + ویژگی‌های کالا/خدمت در سطح هر کسب‌وکار + - عنوان و توضیحات ساده (بدون چندزبانه) + - هر عنوان در هر کسب‌وکار یکتا باشد + """ + __tablename__ = "product_attributes" + __table_args__ = ( + UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'), + ) + + 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, index=True) + description: Mapped[str | None] = mapped_column(Text, 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) + + diff --git a/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py b/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py new file mode 100644 index 0000000..9d42847 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/product_attribute_link.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Integer, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class ProductAttributeLink(Base): + """لینک بین محصول و ویژگی‌ها (چندبه‌چند)""" + __tablename__ = "product_attribute_links" + __table_args__ = ( + UniqueConstraint("product_id", "attribute_id", name="uq_product_attribute_links_unique"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) + attribute_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_attributes.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/build/lib/adapters/db/models/support/__init__.py b/hesabixAPI/build/lib/adapters/db/models/support/__init__.py new file mode 100644 index 0000000..d73b820 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/__init__.py @@ -0,0 +1,8 @@ +from adapters.db.session import Base # re-export Base for Alembic + +# Import support models to register with SQLAlchemy metadata +from .category import Category # noqa: F401 +from .priority import Priority # noqa: F401 +from .status import Status # noqa: F401 +from .ticket import Ticket # noqa: F401 +from .message import Message # noqa: F401 diff --git a/hesabixAPI/build/lib/adapters/db/models/support/category.py b/hesabixAPI/build/lib/adapters/db/models/support/category.py new file mode 100644 index 0000000..ee1c947 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/category.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Category(Base): + """دسته‌بندی تیکت‌های پشتیبانی""" + __tablename__ = "support_categories" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, 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 + tickets = relationship("Ticket", back_populates="category") diff --git a/hesabixAPI/build/lib/adapters/db/models/support/message.py b/hesabixAPI/build/lib/adapters/db/models/support/message.py new file mode 100644 index 0000000..799b09a --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/message.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean, Enum as SQLEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class SenderType(str, Enum): + """نوع فرستنده پیام""" + USER = "user" + OPERATOR = "operator" + SYSTEM = "system" + + +class Message(Base): + """پیام‌های تیکت‌های پشتیبانی""" + __tablename__ = "support_messages" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False, index=True) + sender_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + sender_type: Mapped[SenderType] = mapped_column(SQLEnum(SenderType), nullable=False, index=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا پیام داخلی است؟ + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + ticket = relationship("Ticket", back_populates="messages") + sender = relationship("User") diff --git a/hesabixAPI/build/lib/adapters/db/models/support/priority.py b/hesabixAPI/build/lib/adapters/db/models/support/priority.py new file mode 100644 index 0000000..cbb0e7b --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/priority.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Priority(Base): + """اولویت تیکت‌های پشتیبانی""" + __tablename__ = "support_priorities" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code + order: Mapped[int] = mapped_column(Integer, default=0, 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 + tickets = relationship("Ticket", back_populates="priority") diff --git a/hesabixAPI/build/lib/adapters/db/models/support/status.py b/hesabixAPI/build/lib/adapters/db/models/support/status.py new file mode 100644 index 0000000..d216749 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/status.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Status(Base): + """وضعیت تیکت‌های پشتیبانی""" + __tablename__ = "support_statuses" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color code + is_final: Mapped[bool] = mapped_column(Boolean, default=False, 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 + tickets = relationship("Ticket", back_populates="status") diff --git a/hesabixAPI/build/lib/adapters/db/models/support/ticket.py b/hesabixAPI/build/lib/adapters/db/models/support/ticket.py new file mode 100644 index 0000000..70155e9 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/support/ticket.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Integer, ForeignKey, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class Ticket(Base): + """تیکت‌های پشتیبانی""" + __tablename__ = "support_tickets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str] = mapped_column(Text, nullable=False) + + # Foreign Keys + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + category_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_categories.id", ondelete="RESTRICT"), nullable=False, index=True) + priority_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_priorities.id", ondelete="RESTRICT"), nullable=False, index=True) + status_id: Mapped[int] = mapped_column(Integer, ForeignKey("support_statuses.id", ondelete="RESTRICT"), nullable=False, index=True) + assigned_operator_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + # Additional fields + is_internal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # آیا تیکت داخلی است؟ + closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Timestamps + 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 + user = relationship("User", foreign_keys=[user_id], back_populates="tickets") + assigned_operator = relationship("User", foreign_keys=[assigned_operator_id]) + category = relationship("Category", back_populates="tickets") + priority = relationship("Priority", back_populates="tickets") + status = relationship("Status", back_populates="tickets") + messages = relationship("Message", back_populates="ticket", cascade="all, delete-orphan") diff --git a/hesabixAPI/build/lib/adapters/db/models/tax_unit.py b/hesabixAPI/build/lib/adapters/db/models/tax_unit.py new file mode 100644 index 0000000..56be926 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/tax_unit.py @@ -0,0 +1,24 @@ +from datetime import datetime +from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric +from sqlalchemy.orm import Mapped, mapped_column +from adapters.db.session import Base + + +class TaxUnit(Base): + """ + موجودیت واحد مالیاتی + - مدیریت واحدهای مالیاتی مختلف برای کسب‌وکارها + - پشتیبانی از انواع مختلف مالیات (فروش، خرید، ارزش افزوده و...) + """ + + __tablename__ = "tax_units" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسب‌وکار") + name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی") + code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی") + description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات") + tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/hesabixAPI/build/lib/adapters/db/models/user.py b/hesabixAPI/build/lib/adapters/db/models/user.py new file mode 100644 index 0000000..2e119be --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/models/user.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True) + mobile: Mapped[str | None] = mapped_column(String(32), unique=True, index=True, nullable=True) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + # Marketing/Referral fields + referral_code: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + referred_by_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + # App permissions + app_permissions: 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) + + # Support relationships + tickets = relationship("Ticket", foreign_keys="Ticket.user_id", back_populates="user") + + # Business relationships - using business_permissions instead + # businesses = relationship("BusinessUser", back_populates="user", cascade="all, delete-orphan") + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py new file mode 100644 index 0000000..9c6aa0c --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/api_key_repo.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters.db.models.api_key import ApiKey + + +class ApiKeyRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def create_session_key(self, *, user_id: int, key_hash: str, device_id: str | None, user_agent: str | None, ip: str | None, expires_at: datetime | None) -> ApiKey: + obj = ApiKey(user_id=user_id, key_hash=key_hash, key_type="session", name=None, scopes=None, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_by_hash(self, key_hash: str) -> Optional[ApiKey]: + stmt = select(ApiKey).where(ApiKey.key_hash == key_hash) + return self.db.execute(stmt).scalars().first() + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py new file mode 100644 index 0000000..ffb52b6 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/base_repo.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Type, TypeVar, Generic, Any +from sqlalchemy.orm import Session +from sqlalchemy import select, func + +from app.services.query_service import QueryService +from adapters.api.v1.schemas import QueryInfo + +T = TypeVar('T') + + +class BaseRepository(Generic[T]): + """کلاس پایه برای Repository ها با قابلیت فیلتر پیشرفته""" + + def __init__(self, db: Session, model_class: Type[T]) -> None: + self.db = db + self.model_class = model_class + + def query_with_filters(self, query_info: QueryInfo) -> tuple[list[T], int]: + """ + اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل + + Args: + query_info: اطلاعات کوئری شامل فیلترها، مرتب‌سازی و صفحه‌بندی + + Returns: + tuple: (لیست نتایج, تعداد کل رکوردها) + """ + return QueryService.query_with_filters(self.model_class, self.db, query_info) + + def get_by_id(self, id: int) -> T | None: + """دریافت رکورد بر اساس ID""" + stmt = select(self.model_class).where(self.model_class.id == id) + return self.db.execute(stmt).scalars().first() + + def get_all(self, limit: int = 100, offset: int = 0) -> list[T]: + """دریافت تمام رکوردها با محدودیت""" + stmt = select(self.model_class).offset(offset).limit(limit) + return list(self.db.execute(stmt).scalars().all()) + + def count_all(self) -> int: + """شمارش تمام رکوردها""" + stmt = select(func.count()).select_from(self.model_class) + return int(self.db.execute(stmt).scalar() or 0) + + def exists(self, **filters) -> bool: + """بررسی وجود رکورد بر اساس فیلترهای مشخص شده""" + stmt = select(self.model_class) + for field, value in filters.items(): + if hasattr(self.model_class, field): + column = getattr(self.model_class, field) + stmt = stmt.where(column == value) + + return self.db.execute(stmt).scalars().first() is not None + + def delete(self, obj: T) -> None: + """حذف رکورد از دیتابیس""" + self.db.delete(obj) + self.db.commit() + + def update(self, obj: T) -> None: + """بروزرسانی رکورد در دیتابیس""" + self.db.commit() \ No newline at end of file diff --git a/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py new file mode 100644 index 0000000..b364fa2 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/business_permission_repo.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select, and_, text +from sqlalchemy.orm import Session + +from adapters.db.models.business_permission import BusinessPermission +from adapters.db.repositories.base_repo import BaseRepository + + +class BusinessPermissionRepository(BaseRepository[BusinessPermission]): + def __init__(self, db: Session) -> None: + super().__init__(db, BusinessPermission) + + def get_by_user_and_business(self, user_id: int, business_id: int) -> Optional[BusinessPermission]: + """دریافت دسترسی‌های کاربر برای کسب و کار خاص""" + stmt = select(BusinessPermission).where( + and_( + BusinessPermission.user_id == user_id, + BusinessPermission.business_id == business_id + ) + ) + return self.db.execute(stmt).scalars().first() + + def create_or_update(self, user_id: int, business_id: int, permissions: dict) -> BusinessPermission: + """ایجاد یا به‌روزرسانی دسترسی‌های کاربر برای کسب و کار""" + existing = self.get_by_user_and_business(user_id, business_id) + + if existing: + # Preserve existing permissions and enforce join=True + existing_permissions = existing.business_permissions or {} + + # Always ignore incoming 'join' field from clients + incoming_permissions = dict(permissions or {}) + if 'join' in incoming_permissions: + incoming_permissions.pop('join', None) + + # Merge and enforce join flag + merged_permissions = dict(existing_permissions) + merged_permissions.update(incoming_permissions) + merged_permissions['join'] = True + + existing.business_permissions = merged_permissions + self.db.commit() + self.db.refresh(existing) + return existing + else: + # On creation, ensure join=True exists by default + base_permissions = {'join': True} + incoming_permissions = dict(permissions or {}) + if 'join' in incoming_permissions: + incoming_permissions.pop('join', None) + + new_permission = BusinessPermission( + user_id=user_id, + business_id=business_id, + business_permissions={**base_permissions, **incoming_permissions} + ) + self.db.add(new_permission) + self.db.commit() + self.db.refresh(new_permission) + return new_permission + + def delete_by_user_and_business(self, user_id: int, business_id: int) -> bool: + """حذف دسترسی‌های کاربر برای کسب و کار""" + existing = self.get_by_user_and_business(user_id, business_id) + if existing: + self.db.delete(existing) + self.db.commit() + return True + return False + + def get_user_businesses(self, user_id: int) -> list[BusinessPermission]: + """دریافت تمام کسب و کارهایی که کاربر دسترسی دارد""" + stmt = select(BusinessPermission).where(BusinessPermission.user_id == user_id) + return self.db.execute(stmt).scalars().all() + + def get_business_users(self, business_id: int) -> list[BusinessPermission]: + """دریافت تمام کاربرانی که دسترسی به کسب و کار دارند""" + stmt = select(BusinessPermission).where(BusinessPermission.business_id == business_id) + return self.db.execute(stmt).scalars().all() + + def get_user_member_businesses(self, user_id: int) -> list[BusinessPermission]: + """دریافت تمام کسب و کارهایی که کاربر عضو آن‌ها است (دسترسی join)""" + # ابتدا تمام دسترسی‌های کاربر را دریافت می‌کنیم + all_permissions = self.get_user_businesses(user_id) + + # سپس فیلتر می‌کنیم + member_permissions = [] + for perm in all_permissions: + # Normalize legacy/non-dict JSON values to dict before access + raw = perm.business_permissions + normalized = {} + if isinstance(raw, dict): + normalized = raw + elif isinstance(raw, list): + # If legacy stored as list, try to coerce to dict if it looks like key-value pairs + try: + # e.g., [["join", true], ["sales", {"read": true}]] or [{"join": true}, ...] + if all(isinstance(item, list) and len(item) == 2 for item in raw): + normalized = {k: v for k, v in raw if isinstance(k, str)} + elif all(isinstance(item, dict) for item in raw): + # Merge list of dicts + merged: dict = {} + for item in raw: + merged.update({k: v for k, v in item.items()}) + normalized = merged + except Exception: + normalized = {} + elif raw is None: + normalized = {} + else: + # Unsupported type, skip safely + normalized = {} + + if normalized.get('join') == True: + member_permissions.append(perm) + + return member_permissions \ No newline at end of file diff --git a/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py new file mode 100644 index 0000000..50c9af2 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/business_repo.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from typing import List, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, and_ + +from .base_repo import BaseRepository +from ..models.business import Business, BusinessType, BusinessField + + +class BusinessRepository(BaseRepository[Business]): + """Repository برای مدیریت کسب و کارها""" + + def __init__(self, db: Session) -> None: + super().__init__(db, Business) + + def get_by_owner_id(self, owner_id: int) -> List[Business]: + """دریافت تمام کسب و کارهای یک مالک""" + stmt = select(Business).where(Business.owner_id == owner_id) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_business_type(self, business_type: BusinessType) -> List[Business]: + """دریافت کسب و کارها بر اساس نوع""" + stmt = select(Business).where(Business.business_type == business_type) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_business_field(self, business_field: BusinessField) -> List[Business]: + """دریافت کسب و کارها بر اساس زمینه فعالیت""" + stmt = select(Business).where(Business.business_field == business_field) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_owner_and_type(self, owner_id: int, business_type: BusinessType) -> List[Business]: + """دریافت کسب و کارهای یک مالک بر اساس نوع""" + stmt = select(Business).where( + and_( + Business.owner_id == owner_id, + Business.business_type == business_type + ) + ) + return list(self.db.execute(stmt).scalars().all()) + + def search_by_name(self, name: str) -> List[Business]: + """جستجوی کسب و کارها بر اساس نام (case-insensitive)""" + stmt = select(Business).where(Business.name.ilike(f"%{name}%")) + return list(self.db.execute(stmt).scalars().all()) + + def create_business( + self, + name: str, + business_type: BusinessType, + business_field: BusinessField, + owner_id: int, + default_currency_id: int | None = None, + address: str | None = None, + phone: str | None = None, + mobile: str | None = None, + national_id: str | None = None, + registration_number: str | None = None, + economic_id: str | None = None, + country: str | None = None, + province: str | None = None, + city: str | None = None, + postal_code: str | None = None + ) -> Business: + """ایجاد کسب و کار جدید""" + business = Business( + name=name, + business_type=business_type, + business_field=business_field, + owner_id=owner_id, + default_currency_id=default_currency_id, + address=address, + phone=phone, + mobile=mobile, + national_id=national_id, + registration_number=registration_number, + economic_id=economic_id, + country=country, + province=province, + city=city, + postal_code=postal_code + ) + self.db.add(business) + self.db.commit() + self.db.refresh(business) + return business + + def get_by_national_id(self, national_id: str) -> Business | None: + """دریافت کسب و کار بر اساس شناسه ملی""" + stmt = select(Business).where(Business.national_id == national_id) + return self.db.execute(stmt).scalars().first() + + def get_by_registration_number(self, registration_number: str) -> Business | None: + """دریافت کسب و کار بر اساس شماره ثبت""" + stmt = select(Business).where(Business.registration_number == registration_number) + return self.db.execute(stmt).scalars().first() + + def get_by_economic_id(self, economic_id: str) -> Business | None: + """دریافت کسب و کار بر اساس شناسه اقتصادی""" + stmt = select(Business).where(Business.economic_id == economic_id) + return self.db.execute(stmt).scalars().first() + + def search_by_phone(self, phone: str) -> List[Business]: + """جستجوی کسب و کارها بر اساس شماره تلفن""" + stmt = select(Business).where( + (Business.phone == phone) | (Business.mobile == phone) + ) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_country(self, country: str) -> List[Business]: + """دریافت کسب و کارها بر اساس کشور""" + stmt = select(Business).where(Business.country == country) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_province(self, province: str) -> List[Business]: + """دریافت کسب و کارها بر اساس استان""" + stmt = select(Business).where(Business.province == province) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_city(self, city: str) -> List[Business]: + """دریافت کسب و کارها بر اساس شهرستان""" + stmt = select(Business).where(Business.city == city) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_postal_code(self, postal_code: str) -> List[Business]: + """دریافت کسب و کارها بر اساس کد پستی""" + stmt = select(Business).where(Business.postal_code == postal_code) + return list(self.db.execute(stmt).scalars().all()) + + def get_by_location(self, country: str | None = None, province: str | None = None, city: str | None = None) -> List[Business]: + """دریافت کسب و کارها بر اساس موقعیت جغرافیایی""" + stmt = select(Business) + conditions = [] + + if country: + conditions.append(Business.country == country) + if province: + conditions.append(Business.province == province) + if city: + conditions.append(Business.city == city) + + if conditions: + stmt = stmt.where(and_(*conditions)) + + return list(self.db.execute(stmt).scalars().all()) diff --git a/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py new file mode 100644 index 0000000..43ad61e --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/category_repository.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, or_ + +from .base_repo import BaseRepository +from ..models.category import BusinessCategory + + +class CategoryRepository(BaseRepository[BusinessCategory]): + def __init__(self, db: Session): + super().__init__(db, BusinessCategory) + + def get_tree(self, business_id: int, type_: str | None = None) -> list[Dict[str, Any]]: + stmt = select(BusinessCategory).where(BusinessCategory.business_id == business_id) + # درخت سراسری: نوع نادیده گرفته می‌شود (همه رکوردها) + stmt = stmt.order_by(BusinessCategory.sort_order.asc(), BusinessCategory.id.asc()) + rows = list(self.db.execute(stmt).scalars().all()) + flat = [ + { + "id": r.id, + "parent_id": r.parent_id, + "translations": r.title_translations or {}, + # برچسب واحد بر اساس زبان پیش‌فرض: ابتدا fa سپس en + "title": (r.title_translations or {}).get("fa") + or (r.title_translations or {}).get("en") + or "", + } + for r in rows + ] + return self._build_tree(flat) + + def _build_tree(self, nodes: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + by_id: dict[int, Dict[str, Any]] = {} + roots: list[Dict[str, Any]] = [] + for n in nodes: + item = { + "id": n["id"], + "parent_id": n.get("parent_id"), + "title": n.get("title", ""), + "translations": n.get("translations", {}), + "children": [], + } + by_id[item["id"]] = item + for item in list(by_id.values()): + pid = item.get("parent_id") + if pid and pid in by_id: + by_id[pid]["children"].append(item) + else: + roots.append(item) + return roots + + def create_category(self, *, business_id: int, parent_id: int | None, translations: dict[str, str]) -> BusinessCategory: + obj = BusinessCategory( + business_id=business_id, + parent_id=parent_id, + title_translations=translations or {}, + ) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update_category(self, *, category_id: int, translations: dict[str, str] | None = None) -> BusinessCategory | None: + obj = self.db.get(BusinessCategory, category_id) + if not obj: + return None + if translations: + obj.title_translations = {**(obj.title_translations or {}), **translations} + self.db.commit() + self.db.refresh(obj) + return obj + + def move_category(self, *, category_id: int, new_parent_id: int | None) -> BusinessCategory | None: + obj = self.db.get(BusinessCategory, category_id) + if not obj: + return None + obj.parent_id = new_parent_id + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_category(self, *, category_id: int) -> bool: + obj = self.db.get(BusinessCategory, category_id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py new file mode 100644 index 0000000..834c467 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/email_config_repository.py @@ -0,0 +1,85 @@ +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.base_repo import BaseRepository + + +class EmailConfigRepository(BaseRepository[EmailConfig]): + def __init__(self, db: Session): + super().__init__(db, EmailConfig) + + def get_active_config(self) -> Optional[EmailConfig]: + """Get the currently active email configuration""" + return self.db.query(self.model_class).filter(self.model_class.is_active == True).first() + + def get_default_config(self) -> Optional[EmailConfig]: + """Get the default email configuration""" + return self.db.query(self.model_class).filter(self.model_class.is_default == True).first() + + def set_default_config(self, config_id: int) -> bool: + """Set a configuration as default (removes default from others)""" + try: + # First check if the config exists + config = self.get_by_id(config_id) + if not config: + return False + + # Remove default from all configs + self.db.query(self.model_class).update({self.model_class.is_default: False}) + + # Set the specified config as default + config.is_default = True + self.db.commit() + return True + except Exception as e: + self.db.rollback() + print(f"Error in set_default_config: {e}") # Debug log + return False + + def get_by_name(self, name: str) -> Optional[EmailConfig]: + """Get email configuration by name""" + return self.db.query(self.model_class).filter(self.model_class.name == name).first() + + def get_all_configs(self) -> List[EmailConfig]: + """Get all email configurations""" + return self.db.query(self.model_class).order_by(self.model_class.created_at.desc()).all() + + def set_active_config(self, config_id: int) -> bool: + """Set a specific configuration as active and deactivate others""" + try: + # Deactivate all configs + self.db.query(self.model_class).update({self.model_class.is_active: False}) + + # Activate the specified config + config = self.get_by_id(config_id) + if config: + config.is_active = True + self.db.commit() + return True + return False + except Exception: + self.db.rollback() + return False + + def test_connection(self, config: EmailConfig) -> bool: + """Test SMTP connection for a configuration""" + try: + import smtplib + from email.mime.text import MIMEText + + # Create SMTP connection + if config.use_ssl: + server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + if config.use_tls: + server.starttls() + + # Login + server.login(config.smtp_username, config.smtp_password) + server.quit() + return True + except Exception: + return False diff --git a/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py new file mode 100644 index 0000000..a3ba233 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/file_storage_repository.py @@ -0,0 +1,301 @@ +from typing import List, Optional, Dict, Any +from uuid import UUID +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, desc, func +from datetime import datetime, timedelta + +from adapters.db.models.file_storage import FileStorage, StorageConfig, FileVerification +from adapters.db.repositories.base_repo import BaseRepository + + +class FileStorageRepository(BaseRepository[FileStorage]): + def __init__(self, db: Session): + super().__init__(db, FileStorage) + + async def create_file( + self, + original_name: str, + stored_name: str, + file_path: str, + file_size: int, + mime_type: str, + storage_type: str, + uploaded_by: UUID, + module_context: str, + context_id: Optional[UUID] = None, + developer_data: Optional[Dict] = None, + checksum: Optional[str] = None, + is_temporary: bool = False, + expires_in_days: int = 30, + storage_config_id: Optional[UUID] = None + ) -> FileStorage: + expires_at = None + if is_temporary: + expires_at = datetime.utcnow() + timedelta(days=expires_in_days) + + file_storage = FileStorage( + original_name=original_name, + stored_name=stored_name, + file_path=file_path, + file_size=file_size, + mime_type=mime_type, + storage_type=storage_type, + storage_config_id=storage_config_id, + uploaded_by=uploaded_by, + module_context=module_context, + context_id=context_id, + developer_data=developer_data, + checksum=checksum, + is_temporary=is_temporary, + expires_at=expires_at + ) + + self.db.add(file_storage) + self.db.commit() + self.db.refresh(file_storage) + return file_storage + + async def get_file_by_id(self, file_id: UUID) -> Optional[FileStorage]: + return self.db.query(FileStorage).filter( + and_( + FileStorage.id == file_id, + FileStorage.deleted_at.is_(None) + ) + ).first() + + async def get_files_by_context( + self, + module_context: str, + context_id: UUID + ) -> List[FileStorage]: + return self.db.query(FileStorage).filter( + and_( + FileStorage.module_context == module_context, + FileStorage.context_id == context_id, + FileStorage.deleted_at.is_(None), + FileStorage.is_active == True + ) + ).order_by(desc(FileStorage.created_at)).all() + + async def get_user_files( + self, + user_id: UUID, + limit: int = 50, + offset: int = 0 + ) -> List[FileStorage]: + return self.db.query(FileStorage).filter( + and_( + FileStorage.uploaded_by == user_id, + FileStorage.deleted_at.is_(None) + ) + ).order_by(desc(FileStorage.created_at)).offset(offset).limit(limit).all() + + async def get_unverified_temporary_files(self) -> List[FileStorage]: + return self.db.query(FileStorage).filter( + and_( + FileStorage.is_temporary == True, + FileStorage.is_verified == False, + FileStorage.deleted_at.is_(None), + FileStorage.is_active == True + ) + ).all() + + async def get_expired_temporary_files(self) -> List[FileStorage]: + return self.db.query(FileStorage).filter( + and_( + FileStorage.is_temporary == True, + FileStorage.expires_at < datetime.utcnow(), + FileStorage.deleted_at.is_(None) + ) + ).all() + + async def verify_file(self, file_id: UUID, verification_data: Dict) -> bool: + file_storage = await self.get_file_by_id(file_id) + if not file_storage: + return False + + file_storage.is_verified = True + file_storage.last_verified_at = datetime.utcnow() + file_storage.developer_data = {**(file_storage.developer_data or {}), **verification_data} + + self.db.commit() + return True + + async def soft_delete_file(self, file_id: UUID) -> bool: + file_storage = await self.get_file_by_id(file_id) + if not file_storage: + return False + + file_storage.deleted_at = datetime.utcnow() + file_storage.is_active = False + + self.db.commit() + return True + + async def restore_file(self, file_id: UUID) -> bool: + file_storage = self.db.query(FileStorage).filter(FileStorage.id == file_id).first() + if not file_storage: + return False + + file_storage.deleted_at = None + file_storage.is_active = True + + self.db.commit() + return True + + async def get_storage_statistics(self) -> Dict[str, Any]: + total_files = self.db.query(FileStorage).filter( + FileStorage.deleted_at.is_(None) + ).count() + + total_size = self.db.query(func.sum(FileStorage.file_size)).filter( + FileStorage.deleted_at.is_(None) + ).scalar() or 0 + + temporary_files = self.db.query(FileStorage).filter( + and_( + FileStorage.is_temporary == True, + FileStorage.deleted_at.is_(None) + ) + ).count() + + unverified_files = self.db.query(FileStorage).filter( + and_( + FileStorage.is_temporary == True, + FileStorage.is_verified == False, + FileStorage.deleted_at.is_(None) + ) + ).count() + + return { + "total_files": total_files, + "total_size": total_size, + "temporary_files": temporary_files, + "unverified_files": unverified_files + } + + +class StorageConfigRepository(BaseRepository[StorageConfig]): + def __init__(self, db: Session): + super().__init__(db, StorageConfig) + + async def create_config( + self, + name: str, + storage_type: str, + config_data: Dict, + created_by: int, + is_default: bool = False, + is_active: bool = True + ) -> StorageConfig: + # اگر این config به عنوان پیش‌فرض تنظیم می‌شود، بقیه را غیرفعال کن + if is_default: + await self.clear_default_configs() + + storage_config = StorageConfig( + name=name, + storage_type=storage_type, + config_data=config_data, + created_by=created_by, + is_default=is_default, + is_active=is_active + ) + + self.db.add(storage_config) + self.db.commit() + self.db.refresh(storage_config) + return storage_config + + async def get_default_config(self) -> Optional[StorageConfig]: + return self.db.query(StorageConfig).filter( + and_( + StorageConfig.is_default == True, + StorageConfig.is_active == True + ) + ).first() + + def get_all_configs(self) -> List[StorageConfig]: + return self.db.query(StorageConfig).filter( + StorageConfig.is_active == True + ).order_by(desc(StorageConfig.created_at)).all() + + async def set_default_config(self, config_id: UUID) -> bool: + # ابتدا همه config ها را غیرپیش‌فرض کن + await self.clear_default_configs() + + # config مورد نظر را پیش‌فرض کن + config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first() + if not config: + return False + + config.is_default = True + self.db.commit() + return True + + async def clear_default_configs(self): + self.db.query(StorageConfig).update({"is_default": False}) + self.db.commit() + + def count_files_by_storage_config(self, config_id: str) -> int: + """شمارش تعداد فایل‌های مربوط به یک storage config""" + return self.db.query(FileStorage).filter( + FileStorage.storage_config_id == config_id, + FileStorage.is_active == True, + FileStorage.deleted_at.is_(None) + ).count() + + def delete_config(self, config_id: str) -> bool: + config = self.db.query(StorageConfig).filter(StorageConfig.id == config_id).first() + if not config: + return False + + config.is_active = False + self.db.commit() + return True + + +class FileVerificationRepository(BaseRepository[FileVerification]): + def __init__(self, db: Session): + super().__init__(FileVerification, db) + + async def create_verification( + self, + file_id: UUID, + module_name: str, + verification_token: str, + verification_data: Optional[Dict] = None + ) -> FileVerification: + verification = FileVerification( + file_id=file_id, + module_name=module_name, + verification_token=verification_token, + verification_data=verification_data + ) + + self.db.add(verification) + self.db.commit() + self.db.refresh(verification) + return verification + + async def verify_file( + self, + file_id: UUID, + verification_token: str, + verified_by: UUID + ) -> bool: + verification = self.db.query(FileVerification).filter( + and_( + FileVerification.file_id == file_id, + FileVerification.verification_token == verification_token, + FileVerification.verified_at.is_(None) + ) + ).first() + + if not verification: + return False + + verification.verified_at = datetime.utcnow() + verification.verified_by = verified_by + + self.db.commit() + return True diff --git a/hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/fiscal_year_repo.py new file mode 100644 index 0000000..b4cf059 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/adapters/db/repositories/password_reset_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py new file mode 100644 index 0000000..4b60837 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/password_reset_repo.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from adapters.db.models.password_reset import PasswordReset + + +class PasswordResetRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def create(self, *, user_id: int, token_hash: str, expires_at: datetime) -> PasswordReset: + obj = PasswordReset(user_id=user_id, token_hash=token_hash, expires_at=expires_at) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_by_hash(self, token_hash: str) -> Optional[PasswordReset]: + stmt = select(PasswordReset).where(PasswordReset.token_hash == token_hash) + return self.db.execute(stmt).scalars().first() + + def mark_used(self, pr: PasswordReset) -> None: + pr.used_at = datetime.utcnow() + self.db.add(pr) + self.db.commit() diff --git a/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py new file mode 100644 index 0000000..6d7c500 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/price_list_repository.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, func, and_, or_ + +from .base_repo import BaseRepository +from ..models.price_list import PriceList, PriceItem + + +class PriceListRepository(BaseRepository[PriceList]): + def __init__(self, db: Session) -> None: + super().__init__(db, PriceList) + + def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None) -> dict[str, Any]: + stmt = select(PriceList).where(PriceList.business_id == business_id) + if search: + stmt = stmt.where(PriceList.name.ilike(f"%{search}%")) + + total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 + if sort_by in {"name", "created_at"}: + col = getattr(PriceList, sort_by) + stmt = stmt.order_by(col.desc() if sort_desc else col.asc()) + else: + stmt = stmt.order_by(PriceList.id.desc() if sort_desc else PriceList.id.asc()) + + rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all()) + items = [self._to_dict_list(pl) for pl in rows] + return { + "items": items, + "pagination": { + "total": total, + "page": (skip // take) + 1 if take else 1, + "per_page": take, + "total_pages": (total + take - 1) // take if take else 1, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + } + + def create(self, **data: Any) -> PriceList: + obj = PriceList(**data) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update(self, id: int, **data: Any) -> Optional[PriceList]: + obj = self.db.get(PriceList, id) + if not obj: + return None + for k, v in data.items(): + if hasattr(obj, k) and v is not None: + setattr(obj, k, v) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, id: int) -> bool: + obj = self.db.get(PriceList, id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + def _to_dict_list(self, pl: PriceList) -> dict[str, Any]: + return { + "id": pl.id, + "business_id": pl.business_id, + "name": pl.name, + "is_active": pl.is_active, + "created_at": pl.created_at, + "updated_at": pl.updated_at, + } + + +class PriceItemRepository(BaseRepository[PriceItem]): + def __init__(self, db: Session) -> None: + super().__init__(db, PriceItem) + + def list_for_price_list(self, *, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> dict[str, Any]: + stmt = select(PriceItem).where(PriceItem.price_list_id == price_list_id) + if product_id is not None: + stmt = stmt.where(PriceItem.product_id == product_id) + if currency_id is not None: + stmt = stmt.where(PriceItem.currency_id == currency_id) + total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 + rows = list(self.db.execute(stmt.offset(skip).limit(take)).scalars().all()) + items = [self._to_dict(pi) for pi in rows] + return { + "items": items, + "pagination": { + "total": total, + "page": (skip // take) + 1 if take else 1, + "per_page": take, + "total_pages": (total + take - 1) // take if take else 1, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + } + + def upsert(self, *, price_list_id: int, product_id: int, unit_id: int | None, currency_id: int, tier_name: str | None, min_qty, price) -> PriceItem: + # Try find existing unique combination + stmt = select(PriceItem).where( + and_( + PriceItem.price_list_id == price_list_id, + PriceItem.product_id == product_id, + PriceItem.unit_id.is_(unit_id) if unit_id is None else PriceItem.unit_id == unit_id, + PriceItem.tier_name == (tier_name or 'پیش‌فرض'), + PriceItem.min_qty == min_qty, + PriceItem.currency_id == currency_id, + ) + ) + existing = self.db.execute(stmt).scalars().first() + if existing: + existing.price = price + existing.currency_id = currency_id + self.db.commit() + self.db.refresh(existing) + return existing + obj = PriceItem( + price_list_id=price_list_id, + product_id=product_id, + unit_id=unit_id, + currency_id=currency_id, + tier_name=(tier_name or 'پیش‌فرض'), + min_qty=min_qty, + price=price, + ) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, id: int) -> bool: + obj = self.db.get(PriceItem, id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + def _to_dict(self, pi: PriceItem) -> dict[str, Any]: + return { + "id": pi.id, + "price_list_id": pi.price_list_id, + "product_id": pi.product_id, + "unit_id": pi.unit_id, + "currency_id": pi.currency_id, + "tier_name": pi.tier_name, + "min_qty": pi.min_qty, + "price": pi.price, + "created_at": pi.created_at, + "updated_at": pi.updated_at, + } + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py new file mode 100644 index 0000000..b61846e --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/product_attribute_repository.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Dict, Any, List, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, func + +from .base_repo import BaseRepository +from ..models.product_attribute import ProductAttribute + + +class ProductAttributeRepository(BaseRepository[ProductAttribute]): + def __init__(self, db: Session): + super().__init__(db, ProductAttribute) + + def search( + self, + *, + business_id: int, + take: int = 20, + skip: int = 0, + sort_by: str | None = None, + sort_desc: bool = True, + search: str | None = None, + filters: dict[str, Any] | None = None, + ) -> dict[str, Any]: + stmt = select(ProductAttribute).where(ProductAttribute.business_id == business_id) + + if search: + stmt = stmt.where(ProductAttribute.title.ilike(f"%{search}%")) + + total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 + + # Sorting + if sort_by == 'title': + order_col = ProductAttribute.title.desc() if sort_desc else ProductAttribute.title.asc() + stmt = stmt.order_by(order_col) + else: + order_col = ProductAttribute.id.desc() if sort_desc else ProductAttribute.id.asc() + stmt = stmt.order_by(order_col) + + # Paging + stmt = stmt.offset(skip).limit(take) + rows = list(self.db.execute(stmt).scalars().all()) + + items: list[dict[str, Any]] = [ + { + "id": r.id, + "business_id": r.business_id, + "title": r.title, + "description": r.description, + "created_at": r.created_at, + "updated_at": r.updated_at, + } + for r in rows + ] + + return { + "items": items, + "pagination": { + "total": total, + "page": (skip // take) + 1 if take else 1, + "per_page": take, + "total_pages": (total + take - 1) // take if take else 1, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + } + + def create(self, *, business_id: int, title: str, description: str | None) -> ProductAttribute: + obj = ProductAttribute(business_id=business_id, title=title, description=description) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update(self, *, attribute_id: int, title: str | None, description: str | None) -> Optional[ProductAttribute]: + obj = self.db.get(ProductAttribute, attribute_id) + if not obj: + return None + if title is not None: + obj.title = title + if description is not None: + obj.description = description + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, *, attribute_id: int) -> bool: + obj = self.db.get(ProductAttribute, attribute_id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py new file mode 100644 index 0000000..db63dc2 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/product_repository.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, func + +from .base_repo import BaseRepository +from ..models.product import Product + + +class ProductRepository(BaseRepository[Product]): + def __init__(self, db: Session) -> None: + super().__init__(db, Product) + + def search(self, *, business_id: int, take: int = 20, skip: int = 0, sort_by: str | None = None, sort_desc: bool = True, search: str | None = None, filters: dict[str, Any] | None = None) -> dict[str, Any]: + stmt = select(Product).where(Product.business_id == business_id) + + if search: + like = f"%{search}%" + stmt = stmt.where( + or_( + Product.name.ilike(like), + Product.code.ilike(like), + Product.description.ilike(like), + ) + ) + + total = self.db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0 + + # Sorting + if sort_by in {"name", "code", "created_at"}: + col = getattr(Product, sort_by) + stmt = stmt.order_by(col.desc() if sort_desc else col.asc()) + else: + stmt = stmt.order_by(Product.id.desc() if sort_desc else Product.id.asc()) + + stmt = stmt.offset(skip).limit(take) + rows = list(self.db.execute(stmt).scalars().all()) + + def _to_dict(p: Product) -> dict[str, Any]: + return { + "id": p.id, + "business_id": p.business_id, + "item_type": p.item_type.value if hasattr(p.item_type, 'value') else str(p.item_type), + "code": p.code, + "name": p.name, + "description": p.description, + "category_id": p.category_id, + "main_unit_id": p.main_unit_id, + "secondary_unit_id": p.secondary_unit_id, + "unit_conversion_factor": p.unit_conversion_factor, + "base_sales_price": p.base_sales_price, + "base_sales_note": p.base_sales_note, + "base_purchase_price": p.base_purchase_price, + "base_purchase_note": p.base_purchase_note, + "track_inventory": p.track_inventory, + "reorder_point": p.reorder_point, + "min_order_qty": p.min_order_qty, + "lead_time_days": p.lead_time_days, + "is_sales_taxable": p.is_sales_taxable, + "is_purchase_taxable": p.is_purchase_taxable, + "sales_tax_rate": p.sales_tax_rate, + "purchase_tax_rate": p.purchase_tax_rate, + "tax_type_id": p.tax_type_id, + "tax_code": p.tax_code, + "tax_unit_id": p.tax_unit_id, + "created_at": p.created_at, + "updated_at": p.updated_at, + } + + items = [_to_dict(r) for r in rows] + + return { + "items": items, + "pagination": { + "total": total, + "page": (skip // take) + 1 if take else 1, + "per_page": take, + "total_pages": (total + take - 1) // take if take else 1, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + } + + def create(self, **data: Any) -> Product: + obj = Product(**data) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def update(self, product_id: int, **data: Any) -> Optional[Product]: + obj = self.db.get(Product, product_id) + if not obj: + return None + for k, v in data.items(): + if hasattr(obj, k) and v is not None: + setattr(obj, k, v) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, product_id: int) -> bool: + obj = self.db.get(Product, product_id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py b/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py new file mode 100644 index 0000000..b496a54 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/__init__.py @@ -0,0 +1 @@ +# Support repositories diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py new file mode 100644 index 0000000..0b43003 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/category_repository.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.category import Category + + +class CategoryRepository(BaseRepository[Category]): + def __init__(self, db: Session): + super().__init__(db, Category) + + def get_active_categories(self) -> List[Category]: + """دریافت دسته‌بندی‌های فعال""" + return self.db.query(Category)\ + .filter(Category.is_active == True)\ + .order_by(Category.name)\ + .all() diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py new file mode 100644 index 0000000..11c7e82 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/message_repository.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Optional, List +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, func, and_, or_ + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.message import Message, SenderType +from adapters.api.v1.schemas import QueryInfo + + +class MessageRepository(BaseRepository[Message]): + def __init__(self, db: Session): + super().__init__(db, Message) + + def get_ticket_messages(self, ticket_id: int, query_info: QueryInfo) -> tuple[List[Message], int]: + """دریافت پیام‌های تیکت با فیلتر و صفحه‌بندی""" + query = self.db.query(Message)\ + .options(joinedload(Message.sender))\ + .filter(Message.ticket_id == ticket_id) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Message, field): + search_conditions.append(getattr(Message, field).ilike(f"%{query_info.search}%")) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Message, query_info.sort_by): + sort_column = getattr(Message, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Message.created_at.asc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def create_message( + self, + ticket_id: int, + sender_id: int, + sender_type: SenderType, + content: str, + is_internal: bool = False + ) -> Message: + """ایجاد پیام جدید""" + from datetime import datetime + from adapters.db.models.support.ticket import Ticket + + message = Message( + ticket_id=ticket_id, + sender_id=sender_id, + sender_type=sender_type, + content=content, + is_internal=is_internal + ) + + self.db.add(message) + + # Update ticket's updated_at field + ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first() + if ticket: + ticket.updated_at = datetime.utcnow() + + self.db.commit() + self.db.refresh(message) + return message diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py new file mode 100644 index 0000000..12d7a15 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/priority_repository.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.priority import Priority + + +class PriorityRepository(BaseRepository[Priority]): + def __init__(self, db: Session): + super().__init__(db, Priority) + + def get_priorities_ordered(self) -> List[Priority]: + """دریافت اولویت‌ها به ترتیب""" + return self.db.query(Priority)\ + .order_by(Priority.order, Priority.name)\ + .all() diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py new file mode 100644 index 0000000..32e75c7 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/status_repository.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import List +from sqlalchemy.orm import Session + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.status import Status + + +class StatusRepository(BaseRepository[Status]): + def __init__(self, db: Session): + super().__init__(db, Status) + + def get_all_statuses(self) -> List[Status]: + """دریافت تمام وضعیت‌ها""" + return self.db.query(Status)\ + .order_by(Status.name)\ + .all() + + def get_final_statuses(self) -> List[Status]: + """دریافت وضعیت‌های نهایی""" + return self.db.query(Status)\ + .filter(Status.is_final == True)\ + .order_by(Status.name)\ + .all() diff --git a/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py b/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py new file mode 100644 index 0000000..a44601b --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/support/ticket_repository.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, func, and_, or_ + +from adapters.db.repositories.base_repo import BaseRepository +from adapters.db.models.support.ticket import Ticket +from adapters.db.models.support.message import Message +from adapters.api.v1.schemas import QueryInfo + + +class TicketRepository(BaseRepository[Ticket]): + def __init__(self, db: Session): + super().__init__(db, Ticket) + + def create(self, ticket_data: Dict[str, Any]) -> Ticket: + """ایجاد تیکت جدید""" + ticket = Ticket(**ticket_data) + self.db.add(ticket) + self.db.commit() + self.db.refresh(ticket) + return ticket + + def get_ticket_with_details(self, ticket_id: int, user_id: int) -> Optional[Ticket]: + """دریافت تیکت با جزئیات کامل""" + return self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status), + joinedload(Ticket.messages).joinedload(Message.sender) + )\ + .filter(Ticket.id == ticket_id, Ticket.user_id == user_id)\ + .first() + + def get_operator_ticket_with_details(self, ticket_id: int) -> Optional[Ticket]: + """دریافت تیکت برای اپراتور با جزئیات کامل""" + return self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status), + joinedload(Ticket.messages).joinedload(Message.sender) + )\ + .filter(Ticket.id == ticket_id)\ + .first() + + def get_user_tickets(self, user_id: int, query_info: QueryInfo) -> tuple[List[Ticket], int]: + """دریافت تیکت‌های کاربر با فیلتر و صفحه‌بندی""" + query = self.db.query(Ticket)\ + .options( + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status) + )\ + .filter(Ticket.user_id == user_id) + + # اعمال فیلترها + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == "title" and hasattr(Ticket, "title"): + if filter_item.operator == "*": + query = query.filter(Ticket.title.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": + query = query.filter(Ticket.title.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": + query = query.filter(Ticket.title.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "=": + query = query.filter(Ticket.title == filter_item.value) + elif filter_item.property == "category.name": + query = query.join(Ticket.category) + if filter_item.operator == "in": + from adapters.db.models.support.category import Category + query = query.filter(Category.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.category.has(name=filter_item.value)) + elif filter_item.property == "priority.name": + query = query.join(Ticket.priority) + if filter_item.operator == "in": + from adapters.db.models.support.priority import Priority + query = query.filter(Priority.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.priority.has(name=filter_item.value)) + elif filter_item.property == "status.name": + query = query.join(Ticket.status) + if filter_item.operator == "in": + from adapters.db.models.support.status import Status + query = query.filter(Status.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.status.has(name=filter_item.value)) + elif filter_item.property == "description" and hasattr(Ticket, "description"): + if filter_item.operator == "*": + query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": + query = query.filter(Ticket.description.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": + query = query.filter(Ticket.description.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "=": + query = query.filter(Ticket.description == filter_item.value) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Ticket, field): + search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%")) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Ticket, query_info.sort_by): + sort_column = getattr(Ticket, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Ticket.created_at.desc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def get_operator_tickets(self, query_info: QueryInfo) -> tuple[List[Ticket], int]: + """دریافت تمام تیکت‌ها برای اپراتور با فیلتر و صفحه‌بندی""" + query = self.db.query(Ticket)\ + .options( + joinedload(Ticket.user), + joinedload(Ticket.assigned_operator), + joinedload(Ticket.category), + joinedload(Ticket.priority), + joinedload(Ticket.status) + ) + + # اعمال فیلترها + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == "title" and hasattr(Ticket, "title"): + if filter_item.operator == "*": + query = query.filter(Ticket.title.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": + query = query.filter(Ticket.title.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": + query = query.filter(Ticket.title.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "=": + query = query.filter(Ticket.title == filter_item.value) + elif filter_item.property == "category.name": + query = query.join(Ticket.category) + if filter_item.operator == "in": + from adapters.db.models.support.category import Category + query = query.filter(Category.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.category.has(name=filter_item.value)) + elif filter_item.property == "priority.name": + query = query.join(Ticket.priority) + if filter_item.operator == "in": + from adapters.db.models.support.priority import Priority + query = query.filter(Priority.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.priority.has(name=filter_item.value)) + elif filter_item.property == "status.name": + query = query.join(Ticket.status) + if filter_item.operator == "in": + from adapters.db.models.support.status import Status + query = query.filter(Status.name.in_(filter_item.value)) + else: + query = query.filter(Ticket.status.has(name=filter_item.value)) + elif filter_item.property == "description" and hasattr(Ticket, "description"): + if filter_item.operator == "*": + query = query.filter(Ticket.description.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": + query = query.filter(Ticket.description.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": + query = query.filter(Ticket.description.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "=": + query = query.filter(Ticket.description == filter_item.value) + elif filter_item.property == "user_email": + query = query.join(Ticket.user).filter(Ticket.user.has(email=filter_item.value)) + elif filter_item.property == "user_name": + query = query.join(Ticket.user).filter( + or_( + Ticket.user.has(first_name=filter_item.value), + Ticket.user.has(last_name=filter_item.value) + ) + ) + + # اعمال جستجو + if query_info.search and query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(Ticket, field): + search_conditions.append(getattr(Ticket, field).ilike(f"%{query_info.search}%")) + elif field == "user_email" and hasattr(Ticket.user, "email"): + search_conditions.append(Ticket.user.email.ilike(f"%{query_info.search}%")) + elif field == "user_name": + search_conditions.append( + or_( + Ticket.user.first_name.ilike(f"%{query_info.search}%"), + Ticket.user.last_name.ilike(f"%{query_info.search}%") + ) + ) + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # شمارش کل + total = query.count() + + # اعمال مرتب‌سازی + if query_info.sort_by and hasattr(Ticket, query_info.sort_by): + sort_column = getattr(Ticket, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + else: + query = query.order_by(Ticket.created_at.desc()) + + # اعمال صفحه‌بندی + query = query.offset(query_info.skip).limit(query_info.take) + + return query.all(), total + + def update_ticket_status(self, ticket_id: int, status_id: int, operator_id: Optional[int] = None) -> Optional[Ticket]: + """تغییر وضعیت تیکت""" + ticket = self.get_by_id(ticket_id) + if not ticket: + return None + + ticket.status_id = status_id + if operator_id: + ticket.assigned_operator_id = operator_id + + # اگر وضعیت نهایی است، تاریخ بسته شدن را تنظیم کن + from adapters.db.models.support.status import Status + status = self.db.query(Status).filter(Status.id == status_id).first() + if status and status.is_final: + from datetime import datetime + ticket.closed_at = datetime.utcnow() + + self.db.commit() + self.db.refresh(ticket) + return ticket + + def assign_ticket(self, ticket_id: int, operator_id: int) -> Optional[Ticket]: + """تخصیص تیکت به اپراتور""" + ticket = self.get_by_id(ticket_id) + if not ticket: + return None + + ticket.assigned_operator_id = operator_id + self.db.commit() + self.db.refresh(ticket) + return ticket \ No newline at end of file diff --git a/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py b/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py new file mode 100644 index 0000000..0b8ec1a --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/repositories/user_repo.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import select, func, and_, or_ +from sqlalchemy.orm import Session + +from adapters.db.models.user import User +from adapters.db.repositories.base_repo import BaseRepository +from adapters.api.v1.schemas import QueryInfo + + +class UserRepository(BaseRepository[User]): + def __init__(self, db: Session) -> None: + super().__init__(db, User) + + def get_by_email(self, email: str) -> Optional[User]: + stmt = select(User).where(User.email == email) + return self.db.execute(stmt).scalars().first() + + def get_by_mobile(self, mobile: str) -> Optional[User]: + stmt = select(User).where(User.mobile == mobile) + return self.db.execute(stmt).scalars().first() + + def get_by_referral_code(self, referral_code: str) -> Optional[User]: + stmt = select(User).where(User.referral_code == referral_code) + return self.db.execute(stmt).scalars().first() + + def is_first_user(self) -> bool: + """بررسی اینکه آیا این اولین کاربر سیستم است یا نه""" + stmt = select(func.count()).select_from(User) + count = self.db.execute(stmt).scalar() or 0 + return count == 0 + + def create(self, *, email: str | None, mobile: str | None, password_hash: str, first_name: str | None, last_name: str | None, referral_code: str, referred_by_user_id: int | None = None) -> User: + # تعیین دسترسی‌های برنامه بر اساس اینکه آیا کاربر اول است یا نه + app_permissions = {"superadmin": True} if self.is_first_user() else {} + + user = User( + email=email, + mobile=mobile, + password_hash=password_hash, + first_name=first_name, + last_name=last_name, + referral_code=referral_code, + referred_by_user_id=referred_by_user_id, + app_permissions=app_permissions + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def count_referred(self, referrer_user_id: int, start: str | None = None, end: str | None = None) -> int: + stmt = select(func.count()).select_from(User).where(User.referred_by_user_id == referrer_user_id) + if start is not None: + stmt = stmt.where(User.created_at >= func.cast(start, User.created_at.type)) + if end is not None: + stmt = stmt.where(User.created_at < func.cast(end, User.created_at.type)) + return int(self.db.execute(stmt).scalar() or 0) + + def count_referred_between(self, referrer_user_id: int, start_dt, end_dt) -> int: + stmt = select(func.count()).select_from(User).where( + and_( + User.referred_by_user_id == referrer_user_id, + User.created_at >= start_dt, + User.created_at < end_dt, + ) + ) + return int(self.db.execute(stmt).scalar() or 0) + + def count_referred_filtered(self, referrer_user_id: int, start_dt=None, end_dt=None, search: str | None = None) -> int: + stmt = select(func.count()).select_from(User).where(User.referred_by_user_id == referrer_user_id) + if start_dt is not None: + stmt = stmt.where(User.created_at >= start_dt) + if end_dt is not None: + stmt = stmt.where(User.created_at < end_dt) + if search: + like = f"%{search}%" + stmt = stmt.where(or_(User.first_name.ilike(like), User.last_name.ilike(like), User.email.ilike(like))) + return int(self.db.execute(stmt).scalar() or 0) + + def list_referred(self, referrer_user_id: int, start_dt=None, end_dt=None, search: str | None = None, offset: int = 0, limit: int = 20): + stmt = select(User).where(User.referred_by_user_id == referrer_user_id) + if start_dt is not None: + stmt = stmt.where(User.created_at >= start_dt) + if end_dt is not None: + stmt = stmt.where(User.created_at < end_dt) + if search: + like = f"%{search}%" + stmt = stmt.where(or_(User.first_name.ilike(like), User.last_name.ilike(like), User.email.ilike(like))) + stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit) + return self.db.execute(stmt).scalars().all() + + def to_dict(self, user: User) -> dict: + """تبدیل User object به dictionary برای API response""" + return { + "id": user.id, + "email": user.email, + "mobile": user.mobile, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "referral_code": user.referral_code, + "referred_by_user_id": user.referred_by_user_id, + "app_permissions": user.app_permissions, + "created_at": user.created_at, + "updated_at": user.updated_at, + } + + diff --git a/hesabixAPI/build/lib/adapters/db/session.py b/hesabixAPI/build/lib/adapters/db/session.py new file mode 100644 index 0000000..066d8d3 --- /dev/null +++ b/hesabixAPI/build/lib/adapters/db/session.py @@ -0,0 +1,23 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase, Session + +from app.core.settings import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +engine = create_engine(settings.mysql_dsn, echo=settings.sqlalchemy_echo, pool_pre_ping=True, pool_recycle=3600) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/hesabixAPI/build/lib/app/__init__.py b/hesabixAPI/build/lib/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/app/core/__init__.py b/hesabixAPI/build/lib/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/app/core/auth_dependency.py b/hesabixAPI/build/lib/app/core/auth_dependency.py new file mode 100644 index 0000000..c38b5ac --- /dev/null +++ b/hesabixAPI/build/lib/app/core/auth_dependency.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +from typing import Optional +from fastapi import Depends, Header, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from adapters.db.models.user import User +from app.core.security import hash_api_key +from app.core.responses import ApiError +from app.core.i18n import negotiate_locale, Translator +from app.core.calendar import get_calendar_type_from_header, CalendarType + + +class AuthContext: + """کلاس مرکزی برای نگهداری اطلاعات کاربر کنونی و تنظیمات""" + + def __init__( + self, + user: User, + api_key_id: int, + language: str = "fa", + calendar_type: CalendarType = "jalali", + timezone: Optional[str] = None, + business_id: Optional[int] = None, + fiscal_year_id: Optional[int] = None, + db: Optional[Session] = None + ) -> None: + self.user = user + self.api_key_id = api_key_id + self.language = language + self.calendar_type = calendar_type + self.timezone = timezone + self.business_id = business_id + self.fiscal_year_id = fiscal_year_id + self.db = db + + # دسترسی‌های اپلیکیشن + self.app_permissions = user.app_permissions or {} + + # دسترسی‌های کسب و کار (در صورت وجود business_id) + self.business_permissions = self._get_business_permissions() if business_id and db else {} + + # ایجاد translator برای زبان تشخیص داده شده + self._translator = Translator(language) + + @staticmethod + def _normalize_permissions_value(value) -> dict: + """نرمال‌سازی مقدار JSON دسترسی‌ها به dict برای سازگاری با داده‌های legacy""" + if isinstance(value, dict): + return value + if isinstance(value, list): + try: + # لیست جفت‌ها مانند [["join", true], ["sales", {..}]] + if all(isinstance(item, list) and len(item) == 2 for item in value): + return {k: v for k, v in value if isinstance(k, str)} + # لیست دیکشنری‌ها مانند [{"join": true}, {"sales": {...}}] + if all(isinstance(item, dict) for item in value): + merged = {} + for item in value: + merged.update({k: v for k, v in item.items()}) + return merged + except Exception: + return {} + return {} + + def get_translator(self) -> Translator: + """دریافت translator برای ترجمه""" + return self._translator + + def get_calendar_type(self) -> CalendarType: + """دریافت نوع تقویم""" + return self.calendar_type + + def get_user_id(self) -> int: + """دریافت ID کاربر""" + return self.user.id + + def get_user_email(self) -> Optional[str]: + """دریافت ایمیل کاربر""" + return self.user.email + + def get_user_mobile(self) -> Optional[str]: + """دریافت شماره موبایل کاربر""" + return self.user.mobile + + def get_user_name(self) -> str: + """دریافت نام کامل کاربر""" + first_name = self.user.first_name or "" + last_name = self.user.last_name or "" + return f"{first_name} {last_name}".strip() + + def get_referral_code(self) -> Optional[str]: + """دریافت کد معرف کاربر""" + return getattr(self.user, "referral_code", None) + + def is_user_active(self) -> bool: + """بررسی فعال بودن کاربر""" + return self.user.is_active + + def _get_business_permissions(self) -> dict: + """دریافت دسترسی‌های کسب و کار از دیتابیس""" + if not self.business_id or not self.db: + return {} + + from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository + repo = BusinessPermissionRepository(self.db) + permission_obj = repo.get_by_user_and_business(self.user.id, self.business_id) + + if permission_obj and permission_obj.business_permissions: + return AuthContext._normalize_permissions_value(permission_obj.business_permissions) + return {} + + # بررسی دسترسی‌های اپلیکیشن + def has_app_permission(self, permission: str) -> bool: + """بررسی دسترسی در سطح اپلیکیشن""" + # SuperAdmin تمام دسترسی‌های اپلیکیشن را دارد + if self.app_permissions.get("superadmin", False): + return True + + return self.app_permissions.get(permission, False) + + def is_superadmin(self) -> bool: + """بررسی superadmin بودن""" + return self.has_app_permission("superadmin") + + def can_manage_users(self) -> bool: + """بررسی دسترسی مدیریت کاربران در سطح اپلیکیشن""" + return self.has_app_permission("user_management") + + def can_manage_businesses(self) -> bool: + """بررسی دسترسی مدیریت کسب و کارها""" + return self.has_app_permission("business_management") + + def can_access_system_settings(self) -> bool: + """بررسی دسترسی به تنظیمات سیستم""" + return self.has_app_permission("system_settings") + + def can_access_support_operator(self) -> bool: + """بررسی دسترسی به پنل اپراتور پشتیبانی""" + return self.has_app_permission("support_operator") + + def is_business_owner(self, business_id: int = None) -> bool: + """بررسی اینکه آیا کاربر مالک کسب و کار است یا نه""" + import logging + logger = logging.getLogger(__name__) + + target_business_id = business_id or self.business_id + if not target_business_id or not self.db: + logger.info(f"is_business_owner: no business_id ({target_business_id}) or db ({self.db is not None})") + return False + + from adapters.db.models.business import Business + business = self.db.get(Business, target_business_id) + is_owner = business and business.owner_id == self.user.id + logger.info(f"is_business_owner: business_id={target_business_id}, business={business}, owner_id={business.owner_id if business else None}, user_id={self.user.id}, is_owner={is_owner}") + return is_owner + + # بررسی دسترسی‌های کسب و کار + def has_business_permission(self, section: str, action: str) -> bool: + """بررسی دسترسی در سطح کسب و کار""" + if not self.business_id: + return False + + # SuperAdmin تمام دسترسی‌ها را دارد + if self.is_superadmin(): + return True + + # مالک کسب و کار تمام دسترسی‌ها را دارد + if self.is_business_owner(): + return True + + # بررسی دسترسی‌های عادی + if not self.business_permissions: + return False + + # بررسی وجود بخش + if section not in self.business_permissions: + return False + + section_perms = self.business_permissions[section] + + # اگر بخش خالی است، فقط خواندن + if not section_perms: + return action == "read" + + # بررسی دسترسی خاص + return section_perms.get(action, False) + + def can_read_section(self, section: str) -> bool: + """بررسی دسترسی خواندن بخش در کسب و کار""" + if not self.business_id: + return False + + # SuperAdmin و مالک کسب و کار دسترسی کامل دارند + if self.is_superadmin() or self.is_business_owner(): + return True + + return section in self.business_permissions + + def can_write_section(self, section: str) -> bool: + """بررسی دسترسی نوشتن در بخش""" + return self.has_business_permission(section, "write") + + def can_delete_section(self, section: str) -> bool: + """بررسی دسترسی حذف در بخش""" + return self.has_business_permission(section, "delete") + + def can_approve_section(self, section: str) -> bool: + """بررسی دسترسی تأیید در بخش""" + return self.has_business_permission(section, "approve") + + def can_export_section(self, section: str) -> bool: + """بررسی دسترسی صادرات در بخش""" + return self.has_business_permission(section, "export") + + def can_manage_business_users(self, business_id: int = None) -> bool: + """بررسی دسترسی مدیریت کاربران کسب و کار""" + import logging + logger = logging.getLogger(__name__) + + # SuperAdmin دسترسی کامل دارد + if self.is_superadmin(): + logger.info(f"can_manage_business_users: user {self.user.id} is superadmin") + return True + + # مالک کسب و کار دسترسی کامل دارد + if self.is_business_owner(business_id): + logger.info(f"can_manage_business_users: user {self.user.id} is business owner") + return True + + # بررسی دسترسی در سطح کسب و کار + has_permission = self.has_business_permission("settings", "manage_users") + logger.info(f"can_manage_business_users: user {self.user.id} has permission: {has_permission}") + return has_permission + + # ترکیب دسترسی‌ها + def has_any_permission(self, section: str, action: str) -> bool: + """بررسی دسترسی در هر دو سطح""" + # SuperAdmin دسترسی کامل دارد + if self.is_superadmin(): + return True + + # بررسی دسترسی کسب و کار + return self.has_business_permission(section, action) + + def can_access_business(self, business_id: int) -> bool: + """بررسی دسترسی به کسب و کار خاص""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"Checking business access: user {self.user.id}, business {business_id}, context business_id {self.business_id}") + + # SuperAdmin دسترسی به همه کسب و کارها دارد + if self.is_superadmin(): + logger.info(f"User {self.user.id} is superadmin, granting access to business {business_id}") + return True + + # اگر مالک کسب و کار است، دسترسی دارد + if self.is_business_owner() and business_id == self.business_id: + logger.info(f"User {self.user.id} is business owner of {business_id}, granting access") + return True + + # بررسی دسترسی‌های کسب و کار + has_access = business_id == self.business_id + logger.info(f"Business access check: {business_id} == {self.business_id} = {has_access}") + return has_access + + def is_business_member(self, business_id: int) -> bool: + """بررسی اینکه آیا کاربر عضو کسب و کار است یا نه (دسترسی join)""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"Checking business membership: user {self.user.id}, business {business_id}") + + # SuperAdmin عضو همه کسب و کارها محسوب می‌شود + if self.is_superadmin(): + logger.info(f"User {self.user.id} is superadmin, is member of all businesses") + return True + + # اگر مالک کسب و کار است، عضو محسوب می‌شود + if self.is_business_owner() and business_id == self.business_id: + logger.info(f"User {self.user.id} is business owner of {business_id}, is member") + return True + + # بررسی دسترسی join در business_permissions + if not self.db: + logger.info(f"No database session available") + return False + + from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository + repo = BusinessPermissionRepository(self.db) + permission_obj = repo.get_by_user_and_business(self.user.id, business_id) + + if not permission_obj: + logger.info(f"No business permission found for user {self.user.id} and business {business_id}") + return False + + # بررسی دسترسی join + business_perms = AuthContext._normalize_permissions_value(permission_obj.business_permissions) + has_join_access = business_perms.get('join', False) + logger.info(f"Business membership check: user {self.user.id} join access to business {business_id}: {has_join_access}") + return has_join_access + + def to_dict(self) -> dict: + """تبدیل به dictionary برای استفاده در API""" + return { + "user": { + "id": self.user.id, + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "mobile": self.user.mobile, + "referral_code": getattr(self.user, "referral_code", None), + "is_active": self.user.is_active, + "app_permissions": self.app_permissions, + "created_at": self.user.created_at.isoformat() if self.user.created_at else None, + "updated_at": self.user.updated_at.isoformat() if self.user.updated_at else None, + }, + "api_key_id": self.api_key_id, + "permissions": { + "app_permissions": self.app_permissions, + "business_permissions": self.business_permissions, + "is_superadmin": self.is_superadmin(), + "is_business_owner": self.is_business_owner(), + }, + "settings": { + "language": self.language, + "calendar_type": self.calendar_type, + "timezone": self.timezone, + "business_id": self.business_id, + "fiscal_year_id": self.fiscal_year_id, + } + } + + +def get_current_user( + request: Request, + db: Session = Depends(get_db) +) -> AuthContext: + """دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست""" + import logging + logger = logging.getLogger(__name__) + + # Get authorization from request headers + auth_header = request.headers.get("Authorization") + logger.info(f"Auth header: {auth_header}") + + if not auth_header or not auth_header.startswith("ApiKey "): + logger.warning(f"Invalid auth header: {auth_header}") + raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) + + api_key = auth_header[len("ApiKey ") :].strip() + key_hash = hash_api_key(api_key) + repo = ApiKeyRepository(db) + obj = repo.get_by_hash(key_hash) + if not obj or obj.revoked_at is not None: + raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) + + from adapters.db.models.user import User + user = db.get(User, obj.user_id) + if not user or not user.is_active: + raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) + + # تشخیص زبان از هدر Accept-Language + language = _detect_language(request) + + # تشخیص نوع تقویم از هدر X-Calendar-Type + calendar_type = _detect_calendar_type(request) + + # تشخیص منطقه زمانی از هدر X-Timezone (اختیاری) + timezone = _detect_timezone(request) + + # تشخیص کسب و کار از هدر X-Business-ID (آینده) + business_id = _detect_business_id(request) + + # تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده) + fiscal_year_id = _detect_fiscal_year_id(request) + + return AuthContext( + user=user, + api_key_id=obj.id, + language=language, + calendar_type=calendar_type, + timezone=timezone, + business_id=business_id, + fiscal_year_id=fiscal_year_id, + db=db + ) + + +def _detect_language(request: Request) -> str: + """تشخیص زبان از هدر Accept-Language""" + accept_language = request.headers.get("Accept-Language") + return negotiate_locale(accept_language) + + +def _detect_calendar_type(request: Request) -> CalendarType: + """تشخیص نوع تقویم از هدر X-Calendar-Type""" + calendar_header = request.headers.get("X-Calendar-Type") + return get_calendar_type_from_header(calendar_header) + + +def _detect_timezone(request: Request) -> Optional[str]: + """تشخیص منطقه زمانی از هدر X-Timezone""" + return request.headers.get("X-Timezone") + + +def _detect_business_id(request: Request) -> Optional[int]: + """تشخیص ID کسب و کار از هدر X-Business-ID (آینده)""" + business_id_str = request.headers.get("X-Business-ID") + if business_id_str: + try: + return int(business_id_str) + except ValueError: + pass + return None + + +def _detect_fiscal_year_id(request: Request) -> Optional[int]: + """تشخیص ID سال مالی از هدر X-Fiscal-Year-ID (آینده)""" + fiscal_year_id_str = request.headers.get("X-Fiscal-Year-ID") + if fiscal_year_id_str: + try: + return int(fiscal_year_id_str) + except ValueError: + pass + return None + + diff --git a/hesabixAPI/build/lib/app/core/calendar.py b/hesabixAPI/build/lib/app/core/calendar.py new file mode 100644 index 0000000..27c10f1 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/calendar.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Optional +import jdatetime + +CalendarType = Literal["gregorian", "jalali"] + + +class CalendarConverter: + """Utility class for converting dates between Gregorian and Jalali calendars""" + + @staticmethod + def to_jalali(dt: datetime) -> dict: + """Convert Gregorian datetime to Jalali format""" + if dt is None: + return None + + jalali = jdatetime.datetime.fromgregorian(datetime=dt) + # نام ماه‌های شمسی + jalali_month_names = [ + 'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', + 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند' + ] + # نام روزهای هفته شمسی + jalali_weekday_names = [ + 'شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه' + ] + + return { + "year": jalali.year, + "month": jalali.month, + "day": jalali.day, + "hour": jalali.hour, + "minute": jalali.minute, + "second": jalali.second, + "weekday": jalali.weekday(), + "month_name": jalali_month_names[jalali.month - 1], + "weekday_name": jalali_weekday_names[jalali.weekday()], + "formatted": jalali.strftime("%Y/%m/%d %H:%M:%S"), + "date_only": jalali.strftime("%Y/%m/%d"), + "time_only": jalali.strftime("%H:%M:%S"), + "is_leap_year": jalali.isleap(), + "month_days": jdatetime.j_days_in_month[jalali.month - 1], + } + + @staticmethod + def to_gregorian(dt: datetime) -> dict: + """Convert Gregorian datetime to standard format""" + if dt is None: + return None + + return { + "year": dt.year, + "month": dt.month, + "day": dt.day, + "hour": dt.hour, + "minute": dt.minute, + "second": dt.second, + "weekday": dt.weekday(), + "month_name": dt.strftime("%B"), + "weekday_name": dt.strftime("%A"), + "formatted": dt.strftime("%Y-%m-%d %H:%M:%S"), + "date_only": dt.strftime("%Y-%m-%d"), + "time_only": dt.strftime("%H:%M:%S"), + } + + @staticmethod + def format_datetime(dt: datetime, calendar_type: CalendarType) -> dict: + """Format datetime based on calendar type""" + if calendar_type == "jalali": + return CalendarConverter.to_jalali(dt) + else: + return CalendarConverter.to_gregorian(dt) + + @staticmethod + def format_datetime_list(dt_list: list[datetime], calendar_type: CalendarType) -> list[dict]: + """Format list of datetimes based on calendar type""" + return [CalendarConverter.format_datetime(dt, calendar_type) for dt in dt_list if dt is not None] + + +def get_calendar_type_from_header(calendar_header: Optional[str]) -> CalendarType: + """Extract calendar type from X-Calendar-Type header""" + if not calendar_header: + return "gregorian" + + calendar_type = calendar_header.lower().strip() + if calendar_type in ["jalali", "persian", "shamsi"]: + return "jalali" + else: + return "gregorian" diff --git a/hesabixAPI/build/lib/app/core/calendar_middleware.py b/hesabixAPI/build/lib/app/core/calendar_middleware.py new file mode 100644 index 0000000..675ea2a --- /dev/null +++ b/hesabixAPI/build/lib/app/core/calendar_middleware.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import Request +from .calendar import get_calendar_type_from_header, CalendarType + + +async def add_calendar_type(request: Request, call_next): + """Middleware to add calendar type to request state""" + calendar_header = request.headers.get("X-Calendar-Type") + calendar_type = get_calendar_type_from_header(calendar_header) + request.state.calendar_type = calendar_type + + response = await call_next(request) + return response diff --git a/hesabixAPI/build/lib/app/core/error_handlers.py b/hesabixAPI/build/lib/app/core/error_handlers.py new file mode 100644 index 0000000..9685746 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/error_handlers.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import FastAPI, Request, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette.responses import JSONResponse + + +def _translate_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + translator = getattr(request.state, "translator", None) + if translator is None: + # fallback + return JSONResponse( + status_code=422, + content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}}, + ) + + # translated details + details: list[dict[str, Any]] = [] + for err in exc.errors(): + type_ = err.get("type") + loc = err.get("loc", []) + ctx = err.get("ctx", {}) or {} + msg = err.get("msg", "") + + # extract field name (skip body/query/path) + field_name = None + if isinstance(loc, (list, tuple)): + for part in loc: + if str(part) not in ("body", "query", "path"): + field_name = str(part) + + if type_ == "string_too_short": + # Check if it's a password field + if field_name and "password" in field_name.lower(): + msg = translator.t("PASSWORD_MIN_LENGTH") + else: + msg = translator.t("STRING_TOO_SHORT") + min_len = ctx.get("min_length") + if min_len is not None: + msg = f"{msg} (حداقل {min_len})" + elif type_ == "string_too_long": + msg = translator.t("STRING_TOO_LONG") + max_len = ctx.get("max_length") + if max_len is not None: + msg = f"{msg} (حداکثر {max_len})" + elif type_ in {"missing", "value_error.missing"}: + msg = translator.t("FIELD_REQUIRED") + # broader email detection + elif ( + type_ in {"value_error.email", "email"} + or (field_name == "email" and isinstance(type_, str) and type_.startswith("value_error")) + or (isinstance(msg, str) and "email address" in msg.lower()) + ): + msg = translator.t("INVALID_EMAIL") + + details.append({"loc": loc, "msg": msg, "type": type_}) + + return JSONResponse( + status_code=422, + content={ + "success": False, + "error": { + "code": "VALIDATION_ERROR", + "message": translator.t("VALIDATION_ERROR"), + "details": details, + }, + }, + ) + + +def _translate_http_exception(request: Request, exc: HTTPException) -> JSONResponse: + translator = getattr(request.state, "translator", None) + detail = exc.detail + status_code = exc.status_code or 400 + if isinstance(detail, dict) and isinstance(detail.get("error"), dict): + error = detail["error"] + code = error.get("code") + message = error.get("message") + if translator is not None and isinstance(code, str): + localized = translator.t(code, default=message if isinstance(message, str) else None) + detail["error"]["message"] = localized + return JSONResponse(status_code=status_code, content=detail) + # fallback generic shape + message = "" + if isinstance(detail, str): + message = detail + elif isinstance(detail, dict) and "detail" in detail: + message = str(detail["detail"]) + if translator is not None: + message = translator.t("HTTP_ERROR", default=message) + return JSONResponse(status_code=status_code, content={"success": False, "error": {"code": "HTTP_ERROR", "message": message}}) + + +def register_error_handlers(app: FastAPI) -> None: + app.add_exception_handler(RequestValidationError, _translate_validation_error) + app.add_exception_handler(HTTPException, _translate_http_exception) + + diff --git a/hesabixAPI/build/lib/app/core/i18n.py b/hesabixAPI/build/lib/app/core/i18n.py new file mode 100644 index 0000000..574ca12 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/i18n.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import Request +from .i18n_catalog import get_gettext_translation + + +SUPPORTED_LOCALES: tuple[str, ...] = ("fa", "en") +DEFAULT_LOCALE: str = "en" + + +def negotiate_locale(accept_language: str | None) -> str: + if not accept_language: + return DEFAULT_LOCALE + parts = [p.strip() for p in accept_language.split(",") if p.strip()] + for part in parts: + lang = part.split(";")[0].strip().lower() + base = lang.split("-")[0] + if lang in SUPPORTED_LOCALES: + return lang + if base in SUPPORTED_LOCALES: + return base + return DEFAULT_LOCALE + + +class Translator: + def __init__(self, locale: str) -> None: + self.locale = locale if locale in SUPPORTED_LOCALES else DEFAULT_LOCALE + self._gt = get_gettext_translation(self.locale) + + def t(self, key: str, default: str | None = None) -> str: + """Translate a key using gettext. Falls back to default or key if not found.""" + try: + if self._gt is not None: + msg = self._gt.gettext(key) + if msg and msg != key: + return msg + except Exception: + pass + return default or key + + +async def locale_dependency(request: Request) -> Translator: + lang = negotiate_locale(request.headers.get("Accept-Language")) + return Translator(lang) + + +def get_translator(locale: str = "fa") -> Translator: + """Get translator for the given locale""" + return Translator(locale) + + +def gettext(key: str, locale: str = "fa") -> str: + """Get translation for a key using gettext""" + translator = get_translator(locale) + return translator.t(key) + + diff --git a/hesabixAPI/build/lib/app/core/i18n_catalog.py b/hesabixAPI/build/lib/app/core/i18n_catalog.py new file mode 100644 index 0000000..052bbc6 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/i18n_catalog.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import gettext +import os +from functools import lru_cache +from typing import Optional + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +LOCALES_DIR = os.path.join(BASE_DIR, 'locales') + + +@lru_cache(maxsize=128) +def get_gettext_translation(locale: str, domain: str = 'messages') -> Optional[gettext.NullTranslations]: + try: + return gettext.translation(domain=domain, localedir=LOCALES_DIR, languages=[locale], fallback=True) + except Exception: + return None diff --git a/hesabixAPI/build/lib/app/core/logging.py b/hesabixAPI/build/lib/app/core/logging.py new file mode 100644 index 0000000..e2fd43f --- /dev/null +++ b/hesabixAPI/build/lib/app/core/logging.py @@ -0,0 +1,29 @@ +import logging +import sys +from typing import Any + +import structlog + + +def configure_logging(settings: Any) -> None: + shared_processors = [ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + + structlog.configure( + processors=[ + *shared_processors, + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, settings.log_level, logging.INFO)), + cache_logger_on_first_use=True, + ) + + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, settings.log_level, logging.INFO), + ) diff --git a/hesabixAPI/build/lib/app/core/permissions.py b/hesabixAPI/build/lib/app/core/permissions.py new file mode 100644 index 0000000..ce5d65f --- /dev/null +++ b/hesabixAPI/build/lib/app/core/permissions.py @@ -0,0 +1,201 @@ +from functools import wraps +from typing import Callable, Any +import inspect + +from fastapi import Depends +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import ApiError + + +def require_app_permission(permission: str): + """Decorator برای بررسی دسترسی در سطح اپلیکیشن""" + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + # پیدا کردن AuthContext در kwargs + ctx = None + for key, value in kwargs.items(): + if isinstance(value, AuthContext): + ctx = value + break + + if not ctx: + raise ApiError("UNAUTHORIZED", "Authentication required", http_status=401) + + if not ctx.has_app_permission(permission): + raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) + return await func(*args, **kwargs) + return wrapper + return decorator + + +def require_business_permission(section: str, action: str): + """Decorator برای بررسی دسترسی در سطح کسب و کار""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + ctx = get_current_user() + if not ctx.has_business_permission(section, action): + raise ApiError("FORBIDDEN", f"Missing business permission: {section}.{action}", http_status=403) + return func(*args, **kwargs) + return wrapper + return decorator + + +def require_any_permission(section: str, action: str): + """Decorator برای بررسی دسترسی در هر دو سطح (app یا business)""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + ctx = get_current_user() + if not ctx.has_any_permission(section, action): + raise ApiError("FORBIDDEN", f"Missing permission: {section}.{action}", http_status=403) + return func(*args, **kwargs) + return wrapper + return decorator + + +def require_superadmin(): + """Decorator برای بررسی superadmin بودن""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + ctx = get_current_user() + if not ctx.is_superadmin(): + raise ApiError("FORBIDDEN", "Superadmin access required", http_status=403) + return func(*args, **kwargs) + return wrapper + return decorator + + +def require_business_access(business_id_param: str = "business_id"): + """Decorator برای بررسی دسترسی به کسب و کار خاص. + امضای اصلی endpoint حفظ می‌شود و Request از آرگومان‌ها استخراج می‌گردد. + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + import logging + from fastapi import Request + logger = logging.getLogger(__name__) + + # یافتن Request در args/kwargs + request = None + for arg in args: + if isinstance(arg, Request): + request = arg + break + if request is None: + request = kwargs.get('request') + if request is None: + logger.error("Request not found in function arguments") + raise ApiError("INTERNAL_ERROR", "Request not found", http_status=500) + + # دسترسی به DB و کاربر + from adapters.db.session import get_db + db = next(get_db()) + ctx = get_current_user(request, db) + + # استخراج business_id از kwargs یا path params + business_id = kwargs.get(business_id_param) + if business_id is None: + try: + business_id = request.path_params.get(business_id_param) + except Exception: + business_id = None + + if business_id and not ctx.can_access_business(int(business_id)): + logger.warning(f"User {ctx.get_user_id()} does not have access to business {business_id}") + raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) + + # فراخوانی تابع اصلی و await در صورت نیاز + result = func(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + return result + # Preserve original signature so FastAPI sees correct parameters (including Request) + wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] + return wrapper + return decorator + + +# Decorator های ترکیبی برای استفاده آسان +def require_sales_write(): + """دسترسی نوشتن در بخش فروش""" + return require_any_permission("sales", "write") + + +def require_sales_delete(): + """دسترسی حذف در بخش فروش""" + return require_any_permission("sales", "delete") + + +def require_sales_approve(): + """دسترسی تأیید در بخش فروش""" + return require_any_permission("sales", "approve") + + +def require_purchases_write(): + """دسترسی نوشتن در بخش خرید""" + return require_any_permission("purchases", "write") + + +def require_accounting_write(): + """دسترسی نوشتن در بخش حسابداری""" + return require_any_permission("accounting", "write") + + +def require_inventory_write(): + """دسترسی نوشتن در بخش موجودی""" + return require_any_permission("inventory", "write") + + +def require_reports_export(): + """دسترسی صادرات گزارش""" + return require_any_permission("reports", "export") + + +def require_settings_manage_users(): + """دسترسی مدیریت کاربران کسب و کار""" + return require_any_permission("settings", "manage_users") + + +def require_user_management(): + """دسترسی مدیریت کاربران در سطح اپلیکیشن""" + return require_app_permission("user_management") + + +def require_business_management(): + """دسترسی مدیریت کسب و کارها""" + return require_app_permission("business_management") + + +def require_system_settings(): + """دسترسی تنظیمات سیستم""" + return require_app_permission("system_settings") + + +def require_permission(permission: str): + """Decorator عمومی برای بررسی دسترسی - wrapper برای require_app_permission""" + return require_app_permission(permission) + + +# ========================= +# FastAPI Dependencies (for Depends) +# ========================= +def require_app_permission_dep(permission: str): + """FastAPI dependency جهت بررسی دسترسی در سطح اپلیکیشن. + + استفاده: + _: None = Depends(require_app_permission_dep("business_management")) + """ + def _dependency(auth_context: AuthContext = Depends(get_current_user)) -> None: + if not auth_context.has_app_permission(permission): + raise ApiError("FORBIDDEN", f"Missing app permission: {permission}", http_status=403) + return _dependency + + +def require_business_management_dep(auth_context: AuthContext = Depends(get_current_user)) -> None: + """FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها.""" + if not auth_context.has_app_permission("business_management"): + raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403) diff --git a/hesabixAPI/build/lib/app/core/responses.py b/hesabixAPI/build/lib/app/core/responses.py new file mode 100644 index 0000000..c44d1f1 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/responses.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any +from datetime import datetime + +from fastapi import HTTPException, status, Request +from .calendar import CalendarConverter, CalendarType + + +def success_response(data: Any, request: Request = None, message: str = None) -> dict[str, Any]: + response = {"success": True} + + # Add data if provided + if data is not None: + response["data"] = data + + # Add message if provided + if message is not None: + response["message"] = message + + # Add calendar type information if request is available + if request and hasattr(request.state, 'calendar_type'): + response["calendar_type"] = request.state.calendar_type + + return response + + +def format_datetime_fields(data: Any, request: Request) -> Any: + """Recursively format datetime fields based on calendar type""" + if not request or not hasattr(request.state, 'calendar_type'): + return data + + calendar_type = request.state.calendar_type + + if isinstance(data, dict): + formatted_data = {} + for key, value in data.items(): + if value is None: + formatted_data[key] = None + elif isinstance(value, datetime): + # Format the main date field based on calendar type + if calendar_type == "jalali": + formatted_data[key] = CalendarConverter.to_jalali(value)["formatted"] + else: + formatted_data[key] = value.isoformat() + + # Add formatted date as additional field + formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(value, calendar_type) + # Convert raw date to the same calendar type as the formatted date + if calendar_type == "jalali": + formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"] + else: + formatted_data[f"{key}_raw"] = value.isoformat() + elif isinstance(value, (dict, list)): + formatted_data[key] = format_datetime_fields(value, request) + else: + formatted_data[key] = value + return formatted_data + + elif isinstance(data, list): + return [format_datetime_fields(item, request) for item in data] + + else: + return data + + +class ApiError(HTTPException): + def __init__(self, code: str, message: str, http_status: int = status.HTTP_400_BAD_REQUEST, translator=None) -> None: + # اگر translator موجود است، پیام را ترجمه کن + if translator: + translated_message = translator.t(code) if hasattr(translator, 't') else message + else: + translated_message = message + + super().__init__(status_code=http_status, detail={"success": False, "error": {"code": code, "message": translated_message}}) + + diff --git a/hesabixAPI/build/lib/app/core/security.py b/hesabixAPI/build/lib/app/core/security.py new file mode 100644 index 0000000..1d3f469 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/security.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +from datetime import datetime, timedelta + +from argon2 import PasswordHasher + +from app.core.settings import get_settings + + +_ph = PasswordHasher() + + +def hash_password(password: str) -> str: + return _ph.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + _ph.verify(password_hash, password) + return True + except Exception: + return False + + +def generate_api_key(prefix: str = "ak_live_", length: int = 32) -> tuple[str, str]: + """Return (public_key, key_hash). Store only key_hash in DB.""" + secret = secrets.token_urlsafe(length) + api_key = f"{prefix}{secret}" + settings = get_settings() + key_hash = hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest() + return api_key, key_hash + + +def consteq(a: str, b: str) -> bool: + return hmac.compare_digest(a, b) + + +def hash_api_key(api_key: str) -> str: + settings = get_settings() + return hashlib.sha256(f"{settings.captcha_secret}:{api_key}".encode("utf-8")).hexdigest() + + diff --git a/hesabixAPI/build/lib/app/core/settings.py b/hesabixAPI/build/lib/app/core/settings.py new file mode 100644 index 0000000..54215a2 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/settings.py @@ -0,0 +1,48 @@ +from functools import lru_cache +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "Hesabix API" + app_version: str = "0.1.0" + api_v1_prefix: str = "/api/v1" + environment: str = "development" + debug: bool = True + + # Database + db_user: str = "hesabix" + db_password: str = "change_me" + db_host: str = "localhost" + db_port: int = 3306 + db_name: str = "hesabix" + sqlalchemy_echo: bool = False + + # Logging + log_level: str = "INFO" + + # Captcha / Security + captcha_length: int = 5 + captcha_ttl_seconds: int = 180 + captcha_secret: str = "change_me_captcha" + reset_password_ttl_seconds: int = 3600 + + # Phone normalization + # Used as default region when parsing phone numbers without a country code + default_phone_region: str = "IR" + + # CORS + cors_allowed_origins: list[str] = ["*"] + + @property + def mysql_dsn(self) -> str: + return ( + f"mysql+pymysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" + ) + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/hesabixAPI/build/lib/app/core/smart_normalizer.py b/hesabixAPI/build/lib/app/core/smart_normalizer.py new file mode 100644 index 0000000..8113263 --- /dev/null +++ b/hesabixAPI/build/lib/app/core/smart_normalizer.py @@ -0,0 +1,200 @@ +""" +Smart Number Normalizer +تبدیل هوشمند اعداد فارسی/عربی/هندی به انگلیسی +""" + +import json +import re +import logging +from typing import Any, Dict, List, Union, Optional + +logger = logging.getLogger(__name__) + + +class SmartNormalizerConfig: + """تنظیمات سیستم تبدیل هوشمند""" + + # فیلدهایی که نباید تبدیل شوند + EXCLUDE_FIELDS = {'password', 'token', 'hash', 'secret', 'key'} + + # الگوهای خاص برای شناسایی انواع مختلف + SPECIAL_PATTERNS = { + 'mobile': r'۰۹۱[۰-۹]+', + 'email': r'[۰-۹]+@', + 'code': r'[A-Za-z]+[۰-۹]+', + 'phone': r'[۰-۹]+-[۰-۹]+', + } + + # فعال/غیرفعال کردن + ENABLED = True + LOG_CHANGES = True + + +def smart_normalize_numbers(text: str) -> str: + """ + تبدیل هوشمند اعداد فارسی/عربی/هندی به انگلیسی + فقط اعداد را تبدیل می‌کند، متن باقی می‌ماند + """ + if not text or not isinstance(text, str): + return text + + # جدول تبدیل اعداد + number_mapping = { + # فارسی + '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', + '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', + # عربی + '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', + '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', + # هندی/بنگالی + '০': '0', '১': '1', '২': '2', '৩': '3', '৪': '4', + '৫': '5', '৬': '6', '৭': '7', '৮': '8', '৯': '9', + # هندی (دیگر) + '०': '0', '१': '1', '२': '2', '३': '3', '४': '4', + '५': '5', '६': '6', '७': '7', '८': '8', '९': '9' + } + + result = "" + for char in text: + result += number_mapping.get(char, char) + + return result + + +def smart_normalize_text(text: str) -> str: + """ + تبدیل هوشمند برای متن‌های پیچیده + """ + if not text or not isinstance(text, str): + return text + + # شناسایی الگوهای مختلف + patterns = [ + # شماره موبایل: ۰۹۱۲۳۴۵۶۷۸۹ + (r'۰۹۱[۰-۹]+', lambda m: smart_normalize_numbers(m.group())), + # کدهای ترکیبی: ABC-۱۲۳۴ + (r'[A-Za-z]+[۰-۹]+', lambda m: smart_normalize_numbers(m.group())), + # اعداد خالص + (r'[۰-۹]+', lambda m: smart_normalize_numbers(m.group())), + ] + + result = text + for pattern, replacement in patterns: + result = re.sub(pattern, replacement, result) + + return result + + +def smart_normalize_recursive(obj: Any, exclude_fields: Optional[set] = None) -> Any: + """ + تبدیل recursive در ساختارهای پیچیده + """ + if exclude_fields is None: + exclude_fields = SmartNormalizerConfig.EXCLUDE_FIELDS + + if isinstance(obj, str): + return smart_normalize_text(obj) + + elif isinstance(obj, dict): + result = {} + for key, value in obj.items(): + # اگر فیلد در لیست مستثنیات است، تبدیل نکن + if key.lower() in exclude_fields: + result[key] = value + else: + result[key] = smart_normalize_recursive(value, exclude_fields) + return result + + elif isinstance(obj, list): + return [smart_normalize_recursive(item, exclude_fields) for item in obj] + + else: + return obj + + +def smart_normalize_json(data: bytes) -> bytes: + """ + تبدیل هوشمند اعداد در JSON + """ + if not data: + return data + + try: + # تبدیل bytes به dict + json_data = json.loads(data.decode('utf-8')) + + # تبدیل recursive + normalized_data = smart_normalize_recursive(json_data) + + # تبدیل به bytes + normalized_bytes = json.dumps(normalized_data, ensure_ascii=False).encode('utf-8') + + # لاگ تغییرات + if SmartNormalizerConfig.LOG_CHANGES and normalized_bytes != data: + logger.info("Numbers normalized in JSON request") + + return normalized_bytes + + except (json.JSONDecodeError, UnicodeDecodeError) as e: + # اگر JSON نیست، به صورت متن تبدیل کن + try: + text = data.decode('utf-8', errors='ignore') + normalized_text = smart_normalize_text(text) + normalized_bytes = normalized_text.encode('utf-8') + + if SmartNormalizerConfig.LOG_CHANGES and normalized_bytes != data: + logger.info("Numbers normalized in text request") + + return normalized_bytes + except Exception: + logger.warning(f"Failed to normalize request data: {e}") + return data + + +def smart_normalize_query_params(params: Dict[str, Any]) -> Dict[str, Any]: + """ + تبدیل هوشمند اعداد در query parameters + """ + if not params: + return params + + normalized_params = {} + for key, value in params.items(): + if isinstance(value, str): + normalized_params[key] = smart_normalize_text(value) + else: + normalized_params[key] = smart_normalize_recursive(value) + + return normalized_params + + +def is_number_normalization_needed(text: str) -> bool: + """ + بررسی اینکه آیا متن نیاز به تبدیل اعداد دارد یا نه + """ + if not text or not isinstance(text, str): + return False + + # بررسی وجود اعداد فارسی/عربی/هندی + persian_arabic_numbers = '۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩০১২৩৪৫৬৭৮৯०१२३४५६७८९' + return any(char in persian_arabic_numbers for char in text) + + +def get_normalization_stats(data: bytes) -> Dict[str, int]: + """ + آمار تبدیل اعداد + """ + try: + text = data.decode('utf-8', errors='ignore') + persian_arabic_numbers = '۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩০১২৩৪৫৬৭৮৯०१२३४५६७८९' + + total_chars = len(text) + persian_numbers = sum(1 for char in text if char in persian_arabic_numbers) + + return { + 'total_chars': total_chars, + 'persian_numbers': persian_numbers, + 'normalization_ratio': persian_numbers / total_chars if total_chars > 0 else 0 + } + except Exception: + return {'total_chars': 0, 'persian_numbers': 0, 'normalization_ratio': 0} diff --git a/hesabixAPI/build/lib/app/main.py b/hesabixAPI/build/lib/app/main.py new file mode 100644 index 0000000..b478cab --- /dev/null +++ b/hesabixAPI/build/lib/app/main.py @@ -0,0 +1,361 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware + +from app.core.settings import get_settings +from app.core.logging import configure_logging +from adapters.api.v1.health import router as health_router +from adapters.api.v1.auth import router as auth_router +from adapters.api.v1.users import router as users_router +from adapters.api.v1.businesses import router as businesses_router +from adapters.api.v1.currencies import router as currencies_router +from adapters.api.v1.business_dashboard import router as business_dashboard_router +from adapters.api.v1.business_users import router as business_users_router +from adapters.api.v1.accounts import router as accounts_router +from adapters.api.v1.categories import router as categories_router +from adapters.api.v1.product_attributes import router as product_attributes_router +from adapters.api.v1.products import router as products_router +from adapters.api.v1.price_lists import router as price_lists_router +from adapters.api.v1.persons import router as persons_router +from adapters.api.v1.tax_units import router as tax_units_router +from adapters.api.v1.tax_units import alias_router as units_alias_router +from adapters.api.v1.tax_types import router as tax_types_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 +from adapters.api.v1.support.categories import router as support_categories_router +from adapters.api.v1.support.priorities import router as support_priorities_router +from adapters.api.v1.support.statuses import router as support_statuses_router +from adapters.api.v1.admin.file_storage import router as admin_file_storage_router +from adapters.api.v1.admin.email_config import router as admin_email_config_router +from app.core.i18n import negotiate_locale, Translator +from app.core.error_handlers import register_error_handlers +from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig +from app.core.calendar_middleware import add_calendar_type + + +def create_app() -> FastAPI: + settings = get_settings() + configure_logging(settings) + + application = FastAPI( + title=settings.app_name, + version=settings.app_version, + debug=settings.debug, + description=""" + # Hesabix API + + API جامع برای مدیریت کاربران، احراز هویت و سیستم معرفی + + ## ویژگی‌های اصلی: + - **احراز هویت**: ثبت‌نام، ورود، فراموشی رمز عبور + - **مدیریت کاربران**: لیست، جستجو، فیلتر و آمار کاربران + - **سیستم معرفی**: آمار و مدیریت معرفی‌ها + - **خروجی**: PDF و Excel برای گزارش‌ها + - **امنیت**: کپچا، کلیدهای API، رمزگذاری + + ## 🔐 احراز هویت (Authentication) + + ### کلیدهای API + تمام endpoint های محافظت شده نیاز به کلید API دارند که در header `Authorization` ارسال می‌شود: + + ``` + Authorization: Bearer sk_your_api_key_here + ``` + + ### نحوه دریافت کلید API: + 1. **ثبت‌نام**: با ثبت‌نام، یک کلید session دریافت می‌کنید + 2. **ورود**: با ورود موفق، کلید session دریافت می‌کنید + 3. **کلیدهای شخصی**: از endpoint `/api/v1/auth/api-keys` می‌توانید کلیدهای شخصی ایجاد کنید + + ### انواع کلیدهای API: + - **Session Keys**: کلیدهای موقت که با ورود ایجاد می‌شوند + - **Personal Keys**: کلیدهای دائمی که خودتان ایجاد می‌کنید + + ### مثال درخواست با احراز هویت: + ```bash + curl -X GET "http://localhost:8000/api/v1/auth/me" \\ + -H "Authorization: Bearer sk_1234567890abcdef" \\ + -H "Accept: application/json" + ``` + + ## 🛡️ مجوزهای دسترسی (Permissions) + + برخی endpoint ها نیاز به مجوزهای خاص دارند: + + ### مجوزهای اپلیکیشن (App-Level Permissions): + - `user_management`: دسترسی به مدیریت کاربران + - `superadmin`: دسترسی کامل به سیستم + - `business_management`: مدیریت کسب و کارها + - `system_settings`: دسترسی به تنظیمات سیستم + + ### مثال مجوزها در JSON: + ```json + { + "user_management": true, + "superadmin": false, + "business_management": true, + "system_settings": false + } + ``` + + ### endpoint های محافظت شده: + - تمام endpoint های `/api/v1/users/*` نیاز به مجوز `user_management` دارند + - endpoint های `/api/v1/auth/me` و `/api/v1/auth/api-keys/*` نیاز به احراز هویت دارند + + ## 🌍 چندزبانه (Internationalization) + + API از چندزبانه پشتیبانی می‌کند: + + ### هدر زبان: + ``` + Accept-Language: fa + Accept-Language: en + Accept-Language: fa-IR + Accept-Language: en-US + ``` + + ### زبان‌های پشتیبانی شده: + - **فارسی (fa)**: پیش‌فرض + - **انگلیسی (en)** + + ### مثال درخواست با زبان فارسی: + ```bash + curl -X GET "http://localhost:8000/api/v1/auth/me" \\ + -H "Authorization: Bearer sk_1234567890abcdef" \\ + -H "Accept-Language: fa" \\ + -H "Accept: application/json" + ``` + + ## 📅 تقویم (Calendar) + + API از تقویم شمسی (جلالی) پشتیبانی می‌کند: + + ### هدر تقویم: + ``` + X-Calendar-Type: jalali + X-Calendar-Type: gregorian + ``` + + ### انواع تقویم: + - **جلالی (jalali)**: تقویم شمسی - پیش‌فرض + - **میلادی (gregorian)**: تقویم میلادی + + ### مثال درخواست با تقویم شمسی: + ```bash + curl -X GET "http://localhost:8000/api/v1/users" \\ + -H "Authorization: Bearer sk_1234567890abcdef" \\ + -H "X-Calendar-Type: jalali" \\ + -H "Accept: application/json" + ``` + + ## 📊 فرمت پاسخ‌ها (Response Format) + + تمام پاسخ‌ها در فرمت زیر هستند: + + ```json + { + "success": true, + "message": "پیام توضیحی", + "data": { + // داده‌های اصلی + } + } + ``` + + ### کدهای خطا: + - **200**: موفقیت + - **400**: خطا در اعتبارسنجی داده‌ها + - **401**: احراز هویت نشده + - **403**: دسترسی غیرمجاز + - **404**: منبع یافت نشد + - **422**: خطا در اعتبارسنجی + - **500**: خطای سرور + + ## 🔒 امنیت (Security) + + ### کپچا: + برای عملیات حساس از کپچا استفاده می‌شود: + - دریافت کپچا: `POST /api/v1/auth/captcha` + - استفاده در ثبت‌نام، ورود، فراموشی رمز عبور + + ### رمزگذاری: + - رمزهای عبور با bcrypt رمزگذاری می‌شوند + - کلیدهای API با SHA-256 هش می‌شوند + + ## 📝 مثال کامل درخواست: + + ```bash + # 1. دریافت کپچا + curl -X POST "http://localhost:8000/api/v1/auth/captcha" + + # 2. ورود + curl -X POST "http://localhost:8000/api/v1/auth/login" \\ + -H "Content-Type: application/json" \\ + -H "Accept-Language: fa" \\ + -H "X-Calendar-Type: jalali" \\ + -d '{ + "identifier": "user@example.com", + "password": "password123", + "captcha_id": "captcha_id_from_step_1", + "captcha_code": "12345" + }' + + # 3. استفاده از API با کلید دریافتی + curl -X GET "http://localhost:8000/api/v1/users" \\ + -H "Authorization: Bearer sk_1234567890abcdef" \\ + -H "Accept-Language: fa" \\ + -H "X-Calendar-Type: jalali" \\ + -H "Accept: application/json" + ``` + + ## 🚀 شروع سریع: + + 1. **ثبت‌نام**: `POST /api/v1/auth/register` + 2. **ورود**: `POST /api/v1/auth/login` + 3. **دریافت اطلاعات کاربر**: `GET /api/v1/auth/me` + 4. **مدیریت کاربران**: `GET /api/v1/users` (نیاز به مجوز usermanager) + + ## 📞 پشتیبانی: + - **ایمیل**: support@hesabix.ir + - **مستندات**: `/docs` (Swagger UI) + - **ReDoc**: `/redoc` + """, + contact={ + "name": "Hesabix Team", + "email": "support@hesabix.ir", + "url": "https://hesabix.ir", + }, + license_info={ + "name": "GNU GPLv3 License", + "url": "https://opensource.org/licenses/GPL-3.0", + }, + servers=[ + { + "url": "http://localhost:8000", + "description": "Development server" + }, + { + "url": "https://agent.hesabix.ir", + "description": "Production server" + } + ], + ) + + application.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @application.middleware("http") + async def smart_number_normalizer(request: Request, call_next): + """Middleware هوشمند برای تبدیل اعداد فارسی/عربی به انگلیسی""" + if SmartNormalizerConfig.ENABLED and request.method in ["POST", "PUT", "PATCH"]: + # فقط برای درخواست‌های JSON اعمال شود تا فایل‌های باینری/چندبخشی خراب نشوند + content_type = request.headers.get("Content-Type", "").lower() + if content_type.startswith("application/json"): + # خواندن body درخواست + body = await request.body() + if body: + # تبدیل اعداد در JSON + normalized_body = smart_normalize_json(body) + if normalized_body != body: + # ایجاد request جدید با body تبدیل شده + request._body = normalized_body + + response = await call_next(request) + return response + + @application.middleware("http") + async def add_locale(request: Request, call_next): + lang = negotiate_locale(request.headers.get("Accept-Language")) + request.state.locale = lang + request.state.translator = Translator(lang) + response = await call_next(request) + return response + + @application.middleware("http") + async def add_calendar_middleware(request: Request, call_next): + return await add_calendar_type(request, call_next) + + application.include_router(health_router, prefix=settings.api_v1_prefix) + application.include_router(auth_router, prefix=settings.api_v1_prefix) + application.include_router(users_router, prefix=settings.api_v1_prefix) + application.include_router(businesses_router, prefix=settings.api_v1_prefix) + application.include_router(currencies_router, prefix=settings.api_v1_prefix) + application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix) + application.include_router(business_users_router, prefix=settings.api_v1_prefix) + application.include_router(accounts_router, prefix=settings.api_v1_prefix) + application.include_router(categories_router, prefix=settings.api_v1_prefix) + application.include_router(product_attributes_router, prefix=settings.api_v1_prefix) + application.include_router(products_router, prefix=settings.api_v1_prefix) + application.include_router(price_lists_router, prefix=settings.api_v1_prefix) + application.include_router(persons_router, prefix=settings.api_v1_prefix) + application.include_router(tax_units_router, prefix=settings.api_v1_prefix) + application.include_router(units_alias_router, prefix=settings.api_v1_prefix) + application.include_router(tax_types_router, prefix=settings.api_v1_prefix) + + # Support endpoints + application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") + application.include_router(support_operator_router, prefix=f"{settings.api_v1_prefix}/support/operator") + application.include_router(support_categories_router, prefix=f"{settings.api_v1_prefix}/metadata/categories") + application.include_router(support_priorities_router, prefix=f"{settings.api_v1_prefix}/metadata/priorities") + application.include_router(support_statuses_router, prefix=f"{settings.api_v1_prefix}/metadata/statuses") + + # Admin endpoints + application.include_router(admin_file_storage_router, prefix=settings.api_v1_prefix) + application.include_router(admin_email_config_router, prefix=settings.api_v1_prefix) + + register_error_handlers(application) + + @application.get("/", + summary="اطلاعات سرویس", + description="دریافت اطلاعات کلی سرویس و نسخه", + tags=["general"] + ) + def read_root() -> dict[str, str]: + return {"service": settings.app_name, "version": settings.app_version} + + # اضافه کردن security schemes + from fastapi.openapi.utils import get_openapi + + def custom_openapi(): + if application.openapi_schema: + return application.openapi_schema + + openapi_schema = get_openapi( + title=application.title, + version=application.version, + description=application.description, + routes=application.routes, + ) + + # اضافه کردن security schemes + openapi_schema["components"]["securitySchemes"] = { + "ApiKeyAuth": { + "type": "http", + "scheme": "ApiKey", + "description": "کلید API برای احراز هویت. فرمت: ApiKey sk_your_api_key_here" + } + } + + # اضافه کردن security به endpoint های محافظت شده + for path, methods in openapi_schema["paths"].items(): + for method, details in methods.items(): + if method in ["get", "post", "put", "delete", "patch"]: + # تمام endpoint های auth، users و support نیاز به احراز هویت دارند + if "/auth/" in path or "/users" in path or "/support" in path: + details["security"] = [{"ApiKeyAuth": []}] + + application.openapi_schema = openapi_schema + return application.openapi_schema + + application.openapi = custom_openapi + + return application + + +app = create_app() + + diff --git a/hesabixAPI/build/lib/app/services/api_key_service.py b/hesabixAPI/build/lib/app/services/api_key_service.py new file mode 100644 index 0000000..c8e332e --- /dev/null +++ b/hesabixAPI/build/lib/app/services/api_key_service.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy.orm import Session + +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from app.core.security import generate_api_key + + +def list_personal_keys(db: Session, user_id: int) -> list[dict]: + repo = ApiKeyRepository(db) + from adapters.db.models.api_key import ApiKey + stmt = db.query(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.key_type == "personal") + items: list[dict] = [] + for row in stmt.all(): + items.append({ + "id": row.id, + "name": row.name, + "scopes": row.scopes, + "created_at": row.created_at, + "expires_at": row.expires_at, + "revoked_at": row.revoked_at, + }) + return items + + +def create_personal_key(db: Session, user_id: int, name: str | None, scopes: str | None, expires_at: Optional[datetime]) -> tuple[int, str]: + api_key, key_hash = generate_api_key(prefix="ak_personal_") + repo = ApiKeyRepository(db) + obj = repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=None, user_agent=None, ip=None, expires_at=expires_at) + obj.key_type = "personal" + obj.name = name + obj.scopes = scopes + db.add(obj) + db.commit() + return obj.id, api_key + + +def revoke_key(db: Session, user_id: int, key_id: int) -> None: + from adapters.db.models.api_key import ApiKey + obj = db.get(ApiKey, key_id) + if not obj or obj.user_id != user_id: + from app.core.responses import ApiError + raise ApiError("NOT_FOUND", "Key not found", http_status=404) + obj.revoked_at = datetime.utcnow() + db.add(obj) + db.commit() + + diff --git a/hesabixAPI/build/lib/app/services/auth_service.py b/hesabixAPI/build/lib/app/services/auth_service.py new file mode 100644 index 0000000..ab6a928 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/auth_service.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +import phonenumbers +from sqlalchemy.orm import Session + +from adapters.db.repositories.user_repo import UserRepository +from adapters.db.repositories.api_key_repo import ApiKeyRepository +from app.core.security import hash_password, verify_password, generate_api_key, consteq +from app.core.settings import get_settings +from app.services.captcha_service import validate_captcha +from adapters.db.repositories.password_reset_repo import PasswordResetRepository +import hashlib + + +def _normalize_email(email: str | None) -> str | None: + return email.lower().strip() if email else None + + +def _normalize_mobile(mobile: str | None) -> str | None: + if not mobile: + return None + # Clean input: keep digits and leading plus + raw = mobile.strip() + raw = ''.join(ch for ch in raw if ch.isdigit() or ch == '+') + try: + from app.core.settings import get_settings + settings = get_settings() + region = None if raw.startswith('+') else settings.default_phone_region + num = phonenumbers.parse(raw, region) + if not phonenumbers.is_valid_number(num): + return None + return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164) + except Exception: + return None + + +def _detect_identifier(identifier: str) -> tuple[str, str | None, str | None]: + identifier = identifier.strip() + if "@" in identifier: + return "email", _normalize_email(identifier), None + mobile = _normalize_mobile(identifier) + return ("mobile", None, mobile) if mobile else ("invalid", None, None) + + +def _generate_referral_code(db: Session) -> str: + from secrets import token_urlsafe + repo = UserRepository(db) + # try a few times to ensure uniqueness + for _ in range(10): + code = token_urlsafe(8).replace('-', '').replace('_', '')[:10] + if not repo.get_by_referral_code(code): + return code + # fallback longer code + return token_urlsafe(12).replace('-', '').replace('_', '')[:12] + + +def register_user(*, db: Session, first_name: str | None, last_name: str | None, email: str | None, mobile: str | None, password: str, captcha_id: str, captcha_code: str, referrer_code: str | None = None) -> int: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + email_n = _normalize_email(email) + mobile_n = _normalize_mobile(mobile) + if not email_n and not mobile_n: + from app.core.responses import ApiError + # اگر کاربر موبایل وارد کرده اما نامعتبر بوده، پیام دقیق‌تر بدهیم + if mobile and mobile.strip(): + raise ApiError("INVALID_MOBILE", "Invalid mobile number") + # در غیر این صورت، هیچ شناسهٔ معتبری ارائه نشده است + raise ApiError("IDENTIFIER_REQUIRED", "Email or mobile is required") + + repo = UserRepository(db) + if email_n and repo.get_by_email(email_n): + from app.core.responses import ApiError + raise ApiError("EMAIL_IN_USE", "Email is already in use") + if mobile_n and repo.get_by_mobile(mobile_n): + from app.core.responses import ApiError + raise ApiError("MOBILE_IN_USE", "Mobile is already in use") + + pwd_hash = hash_password(password) + referred_by_user_id = None + if referrer_code: + ref_user = repo.get_by_referral_code(referrer_code) + if ref_user: + # prevent self-referral at signup theoretically not applicable; rule kept for safety + referred_by_user_id = ref_user.id + referral_code = _generate_referral_code(db) + user = repo.create( + email=email_n, + mobile=mobile_n, + password_hash=pwd_hash, + first_name=first_name, + last_name=last_name, + referral_code=referral_code, + referred_by_user_id=referred_by_user_id, + ) + return user.id + + +def login_user(*, db: Session, identifier: str, password: str, captcha_id: str, captcha_code: str, device_id: str | None, user_agent: str | None, ip: str | None) -> tuple[str, datetime | None, dict]: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + kind, email, mobile = _detect_identifier(identifier) + if kind == "invalid": + from app.core.responses import ApiError + raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number") + + repo = UserRepository(db) + user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type] + if not user or not verify_password(password, user.password_hash): + from app.core.responses import ApiError + raise ApiError("INVALID_CREDENTIALS", "Invalid credentials") + if not user.is_active: + from app.core.responses import ApiError + raise ApiError("ACCOUNT_DISABLED", "Your account is disabled") + + settings = get_settings() + api_key, key_hash = generate_api_key() + expires_at = None # could be set from settings later + api_repo = ApiKeyRepository(db) + api_repo.create_session_key(user_id=user.id, key_hash=key_hash, device_id=device_id, user_agent=user_agent, ip=ip, expires_at=expires_at) + + user_data = { + "id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "mobile": user.mobile, + "referral_code": getattr(user, "referral_code", None), + } + return api_key, expires_at, user_data + + +def _hash_reset_token(token: str) -> str: + settings = get_settings() + return hashlib.sha256(f"{settings.captcha_secret}:{token}".encode("utf-8")).hexdigest() + + +def create_password_reset(*, db: Session, identifier: str, captcha_id: str, captcha_code: str) -> str: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + kind, email, mobile = _detect_identifier(identifier) + if kind == "invalid": + from app.core.responses import ApiError + raise ApiError("INVALID_IDENTIFIER", "Identifier must be a valid email or mobile number") + + repo = UserRepository(db) + user = repo.get_by_email(email) if email else repo.get_by_mobile(mobile) # type: ignore[arg-type] + # Always respond OK to avoid user enumeration; but skip creation if user not found + if not user: + return "" + + settings = get_settings() + from secrets import token_urlsafe + token = token_urlsafe(32) + token_hash = _hash_reset_token(token) + expires_at = datetime.utcnow() + timedelta(seconds=settings.reset_password_ttl_seconds) + pr_repo = PasswordResetRepository(db) + pr_repo.create(user_id=user.id, token_hash=token_hash, expires_at=expires_at) + return token + + +def reset_password(*, db: Session, token: str, new_password: str, captcha_id: str, captcha_code: str) -> None: + if not validate_captcha(db, captcha_id, captcha_code): + from app.core.responses import ApiError + raise ApiError("INVALID_CAPTCHA", "Invalid captcha code") + + pr_repo = PasswordResetRepository(db) + token_hash = _hash_reset_token(token) + pr = pr_repo.get_by_hash(token_hash) + if not pr or pr.expires_at < datetime.utcnow() or pr.used_at is not None: + from app.core.responses import ApiError + raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired") + + # Update user password + from adapters.db.models.user import User + user = db.get(User, pr.user_id) + if not user: + from app.core.responses import ApiError + raise ApiError("RESET_TOKEN_INVALID_OR_EXPIRED", "Reset token is invalid or expired") + user.password_hash = hash_password(new_password) + db.add(user) + db.commit() + + pr_repo.mark_used(pr) + + + + +def change_password(*, db: Session, user_id: int, current_password: str, new_password: str, confirm_password: str, translator=None) -> None: + """ + تغییر کلمه عبور کاربر + """ + # بررسی تطبیق کلمه عبور جدید و تکرار آن + if new_password != confirm_password: + from app.core.responses import ApiError + raise ApiError("PASSWORDS_DO_NOT_MATCH", "New password and confirm password do not match", translator=translator) + + # بررسی اینکه کلمه عبور جدید با کلمه عبور فعلی متفاوت باشد + if current_password == new_password: + from app.core.responses import ApiError + raise ApiError("SAME_PASSWORD", "New password must be different from current password", translator=translator) + + # دریافت کاربر + from adapters.db.models.user import User + user = db.get(User, user_id) + if not user: + from app.core.responses import ApiError + raise ApiError("USER_NOT_FOUND", "User not found", translator=translator) + + # بررسی کلمه عبور فعلی + if not verify_password(current_password, user.password_hash): + from app.core.responses import ApiError + raise ApiError("INVALID_CURRENT_PASSWORD", "Current password is incorrect", translator=translator) + + # بررسی اینکه کاربر فعال باشد + if not user.is_active: + from app.core.responses import ApiError + raise ApiError("ACCOUNT_DISABLED", "Your account is disabled", translator=translator) + + # تغییر کلمه عبور + user.password_hash = hash_password(new_password) + db.add(user) + db.commit() + + +def referral_stats(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None) -> dict: + from adapters.db.repositories.user_repo import UserRepository + repo = UserRepository(db) + # totals + total = repo.count_referred(user_id) + # month + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1) + month_count = repo.count_referred_between(user_id, month_start, next_month) + # today + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow = today_start + timedelta(days=1) + today_count = repo.count_referred_between(user_id, today_start, tomorrow) + # custom range + custom = None + if start and end: + custom = repo.count_referred_between(user_id, start, end) + return { + "total": total, + "this_month": month_count, + "today": today_count, + "range": custom, + } + + +def referral_list(*, db: Session, user_id: int, start: datetime | None = None, end: datetime | None = None, search: str | None = None, page: int = 1, limit: int = 20) -> dict: + from adapters.db.repositories.user_repo import UserRepository + repo = UserRepository(db) + page = max(1, page) + limit = max(1, min(100, limit)) + offset = (page - 1) * limit + items = repo.list_referred(user_id, start_dt=start, end_dt=end, search=search, offset=offset, limit=limit) + total = repo.count_referred_filtered(user_id, start_dt=start, end_dt=end, search=search) + def mask_email(email: str | None) -> str | None: + if not email: + return None + try: + local, _, domain = email.partition('@') + if len(local) <= 2: + masked_local = local[0] + "*" + else: + masked_local = local[0] + "*" * (len(local) - 2) + local[-1] + return masked_local + "@" + domain + except Exception: + return email + result = [] + for u in items: + result.append({ + "id": u.id, + "first_name": u.first_name, + "last_name": u.last_name, + "email": mask_email(u.email), + "created_at": u.created_at.isoformat(), + }) + return {"items": result, "total": total, "page": page, "limit": limit} \ No newline at end of file diff --git a/hesabixAPI/build/lib/app/services/business_dashboard_service.py b/hesabixAPI/build/lib/app/services/business_dashboard_service.py new file mode 100644 index 0000000..dd720a6 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/business_dashboard_service.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, func +from datetime import datetime, timedelta + +from adapters.db.repositories.business_repo import BusinessRepository +from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository +from adapters.db.repositories.user_repo import UserRepository +from adapters.db.models.business import Business +from adapters.db.models.business_permission import BusinessPermission +from adapters.db.models.user import User +from app.core.auth_dependency import AuthContext + + +def get_business_dashboard_data(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت داده‌های داشبورد کسب و کار""" + business_repo = BusinessRepository(db) + business = business_repo.get_by_id(business_id) + + if not business: + raise ValueError("کسب و کار یافت نشد") + + # بررسی دسترسی کاربر + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + # دریافت اطلاعات کسب و کار + business_info = _get_business_info(business, db) + + # دریافت آمار + statistics = _get_business_statistics(business_id, db) + + # دریافت فعالیت‌های اخیر + recent_activities = _get_recent_activities(business_id, db) + + return { + "business_info": business_info, + "statistics": statistics, + "recent_activities": recent_activities + } + + +def get_business_members(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت لیست اعضای کسب و کار""" + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + permission_repo = BusinessPermissionRepository(db) + user_repo = UserRepository(db) + + # دریافت دسترسی‌های کسب و کار + permissions = permission_repo.get_business_users(business_id) + + members = [] + for permission in permissions: + user = user_repo.get_by_id(permission.user_id) + if user: + members.append({ + "id": permission.id, + "user_id": user.id, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "mobile": user.mobile, + "role": _get_user_role(permission.business_permissions), + "permissions": permission.business_permissions or {}, + "joined_at": permission.created_at.isoformat() + }) + + return { + "items": members, + "pagination": { + "total": len(members), + "page": 1, + "per_page": len(members), + "total_pages": 1, + "has_next": False, + "has_prev": False + } + } + + +def get_business_statistics(db: Session, business_id: int, ctx: AuthContext) -> Dict[str, Any]: + """دریافت آمار تفصیلی کسب و کار""" + if not ctx.can_access_business(business_id): + raise ValueError("دسترسی غیرمجاز") + + # آمار فروش ماهانه (نمونه) + sales_by_month = [ + {"month": "2024-01", "amount": 500000}, + {"month": "2024-02", "amount": 750000}, + {"month": "2024-03", "amount": 600000} + ] + + # پرفروش‌ترین محصولات (نمونه) + top_products = [ + {"name": "محصول A", "sales_count": 100, "revenue": 500000}, + {"name": "محصول B", "sales_count": 80, "revenue": 400000}, + {"name": "محصول C", "sales_count": 60, "revenue": 300000} + ] + + # آمار فعالیت اعضا + permission_repo = BusinessPermissionRepository(db) + members = permission_repo.get_business_users(business_id) + + member_activity = { + "active_today": len([m for m in members if m.created_at.date() == datetime.now().date()]), + "active_this_week": len([m for m in members if m.created_at >= datetime.now() - timedelta(days=7)]), + "total_members": len(members) + } + + return { + "sales_by_month": sales_by_month, + "top_products": top_products, + "member_activity": member_activity + } + + +def _get_business_info(business: Business, db: Session) -> Dict[str, Any]: + """دریافت اطلاعات کسب و کار""" + permission_repo = BusinessPermissionRepository(db) + member_count = len(permission_repo.get_business_users(business.id)) + + return { + "id": business.id, + "name": business.name, + "business_type": business.business_type.value, + "business_field": business.business_field.value, + "owner_id": business.owner_id, + "address": business.address, + "phone": business.phone, + "mobile": business.mobile, + "created_at": business.created_at.isoformat(), + "member_count": member_count + } + + +def _get_business_statistics(business_id: int, db: Session) -> Dict[str, Any]: + """دریافت آمار کلی کسب و کار""" + # در اینجا می‌توانید آمار واقعی را از جداول مربوطه دریافت کنید + # فعلاً داده‌های نمونه برمی‌گردانیم + return { + "total_sales": 1000000.0, + "total_purchases": 500000.0, + "active_members": 5, + "recent_transactions": 25 + } + + +def _get_recent_activities(business_id: int, db: Session) -> List[Dict[str, Any]]: + """دریافت فعالیت‌های اخیر""" + # در اینجا می‌توانید فعالیت‌های واقعی را از جداول مربوطه دریافت کنید + # فعلاً داده‌های نمونه برمی‌گردانیم + return [ + { + "id": 1, + "title": "فروش جدید", + "description": "فروش محصول A به مبلغ 100,000 تومان", + "icon": "sell", + "time_ago": "2 ساعت پیش" + }, + { + "id": 2, + "title": "عضو جدید", + "description": "احمد احمدی به تیم اضافه شد", + "icon": "person_add", + "time_ago": "5 ساعت پیش" + }, + { + "id": 3, + "title": "گزارش ماهانه", + "description": "گزارش فروش ماه ژانویه تولید شد", + "icon": "assessment", + "time_ago": "1 روز پیش" + } + ] + + +def _get_user_role(permissions: Optional[Dict[str, Any]]) -> str: + """تعیین نقش کاربر بر اساس دسترسی‌ها""" + if not permissions: + return "عضو" + + # بررسی دسترسی‌های مختلف برای تعیین نقش + if permissions.get("settings", {}).get("manage_users"): + return "مدیر" + elif permissions.get("sales", {}).get("write"): + return "مدیر فروش" + elif permissions.get("accounting", {}).get("write"): + return "حسابدار" + else: + return "عضو" diff --git a/hesabixAPI/build/lib/app/services/business_service.py b/hesabixAPI/build/lib/app/services/business_service.py new file mode 100644 index 0000000..2ff1927 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/business_service.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from typing import List, Optional, Dict, Any +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.models.currency import Currency, BusinessCurrency +from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository +from adapters.db.models.business import Business, BusinessType, BusinessField +from adapters.api.v1.schemas import ( + BusinessCreateRequest, BusinessUpdateRequest, BusinessResponse, + BusinessListResponse, BusinessSummaryResponse, PaginationInfo +) +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 قبلاً مقادیر فارسی هستند + business_type_enum = business_data.business_type + business_field_enum = business_data.business_field + + # ذخیره در دیتابیس + created_business = business_repo.create_business( + name=business_data.name, + business_type=business_type_enum, + business_field=business_field_enum, + owner_id=owner_id, + default_currency_id=getattr(business_data, "default_currency_id", None), + address=business_data.address, + phone=business_data.phone, + mobile=business_data.mobile, + national_id=business_data.national_id, + registration_number=business_data.registration_number, + economic_id=business_data.economic_id, + country=business_data.country, + province=business_data.province, + city=business_data.city, + 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) + ) + + # مدیریت ارزها + currency_ids: list[int] = [] + if getattr(business_data, "currency_ids", None): + currency_ids = list(dict.fromkeys(business_data.currency_ids)) # unique + default_currency_id = getattr(business_data, "default_currency_id", None) + if default_currency_id: + if default_currency_id not in currency_ids: + currency_ids.insert(0, default_currency_id) + + # اعتبارسنجی وجود ارزها + if currency_ids: + existing_ids = [cid for (cid,) in db.query(Currency.id).filter(Currency.id.in_(currency_ids)).all()] + if set(existing_ids) != set(currency_ids): + missing = set(currency_ids) - set(existing_ids) + raise ValueError(f"Invalid currency ids: {sorted(list(missing))}") + + # درج ارتباطات در business_currencies + for cid in currency_ids: + bc = BusinessCurrency(business_id=created_business.id, currency_id=cid) + db.add(bc) + db.commit() + + db.refresh(created_business) + + # تبدیل به response format + return _business_to_dict(created_business) + + +def get_business_by_id(db: Session, business_id: int, owner_id: int) -> Optional[Dict[str, Any]]: + """دریافت کسب و کار بر اساس شناسه""" + business_repo = BusinessRepository(db) + business = business_repo.get_by_id(business_id) + + if not business or business.owner_id != owner_id: + return None + + return _business_to_dict(business) + + +def get_businesses_by_owner(db: Session, owner_id: int, query_info: Dict[str, Any]) -> Dict[str, Any]: + """دریافت لیست کسب و کارهای یک مالک""" + business_repo = BusinessRepository(db) + + # دریافت کسب و کارها + businesses = business_repo.get_by_owner_id(owner_id) + + # اعمال فیلترها + if query_info.get('search'): + search_term = query_info['search'] + businesses = [b for b in businesses if search_term.lower() in b.name.lower()] + + # اعمال مرتب‌سازی + sort_by = query_info.get('sort_by', 'created_at') + sort_desc = query_info.get('sort_desc', True) + + if sort_by == 'name': + businesses.sort(key=lambda x: x.name, reverse=sort_desc) + elif sort_by == 'business_type': + businesses.sort(key=lambda x: x.business_type.value, reverse=sort_desc) + elif sort_by == 'created_at': + businesses.sort(key=lambda x: x.created_at, reverse=sort_desc) + + # صفحه‌بندی + total = len(businesses) + skip = query_info.get('skip', 0) + take = query_info.get('take', 10) + + start_idx = skip + end_idx = skip + take + paginated_businesses = businesses[start_idx:end_idx] + + # محاسبه اطلاعات صفحه‌بندی + total_pages = (total + take - 1) // take + current_page = (skip // take) + 1 + + pagination = PaginationInfo( + total=total, + page=current_page, + per_page=take, + total_pages=total_pages, + has_next=current_page < total_pages, + has_prev=current_page > 1 + ) + + # تبدیل به response format + items = [_business_to_dict(business) for business in paginated_businesses] + + return { + "items": items, + "pagination": pagination.dict(), + "query_info": query_info + } + + +def get_user_businesses(db: Session, user_id: int, query_info: Dict[str, Any]) -> Dict[str, Any]: + """دریافت لیست کسب و کارهای کاربر (مالک + عضو)""" + business_repo = BusinessRepository(db) + permission_repo = BusinessPermissionRepository(db) + + # دریافت کسب و کارهای مالک + owned_businesses = business_repo.get_by_owner_id(user_id) + + # دریافت کسب و کارهای عضو + member_permissions = permission_repo.get_user_member_businesses(user_id) + member_business_ids = [perm.business_id for perm in member_permissions] + member_businesses = [] + for business_id in member_business_ids: + business = business_repo.get_by_id(business_id) + if business: + member_businesses.append(business) + + # ترکیب لیست‌ها + all_businesses = [] + + # اضافه کردن کسب و کارهای مالک با نقش owner + for business in owned_businesses: + business_dict = _business_to_dict(business) + business_dict['is_owner'] = True + business_dict['role'] = 'مالک' + business_dict['permissions'] = {} + all_businesses.append(business_dict) + + # اضافه کردن کسب و کارهای عضو با نقش member + for business in member_businesses: + # اگر قبلاً به عنوان مالک اضافه شده، نادیده بگیر + if business.id not in [b['id'] for b in all_businesses]: + business_dict = _business_to_dict(business) + business_dict['is_owner'] = False + business_dict['role'] = 'عضو' + # دریافت دسترسی‌های کاربر برای این کسب و کار + permission_obj = permission_repo.get_by_user_and_business(user_id, business.id) + if permission_obj and permission_obj.business_permissions: + perms = permission_obj.business_permissions + # Normalize to dict to avoid legacy list format + if isinstance(perms, dict): + business_dict['permissions'] = perms + elif isinstance(perms, list): + try: + if all(isinstance(item, list) and len(item) == 2 for item in perms): + business_dict['permissions'] = {k: v for k, v in perms if isinstance(k, str)} + elif all(isinstance(item, dict) for item in perms): + merged = {} + for it in perms: + merged.update({k: v for k, v in it.items()}) + business_dict['permissions'] = merged + else: + business_dict['permissions'] = {} + except Exception: + business_dict['permissions'] = {} + else: + business_dict['permissions'] = {} + else: + business_dict['permissions'] = {} + all_businesses.append(business_dict) + + # اعمال فیلترها + if query_info.get('search'): + search_term = query_info['search'] + all_businesses = [b for b in all_businesses if search_term.lower() in b['name'].lower()] + + # اعمال مرتب‌سازی + sort_by = query_info.get('sort_by', 'created_at') + sort_desc = query_info.get('sort_desc', True) + + if sort_by == 'name': + all_businesses.sort(key=lambda x: x['name'], reverse=sort_desc) + elif sort_by == 'business_type': + all_businesses.sort(key=lambda x: x['business_type'], reverse=sort_desc) + elif sort_by == 'created_at': + all_businesses.sort(key=lambda x: x['created_at'], reverse=sort_desc) + + # صفحه‌بندی + total = len(all_businesses) + skip = query_info.get('skip', 0) + take = query_info.get('take', 10) + + start_idx = skip + end_idx = skip + take + paginated_businesses = all_businesses[start_idx:end_idx] + + # محاسبه اطلاعات صفحه‌بندی + total_pages = (total + take - 1) // take + current_page = (skip // take) + 1 + + pagination = PaginationInfo( + total=total, + page=current_page, + per_page=take, + total_pages=total_pages, + has_next=current_page < total_pages, + has_prev=current_page > 1 + ) + + return { + "items": paginated_businesses, + "pagination": pagination.dict(), + "query_info": query_info + } + + +def update_business(db: Session, business_id: int, business_data: BusinessUpdateRequest, owner_id: int) -> Optional[Dict[str, Any]]: + """ویرایش کسب و کار""" + business_repo = BusinessRepository(db) + business = business_repo.get_by_id(business_id) + + if not business or business.owner_id != owner_id: + return None + + # به‌روزرسانی فیلدها + update_data = business_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(business, field, value) + + # ذخیره تغییرات + updated_business = business_repo.update(business) + + return _business_to_dict(updated_business) + + +def delete_business(db: Session, business_id: int, owner_id: int) -> bool: + """حذف کسب و کار""" + business_repo = BusinessRepository(db) + business = business_repo.get_by_id(business_id) + + if not business or business.owner_id != owner_id: + return False + + business_repo.delete(business_id) + return True + + +def get_business_summary(db: Session, owner_id: int) -> Dict[str, Any]: + """دریافت خلاصه آمار کسب و کارها""" + business_repo = BusinessRepository(db) + businesses = business_repo.get_by_owner_id(owner_id) + + # شمارش بر اساس نوع + by_type = {} + for business_type in BusinessType: + by_type[business_type.value] = len([b for b in businesses if b.business_type == business_type]) + + # شمارش بر اساس زمینه فعالیت + by_field = {} + for business_field in BusinessField: + by_field[business_field.value] = len([b for b in businesses if b.business_field == business_field]) + + return { + "total_businesses": len(businesses), + "by_type": by_type, + "by_field": by_field + } + + +def _business_to_dict(business: Business) -> Dict[str, Any]: + """تبدیل مدل کسب و کار به dictionary""" + data = { + "id": business.id, + "name": business.name, + "business_type": business.business_type.value, + "business_field": business.business_field.value, + "owner_id": business.owner_id, + "address": business.address, + "phone": business.phone, + "mobile": business.mobile, + "national_id": business.national_id, + "registration_number": business.registration_number, + "economic_id": business.economic_id, + "country": business.country, + "province": business.province, + "city": business.city, + "postal_code": business.postal_code, + "created_at": business.created_at, # datetime object بماند + "updated_at": business.updated_at # datetime object بماند + } + + # ارز پیشفرض + if getattr(business, "default_currency", None): + c = business.default_currency + data["default_currency"] = { + "id": c.id, + "code": c.code, + "title": c.title, + "symbol": c.symbol, + } + else: + data["default_currency"] = None + + # ارزهای فعال کسب‌وکار + if getattr(business, "currencies", None): + data["currencies"] = [ + {"id": c.id, "code": c.code, "title": c.title, "symbol": c.symbol} + for c in business.currencies + ] + else: + data["currencies"] = [] + + return data diff --git a/hesabixAPI/build/lib/app/services/captcha_service.py b/hesabixAPI/build/lib/app/services/captcha_service.py new file mode 100644 index 0000000..699af72 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/captcha_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import base64 +import io +import os +import secrets +from datetime import datetime, timedelta +from typing import Tuple + +from PIL import Image, ImageDraw, ImageFont, ImageFilter +from sqlalchemy.orm import Session + +from adapters.db.models.captcha import Captcha +from app.core.settings import get_settings +import hashlib + + +def _generate_numeric_code(length: int) -> str: + return "".join(str(secrets.randbelow(10)) for _ in range(length)) + + +def _hash_code(code: str, secret: str) -> str: + return hashlib.sha256(f"{secret}:{code}".encode("utf-8")).hexdigest() + + +def _render_image(code: str, width: int = 140, height: int = 48) -> Image.Image: + bg_color = (245, 246, 248) + img = Image.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(img) + + try: + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + font = ImageFont.truetype(font_path, 28) + except Exception: + font = ImageFont.load_default() + + # Noise lines + for _ in range(3): + xy = [(secrets.randbelow(width), secrets.randbelow(height)) for _ in range(2)] + draw.line(xy, fill=(200, 205, 210), width=1) + + # measure text + try: + bbox = draw.textbbox((0, 0), code, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + except Exception: + # fallback approximate + text_w, text_h = (len(code) * 16, 24) + + x = (width - text_w) // 2 + y = (height - text_h) // 2 + # Slight jitter per character + avg_char_w = max(1, text_w // max(1, len(code))) + for idx, ch in enumerate(code): + cx = x + idx * avg_char_w + secrets.randbelow(3) + cy = y + secrets.randbelow(3) + draw.text((cx, cy), ch, font=font, fill=(60, 70, 80)) + + img = img.filter(ImageFilter.SMOOTH) + return img + + +def create_captcha(db: Session) -> tuple[str, str, int]: + settings = get_settings() + code = _generate_numeric_code(settings.captcha_length) + code_hash = _hash_code(code, settings.captcha_secret) + captcha_id = f"cpt_{secrets.token_hex(8)}" + expires_at = datetime.utcnow() + timedelta(seconds=settings.captcha_ttl_seconds) + + obj = Captcha(id=captcha_id, code_hash=code_hash, expires_at=expires_at, attempts=0) + db.add(obj) + db.commit() + + image = _render_image(code) + buf = io.BytesIO() + image.save(buf, format="PNG") + image_base64 = base64.b64encode(buf.getvalue()).decode("ascii") + return captcha_id, image_base64, settings.captcha_ttl_seconds + + +def validate_captcha(db: Session, captcha_id: str, code: str) -> bool: + settings = get_settings() + obj = db.get(Captcha, captcha_id) + if obj is None: + return False + if obj.expires_at < datetime.utcnow(): + return False + provided_hash = _hash_code(code.strip(), settings.captcha_secret) + if secrets.compare_digest(provided_hash, obj.code_hash): + return True + obj.attempts += 1 + db.add(obj) + db.commit() + return False + + diff --git a/hesabixAPI/build/lib/app/services/email_service.py b/hesabixAPI/build/lib/app/services/email_service.py new file mode 100644 index 0000000..f1a2399 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/email_service.py @@ -0,0 +1,143 @@ +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional, List +from sqlalchemy.orm import Session + +from adapters.db.models.email_config import EmailConfig +from adapters.db.repositories.email_config_repository import EmailConfigRepository + + +class EmailService: + def __init__(self, db: Session): + self.db = db + self.email_repo = EmailConfigRepository(db) + + def send_email( + self, + to: str, + subject: str, + body: str, + html_body: Optional[str] = None, + config_id: Optional[int] = None + ) -> bool: + """ + Send email using SMTP configuration + + Args: + to: Recipient email address + subject: Email subject + body: Plain text body + html_body: HTML body (optional) + config_id: Specific config ID to use (optional) + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Get email configuration - prioritize default config + if config_id: + config = self.email_repo.get_by_id(config_id) + else: + # First try to get default config + config = self.email_repo.get_default_config() + if not config: + # Fallback to active config + config = self.email_repo.get_active_config() + + if not config: + return False + + # Create message + msg = MIMEMultipart('alternative') + msg['From'] = f"{config.from_name} <{config.from_email}>" + msg['To'] = to + msg['Subject'] = subject + + # Add plain text part + text_part = MIMEText(body, 'plain', 'utf-8') + msg.attach(text_part) + + # Add HTML part if provided + if html_body: + html_part = MIMEText(html_body, 'html', 'utf-8') + msg.attach(html_part) + + # Send email + return self._send_smtp_email(config, msg) + + except Exception as e: + print(f"Error sending email: {e}") + return False + + def send_template_email( + self, + template_name: str, + to: str, + context: dict, + config_id: Optional[int] = None + ) -> bool: + """ + Send email using a template (placeholder for future template system) + + Args: + template_name: Name of the template + to: Recipient email address + context: Template context variables + config_id: Specific config ID to use (optional) + + Returns: + bool: True if email sent successfully, False otherwise + """ + # For now, just use basic template substitution + # This can be extended with a proper template engine later + subject = context.get('subject', 'Email from Hesabix') + body = context.get('body', '') + html_body = context.get('html_body') + + return self.send_email(to, subject, body, html_body, config_id) + + def test_connection(self, config_id: int) -> bool: + """ + Test SMTP connection for a specific configuration + + Args: + config_id: Configuration ID to test + + Returns: + bool: True if connection successful, False otherwise + """ + config = self.email_repo.get_by_id(config_id) + if not config: + return False + + return self.email_repo.test_connection(config) + + def get_active_config(self) -> Optional[EmailConfig]: + """Get the currently active email configuration""" + return self.email_repo.get_active_config() + + def get_all_configs(self) -> List[EmailConfig]: + """Get all email configurations""" + return self.email_repo.get_all_configs() + + def _send_smtp_email(self, config: EmailConfig, msg: MIMEMultipart) -> bool: + """Internal method to send email via SMTP""" + try: + # Create SMTP connection + if config.use_ssl: + server = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + if config.use_tls: + server.starttls() + + # Login and send + server.login(config.smtp_username, config.smtp_password) + server.send_message(msg) + server.quit() + + return True + except Exception as e: + print(f"SMTP error: {e}") + return False diff --git a/hesabixAPI/build/lib/app/services/file_storage_service.py b/hesabixAPI/build/lib/app/services/file_storage_service.py new file mode 100644 index 0000000..c7d4911 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/file_storage_service.py @@ -0,0 +1,227 @@ +import os +import hashlib +import uuid +from typing import Optional, Dict, Any, List +from uuid import UUID +from fastapi import UploadFile, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime, timedelta + +from adapters.db.repositories.file_storage_repository import ( + FileStorageRepository, + StorageConfigRepository, + FileVerificationRepository +) +from adapters.db.models.file_storage import FileStorage, StorageConfig + + +class FileStorageService: + def __init__(self, db: Session): + self.db = db + self.file_repo = FileStorageRepository(db) + self.config_repo = StorageConfigRepository(db) + self.verification_repo = FileVerificationRepository(db) + + async def upload_file( + self, + file: UploadFile, + user_id: UUID, + module_context: str, + context_id: Optional[UUID] = None, + developer_data: Optional[Dict] = None, + is_temporary: bool = False, + expires_in_days: int = 30, + storage_config_id: Optional[UUID] = None + ) -> Dict[str, Any]: + try: + # دریافت تنظیمات ذخیره‌سازی + if storage_config_id: + storage_config = self.db.query(StorageConfig).filter( + StorageConfig.id == storage_config_id + ).first() + else: + storage_config = await self.config_repo.get_default_config() + + if not storage_config: + raise HTTPException(status_code=400, detail="No storage configuration found") + + # تولید نام فایل و مسیر + file_extension = os.path.splitext(file.filename)[1] if file.filename else "" + stored_name = f"{uuid.uuid4()}{file_extension}" + + # تعیین مسیر ذخیره‌سازی + if storage_config.storage_type == "local": + file_path = await self._get_local_file_path(stored_name, storage_config.config_data) + elif storage_config.storage_type == "ftp": + file_path = await self._get_ftp_file_path(stored_name, storage_config.config_data) + else: + raise HTTPException(status_code=400, detail="Unsupported storage type") + + # خواندن محتوای فایل + file_content = await file.read() + file_size = len(file_content) + + # محاسبه checksum + checksum = hashlib.sha256(file_content).hexdigest() + + # ذخیره فایل + await self._save_file_to_storage(file_content, file_path, storage_config) + + # ذخیره اطلاعات در دیتابیس + file_storage = await self.file_repo.create_file( + original_name=file.filename or "unknown", + stored_name=stored_name, + file_path=file_path, + file_size=file_size, + mime_type=file.content_type or "application/octet-stream", + storage_type=storage_config.storage_type, + uploaded_by=user_id, + module_context=module_context, + context_id=context_id, + developer_data=developer_data, + checksum=checksum, + is_temporary=is_temporary, + expires_in_days=expires_in_days, + storage_config_id=storage_config.id + ) + + # تولید توکن تایید برای فایل‌های موقت + verification_token = None + if is_temporary: + verification_token = str(uuid.uuid4()) + await self.verification_repo.create_verification( + file_id=file_storage.id, + module_name=module_context, + verification_token=verification_token, + verification_data=developer_data + ) + + return { + "file_id": str(file_storage.id), + "original_name": file_storage.original_name, + "file_size": file_storage.file_size, + "mime_type": file_storage.mime_type, + "is_temporary": file_storage.is_temporary, + "verification_token": verification_token, + "expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"File upload failed: {str(e)}") + + async def get_file(self, file_id: UUID) -> Dict[str, Any]: + file_storage = await self.file_repo.get_file_by_id(file_id) + if not file_storage: + raise HTTPException(status_code=404, detail="File not found") + + return { + "file_id": str(file_storage.id), + "original_name": file_storage.original_name, + "file_size": file_storage.file_size, + "mime_type": file_storage.mime_type, + "is_temporary": file_storage.is_temporary, + "is_verified": file_storage.is_verified, + "created_at": file_storage.created_at.isoformat(), + "expires_at": file_storage.expires_at.isoformat() if file_storage.expires_at else None + } + + async def download_file(self, file_id: UUID) -> Dict[str, Any]: + file_storage = await self.file_repo.get_file_by_id(file_id) + if not file_storage: + raise HTTPException(status_code=404, detail="File not found") + + # خواندن فایل از storage + file_content = await self._read_file_from_storage(file_storage.file_path, file_storage.storage_type) + + return { + "content": file_content, + "filename": file_storage.original_name, + "mime_type": file_storage.mime_type + } + + async def delete_file(self, file_id: UUID) -> bool: + file_storage = await self.file_repo.get_file_by_id(file_id) + if not file_storage: + return False + + # حذف فایل از storage + await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type) + + # حذف نرم از دیتابیس + return await self.file_repo.soft_delete_file(file_id) + + async def verify_file_usage(self, file_id: UUID, verification_data: Dict) -> bool: + return await self.file_repo.verify_file(file_id, verification_data) + + async def list_files_by_context( + self, + module_context: str, + context_id: UUID + ) -> List[Dict[str, Any]]: + files = await self.file_repo.get_files_by_context(module_context, context_id) + return [ + { + "file_id": str(file.id), + "original_name": file.original_name, + "file_size": file.file_size, + "mime_type": file.mime_type, + "is_temporary": file.is_temporary, + "is_verified": file.is_verified, + "created_at": file.created_at.isoformat() + } + for file in files + ] + + async def cleanup_unverified_files(self) -> Dict[str, Any]: + unverified_files = await self.file_repo.get_unverified_temporary_files() + cleaned_count = 0 + + for file_storage in unverified_files: + if file_storage.expires_at and file_storage.expires_at < datetime.utcnow(): + await self._delete_file_from_storage(file_storage.file_path, file_storage.storage_type) + await self.file_repo.soft_delete_file(file_storage.id) + cleaned_count += 1 + + return { + "cleaned_files": cleaned_count, + "total_unverified": len(unverified_files) + } + + async def get_storage_statistics(self) -> Dict[str, Any]: + return await self.file_repo.get_storage_statistics() + + # Helper methods + async def _get_local_file_path(self, stored_name: str, config_data: Dict) -> str: + base_path = config_data.get("base_path", "/tmp/hesabix_files") + os.makedirs(base_path, exist_ok=True) + return os.path.join(base_path, stored_name) + + async def _get_ftp_file_path(self, stored_name: str, config_data: Dict) -> str: + # برای FTP، مسیر نسبی را برمی‌گردانیم + base_path = config_data.get("base_path", "/hesabix_files") + return f"{base_path}/{stored_name}" + + async def _save_file_to_storage(self, content: bytes, file_path: str, storage_config: StorageConfig): + if storage_config.storage_type == "local": + with open(file_path, "wb") as f: + f.write(content) + elif storage_config.storage_type == "ftp": + # TODO: پیاده‌سازی FTP upload + pass + + async def _read_file_from_storage(self, file_path: str, storage_type: str) -> bytes: + if storage_type == "local": + with open(file_path, "rb") as f: + return f.read() + elif storage_type == "ftp": + # TODO: پیاده‌سازی FTP download + pass + return b"" + + async def _delete_file_from_storage(self, file_path: str, storage_type: str): + if storage_type == "local": + if os.path.exists(file_path): + os.remove(file_path) + elif storage_type == "ftp": + # TODO: پیاده‌سازی FTP delete + pass diff --git a/hesabixAPI/build/lib/app/services/pdf/__init__.py b/hesabixAPI/build/lib/app/services/pdf/__init__.py new file mode 100644 index 0000000..23f103e --- /dev/null +++ b/hesabixAPI/build/lib/app/services/pdf/__init__.py @@ -0,0 +1,6 @@ +""" +PDF Service Package +""" +from .base_pdf_service import PDFService + +__all__ = ['PDFService'] diff --git a/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py b/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py new file mode 100644 index 0000000..d732522 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/pdf/base_pdf_service.py @@ -0,0 +1,135 @@ +""" +Base PDF Service for modular PDF generation +""" +import os +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +from datetime import datetime +from pathlib import Path + +from weasyprint import HTML, CSS +from weasyprint.text.fonts import FontConfiguration +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from app.core.calendar import CalendarConverter, CalendarType +from app.core.i18n import get_translator +from adapters.api.v1.schemas import QueryInfo + + +class BasePDFModule(ABC): + """Base class for PDF modules""" + + def __init__(self, module_name: str): + self.module_name = module_name + self.template_dir = Path(__file__).parent / "modules" / module_name / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(['html', 'xml']) + ) + self.font_config = FontConfiguration() + + @abstractmethod + def generate_pdf( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> bytes: + """Generate PDF for this module""" + pass + + @abstractmethod + def generate_excel_data( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> list: + """Generate Excel data for this module""" + pass + + def format_datetime(self, dt: datetime, calendar_type: CalendarType) -> str: + """Format datetime based on calendar type""" + if dt is None: + return "" + + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + return formatted_date['formatted'] + + def get_translator(self, locale: str = "fa"): + """Get translator for the given locale""" + return get_translator(locale) + + def render_template(self, template_name: str, context: Dict[str, Any]) -> str: + """Render template with context""" + template = self.jinja_env.get_template(template_name) + return template.render(**context) + + +class PDFService: + """Main PDF Service that manages modules""" + + def __init__(self): + self.modules: Dict[str, BasePDFModule] = {} + self._register_modules() + + def _register_modules(self): + """Register all available modules""" + from .modules.marketing.marketing_module import MarketingPDFModule + self.modules['marketing'] = MarketingPDFModule() + + def generate_pdf( + self, + module_name: str, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa", + db=None, + user_id: Optional[int] = None, + query_info: Optional[QueryInfo] = None, + selected_indices: Optional[List[int]] = None, + stats: Optional[Dict[str, Any]] = None + ) -> bytes: + """Generate PDF using specified module""" + if module_name not in self.modules: + raise ValueError(f"Module '{module_name}' not found") + + return self.modules[module_name].generate_pdf_content( + db=db, + user_id=user_id, + query_info=query_info, + selected_indices=selected_indices, + stats=stats, + calendar_type=calendar_type, + locale=locale, + common_data=data + ) + + def generate_excel_data( + self, + module_name: str, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa", + db=None, + user_id: Optional[int] = None, + query_info: Optional[QueryInfo] = None, + selected_indices: Optional[List[int]] = None + ) -> list: + """Generate Excel data using specified module""" + if module_name not in self.modules: + raise ValueError(f"Module '{module_name}' not found") + + return self.modules[module_name].generate_excel_content( + db=db, + user_id=user_id, + query_info=query_info, + selected_indices=selected_indices, + calendar_type=calendar_type, + locale=locale, + common_data=data + ) + + def list_modules(self) -> list: + """List all available modules""" + return list(self.modules.keys()) diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py b/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py new file mode 100644 index 0000000..7d39827 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/pdf/modules/__init__.py @@ -0,0 +1,3 @@ +""" +PDF Modules Package +""" diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py new file mode 100644 index 0000000..9088041 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/__init__.py @@ -0,0 +1,6 @@ +""" +Marketing PDF Module +""" +from .marketing_module import MarketingPDFModule + +__all__ = ['MarketingPDFModule'] diff --git a/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py new file mode 100644 index 0000000..e55a5b4 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/pdf/modules/marketing/marketing_module.py @@ -0,0 +1,441 @@ +""" +Marketing PDF Module for referrals and marketing reports +""" +from typing import Dict, Any, List +from datetime import datetime + +from ...base_pdf_service import BasePDFModule +from app.core.calendar import CalendarType + + +class MarketingPDFModule(BasePDFModule): + """PDF Module for marketing and referrals""" + + def __init__(self): + super().__init__("marketing") + self.template_name = 'marketing_referrals.html' + + def generate_pdf( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> bytes: + """Generate marketing referrals PDF""" + # Get translator + t = self.get_translator(locale) + + # Format data with translations and calendar + formatted_data = self._format_data_for_template(data, calendar_type, t) + + # Render template + html_content = self.render_template('marketing_referrals.html', formatted_data) + + # Generate PDF + html_doc = HTML(string=html_content) + pdf_bytes = html_doc.write_pdf(font_config=self.font_config) + + return pdf_bytes + + def generate_excel_data( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> list: + """Generate marketing referrals Excel data""" + # Get translator + t = self.get_translator(locale) + + # Format data + items = data.get('items', []) + excel_data = [] + + for i, item in enumerate(items, 1): + # Format created_at based on calendar type + created_at = item.get('created_at', '') + if created_at and isinstance(created_at, datetime): + created_at = self.format_datetime(created_at, calendar_type) + elif created_at and isinstance(created_at, str): + try: + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + created_at = self.format_datetime(dt, calendar_type) + except: + pass + + excel_data.append({ + t('row_number'): i, + t('first_name'): item.get('first_name', ''), + t('last_name'): item.get('last_name', ''), + t('email'): item.get('email', ''), + t('registration_date'): created_at, + t('referral_code'): item.get('referral_code', ''), + t('status'): t('active') if item.get('is_active', False) else t('inactive') + }) + + return excel_data + + def _format_data_for_template( + self, + data: Dict[str, Any], + calendar_type: CalendarType, + translator + ) -> Dict[str, Any]: + """Format data for template rendering""" + # Format items + items = data.get('items', []) + formatted_items = [] + + for item in items: + formatted_item = item.copy() + if item.get('created_at'): + if isinstance(item['created_at'], datetime): + formatted_item['created_at'] = self.format_datetime(item['created_at'], calendar_type) + elif isinstance(item['created_at'], str): + try: + dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')) + formatted_item['created_at'] = self.format_datetime(dt, calendar_type) + except: + pass + formatted_items.append(formatted_item) + + # Format current date + now = datetime.now() + formatted_now = self.format_datetime(now, calendar_type) + + # Prepare template data with translations + template_data = { + 'items': formatted_items, + 'total_count': data.get('total_count', 0), + 'report_date': formatted_now.split(' ')[0] if ' ' in formatted_now else formatted_now, + 'report_time': formatted_now.split(' ')[1] if ' ' in formatted_now else '', + 'selected_only': data.get('selected_only', False), + 'stats': data.get('stats', {}), + 'filters': self._format_filters(data.get('filters', []), translator), + 'calendar_type': calendar_type, + 'locale': translator.locale, + 't': translator, # Pass translator to template + } + + return template_data + + def _format_filters(self, query_info, locale: str, calendar_type: CalendarType = "jalali") -> List[str]: + """Format query filters for display in PDF""" + formatted_filters = [] + translator = self.get_translator(locale) + + # Add search filter + if query_info.search and query_info.search.strip(): + search_fields = ', '.join(query_info.search_fields) if query_info.search_fields else translator.t('allFields') + formatted_filters.append(f"{translator.t('search')}: '{query_info.search}' {translator.t('in')} {search_fields}") + + # Add column filters + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == "referred_by_user_id": + continue # Skip internal filter + + # Get translated column name + column_name = self._get_column_translation(filter_item.property, translator) + operator_text = self._get_operator_translation(filter_item.operator, translator) + + # Format value based on column type and calendar + formatted_value = self._format_filter_value(filter_item.property, filter_item.value, calendar_type, translator) + + formatted_filters.append(f"{column_name} {operator_text} '{formatted_value}'") + + return formatted_filters + + def _get_operator_translation(self, op: str, translator) -> str: + """Convert operator to translated text""" + operator_map = { + '=': translator.t('equals'), + '>': translator.t('greater_than'), + '>=': translator.t('greater_equal'), + '<': translator.t('less_than'), + '<=': translator.t('less_equal'), + '!=': translator.t('not_equals'), + '*': translator.t('contains'), + '*?': translator.t('starts_with'), + '?*': translator.t('ends_with'), + 'in': translator.t('in_list') + } + + operator_text = operator_map.get(op, op) + return operator_text + + def _get_column_translation(self, property_name: str, translator) -> str: + """Get translated column name""" + column_map = { + 'first_name': translator.t('firstName'), + 'last_name': translator.t('lastName'), + 'email': translator.t('email'), + 'created_at': translator.t('registrationDate'), + 'referral_code': translator.t('referralCode'), + 'is_active': translator.t('status'), + } + return column_map.get(property_name, property_name) + + def _format_filter_value(self, property_name: str, value: Any, calendar_type: CalendarType, translator) -> str: + """Format filter value based on column type and calendar""" + # Handle date fields + if property_name == 'created_at': + try: + if isinstance(value, str): + # Try to parse ISO format + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + elif isinstance(value, datetime): + dt = value + else: + return str(value) + + # Format based on calendar type - only date, no time + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + return formatted_date['date_only'] # Only show date, not time + except: + return str(value) + + # Handle boolean fields + elif property_name == 'is_active': + if isinstance(value, bool): + return translator.t('active') if value else translator.t('inactive') + elif str(value).lower() in ['true', '1', 'yes']: + return translator.t('active') + elif str(value).lower() in ['false', '0', 'no']: + return translator.t('inactive') + else: + return str(value) + + # Default: return as string + return str(value) + + def _get_referral_data(self, db, user_id: int, query_info, selected_indices: List[int] | None = None) -> tuple[List[Dict[str, Any]], int]: + """Get referral data from database""" + from adapters.db.repositories.user_repo import UserRepository + from adapters.api.v1.schemas import FilterItem + from sqlalchemy.orm import Session + from adapters.db.models.user import User + + repo = UserRepository(db) + + # Add filter for referrals only (users with referred_by_user_id = current user) + referral_filter = FilterItem( + property="referred_by_user_id", + operator="=", + value=user_id + ) + + # Create a mutable copy of query_info.filters + current_filters = list(query_info.filters) if query_info.filters else [] + current_filters.append(referral_filter) + + # For export, we need to get all data without take limit + # Use the repository's direct query method + try: + # Get all referrals for the user without pagination + query = db.query(User).filter(User.referred_by_user_id == user_id) + + # Apply search if provided + if query_info.search and query_info.search.strip(): + search_term = f"%{query_info.search}%" + if query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(User, field): + search_conditions.append(getattr(User, field).ilike(search_term)) + if search_conditions: + from sqlalchemy import or_ + query = query.filter(or_(*search_conditions)) + else: + # Search in common fields + query = query.filter( + (User.first_name.ilike(search_term)) | + (User.last_name.ilike(search_term)) | + (User.email.ilike(search_term)) + ) + + # Apply additional filters + for filter_item in current_filters: + if filter_item.property == "referred_by_user_id": + continue # Already applied + + if hasattr(User, filter_item.property): + field = getattr(User, filter_item.property) + if filter_item.operator == "=": + query = query.filter(field == filter_item.value) + elif filter_item.operator == "!=": + query = query.filter(field != filter_item.value) + elif filter_item.operator == ">": + query = query.filter(field > filter_item.value) + elif filter_item.operator == ">=": + query = query.filter(field >= filter_item.value) + elif filter_item.operator == "<": + query = query.filter(field < filter_item.value) + elif filter_item.operator == "<=": + query = query.filter(field <= filter_item.value) + elif filter_item.operator == "*": # contains + query = query.filter(field.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": # starts with + query = query.filter(field.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": # ends with + query = query.filter(field.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "in": + query = query.filter(field.in_(filter_item.value)) + + # Apply sorting + if query_info.sort_by and hasattr(User, query_info.sort_by): + sort_field = getattr(User, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_field.desc()) + else: + query = query.order_by(sort_field.asc()) + else: + # Default sort by created_at desc + query = query.order_by(User.created_at.desc()) + + # Execute query + referrals = query.all() + total = len(referrals) + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + # Apply selected indices filter if provided + if selected_indices is not None: + filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)] + return filtered_referrals, len(filtered_referrals) + + return referral_dicts, total + + except Exception as e: + print(f"Error in _get_referral_data: {e}") + # Fallback to repository method with max take + data_query_info = query_info.__class__( + sort_by=query_info.sort_by, + sort_desc=query_info.sort_desc, + search=query_info.search, + search_fields=query_info.search_fields, + filters=current_filters, + take=1000, + skip=0, + ) + + referrals, total = repo.query_with_filters(data_query_info) + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + if selected_indices is not None: + filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)] + return filtered_referrals, len(filtered_referrals) + + return referral_dicts, total + + def generate_pdf_content( + self, + db, + user_id: int, + query_info, + selected_indices: List[int] | None = None, + stats: Dict[str, Any] | None = None, + calendar_type: CalendarType = "jalali", + locale: str = "fa", + common_data: Dict[str, Any] = None + ) -> bytes: + """Generate PDF content using the new signature""" + # Get referral data + referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices) + + # Format datetime fields for display in PDF + for item in referrals_data: + if 'created_at' in item and item['created_at']: + if isinstance(item['created_at'], datetime): + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(item['created_at'], calendar_type) + item['formatted_created_at'] = formatted_date['formatted'] + else: + try: + dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')) + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + item['formatted_created_at'] = formatted_date['formatted'] + except: + item['formatted_created_at'] = str(item['created_at']) + else: + item['formatted_created_at'] = '-' + + # Prepare context for template + from app.core.calendar import CalendarConverter + current_time = datetime.now() + formatted_current_time = CalendarConverter.format_datetime(current_time, calendar_type) + + context = { + 'items': referrals_data, + 'total_count': total_count, + 'stats': stats, + 'filters': self._format_filters(query_info, locale, calendar_type), + 'report_date': formatted_current_time['date_only'], + 'report_time': formatted_current_time['time_only'], + 'locale': locale, + 'selected_only': selected_indices is not None and len(selected_indices) > 0, + } + + # Include common data if provided + if common_data: + context.update(common_data) + + # Get translator + t = self.get_translator(locale) + context['t'] = t.t # Pass the t method instead of the object + + # Render template + html_content = self.render_template(self.template_name, context) + + # Generate PDF from HTML + from weasyprint import HTML + from pathlib import Path + pdf_file = HTML(string=html_content, base_url=str(Path(__file__).parent / "templates")).write_pdf(font_config=self.font_config) + return pdf_file + + def generate_excel_content( + self, + db, + user_id: int, + query_info, + selected_indices: List[int] | None = None, + calendar_type: CalendarType = "jalali", + locale: str = "fa", + common_data: Dict[str, Any] = None + ) -> List[Dict[str, Any]]: + """Generate Excel content using the new signature""" + # Get referral data + referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices) + + # Format data for Excel with calendar support + excel_data = [] + t = self.get_translator(locale) + + for i, item in enumerate(referrals_data, 1): + # Format created_at based on calendar type + created_at = item.get('created_at', '') + if created_at and isinstance(created_at, datetime): + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(created_at, calendar_type) + created_at = formatted_date['formatted'] + elif created_at and isinstance(created_at, str): + try: + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + created_at = formatted_date['formatted'] + except: + pass + + excel_data.append({ + t.t('rowNumber'): i, + t.t('firstName'): item.get('first_name', ''), + t.t('lastName'): item.get('last_name', ''), + t.t('email'): item.get('email', ''), + t.t('registrationDate'): created_at, + t.t('referralCode'): item.get('referral_code', ''), + t.t('status'): t.t('active') if item.get('is_active', False) else t.t('inactive') + }) + + return excel_data diff --git a/hesabixAPI/build/lib/app/services/person_service.py b/hesabixAPI/build/lib/app/services/person_service.py new file mode 100644 index 0000000..2c8615a --- /dev/null +++ b/hesabixAPI/build/lib/app/services/person_service.py @@ -0,0 +1,516 @@ +from typing import List, Optional, Dict, Any +import json +from sqlalchemy.exc import IntegrityError +from app.core.responses import ApiError +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, func +from adapters.db.models.person import Person, PersonBankAccount, PersonType +from adapters.api.v1.schema_models.person import ( + PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest +) +from app.core.responses import success_response + + +def create_person(db: Session, business_id: int, person_data: PersonCreateRequest) -> Dict[str, Any]: + """ایجاد شخص جدید""" + # محاسبه/اعتبارسنجی کد یکتا + code: Optional[int] = getattr(person_data, 'code', None) + if code is not None: + exists = db.query(Person).filter( + and_(Person.business_id == business_id, Person.code == code) + ).first() + if exists: + raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400) + else: + # تولید خودکار کد: بیشینه فعلی + 1 (نسبت به همان کسب و کار) + max_code = db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar() + code = (max_code or 0) + 1 + + # آماده‌سازی person_types (چندانتخابی) و سازگاری person_type تکی + types_list: List[str] = [] + if getattr(person_data, 'person_types', None): + types_list = [t.value if hasattr(t, 'value') else str(t) for t in person_data.person_types] # type: ignore[attr-defined] + elif getattr(person_data, 'person_type', None): + t = person_data.person_type + types_list = [t.value if hasattr(t, 'value') else str(t)] + + # نوع تکی برای استفاده‌های بعدی (قبل از هر استفاده تعریف شود) + incoming_single_type = getattr(person_data, 'person_type', None) + + # اعتبارسنجی سهام برای سهامدار + 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 مدل + 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, + # ذخیره مقدار 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, + national_id=person_data.national_id, + registration_number=person_data.registration_number, + economic_id=person_data.economic_id, + country=person_data.country, + province=person_data.province, + city=person_data.city, + address=person_data.address, + postal_code=person_data.postal_code, + phone=person_data.phone, + mobile=person_data.mobile, + 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) + db.flush() # برای دریافت ID + + # ایجاد حساب‌های بانکی + if person_data.bank_accounts: + for bank_account_data in person_data.bank_accounts: + bank_account = PersonBankAccount( + person_id=person.id, + bank_name=bank_account_data.bank_name, + account_number=bank_account_data.account_number, + card_number=bank_account_data.card_number, + sheba_number=bank_account_data.sheba_number, + ) + db.add(bank_account) + + try: + db.commit() + except IntegrityError: + db.rollback() + raise ApiError("DUPLICATE_PERSON_CODE", "کد شخص تکراری است", http_status=400) + db.refresh(person) + + return success_response( + message="شخص با موفقیت ایجاد شد", + data=_person_to_dict(person) + ) + + +def get_person_by_id(db: Session, person_id: int, business_id: int) -> Optional[Dict[str, Any]]: + """دریافت شخص بر اساس شناسه""" + person = db.query(Person).filter( + and_(Person.id == person_id, Person.business_id == business_id) + ).first() + + if not person: + return None + + return _person_to_dict(person) + + +def get_persons_by_business( + db: Session, + business_id: int, + query_info: Dict[str, Any] +) -> Dict[str, Any]: + """دریافت لیست اشخاص با جستجو و فیلتر""" + query = db.query(Person).filter(Person.business_id == business_id) + + # اعمال جستجو + if query_info.get('search') and query_info.get('search_fields'): + search_term = f"%{query_info['search']}%" + search_conditions = [] + + for field in query_info['search_fields']: + if field == 'code': + # تبدیل به رشته برای جستجو مانند LIKE + try: + code_int = int(query_info['search']) # type: ignore[arg-type] + search_conditions.append(Person.code == code_int) + except Exception: + pass + if field == 'alias_name': + search_conditions.append(Person.alias_name.ilike(search_term)) + elif field == 'first_name': + search_conditions.append(Person.first_name.ilike(search_term)) + elif field == 'last_name': + search_conditions.append(Person.last_name.ilike(search_term)) + elif field == 'company_name': + search_conditions.append(Person.company_name.ilike(search_term)) + elif field == 'mobile': + search_conditions.append(Person.mobile.ilike(search_term)) + elif field == 'email': + search_conditions.append(Person.email.ilike(search_term)) + elif field == 'national_id': + search_conditions.append(Person.national_id.ilike(search_term)) + + if search_conditions: + query = query.filter(or_(*search_conditions)) + + # اعمال فیلترها + if query_info.get('filters'): + for filter_item in query_info['filters']: + # پشتیبانی از هر دو حالت: دیکشنری یا شیء Pydantic + if isinstance(filter_item, dict): + field = filter_item.get('property') + operator = filter_item.get('operator') + value = filter_item.get('value') + else: + field = getattr(filter_item, 'property', None) + operator = getattr(filter_item, 'operator', None) + value = getattr(filter_item, 'value', None) + + if not field or not operator: + continue + + # کد + if field == 'code': + if operator == '=': + query = query.filter(Person.code == value) + elif operator == 'in' and isinstance(value, list): + query = query.filter(Person.code.in_(value)) + continue + + # نوع شخص تک‌انتخابی + if field == 'person_type': + if operator == '=': + query = query.filter(Person.person_type == value) + elif operator == 'in' and isinstance(value, list): + query = query.filter(Person.person_type.in_(value)) + continue + + # انواع شخص چندانتخابی (رشته JSON) + if field == 'person_types': + if operator == '=' and isinstance(value, str): + query = query.filter(Person.person_types.ilike(f'%"{value}"%')) + elif operator == 'in' and isinstance(value, list): + sub_filters = [Person.person_types.ilike(f'%"{v}"%') for v in value] + if sub_filters: + query = query.filter(or_(*sub_filters)) + continue + + # فیلترهای متنی عمومی (حمایت از عملگرهای contains/startsWith/endsWith) + def apply_text_filter(column): + nonlocal query + if operator == '=': + query = query.filter(column == value) + elif operator == 'like' or operator == '*': + query = query.filter(column.ilike(f"%{value}%")) + elif operator == '*?': # starts with + query = query.filter(column.ilike(f"{value}%")) + elif operator == '?*': # ends with + query = query.filter(column.ilike(f"%{value}")) + + if field == 'country': + apply_text_filter(Person.country) + continue + + if field == 'province': + apply_text_filter(Person.province) + continue + + if field == 'alias_name': + apply_text_filter(Person.alias_name) + continue + + if field == 'first_name': + apply_text_filter(Person.first_name) + continue + + if field == 'last_name': + apply_text_filter(Person.last_name) + continue + + if field == 'company_name': + apply_text_filter(Person.company_name) + continue + + if field == 'mobile': + apply_text_filter(Person.mobile) + continue + + if field == 'email': + apply_text_filter(Person.email) + continue + + if field == 'national_id': + apply_text_filter(Person.national_id) + continue + + if field == 'registration_number': + apply_text_filter(Person.registration_number) + continue + + if field == 'economic_id': + apply_text_filter(Person.economic_id) + continue + + if field == 'city': + apply_text_filter(Person.city) + continue + + if field == 'address': + apply_text_filter(Person.address) + continue + + # شمارش کل رکوردها + total = query.count() + + # اعمال مرتب‌سازی + sort_by = query_info.get('sort_by', 'created_at') + sort_desc = query_info.get('sort_desc', True) + + if sort_by == 'code': + query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc()) + elif sort_by == 'alias_name': + query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc()) + elif sort_by == 'first_name': + query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc()) + elif sort_by == 'last_name': + query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc()) + elif sort_by == 'person_type': + query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc()) + elif sort_by == 'created_at': + query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc()) + elif sort_by == 'updated_at': + query = query.order_by(Person.updated_at.desc() if sort_desc else Person.updated_at.asc()) + else: + query = query.order_by(Person.created_at.desc()) + + # اعمال صفحه‌بندی + skip = query_info.get('skip', 0) + take = query_info.get('take', 20) + + persons = query.offset(skip).limit(take).all() + + # تبدیل به دیکشنری + items = [_person_to_dict(person) for person in persons] + + # محاسبه اطلاعات صفحه‌بندی + total_pages = (total + take - 1) // take + current_page = (skip // take) + 1 + + pagination = { + 'total': total, + 'page': current_page, + 'per_page': take, + 'total_pages': total_pages, + 'has_next': current_page < total_pages, + 'has_prev': current_page > 1 + } + + return { + 'items': items, + 'pagination': pagination, + 'query_info': query_info + } + + +def update_person( + db: Session, + person_id: int, + business_id: int, + person_data: PersonUpdateRequest +) -> Optional[Dict[str, Any]]: + """ویرایش شخص""" + person = db.query(Person).filter( + and_(Person.id == person_id, Person.business_id == business_id) + ).first() + + if not person: + return None + + # به‌روزرسانی فیلدها + update_data = person_data.dict(exclude_unset=True) + + # مدیریت کد یکتا + if 'code' in update_data and update_data['code'] is not None: + desired_code = update_data['code'] + exists = db.query(Person).filter( + and_(Person.business_id == business_id, Person.code == desired_code, Person.id != person_id) + ).first() + if exists: + raise ValueError("کد شخص تکراری است") + person.code = desired_code + + # مدیریت انواع شخص چندگانه + types_list: Optional[List[str]] = None + if 'person_types' in update_data and update_data['person_types'] is not None: + incoming = update_data['person_types'] or [] + types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming] + person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None + # همگام کردن person_type تکی برای سازگاری + if types_list: + # مقدار 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'}: + continue + setattr(person, field, update_data[field]) + + db.commit() + db.refresh(person) + + return success_response( + message="شخص با موفقیت ویرایش شد", + data=_person_to_dict(person) + ) + + +def delete_person(db: Session, person_id: int, business_id: int) -> bool: + """حذف شخص""" + person = db.query(Person).filter( + and_(Person.id == person_id, Person.business_id == business_id) + ).first() + + if not person: + return False + + db.delete(person) + db.commit() + + return True + + +def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]: + """دریافت خلاصه اشخاص""" + # تعداد کل اشخاص + total_persons = db.query(Person).filter(Person.business_id == business_id).count() + + # حذف مفهوم فعال/غیرفعال + active_persons = 0 + inactive_persons = total_persons + + # تعداد بر اساس نوع + by_type = {} + for person_type in PersonType: + count = db.query(Person).filter( + and_(Person.business_id == business_id, Person.person_type == person_type) + ).count() + by_type[person_type.value] = count + + return { + 'total_persons': total_persons, + 'by_type': by_type, + 'active_persons': active_persons, + 'inactive_persons': inactive_persons + } + + +def _person_to_dict(person: Person) -> Dict[str, Any]: + """تبدیل مدل Person به دیکشنری""" + # Parse person_types JSON to list + types_list: List[str] = [] + if person.person_types: + try: + types = json.loads(person.person_types) + if isinstance(types, list): + types_list = [str(x) for x in types] + except Exception: + types_list = [] + + return { + 'id': person.id, + 'business_id': person.business_id, + 'code': person.code, + 'alias_name': person.alias_name, + 'first_name': person.first_name, + 'last_name': person.last_name, + 'person_type': person.person_type.value, + 'person_types': types_list, + 'company_name': person.company_name, + 'payment_id': person.payment_id, + '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, + 'country': person.country, + 'province': person.province, + 'city': person.city, + 'address': person.address, + 'postal_code': person.postal_code, + 'phone': person.phone, + 'mobile': person.mobile, + 'fax': person.fax, + 'email': person.email, + 'website': person.website, + 'created_at': person.created_at.isoformat(), + 'updated_at': person.updated_at.isoformat(), + 'bank_accounts': [ + { + 'id': ba.id, + 'person_id': ba.person_id, + 'bank_name': ba.bank_name, + 'account_number': ba.account_number, + 'card_number': ba.card_number, + 'sheba_number': ba.sheba_number, + 'created_at': ba.created_at.isoformat(), + 'updated_at': ba.updated_at.isoformat(), + } + for ba in person.bank_accounts + ] + } diff --git a/hesabixAPI/build/lib/app/services/price_list_service.py b/hesabixAPI/build/lib/app/services/price_list_service.py new file mode 100644 index 0000000..4f19668 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/price_list_service.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from app.core.responses import ApiError +from adapters.db.repositories.price_list_repository import PriceListRepository, PriceItemRepository +from adapters.db.models.price_list import PriceList, PriceItem +from adapters.api.v1.schema_models.price_list import PriceListCreateRequest, PriceListUpdateRequest, PriceItemUpsertRequest +from adapters.db.models.product import Product + + +def create_price_list(db: Session, business_id: int, payload: PriceListCreateRequest) -> Dict[str, Any]: + repo = PriceListRepository(db) + # یکتایی نام در هر کسب‌وکار + dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip())).first() + if dup: + raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400) + obj = repo.create( + business_id=business_id, + name=payload.name.strip(), + is_active=payload.is_active, + ) + return {"message": "لیست قیمت ایجاد شد", "data": _pl_to_dict(obj)} + + +def list_price_lists(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + repo = PriceListRepository(db) + take = int(query.get("take", 20) or 20) + skip = int(query.get("skip", 0) or 0) + sort_by = query.get("sort_by") + sort_desc = bool(query.get("sort_desc", True)) + search = query.get("search") + return repo.search(business_id=business_id, take=take, skip=skip, sort_by=sort_by, sort_desc=sort_desc, search=search) + + +def get_price_list(db: Session, business_id: int, id: int) -> Optional[Dict[str, Any]]: + obj = db.get(PriceList, id) + if not obj or obj.business_id != business_id: + return None + return _pl_to_dict(obj) + + +def update_price_list(db: Session, business_id: int, id: int, payload: PriceListUpdateRequest) -> Optional[Dict[str, Any]]: + repo = PriceListRepository(db) + obj = db.get(PriceList, id) + if not obj or obj.business_id != business_id: + return None + if payload.name is not None and payload.name.strip() and payload.name.strip() != obj.name: + dup = db.query(PriceList).filter(and_(PriceList.business_id == business_id, PriceList.name == payload.name.strip(), PriceList.id != id)).first() + if dup: + raise ApiError("DUPLICATE_PRICE_LIST_NAME", "نام لیست قیمت تکراری است", http_status=400) + updated = repo.update(id, name=payload.name.strip() if isinstance(payload.name, str) else None, is_active=payload.is_active) + if not updated: + return None + return {"message": "لیست قیمت بروزرسانی شد", "data": _pl_to_dict(updated)} + + +def delete_price_list(db: Session, business_id: int, id: int) -> bool: + repo = PriceListRepository(db) + obj = db.get(PriceList, id) + if not obj or obj.business_id != business_id: + return False + return repo.delete(id) + + +def list_price_items(db: Session, business_id: int, price_list_id: int, take: int = 50, skip: int = 0, product_id: int | None = None, currency_id: int | None = None) -> Dict[str, Any]: + # مالکیت را از روی price_list بررسی می‌کنیم + pl = db.get(PriceList, price_list_id) + if not pl or pl.business_id != business_id: + raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404) + repo = PriceItemRepository(db) + return repo.list_for_price_list(price_list_id=price_list_id, take=take, skip=skip, product_id=product_id, currency_id=currency_id) + + +def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload: PriceItemUpsertRequest) -> Dict[str, Any]: + pl = db.get(PriceList, price_list_id) + if not pl or pl.business_id != business_id: + raise ApiError("NOT_FOUND", "لیست قیمت یافت نشد", http_status=404) + # صحت وجود محصول + pr = db.get(Product, payload.product_id) + if not pr or pr.business_id != business_id: + raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404) + # اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده + if payload.unit_id is not None and payload.unit_id not in [pr.main_unit_id, pr.secondary_unit_id]: + raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400) + + repo = PriceItemRepository(db) + obj = repo.upsert( + price_list_id=price_list_id, + product_id=payload.product_id, + unit_id=payload.unit_id, + currency_id=payload.currency_id, + tier_name=(payload.tier_name.strip() if isinstance(payload.tier_name, str) and payload.tier_name.strip() else 'پیش‌فرض'), + min_qty=payload.min_qty, + price=payload.price, + ) + return {"message": "قیمت ثبت شد", "data": _pi_to_dict(obj)} + + +def delete_price_item(db: Session, business_id: int, id: int) -> bool: + repo = PriceItemRepository(db) + pi = db.get(PriceItem, id) + if not pi: + return False + # بررسی مالکیت از طریق price_list + pl = db.get(PriceList, pi.price_list_id) + if not pl or pl.business_id != business_id: + return False + return repo.delete(id) + + +def _pl_to_dict(obj: PriceList) -> Dict[str, Any]: + return { + "id": obj.id, + "business_id": obj.business_id, + "name": obj.name, + "is_active": obj.is_active, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + +def _pi_to_dict(obj: PriceItem) -> Dict[str, Any]: + return { + "id": obj.id, + "price_list_id": obj.price_list_id, + "product_id": obj.product_id, + "unit_id": obj.unit_id, + "currency_id": obj.currency_id, + "tier_name": obj.tier_name, + "min_qty": obj.min_qty, + "price": obj.price, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + diff --git a/hesabixAPI/build/lib/app/services/product_attribute_service.py b/hesabixAPI/build/lib/app/services/product_attribute_service.py new file mode 100644 index 0000000..06104fd --- /dev/null +++ b/hesabixAPI/build/lib/app/services/product_attribute_service.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from sqlalchemy import and_, func + +from adapters.db.repositories.product_attribute_repository import ProductAttributeRepository +from adapters.db.models.product_attribute import ProductAttribute +from adapters.api.v1.schema_models.product_attribute import ( + ProductAttributeCreateRequest, + ProductAttributeUpdateRequest, +) +from app.core.responses import ApiError + + +def create_attribute(db: Session, business_id: int, payload: ProductAttributeCreateRequest) -> Dict[str, Any]: + repo = ProductAttributeRepository(db) + # جلوگیری از عنوان تکراری در هر کسب‌وکار + dup = db.query(ProductAttribute).filter( + and_(ProductAttribute.business_id == business_id, func.lower(ProductAttribute.title) == func.lower(payload.title.strip())) + ).first() + if dup: + raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400) + + obj = repo.create(business_id=business_id, title=payload.title.strip(), description=payload.description) + return { + "message": "ویژگی با موفقیت ایجاد شد", + "data": _to_dict(obj), + } + + +def list_attributes(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + repo = ProductAttributeRepository(db) + take = int(query.get("take", 20) or 20) + skip = int(query.get("skip", 0) or 0) + sort_by = query.get("sort_by") + sort_desc = bool(query.get("sort_desc", True)) + search = query.get("search") + filters = query.get("filters") + result = repo.search( + business_id=business_id, + take=take, + skip=skip, + sort_by=sort_by, + sort_desc=sort_desc, + search=search, + filters=filters, + ) + return result + + +def get_attribute(db: Session, attribute_id: int, business_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(ProductAttribute, attribute_id) + if not obj or obj.business_id != business_id: + return None + return _to_dict(obj) + + +def update_attribute(db: Session, attribute_id: int, business_id: int, payload: ProductAttributeUpdateRequest) -> Optional[Dict[str, Any]]: + repo = ProductAttributeRepository(db) + # کنترل مالکیت + obj = db.get(ProductAttribute, attribute_id) + if not obj or obj.business_id != business_id: + return None + # بررسی تکراری نبودن عنوان + if payload.title is not None: + title_norm = payload.title.strip() + dup = db.query(ProductAttribute).filter( + and_( + ProductAttribute.business_id == business_id, + func.lower(ProductAttribute.title) == func.lower(title_norm), + ProductAttribute.id != attribute_id, + ) + ).first() + if dup: + raise ApiError("DUPLICATE_ATTRIBUTE_TITLE", "عنوان ویژگی تکراری است", http_status=400) + updated = repo.update( + attribute_id=attribute_id, + title=payload.title.strip() if isinstance(payload.title, str) else None, + description=payload.description, + ) + if not updated: + return None + return { + "message": "ویژگی با موفقیت ویرایش شد", + "data": _to_dict(updated), + } + + +def delete_attribute(db: Session, attribute_id: int, business_id: int) -> bool: + repo = ProductAttributeRepository(db) + obj = db.get(ProductAttribute, attribute_id) + if not obj or obj.business_id != business_id: + return False + return repo.delete(attribute_id=attribute_id) + + +def _to_dict(obj: ProductAttribute) -> Dict[str, Any]: + return { + "id": obj.id, + "business_id": obj.business_id, + "title": obj.title, + "description": obj.description, + "is_active": obj.is_active, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + diff --git a/hesabixAPI/build/lib/app/services/product_service.py b/hesabixAPI/build/lib/app/services/product_service.py new file mode 100644 index 0000000..71f7666 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/product_service.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import select, and_, func +from decimal import Decimal + +from app.core.responses import ApiError +from adapters.db.models.product import Product, ProductItemType +from adapters.db.models.product_attribute import ProductAttribute +from adapters.db.models.product_attribute_link import ProductAttributeLink +from adapters.db.repositories.product_repository import ProductRepository +from adapters.api.v1.schema_models.product import ProductCreateRequest, ProductUpdateRequest + + +def _generate_auto_code(db: Session, business_id: int) -> str: + codes = [ + r[0] for r in db.execute( + select(Product.code).where(Product.business_id == business_id) + ).all() + ] + max_num = 0 + for c in codes: + if c and c.isdigit(): + try: + max_num = max(max_num, int(c)) + except ValueError: + continue + if max_num > 0: + return str(max_num + 1) + max_id = db.execute(select(func.max(Product.id))).scalar() or 0 + return f"P{max_id + 1:06d}" + + +def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None: + if getattr(payload, 'is_sales_taxable', False) and getattr(payload, 'sales_tax_rate', None) is None: + pass + if getattr(payload, 'is_purchase_taxable', False) and getattr(payload, 'purchase_tax_rate', None) is None: + pass + + +def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None: + if secondary_unit_id and not factor: + raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400) + + +def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute_ids: Optional[List[int]]) -> None: + if attribute_ids is None: + return + db.query(ProductAttributeLink).filter(ProductAttributeLink.product_id == product_id).delete() + if not attribute_ids: + db.commit() + return + valid_ids = [ + a.id for a in db.query(ProductAttribute.id, ProductAttribute.business_id) + .filter(ProductAttribute.id.in_(attribute_ids), ProductAttribute.business_id == business_id) + .all() + ] + for aid in valid_ids: + db.add(ProductAttributeLink(product_id=product_id, attribute_id=aid)) + db.commit() + + +def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]: + repo = ProductRepository(db) + _validate_tax(payload) + _validate_units(payload.main_unit_id, payload.secondary_unit_id, payload.unit_conversion_factor) + + code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None + if code: + dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == code)).first() + if dup: + raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400) + else: + code = _generate_auto_code(db, business_id) + + obj = repo.create( + business_id=business_id, + item_type=payload.item_type, + code=code, + name=payload.name.strip(), + description=payload.description, + category_id=payload.category_id, + main_unit_id=payload.main_unit_id, + secondary_unit_id=payload.secondary_unit_id, + unit_conversion_factor=payload.unit_conversion_factor, + base_sales_price=payload.base_sales_price, + base_sales_note=payload.base_sales_note, + base_purchase_price=payload.base_purchase_price, + base_purchase_note=payload.base_purchase_note, + track_inventory=payload.track_inventory, + reorder_point=payload.reorder_point, + min_order_qty=payload.min_order_qty, + lead_time_days=payload.lead_time_days, + is_sales_taxable=payload.is_sales_taxable, + is_purchase_taxable=payload.is_purchase_taxable, + sales_tax_rate=payload.sales_tax_rate, + purchase_tax_rate=payload.purchase_tax_rate, + tax_type_id=payload.tax_type_id, + tax_code=payload.tax_code, + tax_unit_id=payload.tax_unit_id, + ) + + _upsert_attributes(db, obj.id, business_id, payload.attribute_ids) + + return {"message": "آیتم با موفقیت ایجاد شد", "data": _to_dict(obj)} + + +def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + repo = ProductRepository(db) + take = int(query.get("take", 20) or 20) + skip = int(query.get("skip", 0) or 0) + sort_by = query.get("sort_by") + sort_desc = bool(query.get("sort_desc", True)) + search = query.get("search") + filters = query.get("filters") + return repo.search( + business_id=business_id, + take=take, + skip=skip, + sort_by=sort_by, + sort_desc=sort_desc, + search=search, + filters=filters, + ) + + +def get_product(db: Session, product_id: int, business_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(Product, product_id) + if not obj or obj.business_id != business_id: + return None + return _to_dict(obj) + + +def update_product(db: Session, product_id: int, business_id: int, payload: ProductUpdateRequest) -> Optional[Dict[str, Any]]: + repo = ProductRepository(db) + obj = db.get(Product, product_id) + if not obj or obj.business_id != business_id: + return None + + if payload.code is not None and payload.code.strip() and payload.code.strip() != obj.code: + dup = db.query(Product).filter(and_(Product.business_id == business_id, Product.code == payload.code.strip(), Product.id != product_id)).first() + if dup: + raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400) + + _validate_tax(payload) + _validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id, + payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id, + payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor) + + updated = repo.update( + product_id, + item_type=payload.item_type, + code=payload.code.strip() if isinstance(payload.code, str) else None, + name=payload.name.strip() if isinstance(payload.name, str) else None, + description=payload.description, + category_id=payload.category_id, + main_unit_id=payload.main_unit_id, + secondary_unit_id=payload.secondary_unit_id, + unit_conversion_factor=payload.unit_conversion_factor, + base_sales_price=payload.base_sales_price, + base_sales_note=payload.base_sales_note, + base_purchase_price=payload.base_purchase_price, + base_purchase_note=payload.base_purchase_note, + track_inventory=payload.track_inventory if payload.track_inventory is not None else None, + reorder_point=payload.reorder_point, + min_order_qty=payload.min_order_qty, + lead_time_days=payload.lead_time_days, + is_sales_taxable=payload.is_sales_taxable, + is_purchase_taxable=payload.is_purchase_taxable, + sales_tax_rate=payload.sales_tax_rate, + purchase_tax_rate=payload.purchase_tax_rate, + tax_type_id=payload.tax_type_id, + tax_code=payload.tax_code, + tax_unit_id=payload.tax_unit_id, + ) + if not updated: + return None + + _upsert_attributes(db, product_id, business_id, payload.attribute_ids) + return {"message": "آیتم با موفقیت ویرایش شد", "data": _to_dict(updated)} + + +def delete_product(db: Session, product_id: int, business_id: int) -> bool: + repo = ProductRepository(db) + obj = db.get(Product, product_id) + if not obj or obj.business_id != business_id: + return False + return repo.delete(product_id) + + +def _to_dict(obj: Product) -> Dict[str, Any]: + return { + "id": obj.id, + "business_id": obj.business_id, + "item_type": obj.item_type.value if hasattr(obj.item_type, 'value') else str(obj.item_type), + "code": obj.code, + "name": obj.name, + "description": obj.description, + "category_id": obj.category_id, + "main_unit_id": obj.main_unit_id, + "secondary_unit_id": obj.secondary_unit_id, + "unit_conversion_factor": obj.unit_conversion_factor, + "base_sales_price": obj.base_sales_price, + "base_sales_note": obj.base_sales_note, + "base_purchase_price": obj.base_purchase_price, + "base_purchase_note": obj.base_purchase_note, + "track_inventory": obj.track_inventory, + "reorder_point": obj.reorder_point, + "min_order_qty": obj.min_order_qty, + "lead_time_days": obj.lead_time_days, + "is_sales_taxable": obj.is_sales_taxable, + "is_purchase_taxable": obj.is_purchase_taxable, + "sales_tax_rate": obj.sales_tax_rate, + "purchase_tax_rate": obj.purchase_tax_rate, + "tax_type_id": obj.tax_type_id, + "tax_code": obj.tax_code, + "tax_unit_id": obj.tax_unit_id, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + diff --git a/hesabixAPI/build/lib/app/services/query_service.py b/hesabixAPI/build/lib/app/services/query_service.py new file mode 100644 index 0000000..59c4bb2 --- /dev/null +++ b/hesabixAPI/build/lib/app/services/query_service.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Any, Type, TypeVar +from sqlalchemy import select, func, or_, and_ +from sqlalchemy.orm import Session +from sqlalchemy.sql import Select + +from adapters.api.v1.schemas import QueryInfo, FilterItem + +T = TypeVar('T') + + +class QueryBuilder: + """سرویس برای ساخت کوئری‌های دینامیک بر اساس QueryInfo""" + + def __init__(self, model_class: Type[T], db_session: Session) -> None: + self.model_class = model_class + self.db = db_session + self.stmt: Select = select(model_class) + + def apply_filters(self, filters: list[FilterItem] | None) -> 'QueryBuilder': + """اعمال فیلترها بر روی کوئری""" + if not filters: + return self + + conditions = [] + for filter_item in filters: + try: + column = getattr(self.model_class, filter_item.property) + condition = self._build_condition(column, filter_item.operator, filter_item.value) + conditions.append(condition) + except AttributeError: + # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر + continue + + if conditions: + self.stmt = self.stmt.where(and_(*conditions)) + + return self + + def apply_search(self, search: str | None, search_fields: list[str] | None) -> 'QueryBuilder': + """اعمال جستجو بر روی فیلدهای مشخص شده""" + if not search or not search_fields: + return self + + conditions = [] + for field in search_fields: + try: + column = getattr(self.model_class, field) + conditions.append(column.ilike(f"%{search}%")) + except AttributeError: + # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر + continue + + if conditions: + self.stmt = self.stmt.where(or_(*conditions)) + + return self + + def apply_sorting(self, sort_by: str | None, sort_desc: bool) -> 'QueryBuilder': + """اعمال مرتب‌سازی بر روی کوئری""" + if not sort_by: + return self + + try: + column = getattr(self.model_class, sort_by) + if sort_desc: + self.stmt = self.stmt.order_by(column.desc()) + else: + self.stmt = self.stmt.order_by(column.asc()) + except AttributeError: + # اگر فیلد وجود نداشته باشد، مرتب‌سازی را نادیده بگیر + pass + + return self + + def apply_pagination(self, skip: int, take: int) -> 'QueryBuilder': + """اعمال صفحه‌بندی بر روی کوئری""" + self.stmt = self.stmt.offset(skip).limit(take) + return self + + def apply_query_info(self, query_info: QueryInfo) -> 'QueryBuilder': + """اعمال تمام تنظیمات QueryInfo بر روی کوئری""" + return (self + .apply_filters(query_info.filters) + .apply_search(query_info.search, query_info.search_fields) + .apply_sorting(query_info.sort_by, query_info.sort_desc) + .apply_pagination(query_info.skip, query_info.take)) + + def _build_condition(self, column, operator: str, value: Any): + """ساخت شرط بر اساس عملگر و مقدار""" + if operator == "=": + return column == value + elif operator == ">": + return column > value + elif operator == ">=": + return column >= value + elif operator == "<": + return column < value + elif operator == "<=": + return column <= value + elif operator == "!=": + return column != value + elif operator == "*": # contains + return column.ilike(f"%{value}%") + elif operator == "?*": # ends with + return column.ilike(f"%{value}") + elif operator == "*?": # starts with + return column.ilike(f"{value}%") + elif operator == "in": + if not isinstance(value, list): + raise ValueError("برای عملگر 'in' مقدار باید آرایه باشد") + return column.in_(value) + else: + raise ValueError(f"عملگر پشتیبانی نشده: {operator}") + + def get_count_query(self) -> Select: + """دریافت کوئری شمارش (بدون pagination)""" + return select(func.count()).select_from(self.stmt.subquery()) + + def execute(self) -> list[T]: + """اجرای کوئری و بازگرداندن نتایج""" + return list(self.db.execute(self.stmt).scalars().all()) + + def execute_count(self) -> int: + """اجرای کوئری شمارش""" + count_stmt = self.get_count_query() + return int(self.db.execute(count_stmt).scalar() or 0) + + +class QueryService: + """سرویس اصلی برای مدیریت کوئری‌های فیلتر شده""" + + @staticmethod + def query_with_filters( + model_class: Type[T], + db: Session, + query_info: QueryInfo + ) -> tuple[list[T], int]: + """ + اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل + + Args: + model_class: کلاس مدل SQLAlchemy + db: جلسه پایگاه داده + query_info: اطلاعات کوئری شامل فیلترها، مرتب‌سازی و صفحه‌بندی + + Returns: + tuple: (لیست نتایج, تعداد کل رکوردها) + """ + # کوئری شمارش (بدون pagination) + count_builder = QueryBuilder(model_class, db) + count_builder.apply_filters(query_info.filters) + count_builder.apply_search(query_info.search, query_info.search_fields) + total_count = count_builder.execute_count() + + # کوئری داده‌ها (با pagination) + data_builder = QueryBuilder(model_class, db) + data_builder.apply_query_info(query_info) + results = data_builder.execute() + + return results, total_count diff --git a/hesabixAPI/build/lib/migrations/env.py b/hesabixAPI/build/lib/migrations/env.py new file mode 100644 index 0000000..ff4638e --- /dev/null +++ b/hesabixAPI/build/lib/migrations/env.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +from adapters.db.session import Base +from app.core.settings import get_settings +import adapters.db.models # noqa: F401 # Import models to register metadata + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.mysql_dsn) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + # Ensure alembic_version.version_num can hold long revision strings + try: + res = connection.exec_driver_sql( + "SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.columns " + "WHERE table_name='alembic_version' AND column_name='version_num';" + ) + row = res.fetchone() + if row is not None: + length = row[0] or 0 + if length < 255: + connection.exec_driver_sql( + "ALTER TABLE alembic_version MODIFY COLUMN version_num VARCHAR(255) NOT NULL;" + ) + except Exception: + # Best-effort; ignore if table doesn't exist yet + pass + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py new file mode 100644 index 0000000..739c141 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000003_add_business_table.py @@ -0,0 +1,48 @@ +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. +revision = "20250117_000003" +down_revision = "20250916_000002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + 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 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: + # Drop indexes + op.drop_index('ix_businesses_owner_id', table_name='businesses') + op.drop_index('ix_businesses_name', table_name='businesses') + + # Drop table + op.drop_table('businesses') diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py b/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py new file mode 100644 index 0000000..89862be --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000004_add_business_contact_fields.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20250117_000004" +down_revision = "20250117_000003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add new contact and identification fields to businesses table + op.add_column('businesses', sa.Column('address', sa.Text(), nullable=True)) + op.add_column('businesses', sa.Column('phone', sa.String(length=20), nullable=True)) + op.add_column('businesses', sa.Column('mobile', sa.String(length=20), nullable=True)) + op.add_column('businesses', sa.Column('national_id', sa.String(length=20), nullable=True)) + op.add_column('businesses', sa.Column('registration_number', sa.String(length=50), nullable=True)) + op.add_column('businesses', sa.Column('economic_id', sa.String(length=50), nullable=True)) + + # Create indexes for the new fields + op.create_index('ix_businesses_national_id', 'businesses', ['national_id']) + op.create_index('ix_businesses_registration_number', 'businesses', ['registration_number']) + op.create_index('ix_businesses_economic_id', 'businesses', ['economic_id']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('ix_businesses_economic_id', table_name='businesses') + op.drop_index('ix_businesses_registration_number', table_name='businesses') + op.drop_index('ix_businesses_national_id', table_name='businesses') + + # Drop columns + op.drop_column('businesses', 'economic_id') + op.drop_column('businesses', 'registration_number') + op.drop_column('businesses', 'national_id') + op.drop_column('businesses', 'mobile') + op.drop_column('businesses', 'phone') + op.drop_column('businesses', 'address') diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py b/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py new file mode 100644 index 0000000..d9bdafc --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000005_add_business_geographic_fields.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "20250117_000005" +down_revision = "20250117_000004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add geographic fields to businesses table + op.add_column('businesses', sa.Column('country', sa.String(length=100), nullable=True)) + op.add_column('businesses', sa.Column('province', sa.String(length=100), nullable=True)) + op.add_column('businesses', sa.Column('city', sa.String(length=100), nullable=True)) + op.add_column('businesses', sa.Column('postal_code', sa.String(length=20), nullable=True)) + + +def downgrade() -> None: + # Drop geographic columns + op.drop_column('businesses', 'postal_code') + op.drop_column('businesses', 'city') + op.drop_column('businesses', 'province') + op.drop_column('businesses', 'country') diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py b/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py new file mode 100644 index 0000000..8ea4e9d --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000006_add_app_permissions_to_users.py @@ -0,0 +1,28 @@ +"""add app permissions to users + +Revision ID: 20250117_000006 +Revises: 20250117_000005 +Create Date: 2025-01-17 00:00:06.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '20250117_000006' +down_revision = '20250117_000005' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('app_permissions', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'app_permissions') + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py new file mode 100644 index 0000000..00a06d9 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000007_create_business_permissions_table.py @@ -0,0 +1,42 @@ +"""create business permissions table + +Revision ID: 20250117_000007 +Revises: 20250117_000006 +Create Date: 2025-01-17 00:00:07.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '20250117_000007' +down_revision = '20250117_000006' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('business_permissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('business_permissions', 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(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_business_permissions_business_id'), 'business_permissions', ['business_id'], unique=False) + op.create_index(op.f('ix_business_permissions_user_id'), 'business_permissions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_business_permissions_user_id'), table_name='business_permissions') + op.drop_index(op.f('ix_business_permissions_business_id'), table_name='business_permissions') + op.drop_table('business_permissions') + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py b/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py new file mode 100644 index 0000000..e04e8a8 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000008_add_email_config_table.py @@ -0,0 +1,45 @@ +"""add_email_config_table + +Revision ID: 20250117_000008 +Revises: 5553f8745c6e +Create Date: 2025-01-17 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250117_000008' +down_revision = '5553f8745c6e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('email_configs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('smtp_host', sa.String(length=255), nullable=False), + sa.Column('smtp_port', sa.Integer(), nullable=False), + sa.Column('smtp_username', sa.String(length=255), nullable=False), + sa.Column('smtp_password', sa.String(length=255), nullable=False), + sa.Column('use_tls', sa.Boolean(), nullable=False), + sa.Column('use_ssl', sa.Boolean(), nullable=False), + sa.Column('from_email', sa.String(length=255), nullable=False), + sa.Column('from_name', sa.String(length=100), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_email_configs_name'), 'email_configs', ['name'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_email_configs_name'), table_name='email_configs') + op.drop_table('email_configs') + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py b/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py new file mode 100644 index 0000000..c604f48 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250117_000009_add_is_default_to_email_config.py @@ -0,0 +1,26 @@ +"""add is_default to email_config + +Revision ID: 20250117_000009 +Revises: 20250117_000008 +Create Date: 2025-01-17 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250117_000009' +down_revision = '20250117_000008' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add is_default column to email_configs table + op.add_column('email_configs', sa.Column('is_default', sa.Boolean(), nullable=False, server_default='0')) + + +def downgrade(): + # Remove is_default column from email_configs table + op.drop_column('email_configs', 'is_default') diff --git a/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py b/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py new file mode 100644 index 0000000..9b0e1e8 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250120_000001_add_persons_tables.py @@ -0,0 +1,82 @@ +"""add_persons_tables + +Revision ID: 20250120_000001 +Revises: 5553f8745c6e +Create Date: 2025-01-20 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '20250120_000001' +down_revision = '5553f8745c6e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + # Create persons table + op.create_table('persons', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب و کار'), + sa.Column('alias_name', sa.String(length=255), nullable=False, comment='نام مستعار (الزامی)'), + sa.Column('first_name', sa.String(length=100), nullable=True, comment='نام'), + sa.Column('last_name', sa.String(length=100), nullable=True, comment='نام خانوادگی'), + sa.Column('person_type', sa.Enum('CUSTOMER', 'MARKETER', 'EMPLOYEE', 'SUPPLIER', 'PARTNER', 'SELLER', name='persontype'), nullable=False, comment='نوع شخص'), + sa.Column('company_name', sa.String(length=255), nullable=True, comment='نام شرکت'), + sa.Column('payment_id', sa.String(length=100), nullable=True, comment='شناسه پرداخت'), + sa.Column('national_id', sa.String(length=20), nullable=True, comment='شناسه ملی'), + sa.Column('registration_number', sa.String(length=50), nullable=True, comment='شماره ثبت'), + sa.Column('economic_id', sa.String(length=50), nullable=True, comment='شناسه اقتصادی'), + sa.Column('country', sa.String(length=100), nullable=True, comment='کشور'), + sa.Column('province', sa.String(length=100), nullable=True, comment='استان'), + sa.Column('city', sa.String(length=100), nullable=True, comment='شهرستان'), + sa.Column('address', sa.Text(), nullable=True, comment='آدرس'), + sa.Column('postal_code', sa.String(length=20), nullable=True, comment='کد پستی'), + sa.Column('phone', sa.String(length=20), nullable=True, comment='تلفن'), + sa.Column('mobile', sa.String(length=20), nullable=True, comment='موبایل'), + sa.Column('fax', sa.String(length=20), nullable=True, comment='فکس'), + sa.Column('email', sa.String(length=255), nullable=True, comment='پست الکترونیکی'), + sa.Column('website', sa.String(length=255), nullable=True, comment='وب‌سایت'), + sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'), + 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') + ) + op.create_index(op.f('ix_persons_business_id'), 'persons', ['business_id'], unique=False) + op.create_index(op.f('ix_persons_alias_name'), 'persons', ['alias_name'], unique=False) + op.create_index(op.f('ix_persons_national_id'), 'persons', ['national_id'], unique=False) + + # Create person_bank_accounts table + op.create_table('person_bank_accounts', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('person_id', sa.Integer(), nullable=False, comment='شناسه شخص'), + sa.Column('bank_name', sa.String(length=255), nullable=False, comment='نام بانک'), + sa.Column('account_number', sa.String(length=50), nullable=True, comment='شماره حساب'), + sa.Column('card_number', sa.String(length=20), nullable=True, comment='شماره کارت'), + sa.Column('sheba_number', sa.String(length=30), nullable=True, comment='شماره شبا'), + sa.Column('is_active', sa.Boolean(), nullable=False, comment='وضعیت فعال بودن'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['person_id'], ['persons.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_person_bank_accounts_person_id'), 'person_bank_accounts', ['person_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_person_bank_accounts_person_id'), table_name='person_bank_accounts') + op.drop_table('person_bank_accounts') + op.drop_index(op.f('ix_persons_national_id'), table_name='persons') + op.drop_index(op.f('ix_persons_alias_name'), table_name='persons') + op.drop_index(op.f('ix_persons_business_id'), table_name='persons') + op.drop_table('persons') + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py b/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py new file mode 100644 index 0000000..9d75a0e --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250120_000002_add_join_permission.py @@ -0,0 +1,30 @@ +"""add join permission + +Revision ID: 20250120_000002 +Revises: 20250120_000001 +Create Date: 2025-01-20 00:00:02.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250120_000002' +down_revision = '20250120_000001' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add join permission support""" + # این migration فقط برای مستندسازی است + # جدول business_permissions قبلاً وجود دارد و JSON field است + # بنابراین نیازی به تغییر schema نیست + pass + + +def downgrade(): + """Remove join permission support""" + # این migration فقط برای مستندسازی است + pass diff --git a/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py b/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py new file mode 100644 index 0000000..63f13dc --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250915_000001_init_auth_tables.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from datetime import datetime + +# revision identifiers, used by Alembic. +revision = "20250915_000001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("mobile", sa.String(length=32), nullable=True), + sa.Column("first_name", sa.String(length=100), nullable=True), + sa.Column("last_name", sa.String(length=100), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + op.create_index("ix_users_mobile", "users", ["mobile"], unique=True) + + op.create_table( + "api_keys", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("key_hash", sa.String(length=128), nullable=False), + sa.Column("key_type", sa.String(length=16), nullable=False), + sa.Column("name", sa.String(length=100), nullable=True), + sa.Column("scopes", sa.String(length=500), nullable=True), + sa.Column("device_id", sa.String(length=100), nullable=True), + sa.Column("user_agent", sa.String(length=255), nullable=True), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True) + op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"], unique=False) + + op.create_table( + "captchas", + sa.Column("id", sa.String(length=40), primary_key=True), + sa.Column("code_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + + op.create_table( + "password_resets", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")), + ) + op.create_index("ix_password_resets_token_hash", "password_resets", ["token_hash"], unique=True) + op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_password_resets_user_id", table_name="password_resets") + op.drop_index("ix_password_resets_token_hash", table_name="password_resets") + op.drop_table("password_resets") + + op.drop_table("captchas") + + op.drop_index("ix_api_keys_user_id", table_name="api_keys") + op.drop_index("ix_api_keys_key_hash", table_name="api_keys") + op.drop_table("api_keys") + + op.drop_index("ix_users_mobile", table_name="users") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py b/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py new file mode 100644 index 0000000..51f5b5b --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250916_000002_add_referral_fields.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column +from sqlalchemy import String, Integer + +# revision identifiers, used by Alembic. +revision = "20250916_000002" +down_revision = "20250915_000001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add columns (referral_code nullable for backfill, then set NOT NULL) + op.add_column("users", sa.Column("referral_code", sa.String(length=32), nullable=True)) + op.add_column("users", sa.Column("referred_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)) + + # Backfill referral_code for existing users with unique random strings + bind = op.get_bind() + users_tbl = sa.table("users", sa.column("id", sa.Integer), sa.column("referral_code", sa.String)) + + # Fetch all user ids + res = bind.execute(sa.text("SELECT id FROM users")) + user_ids = [row[0] for row in res] if res else [] + + # Helper to generate unique codes + import secrets + def gen_code(length: int = 10) -> str: + return secrets.token_urlsafe(8).replace('-', '').replace('_', '')[:length] + + # Ensure uniqueness at DB level by checking existing set + codes = set() + for uid in user_ids: + code = gen_code() + # try to avoid duplicates within the batch + while code in codes: + code = gen_code() + codes.add(code) + bind.execute(sa.text("UPDATE users SET referral_code = :code WHERE id = :id"), {"code": code, "id": uid}) + + # Now make referral_code NOT NULL and unique indexed + op.alter_column("users", "referral_code", existing_type=sa.String(length=32), nullable=False) + op.create_index("ix_users_referral_code", "users", ["referral_code"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_referral_code", table_name="users") + op.drop_column("users", "referred_by_user_id") + op.drop_column("users", "referral_code") + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py new file mode 100644 index 0000000..9420d8d --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250926_000010_add_person_code_and_types.py @@ -0,0 +1,47 @@ +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' +down_revision = '20250916_000002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + # اگر جدول persons وجود ندارد، این مایگریشن را نادیده بگیر + if 'persons' not in inspector.get_table_names(): + return + cols = {c['name'] for c in inspector.get_columns('persons')} + with op.batch_alter_table('persons') as batch_op: + if 'code' not in cols: + batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True)) + 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: + bind = op.get_bind() + inspector = inspect(bind) + if 'persons' not in inspector.get_table_names(): + return + with op.batch_alter_table('persons') as batch_op: + try: + batch_op.drop_constraint('uq_persons_business_code', type_='unique') + except Exception: + pass + try: + batch_op.drop_column('person_types') + except Exception: + pass + try: + batch_op.drop_column('code') + except Exception: + pass diff --git a/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py b/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py new file mode 100644 index 0000000..1d8c3e1 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250926_000011_drop_person_is_active.py @@ -0,0 +1,39 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '20250926_000011_drop_person_is_active' +down_revision = '20250926_000010_add_person_code_and_types' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + if 'persons' in tables: + with op.batch_alter_table('persons') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + if 'person_bank_accounts' in tables: + with op.batch_alter_table('person_bank_accounts') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + if 'persons' in tables: + with op.batch_alter_table('persons') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) + if 'person_bank_accounts' in tables: + with op.batch_alter_table('person_bank_accounts') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) diff --git a/hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000012_add_fiscal_years_table.py new file mode 100644 index 0000000..baa60a2 --- /dev/null +++ b/hesabixAPI/build/lib/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 = ('20250117_000003',) + + +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/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py b/hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py new file mode 100644 index 0000000..934e96d --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py @@ -0,0 +1,88 @@ +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' + ) + + # Add default_currency_id to businesses if not exists + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'businesses' in inspector.get_table_names(): + cols = {c['name'] for c in inspector.get_columns('businesses')} + if 'default_currency_id' not in cols: + with op.batch_alter_table('businesses') as batch_op: + batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT') + batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id']) + # Unique and indexes for association + op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id']) + op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id']) + op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id']) + + +def downgrade() -> None: + # Drop index/foreign key/column default_currency_id if exists + with op.batch_alter_table('businesses') as batch_op: + try: + batch_op.drop_index('ix_businesses_default_currency_id') + except Exception: + pass + try: + batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey') + except Exception: + pass + try: + batch_op.drop_column('default_currency_id') + except Exception: + pass + op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies') + op.drop_index('ix_business_currencies_business_id', table_name='business_currencies') + op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique') + 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/build/lib/migrations/versions/20250927_000014_add_documents_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000014_add_documents_table.py new file mode 100644 index 0000000..8f94d86 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000015_add_document_lines_table.py new file mode 100644 index 0000000..be3bbc8 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000016_add_accounts_table.py b/hesabixAPI/build/lib/migrations/versions/20250927_000016_add_accounts_table.py new file mode 100644 index 0000000..236159f --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py b/hesabixAPI/build/lib/migrations/versions/20250927_000017_add_account_id_to_document_lines.py new file mode 100644 index 0000000..687e483 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000018_seed_currencies.py b/hesabixAPI/build/lib/migrations/versions/20250927_000018_seed_currencies.py new file mode 100644 index 0000000..24ee953 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py b/hesabixAPI/build/lib/migrations/versions/20250927_000019_seed_accounts_chart.py new file mode 100644 index 0000000..333a1d7 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py b/hesabixAPI/build/lib/migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py new file mode 100644 index 0000000..23f423b --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py b/hesabixAPI/build/lib/migrations/versions/20250927_000021_update_person_type_enum_to_persian.py new file mode 100644 index 0000000..a2c1be3 --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py b/hesabixAPI/build/lib/migrations/versions/20250927_000022_add_person_commission_fields.py new file mode 100644 index 0000000..da6e07e --- /dev/null +++ b/hesabixAPI/build/lib/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/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py b/hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py new file mode 100644 index 0000000..8b45be2 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250928_000023_remove_person_is_active_force.py @@ -0,0 +1,56 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = '20250928_000023_remove_person_is_active_force' +down_revision = '4b2ea782bcb3' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + # Drop is_active from persons if exists + if 'persons' in tables: + columns = {col['name'] for col in inspector.get_columns('persons')} + if 'is_active' in columns: + with op.batch_alter_table('persons') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + + # Drop is_active from person_bank_accounts if exists + if 'person_bank_accounts' in tables: + columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')} + if 'is_active' in columns: + with op.batch_alter_table('person_bank_accounts') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + + +def downgrade() -> None: + # Recreate columns with safe defaults if needed + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + if 'persons' in tables: + columns = {col['name'] for col in inspector.get_columns('persons')} + if 'is_active' not in columns: + with op.batch_alter_table('persons') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) + + if 'person_bank_accounts' in tables: + columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')} + if 'is_active' not in columns: + with op.batch_alter_table('person_bank_accounts') as batch_op: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py b/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py new file mode 100644 index 0000000..c033524 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250929_000101_add_categories_table.py @@ -0,0 +1,36 @@ +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250929_000101_add_categories_table' +down_revision = '20250928_000023_remove_person_is_active_force' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'categories' in inspector.get_table_names(): + return + + op.create_table( + 'categories', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('parent_id', sa.Integer(), sa.ForeignKey('categories.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('type', sa.String(length=16), nullable=False, index=True), + sa.Column('title_translations', sa.JSON(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + ) + # Indexes are created automatically if defined at ORM/model level or can be added in a later migration if needed + + +def downgrade() -> None: + op.drop_table('categories') + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py b/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py new file mode 100644 index 0000000..17593c5 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250929_000201_drop_type_from_categories.py @@ -0,0 +1,41 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '20250929_000201_drop_type_from_categories' +down_revision = '20250929_000101_add_categories_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # حذف ایندکس مرتبط با ستون type اگر وجود دارد + try: + op.drop_index('ix_categories_type', table_name='categories') + except Exception: + pass + # حذف ستون type + conn = op.get_bind() + inspector = sa.inspect(conn) + cols = [c['name'] for c in inspector.get_columns('categories')] + if 'type' in cols: + with op.batch_alter_table('categories') as batch_op: + try: + batch_op.drop_column('type') + except Exception: + pass + + +def downgrade() -> None: + # بازگردانی ستون type (اختیاری) + with op.batch_alter_table('categories') as batch_op: + try: + batch_op.add_column(sa.Column('type', sa.String(length=16), nullable=False, server_default='global')) + except Exception: + pass + try: + op.create_index('ix_categories_type', 'categories', ['type']) + except Exception: + pass + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py b/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py new file mode 100644 index 0000000..5e514ed --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250929_000301_add_product_attributes_table.py @@ -0,0 +1,34 @@ +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250929_000301_add_product_attributes_table' +down_revision = '20250929_000201_drop_type_from_categories' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if 'product_attributes' in inspector.get_table_names(): + return + + op.create_table( + 'product_attributes', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('title', sa.String(length=255), nullable=False, index=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.UniqueConstraint('business_id', 'title', name='uq_product_attributes_business_title'), + ) + + +def downgrade() -> None: + op.drop_table('product_attributes') + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py b/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py new file mode 100644 index 0000000..1ed252c --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py @@ -0,0 +1,31 @@ +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250929_000401_drop_is_active_from_product_attributes' +down_revision = '20250929_000301_add_product_attributes_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + cols = [c['name'] for c in inspector.get_columns('product_attributes')] + if 'is_active' in cols: + with op.batch_alter_table('product_attributes') as batch_op: + try: + batch_op.drop_column('is_active') + except Exception: + pass + + +def downgrade() -> None: + with op.batch_alter_table('product_attributes') as batch_op: + try: + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'))) + except Exception: + pass + + diff --git a/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py b/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py new file mode 100644 index 0000000..7295181 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20250929_000501_add_products_and_pricing.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250929_000501_add_products_and_pricing' +down_revision = '20250929_000401_drop_is_active_from_product_attributes' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create products table (with existence check) + connection = op.get_bind() + + # Check if products table exists + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'products' + """)).fetchone() + + if result[0] == 0: + op.create_table( + 'products', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('item_type', sa.Enum('کالا', 'خدمت', name='product_item_type_enum'), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('main_unit_id', sa.Integer(), nullable=True), + sa.Column('secondary_unit_id', sa.Integer(), nullable=True), + sa.Column('unit_conversion_factor', sa.Numeric(18, 6), nullable=True), + sa.Column('base_sales_price', sa.Numeric(18, 2), nullable=True), + sa.Column('base_sales_note', sa.Text(), nullable=True), + sa.Column('base_purchase_price', sa.Numeric(18, 2), nullable=True), + sa.Column('base_purchase_note', sa.Text(), nullable=True), + sa.Column('track_inventory', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('reorder_point', sa.Integer(), nullable=True), + sa.Column('min_order_qty', sa.Integer(), nullable=True), + sa.Column('lead_time_days', sa.Integer(), nullable=True), + sa.Column('is_sales_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('is_purchase_taxable', sa.Boolean(), nullable=False, server_default=sa.text('0')), + sa.Column('sales_tax_rate', sa.Numeric(5, 2), nullable=True), + sa.Column('purchase_tax_rate', sa.Numeric(5, 2), nullable=True), + sa.Column('tax_type_id', sa.Integer(), nullable=True), + sa.Column('tax_code', sa.String(length=100), nullable=True), + sa.Column('tax_unit_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + # Create constraints and indexes (with existence checks) + try: + op.create_unique_constraint('uq_products_business_code', 'products', ['business_id', 'code']) + except Exception: + pass # Constraint already exists + + try: + op.create_index('ix_products_business_id', 'products', ['business_id']) + except Exception: + pass # Index already exists + + try: + op.create_index('ix_products_name', 'products', ['name']) + except Exception: + pass # Index already exists + + try: + op.create_foreign_key(None, 'products', 'businesses', ['business_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + try: + op.create_foreign_key(None, 'products', 'categories', ['category_id'], ['id'], ondelete='SET NULL') + except Exception: + pass # Foreign key already exists + + # Create price_lists table (with existence check) + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'price_lists' + """)).fetchone() + + if result[0] == 0: + op.create_table( + 'price_lists', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('currency_id', sa.Integer(), nullable=True), + sa.Column('default_unit_id', sa.Integer(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + try: + op.create_unique_constraint('uq_price_lists_business_name', 'price_lists', ['business_id', 'name']) + except Exception: + pass # Constraint already exists + + try: + op.create_index('ix_price_lists_business_id', 'price_lists', ['business_id']) + except Exception: + pass # Index already exists + + try: + op.create_foreign_key(None, 'price_lists', 'businesses', ['business_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + try: + op.create_foreign_key(None, 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT') + except Exception: + pass # Foreign key already exists + + # Create price_items table (with existence check) + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'price_items' + """)).fetchone() + + if result[0] == 0: + op.create_table( + 'price_items', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('price_list_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('unit_id', sa.Integer(), nullable=True), + sa.Column('currency_id', sa.Integer(), nullable=True), + sa.Column('tier_name', sa.String(length=64), nullable=False), + sa.Column('min_qty', sa.Numeric(18, 3), nullable=False, server_default=sa.text('0')), + sa.Column('price', sa.Numeric(18, 2), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + try: + op.create_unique_constraint('uq_price_items_unique_tier', 'price_items', ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty']) + except Exception: + pass # Constraint already exists + + try: + op.create_index('ix_price_items_price_list_id', 'price_items', ['price_list_id']) + except Exception: + pass # Index already exists + + try: + op.create_index('ix_price_items_product_id', 'price_items', ['product_id']) + except Exception: + pass # Index already exists + + try: + op.create_foreign_key(None, 'price_items', 'price_lists', ['price_list_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + try: + op.create_foreign_key(None, 'price_items', 'products', ['product_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + try: + op.create_foreign_key(None, 'price_items', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT') + except Exception: + pass # Foreign key already exists + + # Create product_attribute_links table (with existence check) + result = connection.execute(sa.text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'product_attribute_links' + """)).fetchone() + + if result[0] == 0: + op.create_table( + 'product_attribute_links', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('attribute_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + try: + op.create_unique_constraint('uq_product_attribute_links_unique', 'product_attribute_links', ['product_id', 'attribute_id']) + except Exception: + pass # Constraint already exists + + try: + op.create_index('ix_product_attribute_links_product_id', 'product_attribute_links', ['product_id']) + except Exception: + pass # Index already exists + + try: + op.create_index('ix_product_attribute_links_attribute_id', 'product_attribute_links', ['attribute_id']) + except Exception: + pass # Index already exists + + try: + op.create_foreign_key(None, 'product_attribute_links', 'products', ['product_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + try: + op.create_foreign_key(None, 'product_attribute_links', 'product_attributes', ['attribute_id'], ['id'], ondelete='CASCADE') + except Exception: + pass # Foreign key already exists + + +def downgrade() -> None: + # Drop links and pricing first due to FKs + op.drop_constraint('uq_product_attribute_links_unique', 'product_attribute_links', type_='unique') + op.drop_table('product_attribute_links') + + op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique') + op.drop_table('price_items') + + op.drop_constraint('uq_price_lists_business_name', 'price_lists', type_='unique') + op.drop_table('price_lists') + + op.drop_constraint('uq_products_business_code', 'products', type_='unique') + op.drop_table('products') + + diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py new file mode 100644 index 0000000..46e0e1b --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251001_000601_update_price_items_currency_unique_not_null' +down_revision = '20250929_000501_add_products_and_pricing' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1) Backfill price_items.currency_id from price_lists.currency_id where NULL + op.execute( + sa.text( + """ + UPDATE price_items pi + JOIN price_lists pl ON pl.id = pi.price_list_id + SET pi.currency_id = pl.currency_id + WHERE pi.currency_id IS NULL + """ + ) + ) + + # 2) Drop old unique constraint if exists + conn = op.get_bind() + dialect_name = conn.dialect.name + + # MySQL: information_schema to check constraints + if dialect_name == 'mysql': + op.execute( + sa.text( + """ + SET @exists := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = 'price_items' + AND CONSTRAINT_NAME = 'uq_price_items_unique_tier' + ); + """ + ) + ) + op.execute(sa.text("""SET @q := IF(@exists > 0, 'ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier', 'SELECT 1'); PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt;""")) + else: + # Generic drop constraint best-effort + try: + op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique') + except Exception: + pass + + # 3) Make currency_id NOT NULL + op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True) + + # 4) Create new unique constraint including currency_id + # For MySQL, unique constraints are created as indexes as well + op.create_unique_constraint( + 'uq_price_items_unique_tier_currency', + 'price_items', + ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id'] + ) + + +def downgrade() -> None: + # Drop new unique constraint + try: + op.drop_constraint('uq_price_items_unique_tier_currency', 'price_items', type_='unique') + except Exception: + pass + + # Make currency_id nullable again + op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=True, existing_nullable=False) + + # Recreate old unique constraint + op.create_unique_constraint( + 'uq_price_items_unique_tier', + 'price_items', + ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty'] + ) + + diff --git a/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py new file mode 100644 index 0000000..6807842 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251001_001101_drop_price_list_currency_default_unit' +down_revision = '20251001_000601_update_price_items_currency_unique_not_null' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Try to drop FK on price_lists.currency_id if exists + if dialect == 'mysql': + # Find foreign key constraint name dynamically and drop it + op.execute(sa.text( + """ + SET @fk_name := ( + SELECT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'price_lists' + AND COLUMN_NAME = 'currency_id' + AND REFERENCED_TABLE_NAME IS NOT NULL + LIMIT 1 + ); + """ + )) + op.execute(sa.text( + """ + SET @q := IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP FOREIGN KEY ', @fk_name), 'SELECT 1'); + PREPARE stmt FROM @q; EXECUTE stmt; DEALLOCATE PREPARE stmt; + """ + )) + # Drop indexes on columns if any + for col in ('currency_id', 'default_unit_id'): + op.execute(sa.text( + f""" + SET @idx := ( + SELECT INDEX_NAME FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = '{col}' LIMIT 1 + ); + """ + )) + op.execute(sa.text( + """ + SET @qi := IF(@idx IS NOT NULL, CONCAT('ALTER TABLE price_lists DROP INDEX ', @idx), 'SELECT 1'); + PREPARE s FROM @qi; EXECUTE s; DEALLOCATE PREPARE s; + """ + )) + + # Finally drop columns if they exist + op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS currency_id")) + op.execute(sa.text("ALTER TABLE price_lists DROP COLUMN IF EXISTS default_unit_id")) + else: + # Best-effort: drop constraint by common names, then drop columns + for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'): + try: + op.drop_constraint(name, 'price_lists', type_='foreignkey') + break + except Exception: + pass + try: + op.drop_column('price_lists', 'currency_id') + except Exception: + pass + try: + op.drop_column('price_lists', 'default_unit_id') + except Exception: + pass + + +def downgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Recreate columns (nullable) and FK back to currencies + with op.batch_alter_table('price_lists') as batch_op: + try: + batch_op.add_column(sa.Column('currency_id', sa.Integer(), nullable=True)) + except Exception: + pass + try: + batch_op.add_column(sa.Column('default_unit_id', sa.Integer(), nullable=True)) + except Exception: + pass + + # Add FK for currency_id where supported + try: + op.create_foreign_key( + 'fk_price_lists_currency_id', + 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT' + ) + except Exception: + pass + + diff --git a/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py b/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py new file mode 100644 index 0000000..a049ce7 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/4b2ea782bcb3_merge_heads.py @@ -0,0 +1,29 @@ +"""merge_heads + +Revision ID: 4b2ea782bcb3 +Revises: 20250120_000003, 20250927_000022_add_person_commission_fields +Create Date: 2025-09-28 20:59:14.557570 + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = '4b2ea782bcb3' +down_revision = ('20250120_000002', '20250927_000022_add_person_commission_fields') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # این migration صرفاً برای ادغام شاخه‌ها است و تغییری در اسکیما ایجاد نمی‌کند + pass + + +def downgrade() -> None: + # بدون تغییر + pass diff --git a/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py b/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py new file mode 100644 index 0000000..c47f75f --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/5553f8745c6e_add_support_tables.py @@ -0,0 +1,132 @@ +"""add_support_tables + +Revision ID: 5553f8745c6e +Revises: 20250117_000007 +Create Date: 2025-09-20 14:02:19.543853 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '5553f8745c6e' +down_revision = '20250117_000007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('support_categories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) + op.create_table('support_priorities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) + op.create_table('support_statuses', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('is_final', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) + op.create_table('support_tickets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('priority_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('assigned_operator_id', sa.Integer(), nullable=True), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False) + op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False) + op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False) + op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) + op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) + op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False) + op.create_table('support_messages', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False) + op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False) + op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) + op.alter_column('businesses', 'business_type', + existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), + type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), + existing_nullable=False) + op.alter_column('businesses', 'business_field', + existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), + type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('businesses', 'business_field', + existing_type=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), + type_=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), + existing_nullable=False) + op.alter_column('businesses', 'business_type', + existing_type=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), + type_=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), + existing_nullable=False) + op.drop_index(op.f('ix_support_messages_ticket_id'), table_name='support_messages') + op.drop_index(op.f('ix_support_messages_sender_type'), table_name='support_messages') + op.drop_index(op.f('ix_support_messages_sender_id'), table_name='support_messages') + op.drop_table('support_messages') + op.drop_index(op.f('ix_support_tickets_user_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_title'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_status_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_priority_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_category_id'), table_name='support_tickets') + op.drop_index(op.f('ix_support_tickets_assigned_operator_id'), table_name='support_tickets') + op.drop_table('support_tickets') + op.drop_index(op.f('ix_support_statuses_name'), table_name='support_statuses') + op.drop_table('support_statuses') + op.drop_index(op.f('ix_support_priorities_name'), table_name='support_priorities') + op.drop_table('support_priorities') + op.drop_index(op.f('ix_support_categories_name'), table_name='support_categories') + op.drop_table('support_categories') + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py new file mode 100644 index 0000000..2079041 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/9f9786ae7191_create_tax_units_table.py @@ -0,0 +1,50 @@ +"""create_tax_units_table + +Revision ID: 9f9786ae7191 +Revises: caf3f4ef4b76 +Create Date: 2025-09-30 14:47:28.281817 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9f9786ae7191' +down_revision = 'caf3f4ef4b76' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create tax_units table + op.create_table('tax_units', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'), + sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'), + sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'), + sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'), + sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + + # Create indexes + op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False) + + # Add foreign key constraint to products table + op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL') + + +def downgrade() -> None: + # Drop foreign key constraint from products table + op.drop_constraint(None, 'products', type_='foreignkey') + + # Drop indexes + op.drop_index(op.f('ix_tax_units_business_id'), table_name='tax_units') + + # Drop tax_units table + op.drop_table('tax_units') diff --git a/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py b/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py new file mode 100644 index 0000000..e27ba39 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/caf3f4ef4b76_add_tax_units_table.py @@ -0,0 +1,169 @@ +"""add_tax_units_table + +Revision ID: caf3f4ef4b76 +Revises: 20250929_000501_add_products_and_pricing +Create Date: 2025-09-30 14:46:58.614162 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'caf3f4ef4b76' +down_revision = '20250929_000501_add_products_and_pricing' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + op.alter_column('persons', 'person_type', + existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), + comment='نوع شخص', + existing_nullable=False) + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + op.alter_column('persons', 'commission_sale_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + comment='درصد پورسانت از فروش', + existing_nullable=True) + op.alter_column('persons', 'commission_sales_return_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + comment='درصد پورسانت از برگشت از فروش', + existing_nullable=True) + op.alter_column('persons', 'commission_sales_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment='مبلغ فروش مبنا برای پورسانت', + existing_nullable=True) + op.alter_column('persons', 'commission_sales_return_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment='مبلغ برگشت از فروش مبنا برای پورسانت', + existing_nullable=True) + op.alter_column('persons', 'commission_exclude_discounts', + existing_type=mysql.TINYINT(display_width=1), + comment='عدم محاسبه تخفیف در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('persons', 'commission_exclude_additions_deductions', + existing_type=mysql.TINYINT(display_width=1), + comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('persons', 'commission_post_in_invoice_document', + existing_type=mysql.TINYINT(display_width=1), + comment='ثبت پورسانت در سند حسابداری فاکتور', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('price_items', 'tier_name', + existing_type=mysql.VARCHAR(length=64), + comment='نام پله قیمت (تکی/عمده/همکار/...)', + existing_nullable=False) + op.create_index(op.f('ix_price_items_currency_id'), 'price_items', ['currency_id'], unique=False) + op.create_index(op.f('ix_price_items_unit_id'), 'price_items', ['unit_id'], unique=False) + op.create_index(op.f('ix_price_lists_currency_id'), 'price_lists', ['currency_id'], unique=False) + op.create_index(op.f('ix_price_lists_default_unit_id'), 'price_lists', ['default_unit_id'], unique=False) + op.create_index(op.f('ix_price_lists_name'), 'price_lists', ['name'], unique=False) + op.alter_column('products', 'item_type', + existing_type=mysql.ENUM('کالا', 'خدمت'), + comment='نوع آیتم (کالا/خدمت)', + existing_nullable=False) + op.alter_column('products', 'code', + existing_type=mysql.VARCHAR(length=64), + comment='کد یکتا در هر کسب\u200cوکار', + existing_nullable=False) + op.create_index(op.f('ix_products_category_id'), 'products', ['category_id'], unique=False) + op.create_index(op.f('ix_products_main_unit_id'), 'products', ['main_unit_id'], unique=False) + op.create_index(op.f('ix_products_secondary_unit_id'), 'products', ['secondary_unit_id'], unique=False) + op.create_index(op.f('ix_products_tax_type_id'), 'products', ['tax_type_id'], unique=False) + op.create_index(op.f('ix_products_tax_unit_id'), 'products', ['tax_unit_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_products_tax_unit_id'), table_name='products') + op.drop_index(op.f('ix_products_tax_type_id'), table_name='products') + op.drop_index(op.f('ix_products_secondary_unit_id'), table_name='products') + op.drop_index(op.f('ix_products_main_unit_id'), table_name='products') + op.drop_index(op.f('ix_products_category_id'), table_name='products') + op.alter_column('products', 'code', + existing_type=mysql.VARCHAR(length=64), + comment=None, + existing_comment='کد یکتا در هر کسب\u200cوکار', + existing_nullable=False) + op.alter_column('products', 'item_type', + existing_type=mysql.ENUM('کالا', 'خدمت'), + comment=None, + existing_comment='نوع آیتم (کالا/خدمت)', + existing_nullable=False) + op.drop_index(op.f('ix_price_lists_name'), table_name='price_lists') + op.drop_index(op.f('ix_price_lists_default_unit_id'), table_name='price_lists') + op.drop_index(op.f('ix_price_lists_currency_id'), table_name='price_lists') + op.drop_index(op.f('ix_price_items_unit_id'), table_name='price_items') + op.drop_index(op.f('ix_price_items_currency_id'), table_name='price_items') + op.alter_column('price_items', 'tier_name', + existing_type=mysql.VARCHAR(length=64), + comment=None, + existing_comment='نام پله قیمت (تکی/عمده/همکار/...)', + existing_nullable=False) + op.alter_column('persons', 'commission_post_in_invoice_document', + existing_type=mysql.TINYINT(display_width=1), + comment=None, + existing_comment='ثبت پورسانت در سند حسابداری فاکتور', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('persons', 'commission_exclude_additions_deductions', + existing_type=mysql.TINYINT(display_width=1), + comment=None, + existing_comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('persons', 'commission_exclude_discounts', + existing_type=mysql.TINYINT(display_width=1), + comment=None, + existing_comment='عدم محاسبه تخفیف در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('persons', 'commission_sales_return_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment=None, + existing_comment='مبلغ برگشت از فروش مبنا برای پورسانت', + existing_nullable=True) + op.alter_column('persons', 'commission_sales_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment=None, + existing_comment='مبلغ فروش مبنا برای پورسانت', + existing_nullable=True) + op.alter_column('persons', 'commission_sales_return_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + comment=None, + existing_comment='درصد پورسانت از برگشت از فروش', + existing_nullable=True) + op.alter_column('persons', 'commission_sale_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + 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', 'person_type', + existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), + comment=None, + existing_comment='نوع شخص', + existing_nullable=False) + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + # ### end Alembic commands ### diff --git a/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py b/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py new file mode 100644 index 0000000..bc3ad76 --- /dev/null +++ b/hesabixAPI/build/lib/migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py @@ -0,0 +1,178 @@ +"""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 +from sqlalchemy import text +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = 'd3e84892c1c2' +down_revision = '20250927_000020_add_share_count_and_shareholder_type' +branch_labels = None +depends_on = None + + +def _table_exists(conn, name: str) -> bool: + res = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name=:t" + ), {"t": name}) + return (res.scalar() or 0) > 0 + + +def _column_exists(conn, table: str, col: str) -> bool: + res = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name=:t AND column_name=:c" + ), {"t": table, "c": col}) + return (res.scalar() or 0) > 0 + + +def upgrade() -> None: + # ### commands auto generated by Alembic - guarded for idempotency ### + bind = op.get_bind() + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names()) + + if 'storage_configs' not in existing_tables: + op.create_table('storage_configs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('storage_type', sa.String(length=20), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('config_data', sa.JSON(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + if 'file_storage' not in existing_tables: + op.create_table('file_storage', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('original_name', sa.String(length=255), nullable=False), + sa.Column('stored_name', sa.String(length=255), nullable=False), + sa.Column('file_path', sa.String(length=500), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('storage_type', sa.String(length=20), nullable=False), + sa.Column('storage_config_id', sa.String(length=36), nullable=True), + sa.Column('uploaded_by', sa.Integer(), nullable=False), + sa.Column('module_context', sa.String(length=50), nullable=False), + sa.Column('context_id', sa.String(length=36), nullable=True), + sa.Column('developer_data', sa.JSON(), nullable=True), + sa.Column('checksum', sa.String(length=64), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_temporary', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.Column('verification_token', sa.String(length=100), nullable=True), + sa.Column('last_verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['storage_config_id'], ['storage_configs.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + if 'file_verifications' not in existing_tables: + op.create_table('file_verifications', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('file_id', sa.String(length=36), nullable=False), + sa.Column('module_name', sa.String(length=50), nullable=False), + sa.Column('verification_token', sa.String(length=100), nullable=False), + sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('verified_by', sa.Integer(), nullable=True), + sa.Column('verification_data', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['file_id'], ['file_storage.id'], ), + sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # Drop index if exists + try: + bind = op.get_bind() + insp = inspect(bind) + if 'fiscal_years' in insp.get_table_names(): + existing_indexes = {idx['name'] for idx in insp.get_indexes('fiscal_years')} + if 'ix_fiscal_years_title' in existing_indexes: + op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years') + except Exception: + pass + + conn = op.get_bind() + if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'): + op.alter_column('person_bank_accounts', 'person_id', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='شناسه شخص', + existing_nullable=False) + + if _table_exists(conn, 'persons'): + if _column_exists(conn, 'persons', 'business_id'): + op.alter_column('persons', 'business_id', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='شناسه کسب و کار', + existing_nullable=False) + if _column_exists(conn, 'persons', 'code'): + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + if _column_exists(conn, 'persons', 'person_types'): + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + if _column_exists(conn, 'persons', 'share_count'): + op.alter_column('persons', 'share_count', + existing_type=mysql.INTEGER(), + comment='تعداد سهام (فقط برای سهامدار)', + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'share_count'): + op.alter_column('persons', 'share_count', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='تعداد سهام (فقط برای سهامدار)', + existing_nullable=True) + if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'person_types'): + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment=None, + existing_comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'code'): + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment=None, + existing_comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'business_id'): + op.alter_column('persons', 'business_id', + existing_type=mysql.INTEGER(), + comment='شناسه کسب و کار', + existing_nullable=False) + if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'): + op.alter_column('person_bank_accounts', 'person_id', + existing_type=mysql.INTEGER(), + comment='شناسه شخص', + 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/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py b/hesabixAPI/build/lib/migrations/versions/f876bfa36805_merge_multiple_heads.py new file mode 100644 index 0000000..d318963 --- /dev/null +++ b/hesabixAPI/build/lib/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/hesabixAPI/build/lib/tests/__init__.py b/hesabixAPI/build/lib/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hesabixAPI/build/lib/tests/test_health.py b/hesabixAPI/build/lib/tests/test_health.py new file mode 100644 index 0000000..21a8759 --- /dev/null +++ b/hesabixAPI/build/lib/tests/test_health.py @@ -0,0 +1,14 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from fastapi import status + +from app.main import app + + +@pytest.mark.asyncio +async def test_health_returns_ok() -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.get("/api/v1/health") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"status": "ok"} diff --git a/hesabixAPI/build/lib/tests/test_permissions.py b/hesabixAPI/build/lib/tests/test_permissions.py new file mode 100644 index 0000000..3b8d700 --- /dev/null +++ b/hesabixAPI/build/lib/tests/test_permissions.py @@ -0,0 +1,226 @@ +""" +تست‌های سیستم دسترسی دو سطحی +""" + +import pytest +from unittest.mock import Mock +from app.core.auth_dependency import AuthContext +from adapters.db.models.user import User + + +class TestAuthContextPermissions: + """تست کلاس AuthContext برای بررسی دسترسی‌ها""" + + def test_app_permissions(self): + """تست دسترسی‌های اپلیکیشن""" + # ایجاد کاربر با دسترسی superadmin + user = Mock(spec=User) + user.app_permissions = {"superadmin": True} + + ctx = AuthContext(user=user, api_key_id=1) + + # تست دسترسی‌های اپلیکیشن - SuperAdmin باید تمام دسترسی‌ها را داشته باشد + assert ctx.has_app_permission("superadmin") == True + assert ctx.has_app_permission("user_management") == True # خودکار + assert ctx.has_app_permission("business_management") == True # خودکار + assert ctx.has_app_permission("system_settings") == True # خودکار + assert ctx.is_superadmin() == True + assert ctx.can_manage_users() == True + assert ctx.can_manage_businesses() == True + + def test_app_permissions_normal_user(self): + """تست دسترسی‌های اپلیکیشن برای کاربر عادی""" + user = Mock(spec=User) + user.app_permissions = {"user_management": True} + + ctx = AuthContext(user=user, api_key_id=1) + + # تست دسترسی‌های اپلیکیشن + assert ctx.has_app_permission("superadmin") == False + assert ctx.has_app_permission("user_management") == True + assert ctx.has_app_permission("business_management") == False + assert ctx.is_superadmin() == False + assert ctx.can_manage_users() == True + assert ctx.can_manage_businesses() == False + + def test_business_permissions(self): + """تست دسترسی‌های کسب و کار""" + user = Mock(spec=User) + user.app_permissions = {} + + # Mock دیتابیس + db = Mock() + business_permission_repo = Mock() + business_permission_repo.get_by_user_and_business.return_value = Mock( + business_permissions={ + "sales": {"write": True, "delete": True}, + "accounting": {"write": True} + } + ) + + ctx = AuthContext( + user=user, + api_key_id=1, + business_id=1, + db=db + ) + + # Mock کردن repository + with pytest.MonkeyPatch().context() as m: + m.setattr("app.core.auth_dependency.BusinessPermissionRepository", + lambda db: business_permission_repo) + + # تست دسترسی‌های کسب و کار + assert ctx.has_business_permission("sales", "write") == True + assert ctx.has_business_permission("sales", "delete") == True + assert ctx.has_business_permission("sales", "approve") == False + assert ctx.has_business_permission("accounting", "write") == True + assert ctx.has_business_permission("purchases", "read") == False + assert ctx.can_read_section("sales") == True + assert ctx.can_write_section("sales") == True + assert ctx.can_delete_section("sales") == True + assert ctx.can_approve_section("sales") == False + + def test_empty_business_permissions(self): + """تست دسترسی‌های خالی کسب و کار""" + user = Mock(spec=User) + user.app_permissions = {} + + ctx = AuthContext(user=user, api_key_id=1, business_id=1) + ctx.business_permissions = {} + + # اگر دسترسی‌ها خالی باشد، فقط خواندن مجاز است + assert ctx.has_business_permission("sales", "read") == False + assert ctx.has_business_permission("sales", "write") == False + assert ctx.can_read_section("sales") == False + + def test_section_with_empty_permissions(self): + """تست بخش با دسترسی‌های خالی""" + user = Mock(spec=User) + user.app_permissions = {} + + ctx = AuthContext(user=user, api_key_id=1, business_id=1) + ctx.business_permissions = { + "sales": {}, # بخش خالی + "accounting": {"write": True} + } + + # بخش خالی فقط خواندن مجاز است + assert ctx.has_business_permission("sales", "read") == True + assert ctx.has_business_permission("sales", "write") == False + assert ctx.has_business_permission("accounting", "write") == True + + def test_superadmin_override(self): + """تست override کردن دسترسی‌ها توسط superadmin""" + user = Mock(spec=User) + user.app_permissions = {"superadmin": True} + + ctx = AuthContext(user=user, api_key_id=1, business_id=1) + ctx.business_permissions = {} # بدون دسترسی کسب و کار + + # SuperAdmin باید دسترسی کامل داشته باشد + assert ctx.has_any_permission("sales", "write") == True + assert ctx.has_any_permission("accounting", "delete") == True + assert ctx.can_access_business(999) == True # هر کسب و کاری + + def test_business_access_control(self): + """تست کنترل دسترسی به کسب و کار""" + user = Mock(spec=User) + user.app_permissions = {} + + ctx = AuthContext(user=user, api_key_id=1, business_id=1) + + # فقط به کسب و کار خود دسترسی دارد + assert ctx.can_access_business(1) == True + assert ctx.can_access_business(2) == False + + # SuperAdmin به همه دسترسی دارد + user.app_permissions = {"superadmin": True} + ctx = AuthContext(user=user, api_key_id=1, business_id=1) + assert ctx.can_access_business(999) == True + + def test_business_owner_permissions(self): + """تست دسترسی‌های مالک کسب و کار""" + user = Mock(spec=User) + user.app_permissions = {} + user.id = 1 + + # Mock دیتابیس و کسب و کار + db = Mock() + business = Mock() + business.owner_id = 1 # کاربر مالک است + + ctx = AuthContext(user=user, api_key_id=1, business_id=1, db=db) + + # Mock کردن Business model + with pytest.MonkeyPatch().context() as m: + m.setattr("app.core.auth_dependency.Business", Mock) + db.get.return_value = business + + # مالک کسب و کار باید تمام دسترسی‌ها را داشته باشد + assert ctx.is_business_owner() == True + assert ctx.has_business_permission("sales", "write") == True + assert ctx.has_business_permission("sales", "delete") == True + assert ctx.has_business_permission("accounting", "write") == True + assert ctx.has_business_permission("reports", "export") == True + assert ctx.can_read_section("sales") == True + assert ctx.can_write_section("sales") == True + assert ctx.can_delete_section("sales") == True + assert ctx.can_approve_section("sales") == True + + def test_business_owner_override(self): + """تست override کردن دسترسی‌ها توسط مالک کسب و کار""" + user = Mock(spec=User) + user.app_permissions = {} + user.id = 1 + + # Mock دیتابیس و کسب و کار + db = Mock() + business = Mock() + business.owner_id = 1 + + ctx = AuthContext(user=user, api_key_id=1, business_id=1, db=db) + ctx.business_permissions = {} # بدون دسترسی کسب و کار + + # Mock کردن Business model + with pytest.MonkeyPatch().context() as m: + m.setattr("app.core.auth_dependency.Business", Mock) + db.get.return_value = business + + # مالک کسب و کار باید دسترسی کامل داشته باشد حتی بدون business_permissions + assert ctx.is_business_owner() == True + assert ctx.has_business_permission("sales", "write") == True + assert ctx.has_business_permission("accounting", "delete") == True + assert ctx.can_read_section("purchases") == True + assert ctx.can_write_section("inventory") == True + + +class TestPermissionDecorators: + """تست decorator های دسترسی""" + + def test_require_app_permission(self): + """تست decorator دسترسی اپلیکیشن""" + from app.core.permissions import require_app_permission + + @require_app_permission("user_management") + def test_function(): + return "success" + + # این تست نیاز به mock کردن get_current_user دارد + # که در محیط تست پیچیده‌تر است + pass + + def test_require_business_permission(self): + """تست decorator دسترسی کسب و کار""" + from app.core.permissions import require_business_permission + + @require_business_permission("sales", "write") + def test_function(): + return "success" + + # این تست نیاز به mock کردن get_current_user دارد + pass + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index d53fd3d..430e268 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -152,6 +152,9 @@ migrations/versions/20250929_000201_drop_type_from_categories.py migrations/versions/20250929_000301_add_product_attributes_table.py migrations/versions/20250929_000401_drop_is_active_from_product_attributes.py migrations/versions/20250929_000501_add_products_and_pricing.py +migrations/versions/20251001_000601_update_price_items_currency_unique_not_null.py +migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py +migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/9f9786ae7191_create_tax_units_table.py diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index be0573564055296cecbc2da003a7dd24a7552875..7ac2fb0503a3c31f3dc8a8408835c96d535c09b0 100644 GIT binary patch delta 2379 zcma*pOKenC9LMovXDGCEru0Ek5V&RffEE}SXsNXV@@P?Nd6ZEL#dc_iwxLc%rYqZw zh7i+)N6L*D1A#7##%Q8Y7LuqbhJ+|YqfuiT0~<)nqYG9T3)b&%dM0tFZb{rqv=2`Q5@CZQJjt^Q2oB|C;z$_;)YWH3993Zs4w{j$*%dqJ|9I5 zc-z+RqnG+a)PRqy88p^}3sC)*qBdU$)&C|`KMmRBUk$_D(9HLt27JT5(1&Vp81?1H ztS+kE2Y44hLZy0;QPt0A4soFlj@uibp+o&D>X?k7 zX8ahHi9&9YWmAsoxEi%dcOpNglV9qu-`+osA?oK*oA4GY^%Lmm{HM`cnbMit(LvPaddt=aP#wAU{)eamE};7P8nu~6P^aWNs{K#ao2dO~ z#<|dolgKignasw63sExA%nyoVlAPfzXvMX{nf-vs{E^Lwb#m=PJ}WR(DyGT zRP>KR1+j%tno~tf5++_Dv~w2|+W8!S*QYlK6nY$voAT2!GV zp6A!IL<3Pss4OA$e*sQPvOH(iS~lD36;|;o(MG&X@P{y2HuAfQ(Edsl?F}8(dSb`a z&8gS-QdvW!8@D>WB|j32^~9a_Xm>Ooh5eDk<#)BXWdGvh%3 delta 1764 zcmYM!Sx8h-9LMpaj=AS#mP=XJ)=6`!DG4;86s(}Ez!yWRYfT2}C@w{`Oi2<2AvJ9z z6%j2`5F|t^ZBz*LQqoIQ53Mh0Q4~>7-`{wkVVuwXpL@>#{?EDR_&3n)55Lav95#IF z_#4aL^f*QTKl6qe<0UM^G_1lASZm`Djv?NL`FIR-@CJ^c&yT#Tx?5|yCV+KMVeYNg#xK<8Z?stGS-ya4ppcJ*4TIrdWbh7U(;;ejw*N`DseYz)15=*zlO?lANiU- zepL92RMuY)d}lj+K@Iqcnt2SpWCGG;(r{&rF}bKs*3L%Y(sZF8PoPf06P$ztHXh0B z)V~-5xD2&7I>KD2;}O(5zKI3ck3Rfs^)Wr2iWR8)O{n|Fu@E1imVOX5^8|kMz#PoQ z*{JbVHf}(NIJ}1o9g7R7ncqf=HjhyWU!XSS0IHvdEv!U2sP?(287)O^x}B({K8g;W zMD2w;=*Rmw1;641o&RjsL5WLo4lc)u*ooS`J*eIO5H<79)@%liC$7MmxE8e%hmhRn zF4EOJM+ZNmUQHrZYsCxDtMk8%3st-ZHS>DZCfbCmd>3l>?nV~D>__d1Q>Ya?iyD6! zHSQ|%7R)`=o_U6P6>m`aexXjqA57Hw_pokCl!n?&SvH=GN;Jc^&qqC=3YDk^wRvk% zr(y$Ye4}+Ms^Gn-e%+`QIgk1;e-kTJ`5i7a@DXYS`cVVkqV5l(9{dBNr-F?UJr$^N zX<3mSiK}~KlImi6_oU4BM6Rb#NvJNWXbHM)k@x9Nf7D=U{Q6@ zZH%@oZ)k6E8;UBMS{hqRoKSN#Y%L3vIzyLbzL1k&=FFN_8Y%V`$F;Yuk2HEG<}M6| pf-P=aa9vUPmJ+AjtqV3d0spMI{+W?3@9Ey^5k0ZJ`I)QY{{gU7wWG*)y#C9$)?E~%W7&mE9$5(oJpISCMTzS)AzS~NNSv(+5h=G&vVXs?q}c0 z?#&Ba8#(ZxAypIOi27J#RBWd{v@N34`(a>~`1LOFn1kv1HO@1X{8 z8nt*Aocot>0OhMV2Ct*qy(j7445bw{kmi_$deDvPc(Y>-YAN@ko_`bhnQkr?o<(id zRn$y>LA5iGQOQ))z_V~7uExM*GJD9VqeG|$I#EmZG3v$dQG0gBxgXENnqdmkhe<=N z+;r4T=i^|^MzxoRgV2k5ZX@db@}eHGPU2gc(ORKp?1v#725 z4Assz$j@Bn!mgUzsQZ7QI*eg7Di6kin4HA=tHTs32%_59nYnRhz&px!%)xAA;{%xp5p=tUjf;IbNj$GNzSjb4NexEzn; zGQ5lW%;s>Cw&FU}7M;ZT_$_8*0-u8RJRjB07F>dda3KbMBr}c72u_R|UV?7ihb!?@ zd;*7Y5cL1B5GUa}oQ3;vG4|k-_%klRRQ7KwdQtcHqh@{v)A0iGnFq|BXvU0Un7Q0o zf@&y$7ItF^UUKR!PNvR4F|NS%PWiA??nF)GH)L*R)}zstDnJcrC(^Nb4KwxqpCO|e z-a<8)%sT4-U=wb@0BXiz)RJo%w1nwcfZI@8(~fiSHg3W+{X5~zMeiq~zT*_sVO@$k{e@0>1M0ArBV#hv zs591r8u%L+&}VRij85?_qZ9aqZgK<8eD;@_n-!{8`V&gb3f>m-*U=FQ3E)IERs2k zF$m@E>Sx-zN{6zn>fWjhTF_Gom&`QlCiizoj2B`C+ z89q(u5PJxHO&Xk%4zkihLVsxE=|7ToIL<;H8tu0ZQ7)l9)jpRIm4x=LFAeI;phfH? zw1PT_?4sFBJWj+BlL;lxapb^1gMm^m`Yz`ZeQ7?K9AXBclifcJpui^{`2zatw~*OJ z=x}Z%bZFNSO8HSD|1tb${VS!@f25+EtC!ICIg_X&5{Pw#(uOFe&~cgL6x8|1CUz0p z`wBvbQwMA%p;R5^pX(YYm!aPDC(%E}QtaFc{wL7XR8=m#oSJl-9Uyo}`SX1KN;43IBat9mZM~9ZjpN`2W@K@DTlw0+6 z)^inV;bE`-HTZI%#8a5(_7!^FYkXxTrEZVcoo}LbMTMnaU!KRE`=6?^l6e187=p{#$#@;&s)6Oz1CN_Hgq$gC^k65H8Hr)l@t8Z zHDp+kzqYc$Us;iD?eGWhx#q@}c?*IwhG&F!508(@2{-k&-h83e?h3cq?cruCe6F|k za;JUV?g%&AJ-w}w3j0L3k*W_ZF71|m%zi)IXhojt3GGSr#EfmU+k0EX2Q0grMh@_n z-EN2MuFxk*>9JF2yV?GT`%VAfdg$xqR|XV>U(}O!mt`NZJH!dQ$EZy;Xg#<~hxSpr z>3QfALD$G}Gn?%$JrsVK;i%E@IVO2rtu!%*li`-|0UGEs_PadR6>bWSNEsEpHa02# VK{?qKZs8`==6b>kdB=Pe_ctR*(0>2` delta 1780 zcmYM!ZAg`89LMqh@ua3{d5))5I@@WL*;1#LYddPABbl<%1GX1#QM;qN7PFHbwFufy zL@^eO9&Q>!u&}_W##WuZSZgn;5hPo|pal^`RE)g|dZIR>==;+Pb~yUE{?~my{I7@m z+-x08WNt;r95g;__ae2!Kh1e&C4?R3UCy0YL40zwfepL*Cpd@tim_&sumi`VhcWEI3Ah`Tz(Jgd zKVlp&q1ye81^65bncs4WN~tJ84KTyS^Kde8J8I<~MsX8r;+@E%Y`=RxfH}lN_!=HZ zje8zPb^_J^F>1WLLK0(si&4-PH=;6bLk+Of*^5eK56;A+I17hS2|q;j4~-jLc@)*Z z8nxxEsOPIu3HP89*oqmFvE3Au`CepK>;S66A%nksL)CpkiM354VIuL>~is^IEMIhqYFpCcNhwJV14LhFW=uRx%%{vSREEnU&#O;uIG_6CJ@1 z@F#3Ut)>ay!S^wZdOObGD!hg|nzwoJy+9>6fzBFuGBTG{qMk2AcHKH%{RUL~U8vt0`^G&O zKqZnz4fH$e`3)D}bMYfo0xwX%Y&jLd)PmK)V8OT9WZ@s7prPnSc6;&1;oz^5%KQ~I zt!dxu3qp~2@Lx$Xk%+JJ3P1Ex>-)X+e$5Ks+c;9Qth+z$b=S0QN^k5<#8W*ZVQ))) zQ+%}8Qr8qLk2Hq+`_=_tN2Zr8_EUb^>+{#vB)28vNpFqc9dD?upI=*-JsBAr%07)Q Z4F%DO<=Jhq+;H~Xq%Aqw((=oBuK?c None: + # 1) Backfill price_items.currency_id from price_lists.currency_id where NULL + op.execute( + sa.text( + """ + UPDATE price_items pi + JOIN price_lists pl ON pl.id = pi.price_list_id + SET pi.currency_id = pl.currency_id + WHERE pi.currency_id IS NULL + """ + ) + ) + + # 2) Drop old unique constraint if exists + conn = op.get_bind() + dialect_name = conn.dialect.name + + if dialect_name == 'mysql': + # Check via information_schema and drop index if present + exists = conn.execute(sa.text( + """ + SELECT COUNT(*) as cnt + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'price_items' + AND INDEX_NAME = 'uq_price_items_unique_tier' + """ + )).scalar() or 0 + if int(exists) > 0: + conn.execute(sa.text("ALTER TABLE price_items DROP INDEX uq_price_items_unique_tier")) + else: + # Generic drop constraint best-effort + try: + op.drop_constraint('uq_price_items_unique_tier', 'price_items', type_='unique') + except Exception: + pass + + # 3) Make currency_id NOT NULL + op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=False, existing_nullable=True) + + # 4) Create new unique constraint including currency_id (idempotent) + if dialect_name == 'mysql': + exists_uc = conn.execute(sa.text( + """ + SELECT COUNT(*) as cnt + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'price_items' + AND INDEX_NAME = 'uq_price_items_unique_tier_currency' + """ + )).scalar() or 0 + if int(exists_uc) == 0: + op.create_unique_constraint( + 'uq_price_items_unique_tier_currency', + 'price_items', + ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id'] + ) + else: + try: + op.create_unique_constraint( + 'uq_price_items_unique_tier_currency', + 'price_items', + ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty', 'currency_id'] + ) + except Exception: + pass + + +def downgrade() -> None: + # Drop new unique constraint + try: + op.drop_constraint('uq_price_items_unique_tier_currency', 'price_items', type_='unique') + except Exception: + pass + + # Make currency_id nullable again + op.alter_column('price_items', 'currency_id', existing_type=sa.Integer(), nullable=True, existing_nullable=False) + + # Recreate old unique constraint + op.create_unique_constraint( + 'uq_price_items_unique_tier', + 'price_items', + ['price_list_id', 'product_id', 'unit_id', 'tier_name', 'min_qty'] + ) + + diff --git a/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py b/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py new file mode 100644 index 0000000..1af902e --- /dev/null +++ b/hesabixAPI/migrations/versions/20251001_001101_drop_price_list_currency_default_unit.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251001_001101_drop_price_list_currency_default_unit' +down_revision = '20251001_000601_update_price_items_currency_unique_not_null' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Try to drop FK on price_lists.currency_id if exists + if dialect == 'mysql': + # Find foreign key constraint name dynamically and drop it + fk_rows = conn.execute(sa.text( + """ + SELECT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'price_lists' + AND COLUMN_NAME = 'currency_id' + AND REFERENCED_TABLE_NAME IS NOT NULL + GROUP BY CONSTRAINT_NAME + """ + )).fetchall() + for (fk_name,) in fk_rows: + conn.execute(sa.text(f"ALTER TABLE price_lists DROP FOREIGN KEY {fk_name}")) + + # Finally drop columns if they exist (manual check) + for col in ('currency_id', 'default_unit_id'): + exists = conn.execute(sa.text( + """ + SELECT COUNT(*) as cnt FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'price_lists' AND COLUMN_NAME = :col + """ + ), {"col": col}).scalar() or 0 + if int(exists) > 0: + conn.execute(sa.text(f"ALTER TABLE price_lists DROP COLUMN {col}")) + else: + # Best-effort: drop constraint by common names, then drop columns + for name in ('price_lists_currency_id_fkey', 'fk_price_lists_currency_id', 'price_lists_currency_id_fk'): + try: + op.drop_constraint(name, 'price_lists', type_='foreignkey') + break + except Exception: + pass + try: + op.drop_column('price_lists', 'currency_id') + except Exception: + pass + try: + op.drop_column('price_lists', 'default_unit_id') + except Exception: + pass + + +def downgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Recreate columns (nullable) and FK back to currencies + with op.batch_alter_table('price_lists') as batch_op: + try: + batch_op.add_column(sa.Column('currency_id', sa.Integer(), nullable=True)) + except Exception: + pass + try: + batch_op.add_column(sa.Column('default_unit_id', sa.Integer(), nullable=True)) + except Exception: + pass + + # Add FK for currency_id where supported + try: + op.create_foreign_key( + 'fk_price_lists_currency_id', + 'price_lists', 'currencies', ['currency_id'], ['id'], ondelete='RESTRICT' + ) + except Exception: + pass + + diff --git a/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py b/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py new file mode 100644 index 0000000..16b64c1 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from alembic import op # noqa: F401 +import sqlalchemy as sa # noqa: F401 + + +# revision identifiers, used by Alembic. +revision = '20251001_001201_merge_heads_drop_currency_tax_units' +down_revision = ( + '20251001_001101_drop_price_list_currency_default_unit', + '9f9786ae7191', +) +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Merge only; no operations. + pass + + +def downgrade() -> None: + # Merge only; no operations. + pass + + diff --git a/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py index 2079041..713bcd1 100644 --- a/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py +++ b/hesabixAPI/migrations/versions/9f9786ae7191_create_tax_units_table.py @@ -7,6 +7,7 @@ Create Date: 2025-09-30 14:47:28.281817 """ from alembic import op import sqlalchemy as sa +from sqlalchemy import inspect # revision identifiers, used by Alembic. @@ -17,26 +18,33 @@ depends_on = None def upgrade() -> None: - # Create tax_units table - op.create_table('tax_units', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'), - sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'), - sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'), - sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'), - sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - mysql_charset='utf8mb4' - ) - - # Create indexes - op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False) - - # Add foreign key constraint to products table - op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL') + bind = op.get_bind() + inspector = inspect(bind) + + created_tax_units = False + if not inspector.has_table('tax_units'): + op.create_table( + 'tax_units', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسب\u200cوکار'), + sa.Column('name', sa.String(length=255), nullable=False, comment='نام واحد مالیاتی'), + sa.Column('code', sa.String(length=64), nullable=False, comment='کد واحد مالیاتی'), + sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'), + sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, comment='نرخ مالیات (درصد)'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + created_tax_units = True + + if created_tax_units: + # Create indexes + op.create_index(op.f('ix_tax_units_business_id'), 'tax_units', ['business_id'], unique=False) + + # Add foreign key constraint to products table + op.create_foreign_key(None, 'products', 'tax_units', ['tax_unit_id'], ['id'], ondelete='SET NULL') def downgrade() -> None: diff --git a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart index 263e9f9..a96dbd1 100644 --- a/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart +++ b/hesabixUI/hesabix_ui/lib/controllers/product_form_controller.dart @@ -5,6 +5,8 @@ import '../services/category_service.dart'; import '../services/product_attribute_service.dart'; import '../services/unit_service.dart'; import '../services/tax_service.dart'; +import '../services/price_list_service.dart'; +import '../services/currency_service.dart'; import '../core/api_client.dart'; class ProductFormController extends ChangeNotifier { @@ -16,6 +18,8 @@ class ProductFormController extends ChangeNotifier { late final ProductAttributeService _attributeService; late final UnitService _unitService; late final TaxService _taxService; + late final PriceListService _priceListService; + late final CurrencyService _currencyService; ProductFormData _formData = ProductFormData(); bool _isLoading = false; @@ -28,6 +32,11 @@ class ProductFormController extends ChangeNotifier { List> _units = []; List> _taxTypes = []; List> _taxUnits = []; + List> _priceLists = []; + List> _currencies = []; + + // Draft price items per price list (for multi-currency) + final List> _draftPriceItems = []; ProductFormController({ required this.businessId, @@ -42,6 +51,8 @@ class ProductFormController extends ChangeNotifier { _attributeService = ProductAttributeService(apiClient: _apiClient); _unitService = UnitService(apiClient: _apiClient); _taxService = TaxService(apiClient: _apiClient); + _priceListService = PriceListService(apiClient: _apiClient); + _currencyService = CurrencyService(_apiClient); } // Getters @@ -53,20 +64,66 @@ class ProductFormController extends ChangeNotifier { List> get units => _units; List> get taxTypes => _taxTypes; List> get taxUnits => _taxUnits; + List> get priceLists => _priceLists; + List> get currencies => _currencies; + List> get draftPriceItems => List.unmodifiable(_draftPriceItems); + + void addOrUpdateDraftPriceItem(Map item) { + final String key = ( + (item['price_list_id']?.toString() ?? '') + '|' + + (item['product_id']?.toString() ?? '') + '|' + + (item['unit_id']?.toString() ?? 'null') + '|' + + (item['currency_id']?.toString() ?? '') + '|' + + (item['tier_name']?.toString() ?? '') + '|' + + (item['min_qty']?.toString() ?? '0') + ); + int existingIndex = -1; + for (int i = 0; i < _draftPriceItems.length; i++) { + final it = _draftPriceItems[i]; + final itKey = ( + (it['price_list_id']?.toString() ?? '') + '|' + + (it['product_id']?.toString() ?? '') + '|' + + (it['unit_id']?.toString() ?? 'null') + '|' + + (it['currency_id']?.toString() ?? '') + '|' + + (it['tier_name']?.toString() ?? '') + '|' + + (it['min_qty']?.toString() ?? '0') + ); + if (itKey == key) { + existingIndex = i; + break; + } + } + if (existingIndex >= 0) { + _draftPriceItems[existingIndex] = item; + } else { + _draftPriceItems.add(item); + } + notifyListeners(); + } + + void removeDraftPriceItem(Map item) { + _draftPriceItems.remove(item); + notifyListeners(); + } // Initialize form with existing product data Future initializeWithProduct(Map? product) async { _setLoading(true); try { await _loadReferenceData(); + await _loadPriceListsAndCurrencies(); if (product != null) { _editingProductId = product['id'] as int?; _formData = ProductFormData.fromProduct(product); + if (_editingProductId != null) { + await _loadExistingPriceItems(productId: _editingProductId!); + } } else { _formData = ProductFormData( baseSalesPrice: 0, basePurchasePrice: 0, + unitConversionFactor: 1, ); // پیش‌فرض انتخاب اولین نوع مالیات و واحد مالیاتی اگر موجود باشد if (_taxTypes.isNotEmpty && _formData.taxTypeId == null) { @@ -146,6 +203,47 @@ class ProductFormController extends ChangeNotifier { } } + Future _loadPriceListsAndCurrencies() async { + try { + // Price lists (first page only for selection) + try { + final res = await _priceListService.listPriceLists(businessId: businessId, page: 1, limit: 100); + _priceLists = List>.from(res['items'] ?? const []); + } catch (_) { + _priceLists = []; + } + // Currencies: load only business default + active currencies + try { + _currencies = await _currencyService.listBusinessCurrencies(businessId: businessId); + } catch (_) { + _currencies = []; + } + } catch (e) { + // ignore; optional reference data + } + } + + Future _loadExistingPriceItems({required int productId}) async { + _draftPriceItems.clear(); + // Iterate over price lists and collect items for this product + for (final pl in _priceLists) { + final plId = (pl['id'] as num?)?.toInt(); + if (plId == null) continue; + try { + final items = await _priceListService.listItems( + businessId: businessId, + priceListId: plId, + productId: productId, + ); + for (final it in items) { + _draftPriceItems.add(Map.from(it)); + } + } catch (_) { + // skip this price list on error + } + } + } + // Update form data void updateFormData(ProductFormData newData) { _formData = newData; @@ -158,7 +256,7 @@ class ProductFormController extends ChangeNotifier { return formKey.currentState?.validate() ?? false; } - // Submit form + // Submit form (create new product only). For editing, call updateProduct. Future submitForm() async { if (!_formData.name.trim().isNotEmpty) { _setError('نام کالا الزامی است'); @@ -182,14 +280,11 @@ class ProductFormController extends ChangeNotifier { } } - // Check if this is an update or create - final isUpdate = _formData.code != null; // Assuming code indicates existing product - - if (isUpdate) { - // For update, we need the product ID - this should be passed from the calling widget - throw UnimplementedError('Update functionality needs product ID'); - } else { - await _productService.createProduct(businessId: businessId, payload: payload); + // Always create in submitForm; editing handled by updateProduct + final created = await _productService.createProduct(businessId: businessId, payload: payload); + final newId = (created['id'] as num?)?.toInt(); + if (newId != null) { + await _saveDraftPriceItems(productId: newId); } _clearError(); @@ -230,6 +325,7 @@ class ProductFormController extends ChangeNotifier { productId: productId, payload: payload, ); + await _saveDraftPriceItems(productId: productId); _clearError(); return true; @@ -241,6 +337,31 @@ class ProductFormController extends ChangeNotifier { } } + Future _saveDraftPriceItems({required int productId}) async { + // Group by price_list_id and call upsert for each draft row + for (final it in _draftPriceItems) { + final plId = (it['price_list_id'] as num?)?.toInt(); + if (plId == null) continue; + final payload = { + 'product_id': productId, + 'unit_id': it['unit_id'], + 'currency_id': it['currency_id'], + 'tier_name': it['tier_name'], + 'min_qty': it['min_qty'], + 'price': it['price'], + }..removeWhere((k, v) => v == null); + try { + await _priceListService.upsertItem( + businessId: businessId, + priceListId: plId, + payload: payload, + ); + } catch (_) { + // keep going for other items; errors can be surfaced later + } + } + } + // Reset form void resetForm() { _formData = ProductFormData(); diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 1e45f9e..aa7fdf8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -957,6 +957,100 @@ "createCategory": "Create Category", "updateCategory": "Update Category", "deleteCategorySuccess": "Category deleted", - "operationFailed": "Operation failed" + "operationFailed": "Operation failed", + + "productGeneralInfo": "General Information", + "pricingAndInventory": "Pricing & Inventory", + "tax": "Tax", + + "inventoryControl": "Inventory control", + "reorderPoint": "Reorder point", + "reorderPointRepeat": "Reorder point", + "minOrderQty": "Minimum order quantity", + "leadTimeDays": "Lead time (days)", + + "pricing": "Pricing", + "salesPrice": "Sales price", + "salesPriceNote": "Sales price note", + "purchasePrice": "Purchase price", + "purchasePriceNote": "Purchase price note", + + "pricesInPriceLists": "Prices in price lists", + "addPrice": "Add price", + "price": "Price", + "currency": "Currency", + + "noPriceListsTitle": "No price list", + "noPriceListsMessage": "To add a price, first create a price list.", + "noPriceListsHint": "Use \"Manage price lists\" button in Products page to create one.", + "gotIt": "Got it", + + "unitsTitle": "Units", + "mainUnit": "Main unit", + "secondaryUnit": "Secondary unit", + "unitConversionFactor": "Unit conversion factor", + + "itemType": "Type", + "type": "Type", + "productPhysicalDesc": "Physical products", + "serviceDesc": "Services", + + "taxTitle": "Tax", + "taxCode": "Tax code", + "isSalesTaxable": "Sales taxable", + "salesTaxRate": "Sales tax rate (%)", + "isPurchaseTaxable": "Purchase taxable", + "purchaseTaxRate": "Purchase tax rate (%)", + "taxType": "Tax type", + "taxTypeId": "Tax type id", + "taxUnit": "Tax unit", + "taxUnitId": "Tax unit id", + + "bulkPriceUpdateTitle": "Bulk price update", + "bulkPriceUpdateSubtitle": "Increase or decrease prices with advanced filters", + "preview": "Preview", + "applyChanges": "Apply changes", + "changeTypeAndDirection": "Change type & direction", + "changeTarget": "Target", + "changeAmount": "Change amount", + "filters": "Filters", + "previewChanges": "Preview changes", + "percentage": "Percentage", + "amount": "Amount", + "samplePercent": "e.g. 10%", + "sampleAmount": "e.g. 1,000,000", + "increase": "Increase", + "decrease": "Decrease", + "both": "Both", + "allCurrencies": "All currencies", + "priceList": "Price list", + "allPriceLists": "All lists", + "itemTypeLabel": "Item type", + "allTypes": "All types", + "productsWithInventoryOnly": "Only products with inventory", + "productsWithInventoryOnlySubtitle": "Only products with inventory control", + "productsWithBasePriceOnly": "Only products with base price", + "productsWithBasePriceOnlySubtitle": "Only products that have a base price", + "confirmChangesTitle": "Confirm changes", + "confirmApplyChangesForNProducts": "Apply changes to {count} products?", + "irreversibleWarning": "This action is irreversible.", + "summary": "Summary", + "totalProducts": "Total products", + "affectedProducts": "Affected products", + "salesPriceChanges": "Sales price changes", + "purchasePriceChanges": "Purchase price changes", + "codeLabel": "Code", + "salesLabel": "Sales", + "purchaseLabel": "Purchase", + + "managePriceLists": "Manage price lists", + "noProductsReadAccess": "You do not have permission to view products & services", + + "productId": "Product ID", + "invalid": "Invalid", + "unit": "Unit", + "minQty": "Minimum quantity", + "addPriceTitle": "Add price", + "editPriceTitle": "Edit price" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 1f5eecf..72042d1 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -941,6 +941,100 @@ "createCategory": "ایجاد دسته‌بندی", "updateCategory": "به‌روزرسانی دسته‌بندی", "deleteCategorySuccess": "دسته‌بندی حذف شد", - "operationFailed": "عملیات ناموفق بود" + "operationFailed": "عملیات ناموفق بود", + + "productGeneralInfo": "اطلاعات کلی", + "pricingAndInventory": "قیمت و موجودی", + "tax": "مالیات", + + "inventoryControl": "کنترل موجودی", + "reorderPoint": "نقطه سفارش", + "reorderPointRepeat": "نقطه سفارش مجدد", + "minOrderQty": "کمینه مقدار سفارش", + "leadTimeDays": "زمان تحویل (روز)", + + "pricing": "قیمت‌گذاری", + "salesPrice": "قیمت فروش", + "salesPriceNote": "توضیح قیمت فروش", + "purchasePrice": "قیمت خرید", + "purchasePriceNote": "توضیح قیمت خرید", + + "pricesInPriceLists": "قیمت در لیست‌های قیمت", + "addPrice": "افزودن قیمت", + "price": "قیمت", + "currency": "ارز", + + "noPriceListsTitle": "لیست قیمت موجود نیست", + "noPriceListsMessage": "برای افزودن قیمت، ابتدا باید یک لیست قیمت ایجاد کنید.", + "noPriceListsHint": "برای ایجاد لیست قیمت، از دکمه \"مدیریت لیست‌های قیمت\" در صفحه محصولات استفاده کنید.", + "gotIt": "متوجه شدم", + + "unitsTitle": "واحدها", + "mainUnit": "واحد اصلی", + "secondaryUnit": "واحد فرعی", + "unitConversionFactor": "ضریب تبدیل واحد", + + "itemType": "نوع", + "type": "نوع", + "productPhysicalDesc": "محصولات فیزیکی", + "serviceDesc": "خدمات و سرویس‌ها", + + "taxTitle": "مالیات", + "taxCode": "کُد مالیاتی", + "isSalesTaxable": "مشمول مالیات فروش", + "salesTaxRate": "نرخ مالیات فروش (%)", + "isPurchaseTaxable": "مشمول مالیات خرید", + "purchaseTaxRate": "نرخ مالیات خرید (%)", + "taxType": "نوع مالیات", + "taxTypeId": "شناسه نوع مالیات", + "taxUnit": "واحد مالیاتی", + "taxUnitId": "شناسه واحد مالیاتی", + + "bulkPriceUpdateTitle": "تغییر قیمت‌های گروهی", + "bulkPriceUpdateSubtitle": "اعمال افزایش یا کاهش قیمت با فیلترهای پیشرفته", + "preview": "پیش‌نمایش", + "applyChanges": "اعمال تغییرات", + "changeTypeAndDirection": "نوع و جهت تغییر", + "changeTarget": "هدف تغییر", + "changeAmount": "مقدار تغییر", + "filters": "فیلترها", + "previewChanges": "پیش‌نمایش تغییرات", + "percentage": "درصدی", + "amount": "مقداری", + "samplePercent": "مثلاً 10٪", + "sampleAmount": "مثلاً 1,000,000", + "increase": "افزایش", + "decrease": "کاهش", + "both": "هر دو", + "allCurrencies": "همه ارزها", + "priceList": "لیست قیمت", + "allPriceLists": "همه لیست‌ها", + "itemTypeLabel": "نوع آیتم", + "allTypes": "همه انواع", + "productsWithInventoryOnly": "فقط کالاهای با موجودی", + "productsWithInventoryOnlySubtitle": "فقط کالاهایی که موجودی آن‌ها کنترل می‌شود", + "productsWithBasePriceOnly": "فقط کالاهای با قیمت پایه", + "productsWithBasePriceOnlySubtitle": "فقط کالاهایی که قیمت پایه دارند", + "confirmChangesTitle": "تأیید تغییرات", + "confirmApplyChangesForNProducts": "آیا از اعمال تغییرات بر روی {count} کالا اطمینان دارید؟", + "irreversibleWarning": "این عمل قابل بازگشت نیست.", + "summary": "خلاصه تغییرات", + "totalProducts": "کل کالاها", + "affectedProducts": "کالاهای تأثیرپذیر", + "salesPriceChanges": "تغییرات قیمت فروش", + "purchasePriceChanges": "تغییرات قیمت خرید", + "codeLabel": "کُد", + "salesLabel": "فروش", + "purchaseLabel": "خرید", + + "managePriceLists": "مدیریت لیست‌های قیمت", + "noProductsReadAccess": "دسترسی مشاهده کالا و خدمات را ندارید", + + "productId": "شناسه کالا", + "invalid": "نامعتبر", + "unit": "واحد", + "minQty": "حداقل تعداد", + "addPriceTitle": "افزودن قیمت", + "editPriceTitle": "ویرایش قیمت" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index 7a0a1ba..0f32edc 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -5071,6 +5071,498 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Operation failed'** String get operationFailed; + + /// No description provided for @productGeneralInfo. + /// + /// In en, this message translates to: + /// **'General Information'** + String get productGeneralInfo; + + /// No description provided for @pricingAndInventory. + /// + /// In en, this message translates to: + /// **'Pricing & Inventory'** + String get pricingAndInventory; + + /// No description provided for @tax. + /// + /// In en, this message translates to: + /// **'Tax'** + String get tax; + + /// No description provided for @inventoryControl. + /// + /// In en, this message translates to: + /// **'Inventory control'** + String get inventoryControl; + + /// No description provided for @reorderPoint. + /// + /// In en, this message translates to: + /// **'Reorder point'** + String get reorderPoint; + + /// No description provided for @reorderPointRepeat. + /// + /// In en, this message translates to: + /// **'Reorder point'** + String get reorderPointRepeat; + + /// No description provided for @minOrderQty. + /// + /// In en, this message translates to: + /// **'Minimum order quantity'** + String get minOrderQty; + + /// No description provided for @leadTimeDays. + /// + /// In en, this message translates to: + /// **'Lead time (days)'** + String get leadTimeDays; + + /// No description provided for @pricing. + /// + /// In en, this message translates to: + /// **'Pricing'** + String get pricing; + + /// No description provided for @salesPrice. + /// + /// In en, this message translates to: + /// **'Sales price'** + String get salesPrice; + + /// No description provided for @salesPriceNote. + /// + /// In en, this message translates to: + /// **'Sales price note'** + String get salesPriceNote; + + /// No description provided for @purchasePrice. + /// + /// In en, this message translates to: + /// **'Purchase price'** + String get purchasePrice; + + /// No description provided for @purchasePriceNote. + /// + /// In en, this message translates to: + /// **'Purchase price note'** + String get purchasePriceNote; + + /// No description provided for @pricesInPriceLists. + /// + /// In en, this message translates to: + /// **'Prices in price lists'** + String get pricesInPriceLists; + + /// No description provided for @addPrice. + /// + /// In en, this message translates to: + /// **'Add price'** + String get addPrice; + + /// No description provided for @price. + /// + /// In en, this message translates to: + /// **'Price'** + String get price; + + /// No description provided for @currency. + /// + /// In en, this message translates to: + /// **'Currency'** + String get currency; + + /// No description provided for @noPriceListsTitle. + /// + /// In en, this message translates to: + /// **'No price list'** + String get noPriceListsTitle; + + /// No description provided for @noPriceListsMessage. + /// + /// In en, this message translates to: + /// **'To add a price, first create a price list.'** + String get noPriceListsMessage; + + /// No description provided for @noPriceListsHint. + /// + /// In en, this message translates to: + /// **'Use \"Manage price lists\" button in Products page to create one.'** + String get noPriceListsHint; + + /// No description provided for @gotIt. + /// + /// In en, this message translates to: + /// **'Got it'** + String get gotIt; + + /// No description provided for @unitsTitle. + /// + /// In en, this message translates to: + /// **'Units'** + String get unitsTitle; + + /// No description provided for @mainUnit. + /// + /// In en, this message translates to: + /// **'Main unit'** + String get mainUnit; + + /// No description provided for @secondaryUnit. + /// + /// In en, this message translates to: + /// **'Secondary unit'** + String get secondaryUnit; + + /// No description provided for @unitConversionFactor. + /// + /// In en, this message translates to: + /// **'Unit conversion factor'** + String get unitConversionFactor; + + /// No description provided for @itemType. + /// + /// In en, this message translates to: + /// **'Type'** + String get itemType; + + /// No description provided for @type. + /// + /// In en, this message translates to: + /// **'Type'** + String get type; + + /// No description provided for @productPhysicalDesc. + /// + /// In en, this message translates to: + /// **'Physical products'** + String get productPhysicalDesc; + + /// No description provided for @serviceDesc. + /// + /// In en, this message translates to: + /// **'Services'** + String get serviceDesc; + + /// No description provided for @taxTitle. + /// + /// In en, this message translates to: + /// **'Tax'** + String get taxTitle; + + /// No description provided for @taxCode. + /// + /// In en, this message translates to: + /// **'Tax code'** + String get taxCode; + + /// No description provided for @isSalesTaxable. + /// + /// In en, this message translates to: + /// **'Sales taxable'** + String get isSalesTaxable; + + /// No description provided for @salesTaxRate. + /// + /// In en, this message translates to: + /// **'Sales tax rate (%)'** + String get salesTaxRate; + + /// No description provided for @isPurchaseTaxable. + /// + /// In en, this message translates to: + /// **'Purchase taxable'** + String get isPurchaseTaxable; + + /// No description provided for @purchaseTaxRate. + /// + /// In en, this message translates to: + /// **'Purchase tax rate (%)'** + String get purchaseTaxRate; + + /// No description provided for @taxType. + /// + /// In en, this message translates to: + /// **'Tax type'** + String get taxType; + + /// No description provided for @taxTypeId. + /// + /// In en, this message translates to: + /// **'Tax type id'** + String get taxTypeId; + + /// No description provided for @taxUnit. + /// + /// In en, this message translates to: + /// **'Tax unit'** + String get taxUnit; + + /// No description provided for @taxUnitId. + /// + /// In en, this message translates to: + /// **'Tax unit id'** + String get taxUnitId; + + /// No description provided for @bulkPriceUpdateTitle. + /// + /// In en, this message translates to: + /// **'Bulk price update'** + String get bulkPriceUpdateTitle; + + /// No description provided for @bulkPriceUpdateSubtitle. + /// + /// In en, this message translates to: + /// **'Increase or decrease prices with advanced filters'** + String get bulkPriceUpdateSubtitle; + + /// No description provided for @preview. + /// + /// In en, this message translates to: + /// **'Preview'** + String get preview; + + /// No description provided for @applyChanges. + /// + /// In en, this message translates to: + /// **'Apply changes'** + String get applyChanges; + + /// No description provided for @changeTypeAndDirection. + /// + /// In en, this message translates to: + /// **'Change type & direction'** + String get changeTypeAndDirection; + + /// No description provided for @changeTarget. + /// + /// In en, this message translates to: + /// **'Target'** + String get changeTarget; + + /// No description provided for @changeAmount. + /// + /// In en, this message translates to: + /// **'Change amount'** + String get changeAmount; + + /// No description provided for @filters. + /// + /// In en, this message translates to: + /// **'Filters'** + String get filters; + + /// No description provided for @previewChanges. + /// + /// In en, this message translates to: + /// **'Preview changes'** + String get previewChanges; + + /// No description provided for @percentage. + /// + /// In en, this message translates to: + /// **'Percentage'** + String get percentage; + + /// No description provided for @amount. + /// + /// In en, this message translates to: + /// **'Amount'** + String get amount; + + /// No description provided for @samplePercent. + /// + /// In en, this message translates to: + /// **'e.g. 10%'** + String get samplePercent; + + /// No description provided for @sampleAmount. + /// + /// In en, this message translates to: + /// **'e.g. 1,000,000'** + String get sampleAmount; + + /// No description provided for @increase. + /// + /// In en, this message translates to: + /// **'Increase'** + String get increase; + + /// No description provided for @decrease. + /// + /// In en, this message translates to: + /// **'Decrease'** + String get decrease; + + /// No description provided for @both. + /// + /// In en, this message translates to: + /// **'Both'** + String get both; + + /// No description provided for @allCurrencies. + /// + /// In en, this message translates to: + /// **'All currencies'** + String get allCurrencies; + + /// No description provided for @priceList. + /// + /// In en, this message translates to: + /// **'Price list'** + String get priceList; + + /// No description provided for @allPriceLists. + /// + /// In en, this message translates to: + /// **'All lists'** + String get allPriceLists; + + /// No description provided for @itemTypeLabel. + /// + /// In en, this message translates to: + /// **'Item type'** + String get itemTypeLabel; + + /// No description provided for @allTypes. + /// + /// In en, this message translates to: + /// **'All types'** + String get allTypes; + + /// No description provided for @productsWithInventoryOnly. + /// + /// In en, this message translates to: + /// **'Only products with inventory'** + String get productsWithInventoryOnly; + + /// No description provided for @productsWithInventoryOnlySubtitle. + /// + /// In en, this message translates to: + /// **'Only products with inventory control'** + String get productsWithInventoryOnlySubtitle; + + /// No description provided for @productsWithBasePriceOnly. + /// + /// In en, this message translates to: + /// **'Only products with base price'** + String get productsWithBasePriceOnly; + + /// No description provided for @productsWithBasePriceOnlySubtitle. + /// + /// In en, this message translates to: + /// **'Only products that have a base price'** + String get productsWithBasePriceOnlySubtitle; + + /// No description provided for @confirmChangesTitle. + /// + /// In en, this message translates to: + /// **'Confirm changes'** + String get confirmChangesTitle; + + /// No description provided for @confirmApplyChangesForNProducts. + /// + /// In en, this message translates to: + /// **'Apply changes to {count} products?'** + String confirmApplyChangesForNProducts(Object count); + + /// No description provided for @irreversibleWarning. + /// + /// In en, this message translates to: + /// **'This action is irreversible.'** + String get irreversibleWarning; + + /// No description provided for @summary. + /// + /// In en, this message translates to: + /// **'Summary'** + String get summary; + + /// No description provided for @totalProducts. + /// + /// In en, this message translates to: + /// **'Total products'** + String get totalProducts; + + /// No description provided for @affectedProducts. + /// + /// In en, this message translates to: + /// **'Affected products'** + String get affectedProducts; + + /// No description provided for @salesPriceChanges. + /// + /// In en, this message translates to: + /// **'Sales price changes'** + String get salesPriceChanges; + + /// No description provided for @purchasePriceChanges. + /// + /// In en, this message translates to: + /// **'Purchase price changes'** + String get purchasePriceChanges; + + /// No description provided for @codeLabel. + /// + /// In en, this message translates to: + /// **'Code'** + String get codeLabel; + + /// No description provided for @salesLabel. + /// + /// In en, this message translates to: + /// **'Sales'** + String get salesLabel; + + /// No description provided for @purchaseLabel. + /// + /// In en, this message translates to: + /// **'Purchase'** + String get purchaseLabel; + + /// No description provided for @managePriceLists. + /// + /// In en, this message translates to: + /// **'Manage price lists'** + String get managePriceLists; + + /// No description provided for @noProductsReadAccess. + /// + /// In en, this message translates to: + /// **'You do not have permission to view products & services'** + String get noProductsReadAccess; + + /// No description provided for @productId. + /// + /// In en, this message translates to: + /// **'Product ID'** + String get productId; + + /// No description provided for @unit. + /// + /// In en, this message translates to: + /// **'Unit'** + String get unit; + + /// No description provided for @minQty. + /// + /// In en, this message translates to: + /// **'Minimum quantity'** + String get minQty; + + /// No description provided for @addPriceTitle. + /// + /// In en, this message translates to: + /// **'Add price'** + String get addPriceTitle; + + /// No description provided for @editPriceTitle. + /// + /// In en, this message translates to: + /// **'Edit price'** + String get editPriceTitle; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 2150e6a..5e93aa8 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -2563,4 +2563,258 @@ class AppLocalizationsEn extends AppLocalizations { @override String get operationFailed => 'Operation failed'; + + @override + String get productGeneralInfo => 'General Information'; + + @override + String get pricingAndInventory => 'Pricing & Inventory'; + + @override + String get tax => 'Tax'; + + @override + String get inventoryControl => 'Inventory control'; + + @override + String get reorderPoint => 'Reorder point'; + + @override + String get reorderPointRepeat => 'Reorder point'; + + @override + String get minOrderQty => 'Minimum order quantity'; + + @override + String get leadTimeDays => 'Lead time (days)'; + + @override + String get pricing => 'Pricing'; + + @override + String get salesPrice => 'Sales price'; + + @override + String get salesPriceNote => 'Sales price note'; + + @override + String get purchasePrice => 'Purchase price'; + + @override + String get purchasePriceNote => 'Purchase price note'; + + @override + String get pricesInPriceLists => 'Prices in price lists'; + + @override + String get addPrice => 'Add price'; + + @override + String get price => 'Price'; + + @override + String get currency => 'Currency'; + + @override + String get noPriceListsTitle => 'No price list'; + + @override + String get noPriceListsMessage => + 'To add a price, first create a price list.'; + + @override + String get noPriceListsHint => + 'Use \"Manage price lists\" button in Products page to create one.'; + + @override + String get gotIt => 'Got it'; + + @override + String get unitsTitle => 'Units'; + + @override + String get mainUnit => 'Main unit'; + + @override + String get secondaryUnit => 'Secondary unit'; + + @override + String get unitConversionFactor => 'Unit conversion factor'; + + @override + String get itemType => 'Type'; + + @override + String get type => 'Type'; + + @override + String get productPhysicalDesc => 'Physical products'; + + @override + String get serviceDesc => 'Services'; + + @override + String get taxTitle => 'Tax'; + + @override + String get taxCode => 'Tax code'; + + @override + String get isSalesTaxable => 'Sales taxable'; + + @override + String get salesTaxRate => 'Sales tax rate (%)'; + + @override + String get isPurchaseTaxable => 'Purchase taxable'; + + @override + String get purchaseTaxRate => 'Purchase tax rate (%)'; + + @override + String get taxType => 'Tax type'; + + @override + String get taxTypeId => 'Tax type id'; + + @override + String get taxUnit => 'Tax unit'; + + @override + String get taxUnitId => 'Tax unit id'; + + @override + String get bulkPriceUpdateTitle => 'Bulk price update'; + + @override + String get bulkPriceUpdateSubtitle => + 'Increase or decrease prices with advanced filters'; + + @override + String get preview => 'Preview'; + + @override + String get applyChanges => 'Apply changes'; + + @override + String get changeTypeAndDirection => 'Change type & direction'; + + @override + String get changeTarget => 'Target'; + + @override + String get changeAmount => 'Change amount'; + + @override + String get filters => 'Filters'; + + @override + String get previewChanges => 'Preview changes'; + + @override + String get percentage => 'Percentage'; + + @override + String get amount => 'Amount'; + + @override + String get samplePercent => 'e.g. 10%'; + + @override + String get sampleAmount => 'e.g. 1,000,000'; + + @override + String get increase => 'Increase'; + + @override + String get decrease => 'Decrease'; + + @override + String get both => 'Both'; + + @override + String get allCurrencies => 'All currencies'; + + @override + String get priceList => 'Price list'; + + @override + String get allPriceLists => 'All lists'; + + @override + String get itemTypeLabel => 'Item type'; + + @override + String get allTypes => 'All types'; + + @override + String get productsWithInventoryOnly => 'Only products with inventory'; + + @override + String get productsWithInventoryOnlySubtitle => + 'Only products with inventory control'; + + @override + String get productsWithBasePriceOnly => 'Only products with base price'; + + @override + String get productsWithBasePriceOnlySubtitle => + 'Only products that have a base price'; + + @override + String get confirmChangesTitle => 'Confirm changes'; + + @override + String confirmApplyChangesForNProducts(Object count) { + return 'Apply changes to $count products?'; + } + + @override + String get irreversibleWarning => 'This action is irreversible.'; + + @override + String get summary => 'Summary'; + + @override + String get totalProducts => 'Total products'; + + @override + String get affectedProducts => 'Affected products'; + + @override + String get salesPriceChanges => 'Sales price changes'; + + @override + String get purchasePriceChanges => 'Purchase price changes'; + + @override + String get codeLabel => 'Code'; + + @override + String get salesLabel => 'Sales'; + + @override + String get purchaseLabel => 'Purchase'; + + @override + String get managePriceLists => 'Manage price lists'; + + @override + String get noProductsReadAccess => + 'You do not have permission to view products & services'; + + @override + String get productId => 'Product ID'; + + @override + String get unit => 'Unit'; + + @override + String get minQty => 'Minimum quantity'; + + @override + String get addPriceTitle => 'Add price'; + + @override + String get editPriceTitle => 'Edit price'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 80be4d8..0017fd6 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -2544,4 +2544,257 @@ class AppLocalizationsFa extends AppLocalizations { @override String get operationFailed => 'عملیات ناموفق بود'; + + @override + String get productGeneralInfo => 'اطلاعات کلی'; + + @override + String get pricingAndInventory => 'قیمت و موجودی'; + + @override + String get tax => 'مالیات'; + + @override + String get inventoryControl => 'کنترل موجودی'; + + @override + String get reorderPoint => 'نقطه سفارش'; + + @override + String get reorderPointRepeat => 'نقطه سفارش مجدد'; + + @override + String get minOrderQty => 'کمینه مقدار سفارش'; + + @override + String get leadTimeDays => 'زمان تحویل (روز)'; + + @override + String get pricing => 'قیمت‌گذاری'; + + @override + String get salesPrice => 'قیمت فروش'; + + @override + String get salesPriceNote => 'توضیح قیمت فروش'; + + @override + String get purchasePrice => 'قیمت خرید'; + + @override + String get purchasePriceNote => 'توضیح قیمت خرید'; + + @override + String get pricesInPriceLists => 'قیمت در لیست‌های قیمت'; + + @override + String get addPrice => 'افزودن قیمت'; + + @override + String get price => 'قیمت'; + + @override + String get currency => 'ارز'; + + @override + String get noPriceListsTitle => 'لیست قیمت موجود نیست'; + + @override + String get noPriceListsMessage => + 'برای افزودن قیمت، ابتدا باید یک لیست قیمت ایجاد کنید.'; + + @override + String get noPriceListsHint => + 'برای ایجاد لیست قیمت، از دکمه \"مدیریت لیست‌های قیمت\" در صفحه محصولات استفاده کنید.'; + + @override + String get gotIt => 'متوجه شدم'; + + @override + String get unitsTitle => 'واحدها'; + + @override + String get mainUnit => 'واحد اصلی'; + + @override + String get secondaryUnit => 'واحد فرعی'; + + @override + String get unitConversionFactor => 'ضریب تبدیل واحد'; + + @override + String get itemType => 'نوع'; + + @override + String get type => 'نوع'; + + @override + String get productPhysicalDesc => 'محصولات فیزیکی'; + + @override + String get serviceDesc => 'خدمات و سرویس‌ها'; + + @override + String get taxTitle => 'مالیات'; + + @override + String get taxCode => 'کُد مالیاتی'; + + @override + String get isSalesTaxable => 'مشمول مالیات فروش'; + + @override + String get salesTaxRate => 'نرخ مالیات فروش (%)'; + + @override + String get isPurchaseTaxable => 'مشمول مالیات خرید'; + + @override + String get purchaseTaxRate => 'نرخ مالیات خرید (%)'; + + @override + String get taxType => 'نوع مالیات'; + + @override + String get taxTypeId => 'شناسه نوع مالیات'; + + @override + String get taxUnit => 'واحد مالیاتی'; + + @override + String get taxUnitId => 'شناسه واحد مالیاتی'; + + @override + String get bulkPriceUpdateTitle => 'تغییر قیمت‌های گروهی'; + + @override + String get bulkPriceUpdateSubtitle => + 'اعمال افزایش یا کاهش قیمت با فیلترهای پیشرفته'; + + @override + String get preview => 'پیش‌نمایش'; + + @override + String get applyChanges => 'اعمال تغییرات'; + + @override + String get changeTypeAndDirection => 'نوع و جهت تغییر'; + + @override + String get changeTarget => 'هدف تغییر'; + + @override + String get changeAmount => 'مقدار تغییر'; + + @override + String get filters => 'فیلترها'; + + @override + String get previewChanges => 'پیش‌نمایش تغییرات'; + + @override + String get percentage => 'درصدی'; + + @override + String get amount => 'مقداری'; + + @override + String get samplePercent => 'مثلاً 10٪'; + + @override + String get sampleAmount => 'مثلاً 1,000,000'; + + @override + String get increase => 'افزایش'; + + @override + String get decrease => 'کاهش'; + + @override + String get both => 'هر دو'; + + @override + String get allCurrencies => 'همه ارزها'; + + @override + String get priceList => 'لیست قیمت'; + + @override + String get allPriceLists => 'همه لیست‌ها'; + + @override + String get itemTypeLabel => 'نوع آیتم'; + + @override + String get allTypes => 'همه انواع'; + + @override + String get productsWithInventoryOnly => 'فقط کالاهای با موجودی'; + + @override + String get productsWithInventoryOnlySubtitle => + 'فقط کالاهایی که موجودی آن‌ها کنترل می‌شود'; + + @override + String get productsWithBasePriceOnly => 'فقط کالاهای با قیمت پایه'; + + @override + String get productsWithBasePriceOnlySubtitle => + 'فقط کالاهایی که قیمت پایه دارند'; + + @override + String get confirmChangesTitle => 'تأیید تغییرات'; + + @override + String confirmApplyChangesForNProducts(Object count) { + return 'آیا از اعمال تغییرات بر روی $count کالا اطمینان دارید؟'; + } + + @override + String get irreversibleWarning => 'این عمل قابل بازگشت نیست.'; + + @override + String get summary => 'خلاصه تغییرات'; + + @override + String get totalProducts => 'کل کالاها'; + + @override + String get affectedProducts => 'کالاهای تأثیرپذیر'; + + @override + String get salesPriceChanges => 'تغییرات قیمت فروش'; + + @override + String get purchasePriceChanges => 'تغییرات قیمت خرید'; + + @override + String get codeLabel => 'کُد'; + + @override + String get salesLabel => 'فروش'; + + @override + String get purchaseLabel => 'خرید'; + + @override + String get managePriceLists => 'مدیریت لیست‌های قیمت'; + + @override + String get noProductsReadAccess => 'دسترسی مشاهده کالا و خدمات را ندارید'; + + @override + String get productId => 'شناسه کالا'; + + @override + String get unit => 'واحد'; + + @override + String get minQty => 'حداقل تعداد'; + + @override + String get addPriceTitle => 'افزودن قیمت'; + + @override + String get editPriceTitle => 'ویرایش قیمت'; } diff --git a/hesabixUI/hesabix_ui/lib/models/bulk_price_update_data.dart b/hesabixUI/hesabix_ui/lib/models/bulk_price_update_data.dart new file mode 100644 index 0000000..cd04a02 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/bulk_price_update_data.dart @@ -0,0 +1,147 @@ +enum BulkPriceUpdateType { + percentage, + amount, +} + +enum BulkPriceUpdateDirection { + increase, + decrease, +} + +enum BulkPriceUpdateTarget { + salesPrice, + purchasePrice, + both, +} + +class BulkPriceUpdateRequest { + final BulkPriceUpdateType updateType; + final BulkPriceUpdateDirection direction; + final BulkPriceUpdateTarget target; + final double value; + + // فیلترهای انتخاب کالاها + final List? categoryIds; + final List? currencyIds; + final List? priceListIds; + final List? itemTypes; + final List? productIds; + + // گزینه‌های اضافی + final bool? onlyProductsWithInventory; + final bool onlyProductsWithBasePrice; + + BulkPriceUpdateRequest({ + required this.updateType, + this.direction = BulkPriceUpdateDirection.increase, + required this.target, + required this.value, + this.categoryIds, + this.currencyIds, + this.priceListIds, + this.itemTypes, + this.productIds, + this.onlyProductsWithInventory, + this.onlyProductsWithBasePrice = true, + }); + + Map toJson() { + return { + 'update_type': updateType.name, + 'direction': direction.name, + 'target': _targetToSnakeCase(target), + 'value': value, + if (categoryIds != null) 'category_ids': categoryIds, + if (currencyIds != null) 'currency_ids': currencyIds, + if (priceListIds != null) 'price_list_ids': priceListIds, + if (itemTypes != null) 'item_types': itemTypes, + if (productIds != null) 'product_ids': productIds, + if (onlyProductsWithInventory != null) 'only_products_with_inventory': onlyProductsWithInventory, + 'only_products_with_base_price': onlyProductsWithBasePrice, + }; + } + + String _targetToSnakeCase(BulkPriceUpdateTarget target) { + switch (target) { + case BulkPriceUpdateTarget.salesPrice: + return 'sales_price'; + case BulkPriceUpdateTarget.purchasePrice: + return 'purchase_price'; + case BulkPriceUpdateTarget.both: + return 'both'; + } + } +} + +class BulkPriceUpdatePreview { + final int productId; + final String productName; + final String productCode; + final String? categoryName; + final double? currentSalesPrice; + final double? currentPurchasePrice; + final double? newSalesPrice; + final double? newPurchasePrice; + final double? salesPriceChange; + final double? purchasePriceChange; + + BulkPriceUpdatePreview({ + required this.productId, + required this.productName, + required this.productCode, + this.categoryName, + this.currentSalesPrice, + this.currentPurchasePrice, + this.newSalesPrice, + this.newPurchasePrice, + this.salesPriceChange, + this.purchasePriceChange, + }); + + factory BulkPriceUpdatePreview.fromJson(Map json) { + return BulkPriceUpdatePreview( + productId: json['product_id'] as int, + productName: json['product_name']?.toString() ?? 'بدون نام', + productCode: json['product_code']?.toString() ?? 'بدون کد', + categoryName: json['category_name']?.toString(), + currentSalesPrice: _parsePrice(json['current_sales_price']), + currentPurchasePrice: _parsePrice(json['current_purchase_price']), + newSalesPrice: _parsePrice(json['new_sales_price']), + newPurchasePrice: _parsePrice(json['new_purchase_price']), + salesPriceChange: _parsePrice(json['sales_price_change']), + purchasePriceChange: _parsePrice(json['purchase_price_change']), + ); + } + + static double? _parsePrice(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + if (value is String) { + final parsed = double.tryParse(value); + return parsed; + } + return null; + } +} + +class BulkPriceUpdatePreviewResponse { + final int totalProducts; + final List affectedProducts; + final Map summary; + + BulkPriceUpdatePreviewResponse({ + required this.totalProducts, + required this.affectedProducts, + required this.summary, + }); + + factory BulkPriceUpdatePreviewResponse.fromJson(Map json) { + return BulkPriceUpdatePreviewResponse( + totalProducts: json['total_products'] as int, + affectedProducts: (json['affected_products'] as List) + .map((e) => BulkPriceUpdatePreview.fromJson(e as Map)) + .toList(), + summary: json['summary'] as Map, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/models/product_form_data.dart b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart index 24c9d60..b642e89 100644 --- a/hesabixUI/hesabix_ui/lib/models/product_form_data.dart +++ b/hesabixUI/hesabix_ui/lib/models/product_form_data.dart @@ -21,7 +21,7 @@ class ProductFormData { // Units int? mainUnitId; int? secondaryUnitId; - num? unitConversionFactor; + num unitConversionFactor; // Taxes bool isSalesTaxable; @@ -51,7 +51,7 @@ class ProductFormData { this.basePurchaseNote, this.mainUnitId, this.secondaryUnitId, - this.unitConversionFactor, + this.unitConversionFactor = 1, this.isSalesTaxable = false, this.isPurchaseTaxable = false, this.salesTaxRate, @@ -117,32 +117,37 @@ class ProductFormData { } Map toPayload() { - return { + final payload = { 'item_type': itemType, 'code': code, 'name': name, 'description': description, 'category_id': categoryId, 'track_inventory': trackInventory, - 'base_sales_price': baseSalesPrice, - 'base_purchase_price': basePurchasePrice, + // Default numeric fields to zero when null + 'base_sales_price': baseSalesPrice ?? 0, + 'base_purchase_price': basePurchasePrice ?? 0, + 'reorder_point': reorderPoint ?? 0, + 'min_order_qty': minOrderQty ?? 0, + 'lead_time_days': leadTimeDays ?? 0, + 'is_sales_taxable': isSalesTaxable, + 'is_purchase_taxable': isPurchaseTaxable, + 'sales_tax_rate': salesTaxRate ?? 0, + 'purchase_tax_rate': purchaseTaxRate ?? 0, + // Keep optional IDs and factor as-is (do not force zero) 'main_unit_id': mainUnitId, 'secondary_unit_id': secondaryUnitId, 'unit_conversion_factor': unitConversionFactor, 'base_sales_note': baseSalesNote, 'base_purchase_note': basePurchaseNote, - 'reorder_point': reorderPoint, - 'min_order_qty': minOrderQty, - 'lead_time_days': leadTimeDays, - 'is_sales_taxable': isSalesTaxable, - 'is_purchase_taxable': isPurchaseTaxable, - 'sales_tax_rate': salesTaxRate, - 'purchase_tax_rate': purchaseTaxRate, 'tax_type_id': taxTypeId, 'tax_code': taxCode, 'tax_unit_id': taxUnitId, 'attribute_ids': selectedAttributeIds.isEmpty ? null : selectedAttributeIds.toList(), - }..removeWhere((k, v) => v == null); + }; + // Remove only nulls we intentionally kept nullable + payload.removeWhere((k, v) => v == null); + return payload; } factory ProductFormData.fromProduct(Map product) { @@ -157,7 +162,7 @@ class ProductFormData { basePurchasePrice: _parseNumeric(product['base_purchase_price']), mainUnitId: product['main_unit_id'] as int?, secondaryUnitId: product['secondary_unit_id'] as int?, - unitConversionFactor: _parseNumeric(product['unit_conversion_factor']), + unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1, baseSalesNote: product['base_sales_note']?.toString(), basePurchaseNote: product['base_purchase_note']?.toString(), reorderPoint: _parseInt(product['reorder_point']), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index fe2dc7e..26d5b50 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -6,6 +6,7 @@ import '../../core/calendar_controller.dart'; import '../../theme/theme_controller.dart'; import '../../widgets/combined_user_menu_button.dart'; import '../../widgets/person/person_form_dialog.dart'; +import '../../widgets/product/product_form_dialog.dart'; import '../../widgets/category/category_tree_dialog.dart'; import '../../services/business_dashboard_service.dart'; import '../../core/api_client.dart'; @@ -504,6 +505,23 @@ class _BusinessShellState extends State { } } + Future showAddProductDialog() async { + final result = await showDialog( + context: context, + builder: (context) => ProductFormDialog( + businessId: widget.businessId, + authStore: widget.authStore, + onSuccess: () { + // Refresh the products page if it's currently open + // This will be handled by the ProductsPage itself + }, + ), + ); + if (result == true) { + // Product was successfully added + } + } + bool isExpanded(_MenuItem item) { if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded; if (item.label == t.banking) return _isBankingExpanded; @@ -680,7 +698,8 @@ class _BusinessShellState extends State { // Navigate to add person showAddPersonDialog(); } else if (child.label == t.products) { - // Navigate to add product + // Show add product dialog + showAddProductDialog(); } else if (child.label == t.categories) { // Navigate to add category } else if (child.label == t.productAttributes) { @@ -972,7 +991,8 @@ class _BusinessShellState extends State { context.pop(); // Navigate to add new item if (child.label == t.products) { - // Navigate to add product + // Show add product dialog + showAddProductDialog(); } else if (child.label == t.categories) { // Navigate to add category } else if (child.label == t.productAttributes) { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart index ff1869f..2487a4e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/price_list_items_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../core/auth_store.dart'; import '../../services/price_list_service.dart'; @@ -57,7 +58,7 @@ class _PriceListItemsPageState extends State { IconButton( icon: const Icon(Icons.add), onPressed: () => _openEditor(), - tooltip: 'افزودن قیمت', + tooltip: t.addPrice, ), ], ), @@ -68,9 +69,10 @@ class _PriceListItemsPageState extends State { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (ctx, i) { final it = _items[i]; + final t = AppLocalizations.of(ctx); return ListTile( - title: Text('کالا ${it['product_id']} - ${it['tier_name']}'), - subtitle: Text('حداقل ${it['min_qty']} - قیمت ${it['price']}'), + title: Text('${t.products} ${it['product_id']} - ${it['tier_name']}'), + subtitle: Text('${t.minQty} ${it['min_qty']} - ${t.price} ${it['price']} - ${t.currency} ${it['currency_id'] ?? '-'}'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -100,84 +102,115 @@ class _PriceListItemsPageState extends State { int? unitId = item?['unit_id'] as int?; num minQty = (item?['min_qty'] as num?) ?? 0; num price = (item?['price'] as num?) ?? 0; + int? currencyId = item?['currency_id'] as int? ?? (_fallbackCurrencies.first['id'] as int); await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(item == null ? 'افزودن قیمت' : 'ویرایش قیمت'), - content: SizedBox( - width: 520, - child: Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - initialValue: productId?.toString(), - decoration: const InputDecoration(labelText: 'شناسه کالا'), - keyboardType: TextInputType.number, - validator: (v) => (int.tryParse(v ?? '') == null) ? 'نامعتبر' : null, - onChanged: (v) => productId = int.tryParse(v), - ), - TextFormField( - initialValue: tierName, - decoration: const InputDecoration(labelText: 'نام پله (مثلاً: تکی/عمده/همکار)'), - validator: (v) => (v == null || v.trim().isEmpty) ? 'ضروری' : null, - onChanged: (v) => tierName = v, - ), - DropdownButtonFormField( - value: unitId, - items: _fallbackUnits - .map((u) => DropdownMenuItem( - value: u['id'] as int, - child: Text(u['title'] as String), - )) - .toList(), - onChanged: (v) => unitId = v, - decoration: const InputDecoration(labelText: 'واحد'), - ), - TextFormField( - initialValue: minQty.toString(), - decoration: const InputDecoration(labelText: 'حداقل تعداد'), - keyboardType: TextInputType.number, - validator: (v) => (num.tryParse(v ?? '') == null) ? 'نامعتبر' : null, - onChanged: (v) => minQty = num.tryParse(v) ?? 0, - ), - TextFormField( - initialValue: price.toString(), - decoration: const InputDecoration(labelText: 'قیمت'), - keyboardType: TextInputType.number, - validator: (v) => (num.tryParse(v ?? '') == null) ? 'نامعتبر' : null, - onChanged: (v) => price = num.tryParse(v) ?? 0, - ), - ], + builder: (ctx) { + final t = AppLocalizations.of(ctx); + return AlertDialog( + title: Text(item == null ? t.addPriceTitle : t.editPriceTitle), + content: SizedBox( + width: 520, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: productId?.toString(), + decoration: InputDecoration(labelText: t.productId), + keyboardType: TextInputType.number, + validator: (v) => (int.tryParse(v ?? '') == null) ? t.invalid : null, + onChanged: (v) => productId = int.tryParse(v), + ), + DropdownButtonFormField( + value: currencyId, + items: _fallbackCurrencies + .map((c) => DropdownMenuItem( + value: c['id'] as int, + child: Text('${c['title']} (${c['code']})'), + )) + .toList(), + onChanged: (v) => currencyId = v, + decoration: InputDecoration(labelText: t.currency), + validator: (v) => (v == null) ? t.required : null, + ), + TextFormField( + initialValue: tierName, + decoration: const InputDecoration(labelText: 'نام پله (مثلاً: تکی/عمده/همکار)'), + validator: (v) => (v == null || v.trim().isEmpty) ? t.required : null, + onChanged: (v) => tierName = v, + ), + DropdownButtonFormField( + value: unitId, + items: _fallbackUnits + .map((u) => DropdownMenuItem( + value: u['id'] as int, + child: Text(u['title'] as String), + )) + .toList(), + onChanged: (v) => unitId = v, + decoration: InputDecoration(labelText: t.unit), + ), + TextFormField( + initialValue: minQty.toString(), + decoration: InputDecoration(labelText: t.minQty), + keyboardType: TextInputType.number, + validator: (v) => (num.tryParse(v ?? '') == null) ? t.invalid : null, + onChanged: (v) => minQty = num.tryParse(v) ?? 0, + ), + TextFormField( + initialValue: price.toString(), + decoration: InputDecoration(labelText: t.price), + keyboardType: TextInputType.number, + validator: (v) => (num.tryParse(v ?? '') == null) ? t.invalid : null, + onChanged: (v) => price = num.tryParse(v) ?? 0, + ), + ], + ), ), ), - ), - actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(AppLocalizations.of(ctx).cancel)), - FilledButton( - onPressed: () async { - if (!(formKey.currentState?.validate() ?? false)) return; - final payload = { - 'product_id': productId, - 'tier_name': tierName, - 'unit_id': unitId, - 'min_qty': minQty, - 'price': price, - }..removeWhere((k, v) => v == null); - await _svc.upsertItem( - businessId: widget.businessId, - priceListId: widget.priceListId, - payload: payload, - ); - if (mounted) Navigator.of(ctx).pop(true); - _load(); - }, - child: Text(AppLocalizations.of(ctx).save), - ), - ], - ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(AppLocalizations.of(ctx).cancel)), + FilledButton( + onPressed: () async { + if (!(formKey.currentState?.validate() ?? false)) return; + try { + final payload = { + 'product_id': productId, + 'tier_name': tierName, + 'unit_id': unitId, + 'min_qty': minQty, + 'price': price, + 'currency_id': currencyId, + }..removeWhere((k, v) => v == null); + await _svc.upsertItem( + businessId: widget.businessId, + priceListId: widget.priceListId, + payload: payload, + ); + if (mounted) Navigator.of(ctx).pop(true); + _load(); + } catch (e) { + String message = t.operationFailed; + if (e is DioException) { + final data = e.response?.data; + final serverMsg = (data is Map && data['error'] is Map) ? (data['error']['message']?.toString()) : null; + message = serverMsg?.isNotEmpty == true ? serverMsg! : (e.message ?? message); + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + }, + child: Text(AppLocalizations.of(ctx).save), + ), + ], + ); + }, ); } @@ -186,6 +219,11 @@ class _PriceListItemsPageState extends State { {'id': 2, 'title': 'کیلوگرم'}, {'id': 3, 'title': 'لیتر'}, ]; + + List> get _fallbackCurrencies => [ + {'id': 1, 'title': 'تومان', 'code': 'IRR'}, + {'id': 2, 'title': 'دلار آمریکا', 'code': 'USD'}, + ]; } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart index 3671f5b..91add61 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/price_lists_page.dart @@ -1,46 +1,457 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; -import '../../widgets/data_table/data_table_widget.dart'; -import '../../widgets/data_table/data_table_config.dart'; -import 'package:go_router/go_router.dart'; -import 'price_list_items_page.dart'; +import '../../services/price_list_service.dart'; +import '../../core/api_client.dart'; import '../../core/auth_store.dart'; +import '../../utils/date_formatters.dart'; -class PriceListsPage extends StatelessWidget { +class PriceListsPage extends StatefulWidget { final int businessId; final AuthStore authStore; const PriceListsPage({super.key, required this.businessId, required this.authStore}); + @override + State createState() => _PriceListsPageState(); +} + +class _PriceListsPageState extends State { + final _svc = PriceListService(apiClient: ApiClient()); + final _searchController = TextEditingController(); + List> _priceLists = []; + bool _loading = false; + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _loadPriceLists(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadPriceLists() async { + setState(() => _loading = true); + try { + final result = await _svc.listPriceLists( + businessId: widget.businessId, + search: _searchQuery.isNotEmpty ? _searchQuery : null, + ); + final items = result['items'] as List? ?? []; + setState(() { + _priceLists = items.map>((e) => Map.from(e)).toList(); + _loading = false; + }); + } catch (e) { + setState(() => _loading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری لیست‌ها: $e')), + ); + } + } + } + + void _onSearchChanged(String value) { + setState(() => _searchQuery = value); + _loadPriceLists(); + } + + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); - return Scaffold( - body: DataTableWidget>( - config: DataTableConfig>( - endpoint: '/api/v1/price-lists/business/$businessId/search', - title: t.priceLists, - columns: [ - TextColumn('name', t.title, width: ColumnWidth.large), - TextColumn('currency_id', 'ارز', width: ColumnWidth.small), - TextColumn('default_unit_id', 'واحد پیش‌فرض', width: ColumnWidth.small), - TextColumn('created_at_formatted', t.createdAt, width: ColumnWidth.medium), - ], - searchFields: const ['name'], - filterFields: const [], - defaultPageSize: 20, - onRowDoubleTap: (row) { - final id = row['id'] as int?; - if (id != null) { - context.go('/business/$businessId/price-lists/$id/items'); - } - }, + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: 'جستجو در لیست‌های قیمت', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + border: const OutlineInputBorder(), + ), + onChanged: _onSearchChanged, + ), ), - fromJson: (json) => json, + // Header with create button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + t.priceLists, + style: Theme.of(context).textTheme.titleLarge, + ), + Row( + children: [ + IconButton( + onPressed: _loadPriceLists, + icon: const Icon(Icons.refresh), + tooltip: 'تازه‌سازی', + ), + ElevatedButton.icon( + onPressed: () async { + final ok = await _openCreateDialog(context); + if (ok == true) { + _loadPriceLists(); + } + }, + icon: const Icon(Icons.add), + label: const Text('ایجاد لیست قیمت'), + ), + ], + ), + ], + ), + ), + const Divider(), + // Price lists list + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _priceLists.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.price_change_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + _searchQuery.isNotEmpty + ? 'هیچ لیست قیمتی یافت نشد' + : 'هیچ لیست قیمتی وجود ندارد', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey[600], + ), + ), + if (_searchQuery.isEmpty) ...[ + const SizedBox(height: 8), + Text( + 'برای شروع، یک لیست قیمت جدید ایجاد کنید', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[500], + ), + ), + ], + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(16.0), + itemCount: _priceLists.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final priceList = _priceLists[index]; + final isActive = priceList['is_active'] == true; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: isActive + ? Theme.of(context).primaryColor.withOpacity(0.1) + : Colors.grey.withOpacity(0.1), + child: Icon( + Icons.price_change, + color: isActive + ? Theme.of(context).primaryColor + : Colors.grey, + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + priceList['name']?.toString() ?? 'بدون نام', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isActive ? null : Colors.grey, + ), + ), + ), + if (!isActive) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'غیرفعال', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ), + ], + ), + subtitle: Text( + 'ایجاد شده: ${DateFormatters.formatServerDate(priceList['created_at_formatted'])}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _openEditDialog(context, priceList), + tooltip: 'ویرایش', + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _deletePriceList(context, priceList), + tooltip: 'حذف', + ), + ], + ), + onTap: () => _openEditDialog(context, priceList), + ), + ); + }, + ), + ), + ], + ); + } + + Future _openCreateDialog(BuildContext context) async { + final formKey = GlobalKey(); + String name = ''; + + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('ایجاد لیست قیمت'), + content: SizedBox( + width: 400, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + decoration: const InputDecoration( + labelText: 'نام لیست قیمت', + border: OutlineInputBorder(), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'نام لیست قیمت ضروری است' : null, + onChanged: (v) => name = v, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () async { + if (!(formKey.currentState?.validate() ?? false)) return; + try { + await _svc.createPriceList( + businessId: widget.businessId, + payload: {'name': name.trim()}, + ); + if (mounted) { + Navigator.of(ctx).pop(true); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('لیست قیمت با موفقیت ایجاد شد')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ایجاد لیست قیمت: $e')), + ); + } + } + }, + child: const Text('ایجاد'), + ), + ], ), ); } + + Future _openEditDialog(BuildContext context, Map priceList) async { + final formKey = GlobalKey(); + String name = priceList['name']?.toString() ?? ''; + bool isActive = priceList['is_active'] == true; + final priceListId = priceList['id'] as int; + + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('ویرایش لیست قیمت'), + content: SizedBox( + width: 400, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: name, + decoration: const InputDecoration( + labelText: 'نام لیست قیمت', + border: OutlineInputBorder(), + ), + validator: (v) => (v == null || v.trim().isEmpty) ? 'نام لیست قیمت ضروری است' : null, + onChanged: (v) => name = v, + ), + const SizedBox(height: 16), + CheckboxListTile( + title: const Text('فعال'), + subtitle: const Text('لیست قیمت در دسترس باشد'), + value: isActive, + onChanged: (value) => setState(() => isActive = value ?? false), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () async { + if (!(formKey.currentState?.validate() ?? false)) return; + try { + await _svc.updatePriceList( + businessId: widget.businessId, + priceListId: priceListId, + payload: { + 'name': name.trim(), + 'is_active': isActive, + }, + ); + if (mounted) { + Navigator.of(ctx).pop(true); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('لیست قیمت با موفقیت بروزرسانی شد')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در ویرایش لیست قیمت: $e')), + ); + } + } + }, + child: const Text('ذخیره'), + ), + ], + ), + ); + + if (result == true) { + _loadPriceLists(); + } + } + + Future _deletePriceList(BuildContext context, Map priceList) async { + final priceListName = priceList['name']?.toString() ?? 'بدون نام'; + final priceListId = priceList['id'] as int; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('حذف لیست قیمت'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('آیا از حذف لیست قیمت "$priceListName" اطمینان دارید؟'), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.red[700], size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'تمام آیتم‌های قیمت این لیست نیز حذف خواهند شد.', + style: TextStyle( + color: Colors.red[700], + fontSize: 14, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('حذف'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final success = await _svc.deletePriceList( + businessId: widget.businessId, + priceListId: priceListId, + ); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('لیست قیمت "$priceListName" با موفقیت حذف شد')), + ); + _loadPriceLists(); // Refresh the list + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا در حذف لیست قیمت')), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در حذف لیست قیمت: $e')), + ); + } + } + } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart index 651a519..dae41af 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/products_page.dart @@ -3,7 +3,11 @@ import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../widgets/data_table/data_table_widget.dart'; import '../../widgets/data_table/data_table_config.dart'; import '../../widgets/product/product_form_dialog.dart'; +import '../../widgets/product/bulk_price_update_dialog.dart'; +import '../../widgets/product/product_import_dialog.dart'; +import 'price_lists_page.dart'; import '../../core/auth_store.dart'; +import '../../utils/number_formatters.dart'; class ProductsPage extends StatefulWidget { final int businessId; @@ -24,7 +28,7 @@ class _ProductsPageState extends State { if (!widget.authStore.canReadSection('products')) { return Scaffold( - body: Center(child: Text('دسترسی مشاهده کالا و خدمات را ندارید')), + body: Center(child: Text(t.noProductsReadAccess)), ); } @@ -36,26 +40,75 @@ class _ProductsPageState extends State { title: t.products, excelEndpoint: '/api/v1/products/business/${widget.businessId}/export/excel', pdfEndpoint: '/api/v1/products/business/${widget.businessId}/export/pdf', + showRowNumbers: true, + enableRowSelection: true, + enableMultiRowSelection: true, columns: [ TextColumn('code', t.code, width: ColumnWidth.small), TextColumn('name', t.title, width: ColumnWidth.large), TextColumn('item_type', t.service, width: ColumnWidth.small), - NumberColumn('base_sales_price', 'قیمت فروش', width: ColumnWidth.medium, decimalPlaces: 2), - NumberColumn('base_purchase_price', 'قیمت خرید', width: ColumnWidth.medium, decimalPlaces: 2), + NumberColumn( + 'base_sales_price', + t.salesPrice, + width: ColumnWidth.medium, + decimalPlaces: 0, + formatter: (row) => formatWithThousands(row['base_sales_price'], decimalPlaces: 0), + ), + NumberColumn( + 'base_purchase_price', + t.purchasePrice, + width: ColumnWidth.medium, + decimalPlaces: 0, + formatter: (row) => formatWithThousands(row['base_purchase_price'], decimalPlaces: 0), + ), // Inventory TextColumn( 'track_inventory', - 'کنترل موجودی', + t.inventoryControl, width: ColumnWidth.small, - formatter: (row) => (row['track_inventory'] == true) ? 'بله' : 'خیر', + formatter: (row) => (row['track_inventory'] == true) ? t.yes : t.no, ), - NumberColumn('reorder_point', 'نقطه سفارش', width: ColumnWidth.small, decimalPlaces: 0), - NumberColumn('min_order_qty', 'کمینه سفارش', width: ColumnWidth.small, decimalPlaces: 0), + NumberColumn('reorder_point', t.reorderPoint, width: ColumnWidth.small, decimalPlaces: 0), + NumberColumn('min_order_qty', t.minOrderQty, width: ColumnWidth.small, decimalPlaces: 0), // Taxes - NumberColumn('sales_tax_rate', 'مالیات فروش %', width: ColumnWidth.small, decimalPlaces: 2), - NumberColumn('purchase_tax_rate', 'مالیات خرید %', width: ColumnWidth.small, decimalPlaces: 2), - TextColumn('tax_code', 'کُد مالیاتی', width: ColumnWidth.small), - TextColumn('created_at_formatted', t.createdAt, width: ColumnWidth.medium), + NumberColumn('sales_tax_rate', t.salesTaxRate, width: ColumnWidth.small, decimalPlaces: 2), + NumberColumn('purchase_tax_rate', t.purchaseTaxRate, width: ColumnWidth.small, decimalPlaces: 2), + TextColumn('tax_code', t.taxCode, width: ColumnWidth.small), + // Show human-friendly date; keep sorting by actual `created_at` + TextColumn( + 'created_at', + t.createdAt, + width: ColumnWidth.medium, + formatter: (row) { + final dynamic caf = row['created_at_formatted']; + if (caf is Map && caf['formatted'] != null && caf['formatted'].toString().isNotEmpty) { + return caf['formatted'].toString(); + } + final dynamic ca = row['created_at']; + if (ca is String && ca.isNotEmpty) return ca; + final dynamic car = row['created_at_raw']; + if (car is String && car.isNotEmpty) return car; + return '-'; + }, + ), + // Last update, display pretty while disabling unsupported server-side sorting + TextColumn( + 'updated_at', + t.updatedAt, + width: ColumnWidth.medium, + sortable: false, + formatter: (row) { + final dynamic uaf = row['updated_at_formatted']; + if (uaf is Map && uaf['formatted'] != null && uaf['formatted'].toString().isNotEmpty) { + return uaf['formatted'].toString(); + } + final dynamic ua = row['updated_at']; + if (ua is String && ua.isNotEmpty) return ua; + final dynamic uar = row['updated_at_raw']; + if (uar is String && uar.isNotEmpty) return uar; + return '-'; + }, + ), ActionColumn('actions', t.actions, actions: [ DataTableAction( icon: Icons.edit, @@ -82,6 +135,44 @@ class _ProductsPageState extends State { filterFields: const ['item_type', 'category_id'], defaultPageSize: 20, customHeaderActions: [ + Tooltip( + message: t.importFromExcel, + child: IconButton( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => ProductImportDialog( + businessId: widget.businessId, + ), + ); + if (ok == true) { + try { + ( _tableKey.currentState as dynamic)?.refresh(); + } catch (_) {} + } + }, + icon: const Icon(Icons.upload_file), + ), + ), + Tooltip( + message: t.bulkPriceUpdateTitle, + child: IconButton( + onPressed: () async { + await showDialog( + context: context, + builder: (ctx) => BulkPriceUpdateDialog( + businessId: widget.businessId, + onSuccess: () { + try { + ( _tableKey.currentState as dynamic)?.refresh(); + } catch (_) {} + }, + ), + ); + }, + icon: const Icon(Icons.auto_graph), + ), + ), Tooltip( message: t.addProduct, child: IconButton( @@ -102,6 +193,34 @@ class _ProductsPageState extends State { icon: const Icon(Icons.add), ), ), + Tooltip( + message: t.managePriceLists, + child: IconButton( + onPressed: () async { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.managePriceLists), + content: SizedBox( + width: 700, + height: 480, + child: PriceListsPage( + businessId: widget.businessId, + authStore: widget.authStore, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(AppLocalizations.of(ctx).close), + ), + ], + ), + ); + }, + icon: const Icon(Icons.list_alt), + ), + ), ], ), fromJson: (json) => json, diff --git a/hesabixUI/hesabix_ui/lib/services/bulk_price_update_service.dart b/hesabixUI/hesabix_ui/lib/services/bulk_price_update_service.dart new file mode 100644 index 0000000..4e85532 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/bulk_price_update_service.dart @@ -0,0 +1,31 @@ +import '../core/api_client.dart'; + +class BulkPriceUpdateService { + final ApiClient _api; + + BulkPriceUpdateService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + /// پیش‌نمایش تغییرات قیمت گروهی + Future> previewBulkPriceUpdate({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/products/business/$businessId/bulk-price-update/preview', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + /// اعمال تغییرات قیمت گروهی + Future> applyBulkPriceUpdate({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/products/business/$businessId/bulk-price-update/apply', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/category_service.dart b/hesabixUI/hesabix_ui/lib/services/category_service.dart index 21aa149..7feaac9 100644 --- a/hesabixUI/hesabix_ui/lib/services/category_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/category_service.dart @@ -26,6 +26,30 @@ class CategoryService { } } + Future>> search({ + required int businessId, + required String query, + int limit = 50, + }) async { + try { + final res = await _apiClient.post>( + '/api/v1/categories/business/$businessId/search', + data: { + 'query': query, + 'limit': limit, + }, + ); + final data = res.data?['data']; + final items = (data is Map) ? data['items'] : null; + if (items is List) { + return items.map>((e) => Map.from(e)).toList(); + } + return const >[]; + } on DioException catch (e) { + throw Exception(e.message); + } + } + Future> create({ required int businessId, int? parentId, diff --git a/hesabixUI/hesabix_ui/lib/services/currency_service.dart b/hesabixUI/hesabix_ui/lib/services/currency_service.dart new file mode 100644 index 0000000..cda5473 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/currency_service.dart @@ -0,0 +1,33 @@ +import '../core/api_client.dart'; + +class CurrencyService { + final ApiClient _api; + + CurrencyService(ApiClient apiClient) : _api = apiClient; + + Future>> listCurrencies() async { + final res = await _api.get>('/api/v1/currencies'); + final body = res.data; + final items = (body is Map) ? body['data'] : body; + if (items is List) { + return items + .map>((e) => Map.from(e as Map)) + .toList(); + } + return const >[]; + } + + Future>> listBusinessCurrencies({required int businessId}) async { + final res = await _api.get>('/api/v1/currencies/business/$businessId'); + final body = res.data; + final items = (body is Map) ? body['data'] : body; + if (items is List) { + return items + .map>((e) => Map.from(e as Map)) + .toList(); + } + return const >[]; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/price_list_service.dart b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart index 703faac..dc2ccb6 100644 --- a/hesabixUI/hesabix_ui/lib/services/price_list_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/price_list_service.dart @@ -26,10 +26,14 @@ class PriceListService { Future>> listItems({ required int businessId, required int priceListId, + int? productId, + int? currencyId, }) async { - final res = await _api.get>( - '/api/v1/price-lists/business/$businessId/$priceListId/items', - ); + final qp = {}; + if (productId != null) qp['product_id'] = '$productId'; + if (currencyId != null) qp['currency_id'] = '$currencyId'; + final query = qp.isEmpty ? '' : ('?' + qp.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')); + final res = await _api.get>('/api/v1/price-lists/business/$businessId/$priceListId/items$query'); final data = res.data?['data']; final items = (data is Map) ? data['items'] : null; if (items is List) { @@ -38,6 +42,39 @@ class PriceListService { return const >[]; } + Future> createPriceList({ + required int businessId, + required Map payload, + }) async { + final res = await _api.post>( + '/api/v1/price-lists/business/$businessId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future> updatePriceList({ + required int businessId, + required int priceListId, + required Map payload, + }) async { + final res = await _api.put>( + '/api/v1/price-lists/business/$businessId/$priceListId', + data: payload, + ); + return Map.from(res.data?['data'] ?? const {}); + } + + Future deletePriceList({ + required int businessId, + required int priceListId, + }) async { + final res = await _api.delete( + '/api/v1/price-lists/business/$businessId/$priceListId', + ); + return res.statusCode == 200 && (res.data['data']?['deleted'] == true); + } + Future> upsertItem({ required int businessId, required int priceListId, diff --git a/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart b/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart index 9ff5c12..7e42016 100644 --- a/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/product_attribute_service.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import '../core/api_client.dart'; class ProductAttributeService { diff --git a/hesabixUI/hesabix_ui/lib/services/product_service.dart b/hesabixUI/hesabix_ui/lib/services/product_service.dart index cc51e88..2c91a8f 100644 --- a/hesabixUI/hesabix_ui/lib/services/product_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/product_service.dart @@ -49,7 +49,7 @@ class ProductService { int? excludeProductId, }) async { final body = { - 'take': 1, + 'take': 5, // fetch a few matches to reliably detect duplicates beyond the current record 'skip': 0, 'filters': [ { @@ -66,12 +66,15 @@ class ProductService { final data = res.data?['data']; final items = (data is Map) ? data['items'] : null; if (items is List && items.isNotEmpty) { - final first = Map.from(items.first); - final foundId = first['id'] as int?; - if (excludeProductId != null && foundId == excludeProductId) { - return false; + for (final it in items) { + final m = Map.from(it as Map); + final foundId = m['id'] as int?; + if (excludeProductId == null || foundId != excludeProductId) { + return true; + } } - return true; + // only the same product found + return false; } return false; } diff --git a/hesabixUI/hesabix_ui/lib/utils/date_formatters.dart b/hesabixUI/hesabix_ui/lib/utils/date_formatters.dart new file mode 100644 index 0000000..b6630ed --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/date_formatters.dart @@ -0,0 +1,69 @@ +/// Utility functions for formatting dates from server responses +class DateFormatters { + /// Format date from server response + /// Handles both string dates and formatted date objects + static String formatServerDate(dynamic dateData) { + if (dateData == null) return '-'; + + // If it's already a string, return it + if (dateData is String) return dateData; + + // If it's a Map (formatted object), extract the formatted field + if (dateData is Map) { + final formatted = dateData['formatted']; + if (formatted != null) return formatted.toString(); + + // Fallback to date_only if formatted is not available + final dateOnly = dateData['date_only']; + if (dateOnly != null) return dateOnly.toString(); + } + + return dateData.toString(); + } + + /// Format date with time from server response + /// Returns formatted string with time if available, otherwise just date + static String formatServerDateTime(dynamic dateData) { + if (dateData == null) return '-'; + + // If it's already a string, return it + if (dateData is String) return dateData; + + // If it's a Map (formatted object), extract the formatted field + if (dateData is Map) { + final formatted = dateData['formatted']; + if (formatted != null) return formatted.toString(); + + // Try to construct date with time + final dateOnly = dateData['date_only']; + final timeOnly = dateData['time_only']; + if (dateOnly != null && timeOnly != null) { + return '$dateOnly $timeOnly'; + } else if (dateOnly != null) { + return dateOnly.toString(); + } + } + + return dateData.toString(); + } + + /// Format date only (without time) from server response + static String formatServerDateOnly(dynamic dateData) { + if (dateData == null) return '-'; + + // If it's already a string, return it + if (dateData is String) return dateData; + + // If it's a Map (formatted object), extract the date_only field + if (dateData is Map) { + final dateOnly = dateData['date_only']; + if (dateOnly != null) return dateOnly.toString(); + + // Fallback to formatted if date_only is not available + final formatted = dateData['formatted']; + if (formatted != null) return formatted.toString(); + } + + return dateData.toString(); + } +} diff --git a/hesabixUI/hesabix_ui/lib/utils/number_formatters.dart b/hesabixUI/hesabix_ui/lib/utils/number_formatters.dart new file mode 100644 index 0000000..c844eb3 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/number_formatters.dart @@ -0,0 +1,61 @@ +import 'package:flutter/services.dart'; + +String formatWithThousands(dynamic value, {int? decimalPlaces}) { + if (value == null) return '-'; + num? n; + if (value is num) { + n = value; + } else if (value is String) { + n = num.tryParse(value); + } + if (n == null) return value.toString(); + + // Determine effective decimal digits + int effectiveDecimalDigits = decimalPlaces ?? (n is int ? 0 : 2); + if (decimalPlaces != null && decimalPlaces > 0) { + final fixed = n.toStringAsFixed(decimalPlaces); + final parts = fixed.split('.'); + if (parts.length == 2) { + final fractional = parts[1]; + final isAllZeros = fractional.replaceAll('0', '').isEmpty; + if (isAllZeros) { + effectiveDecimalDigits = 0; + } + } + } + + final parts = n.toStringAsFixed(effectiveDecimalDigits).split('.'); + final intPart = parts[0]; + final decPart = parts.length > 1 ? parts[1] : null; + final reg = RegExp(r'\B(?=(\d{3})+(?!\d))'); + final grouped = intPart.replaceAllMapped(reg, (m) => ','); + return decPart == null || decPart.isEmpty ? grouped : '$grouped.$decPart'; +} + +class ThousandsSeparatorInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + final selectionIndexFromTheRight = text.length - newValue.selection.end; + String cleaned = text.replaceAll(',', ''); + // Keep decimal part + String integerPart = cleaned; + String decimalPart = ''; + final dotIndex = cleaned.indexOf('.'); + if (dotIndex >= 0) { + integerPart = cleaned.substring(0, dotIndex); + decimalPart = cleaned.substring(dotIndex); // includes '.' + } + final reg = RegExp(r'\B(?=(\d{3})+(?!\d))'); + final formattedInt = integerPart.replaceAllMapped(reg, (m) => ','); + final formatted = formattedInt + decimalPart; + final newSelectionIndex = formatted.length - selectionIndexFromTheRight; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: newSelectionIndex.clamp(0, formatted.length)), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart index 8a30d4a..cac8a32 100644 --- a/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart +++ b/hesabixUI/hesabix_ui/lib/utils/product_form_validator.dart @@ -23,7 +23,7 @@ class ProductFormValidator { static String? validatePrice(String? value, {String fieldName = 'قیمت'}) { if (value != null && value.trim().isNotEmpty) { - final price = num.tryParse(value); + final price = num.tryParse(value.replaceAll(',', '')); if (price == null) { return '$fieldName باید عدد معتبر باشد'; } diff --git a/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart b/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart new file mode 100644 index 0000000..c599243 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/category/category_picker_field.dart @@ -0,0 +1,321 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../services/category_service.dart'; +import '../../core/api_client.dart'; + +class CategoryPickerField extends FormField { + CategoryPickerField({ + super.key, + required this.businessId, + required List> categoriesTree, + required ValueChanged onChanged, + int? initialValue, + String? label, + String? Function(int?)? validator, + }) : super( + initialValue: initialValue, + validator: validator, + builder: (state) { + final context = state.context; + final t = AppLocalizations.of(context); + final selectedLabel = _selectedCategoryBreadcrumb( + categoriesTree, + state.value, + ); + return InkWell( + onTap: () async { + final picked = await showDialog( + context: context, + builder: (ctx) => _CategoryPickerDialog( + businessId: businessId, + categoriesTree: categoriesTree, + initialCategoryId: state.value, + ), + ); + if (picked != null) { + state.didChange(picked); + onChanged(picked); + } + }, + borderRadius: BorderRadius.circular(8), + child: InputDecorator( + decoration: InputDecoration( + labelText: label ?? t.categories, + errorText: state.errorText, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.expand_more), + ), + child: _BreadcrumbChips(label: selectedLabel ?? 'انتخاب'), + ), + ); + }, + ); + final int businessId; +} + +class _BreadcrumbChips extends StatelessWidget { + final String label; + + const _BreadcrumbChips({required this.label}); + + @override + Widget build(BuildContext context) { + final parts = label.split(' / ').where((e) => e.trim().isNotEmpty).toList(); + if (parts.isEmpty) { + return Text('انتخاب'); + } + return Wrap( + spacing: 6, + runSpacing: 6, + children: parts + .map((p) => Chip( + label: Text(p), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + )) + .toList(), + ); + } +} + +String? _selectedCategoryBreadcrumb(List> tree, int? id) { + if (id == null) return null; + final path = _findPathById(tree, id); + if (path.isEmpty) return null; + return path.map((e) => (e['label'] ?? e['title'] ?? '').toString()).join(' / '); +} + +List> _findPathById(List> nodes, int id) { + for (final n in nodes) { + final current = Map.from(n); + if ((current['id'] as num?)?.toInt() == id) { + return [current]; + } + final children = (current['children'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + final sub = _findPathById(children, id); + if (sub.isNotEmpty) { + return [current, ...sub]; + } + } + return const >[]; +} + +class _CategoryPickerDialog extends StatefulWidget { + final List> categoriesTree; + final int? initialCategoryId; + final int businessId; + + const _CategoryPickerDialog({ + required this.categoriesTree, + required this.initialCategoryId, + required this.businessId, + }); + + @override + State<_CategoryPickerDialog> createState() => _CategoryPickerDialogState(); +} + +class _CategoryPickerDialogState extends State<_CategoryPickerDialog> { + String _query = ''; + int? _selectedId; + bool _loading = false; + List> _serverResults = const >[]; + late final CategoryService _service; + Timer? _debounce; + + @override + void initState() { + super.initState(); + _selectedId = widget.initialCategoryId; + _service = CategoryService(ApiClient()); + } + + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final useServer = _query.trim().length >= 3 || _countNodes(widget.categoriesTree) > 500; + final filteredTree = useServer + ? _resultsToTree(_serverResults) + : (_query.isEmpty ? widget.categoriesTree : _filterTree(widget.categoriesTree, _query)); + return AlertDialog( + title: Text(t.categories), + content: SizedBox( + width: 520, + height: 460, + child: Column( + children: [ + TextField( + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: t.search, + ), + onChanged: (v) { + final q = v.trim(); + setState(() => _query = q); + _scheduleServerSearch(q); + }, + ), + const SizedBox(height: 12), + if (_loading) const LinearProgressIndicator(), + Expanded( + child: _CategoryList( + tree: filteredTree, + selectedId: _selectedId, + onSelect: (id) => setState(() => _selectedId = id), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _selectedId), + child: const Text('انتخاب'), + ), + ], + ); + } + + List> _filterTree(List> nodes, String q) { + if (q.isEmpty) return nodes; + final query = q.toLowerCase(); + List> result = []; + for (final n in nodes) { + final current = Map.from(n); + final label = (current['label'] ?? current['title'] ?? '').toString(); + final children = (current['children'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + final filteredChildren = _filterTree(children, q); + final matches = label.toLowerCase().contains(query); + if (matches || filteredChildren.isNotEmpty) { + current['children'] = filteredChildren; + result.add(current); + } + } + return result; + } + + void _scheduleServerSearch(String q) { + _debounce?.cancel(); + if (q.trim().length < 3) { + setState(() => _serverResults = const >[]); + return; + } + _debounce = Timer(const Duration(milliseconds: 350), () => _performServerSearch(q)); + } + + Future _performServerSearch(String q) async { + setState(() => _loading = true); + try { + final items = await _service.search(businessId: widget.businessId, query: q.trim(), limit: 100); + if (!mounted) return; + setState(() => _serverResults = items); + } catch (_) { + // ignore + } finally { + if (mounted) setState(() => _loading = false); + } + } + + int _countNodes(List> nodes) { + int c = 0; + for (final n in nodes) { + c += 1; + final children = (n['children'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + c += _countNodes(children); + } + return c; + } + + List> _resultsToTree(List> items) { + // Items already contain 'path' breadcrumb; we flatten to a pseudo-tree with only matching leaves under their ancestors + final Map> byId = {}; + final List> roots = []; + for (final it in items) { + final path = (it['path'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + Map? parent; + for (final node in path) { + final nid = (node['id'] as num?)?.toInt(); + if (nid == null) continue; + var existing = byId[nid]; + if (existing == null) { + existing = { + 'id': nid, + 'label': (node['title'] ?? '').toString(), + 'children': >[], + }; + byId[nid] = existing; + if (parent == null) roots.add(existing); + else (parent['children'] as List).add(existing); + } + parent = existing; + } + } + return roots; + } +} + +class _CategoryList extends StatelessWidget { + final List> tree; + final int? selectedId; + final ValueChanged onSelect; + + const _CategoryList({ + required this.tree, + required this.selectedId, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + return ListView( + children: tree.map((n) => _buildNode(context, n, 0)).toList(), + ); + } + + Widget _buildNode(BuildContext context, Map node, int depth) { + final id = (node['id'] as num?)?.toInt(); + final label = (node['label'] ?? node['title'] ?? '').toString(); + final children = (node['children'] as List?)?.cast() + .map((e) => Map.from(e as Map)) + .toList() ?? const >[]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsetsDirectional.only(start: 16.0 * depth, end: 8), + dense: true, + leading: Radio ( + value: id, + groupValue: selectedId, + onChanged: (_) => onSelect(id), + ), + title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => onSelect(id), + ), + if (children.isNotEmpty) + ...children.map((c) => _buildNode(context, c, depth + 1)), + ], + ); + } +} + + 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 d99499b..761f9f8 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 @@ -588,8 +588,19 @@ class _DataTableWidgetState extends State> { }, options: Options( headers: { - 'X-Calendar-Type': 'jalali', // Send Jalali calendar type - 'Accept-Language': Localizations.localeOf(context).languageCode, // Send locale + // Calendar type based on current locale + 'X-Calendar-Type': (() { + final loc = Localizations.localeOf(context); + final lang = (loc.languageCode).toLowerCase(); + return (lang == 'fa') ? 'jalali' : 'gregorian'; + })(), + // Send full locale code if available (e.g., fa-IR) + 'Accept-Language': (() { + final loc = Localizations.localeOf(context); + final lang = loc.languageCode; + final country = loc.countryCode; + return (country != null && country.isNotEmpty) ? '$lang-$country' : lang; + })(), }, ), responseType: ResponseType.bytes, // Both PDF and Excel now return binary data diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart index 7f9d84b..4164635 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart @@ -17,9 +17,25 @@ class DataTableUtils { if (value == null) return ''; final number = value is num ? value : double.tryParse(value.toString()) ?? 0; + // Determine effective decimal digits: + // - If decimalPlaces provided and fractional part is effectively zero -> hide decimals + // - Otherwise use provided decimalPlaces (or 0 by default) + int effectiveDecimalDigits = decimalPlaces ?? 0; + if (decimalPlaces != null && decimalPlaces > 0) { + final fixed = number.toStringAsFixed(decimalPlaces); + final parts = fixed.split('.'); + if (parts.length == 2) { + final fractional = parts[1]; + final isAllZeros = fractional.replaceAll('0', '').isEmpty; + if (isAllZeros) { + effectiveDecimalDigits = 0; + } + } + } + final formatter = NumberFormat.currency( symbol: '', - decimalDigits: decimalPlaces ?? 0, + decimalDigits: effectiveDecimalDigits, ); final formatted = formatter.format(number); diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart new file mode 100644 index 0000000..a7d5978 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/bulk_price_update_dialog.dart @@ -0,0 +1,903 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:flutter/services.dart'; +import '../../models/bulk_price_update_data.dart'; +import '../../services/bulk_price_update_service.dart'; +import '../../services/category_service.dart'; +import '../../services/currency_service.dart'; +import '../../services/price_list_service.dart'; +import '../../widgets/category/category_picker_field.dart'; +import '../../core/api_client.dart'; +import '../../utils/number_formatters.dart'; + +class BulkPriceUpdateDialog extends StatefulWidget { + final int businessId; + final List? selectedProductIds; + final VoidCallback? onSuccess; + + const BulkPriceUpdateDialog({ + super.key, + required this.businessId, + this.selectedProductIds, + this.onSuccess, + }); + + @override + State createState() => _BulkPriceUpdateDialogState(); +} + +class _BulkPriceUpdateDialogState extends State { + final _formKey = GlobalKey(); + final _apiClient = ApiClient(); + final _valueController = TextEditingController(); + + late final BulkPriceUpdateService _bulkPriceService; + late final CategoryService _categoryService; + late final CurrencyService _currencyService; + late final PriceListService _priceListService; + + // فرم داده‌ها + BulkPriceUpdateType _updateType = BulkPriceUpdateType.percentage; + BulkPriceUpdateTarget _target = BulkPriceUpdateTarget.salesPrice; + BulkPriceUpdateDirection _direction = BulkPriceUpdateDirection.increase; + double _value = 0.0; + + // فیلترها + List _selectedCategoryIds = []; + List _selectedCurrencyIds = []; + List _selectedPriceListIds = []; + List _selectedItemTypes = []; + bool? _onlyProductsWithInventory; + bool _onlyProductsWithBasePrice = true; + + // داده‌های مرجع + List> _categories = []; + List> _currencies = []; + List> _priceLists = []; + + // وضعیت + bool _isLoading = false; + bool _isPreviewLoading = false; + BulkPriceUpdatePreviewResponse? _previewResponse; + + @override + void initState() { + super.initState(); + _initializeServices(); + _loadReferenceData(); + _updateValueController(); + } + + @override + void dispose() { + _valueController.dispose(); + super.dispose(); + } + + void _updateValueController() { + _valueController.text = _updateType == BulkPriceUpdateType.percentage + ? _value.toString() + : formatWithThousands(_value); + } + + void _initializeServices() { + _bulkPriceService = BulkPriceUpdateService(apiClient: _apiClient); + _categoryService = CategoryService(_apiClient); + _currencyService = CurrencyService(_apiClient); + _priceListService = PriceListService(apiClient: _apiClient); + } + + Future _loadReferenceData() async { + setState(() => _isLoading = true); + try { + final futures = await Future.wait([ + _categoryService.getTree(businessId: widget.businessId), + _currencyService.listBusinessCurrencies(businessId: widget.businessId), + _priceListService.listPriceLists(businessId: widget.businessId), + ]); + + setState(() { + _categories = (futures[0] as List).map((e) => Map.from(e)).toList(); + _currencies = (futures[1] as List).map((e) => Map.from(e)).toList(); + _priceLists = (futures[2] as Map)['items'] != null + ? ((futures[2] as Map)['items'] as List) + .map((e) => Map.from(e)) + .toList() + : >[]; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.dataLoadingError}: $e')), + ); + } + } + } + + Future _previewChanges() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isPreviewLoading = true); + try { + final request = BulkPriceUpdateRequest( + updateType: _updateType, + direction: _direction, + target: _target, + value: _value, + categoryIds: _selectedCategoryIds.isNotEmpty ? _selectedCategoryIds : null, + currencyIds: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds : null, + priceListIds: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds : null, + itemTypes: _selectedItemTypes.isNotEmpty ? _selectedItemTypes : null, + productIds: widget.selectedProductIds, + onlyProductsWithInventory: _onlyProductsWithInventory, + onlyProductsWithBasePrice: _onlyProductsWithBasePrice, + ); + + final response = await _bulkPriceService.previewBulkPriceUpdate( + businessId: widget.businessId, + payload: request.toJson(), + ); + + setState(() { + _previewResponse = BulkPriceUpdatePreviewResponse.fromJson(response); + _isPreviewLoading = false; + }); + } catch (e) { + setState(() => _isPreviewLoading = false); + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.operationFailed}: $e')), + ); + } + } + } + + Future _applyChanges() async { + if (_previewResponse == null) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppLocalizations.of(ctx).confirmChangesTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(ctx).confirmApplyChangesForNProducts(_previewResponse!.affectedProducts.length)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.orange[700], size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(ctx).irreversibleWarning, + style: TextStyle( + color: Colors.orange[700], + fontSize: 14, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(AppLocalizations.of(ctx).cancel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom(backgroundColor: Colors.orange), + child: Text(AppLocalizations.of(ctx).confirm), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _isLoading = true); + try { + final request = BulkPriceUpdateRequest( + updateType: _updateType, + direction: _direction, + target: _target, + value: _value, + categoryIds: _selectedCategoryIds.isNotEmpty ? _selectedCategoryIds : null, + currencyIds: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds : null, + priceListIds: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds : null, + itemTypes: _selectedItemTypes.isNotEmpty ? _selectedItemTypes : null, + productIds: widget.selectedProductIds, + onlyProductsWithInventory: _onlyProductsWithInventory, + onlyProductsWithBasePrice: _onlyProductsWithBasePrice, + ); + + final result = await _bulkPriceService.applyBulkPriceUpdate( + businessId: widget.businessId, + payload: request.toJson(), + ); + + if (mounted) { + Navigator.of(context).pop(true); + widget.onSuccess?.call(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result['message'] ?? AppLocalizations.of(context).applyChanges)), + ); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.operationFailed}: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final t = AppLocalizations.of(context); + final surface = theme.colorScheme.surface; + final primary = theme.colorScheme.primary; + final onPrimary = theme.colorScheme.onPrimary; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 900, + height: 640, + color: surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [primary.withOpacity(0.90), primary.withOpacity(0.75)], + begin: Alignment.centerRight, + end: Alignment.centerLeft, + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: onPrimary.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.price_change, color: onPrimary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.bulkPriceUpdateTitle, + style: theme.textTheme.titleLarge?.copyWith(color: onPrimary, fontWeight: FontWeight.w700), + ), + Text( + t.bulkPriceUpdateSubtitle, + style: theme.textTheme.bodySmall?.copyWith(color: onPrimary.withOpacity(0.9)), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + style: IconButton.styleFrom(foregroundColor: onPrimary), + icon: const Icon(Icons.close), + ), + ], + ), + ), + + // Body + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSectionCard( + title: t.changeTypeAndDirection, + icon: Icons.tune, + child: _buildUpdateTypeSection(), + ), + const SizedBox(height: 14), + _buildSectionCard( + title: t.changeTarget, + icon: Icons.track_changes, + child: _buildTargetSection(), + ), + const SizedBox(height: 14), + _buildSectionCard( + title: t.changeAmount, + icon: Icons.calculate, + child: _buildValueSection(), + ), + const SizedBox(height: 14), + _buildSectionCard( + title: t.filters, + icon: Icons.filter_list, + child: _buildFiltersSection(), + ), + if (_previewResponse != null) ...[ + const SizedBox(height: 14), + _buildSectionCard( + title: t.previewChanges, + icon: Icons.preview, + child: _buildPreviewSection(), + ), + ], + ], + ), + ), + ), + ), + + // Footer actions + Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: theme.dividerColor.withOpacity(0.4))), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton.icon( + onPressed: _isPreviewLoading ? null : _previewChanges, + icon: _isPreviewLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.visibility), + label: Text(t.preview), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _isLoading || _previewResponse == null ? null : _applyChanges, + icon: _isLoading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check_circle), + label: Text(t.applyChanges), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildUpdateTypeSection() { + return Column( + children: [ + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('درصدی'), + subtitle: const Text('مثلاً 10%'), + value: BulkPriceUpdateType.percentage, + groupValue: _updateType, + onChanged: (value) { + setState(() => _updateType = value!); + _updateValueController(); + }, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('مقداری'), + subtitle: const Text('مثلاً 1000'), + value: BulkPriceUpdateType.amount, + groupValue: _updateType, + onChanged: (value) { + setState(() => _updateType = value!); + _updateValueController(); + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('افزایش'), + value: BulkPriceUpdateDirection.increase, + groupValue: _direction, + onChanged: (value) => setState(() => _direction = value!), + ), + ), + Expanded( + child: RadioListTile( + title: const Text('کاهش'), + value: BulkPriceUpdateDirection.decrease, + groupValue: _direction, + onChanged: (value) => setState(() => _direction = value!), + ), + ), + ], + ), + ], + ); + } + + Widget _buildTargetSection() { + return Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('قیمت فروش'), + value: BulkPriceUpdateTarget.salesPrice, + groupValue: _target, + onChanged: (value) => setState(() => _target = value!), + ), + ), + Expanded( + child: RadioListTile( + title: const Text('قیمت خرید'), + value: BulkPriceUpdateTarget.purchasePrice, + groupValue: _target, + onChanged: (value) => setState(() => _target = value!), + ), + ), + Expanded( + child: RadioListTile( + title: const Text('هر دو'), + value: BulkPriceUpdateTarget.both, + groupValue: _target, + onChanged: (value) => setState(() => _target = value!), + ), + ), + ], + ); + } + + Widget _buildValueSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _valueController, + decoration: InputDecoration( + labelText: 'مبلغ/درصد', + helperText: _updateType == BulkPriceUpdateType.percentage + ? 'مثلاً 10؛ درصد اعمال می‌شود' + : 'مثلاً 1,000,000؛ مبلغ اعمال می‌شود', + helperMaxLines: 2, + suffixText: _updateType == BulkPriceUpdateType.percentage ? '%' : null, + border: const OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false), + inputFormatters: _updateType == BulkPriceUpdateType.percentage + ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), ThousandsSeparatorInputFormatter()] + : [FilteringTextInputFormatter.allow(RegExp(r'^[\d,]*\.?\d*')), ThousandsSeparatorInputFormatter()], + validator: (value) { + if (value == null || value.isEmpty) { + return 'مقدار تغییر ضروری است'; + } + + // برای درصد، مستقیماً پارس کن + if (_updateType == BulkPriceUpdateType.percentage) { + final parsed = double.tryParse(value.replaceAll(',', '')); + if (parsed == null) { + return 'مقدار نامعتبر'; + } + if (parsed < 0) { + return 'مقدار نمی\تواند منفی باشد'; + } + return null; + } + + // برای مبلغ، جداکننده هزارگان را حذف کن و سپس پارس کن + final cleanValue = value.replaceAll(',', ''); + final parsed = double.tryParse(cleanValue); + if (parsed == null) { + return 'مقدار نامعتبر'; + } + if (parsed < 0) { + return 'مقدار نمی\تواند منفی باشد'; + } + return null; + }, + onChanged: (value) { + if (_updateType == BulkPriceUpdateType.percentage) { + final parsed = double.tryParse(value.replaceAll(',', '')); + if (parsed != null) { + setState(() => _value = parsed); + } + } else { + // برای مبلغ، جداکننده هزارگان را حذف کن + final cleanValue = value.replaceAll(',', ''); + final parsed = double.tryParse(cleanValue); + if (parsed != null) { + setState(() => _value = parsed); + } + } + }, + ), + ], + ); + } + + Widget _buildFiltersSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildCategoryFilter(), + ), + const SizedBox(width: 16), + Expanded( + child: _buildCurrencyFilter(), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildPriceListFilter(), + ), + const SizedBox(width: 16), + Expanded( + child: _buildItemTypeFilter(), + ), + ], + ), + const SizedBox(height: 16), + _buildOptionsFilter(), + ], + ); + } + + Widget _buildCategoryFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('دسته‌بندی'), + const SizedBox(height: 4), + CategoryPickerField( + businessId: widget.businessId, + categoriesTree: _categories, + initialValue: _selectedCategoryIds.isNotEmpty ? _selectedCategoryIds.first : null, + label: 'انتخاب دسته‌بندی', + onChanged: (value) { + setState(() { + _selectedCategoryIds = value != null ? [value] : []; + }); + }, + ), + ], + ); + } + + Widget _buildCurrencyFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('ارز'), + const SizedBox(height: 4), + DropdownButtonFormField( + value: _selectedCurrencyIds.isNotEmpty ? _selectedCurrencyIds.first : null, + items: [ + const DropdownMenuItem( + value: null, + child: Text('همه ارزها'), + ), + ..._currencies.map((currency) => DropdownMenuItem( + value: currency['id'] as int, + child: Text('${currency['title'] ?? 'بدون نام'} (${currency['code'] ?? 'بدون کد'})'), + )), + ], + onChanged: (value) { + setState(() { + _selectedCurrencyIds = value != null ? [value] : []; + }); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ); + } + + Widget _buildPriceListFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('لیست قیمت'), + const SizedBox(height: 4), + DropdownButtonFormField( + value: _selectedPriceListIds.isNotEmpty ? _selectedPriceListIds.first : null, + items: [ + const DropdownMenuItem( + value: null, + child: Text('همه لیست‌ها'), + ), + ..._priceLists.map((priceList) => DropdownMenuItem( + value: priceList['id'] as int, + child: Text(priceList['name']?.toString() ?? 'بدون نام'), + )), + ], + onChanged: (value) { + setState(() { + _selectedPriceListIds = value != null ? [value] : []; + }); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ); + } + + Widget _buildItemTypeFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('نوع آیتم'), + const SizedBox(height: 4), + DropdownButtonFormField( + value: _selectedItemTypes.isNotEmpty ? _selectedItemTypes.first : null, + items: const [ + DropdownMenuItem( + value: null, + child: Text('همه انواع'), + ), + DropdownMenuItem( + value: 'کالا', + child: Text('کالا'), + ), + DropdownMenuItem( + value: 'خدمت', + child: Text('خدمت'), + ), + ], + onChanged: (value) { + setState(() { + _selectedItemTypes = value != null ? [value] : []; + }); + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ); + } + + Widget _buildOptionsFilter() { + return Column( + children: [ + CheckboxListTile( + title: const Text('فقط کالاهای با موجودی'), + subtitle: const Text('فقط کالاهایی که موجودی آن‌ها کنترل می‌شود'), + value: _onlyProductsWithInventory ?? false, + onChanged: (value) { + setState(() => _onlyProductsWithInventory = value); + }, + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + title: const Text('فقط کالاهای با قیمت پایه'), + subtitle: const Text('فقط کالاهایی که قیمت پایه دارند'), + value: _onlyProductsWithBasePrice, + onChanged: (value) { + setState(() => _onlyProductsWithBasePrice = value ?? true); + }, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ); + } + + Widget _buildPreviewSection() { + if (_previewResponse == null) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue[700]), + const SizedBox(width: 8), + Text( + 'خلاصه تغییرات', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.blue[700], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + 'کل کالاها', + _previewResponse!.totalProducts.toString(), + Icons.inventory_2, + ), + ), + Expanded( + child: _buildSummaryItem( + 'کالاهای تأثیرپذیر', + _previewResponse!.affectedProducts.length.toString(), + Icons.touch_app, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + 'تغییرات قیمت فروش', + _previewResponse!.summary['products_with_sales_change']?.toString() ?? '0', + Icons.sell, + ), + ), + Expanded( + child: _buildSummaryItem( + 'تغییرات قیمت خرید', + _previewResponse!.summary['products_with_purchase_change']?.toString() ?? '0', + Icons.shopping_cart, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: ListView.builder( + itemCount: _previewResponse!.affectedProducts.length, + itemBuilder: (context, index) { + final product = _previewResponse!.affectedProducts[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(product.productName), + subtitle: Text('کد: ${product.productCode}'), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (product.salesPriceChange != null) + Text( + 'فروش: ${formatWithThousands(product.currentSalesPrice ?? 0)} → ${formatWithThousands(product.newSalesPrice ?? 0)}', + style: const TextStyle(fontSize: 12), + ), + if (product.purchasePriceChange != null) + Text( + 'خرید: ${formatWithThousands(product.currentPurchasePrice ?? 0)} → ${formatWithThousands(product.newPurchasePrice ?? 0)}', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildSummaryItem(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 16, color: Colors.grey[600]), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionCard({required String title, required IconData icon, required Widget child}) { + final theme = Theme.of(context); + return Card( + elevation: 1.5, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.10), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: theme.colorScheme.primary), + ), + const SizedBox(width: 8), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart index 0b450e8..8253de1 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart @@ -63,7 +63,7 @@ class _ProductFormDialogState extends State { content: SizedBox( width: MediaQuery.of(context).size.width > 1200 ? 1000 : 800, child: _controller.isLoading - ? _buildLoadingWidget() + ? _buildLoadingWidget(t) : _buildFormContent(), ), actions: _buildActions(t), @@ -72,18 +72,11 @@ class _ProductFormDialogState extends State { ); } - Widget _buildLoadingWidget() { - return const SizedBox( + Widget _buildLoadingWidget(AppLocalizations t) { + return SizedBox( height: 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('در حال بارگذاری...'), - ], - ), + child: const Center( + child: CircularProgressIndicator(), ), ); } @@ -118,12 +111,13 @@ class _ProductFormDialogState extends State { } Widget _buildTabBar() { + final t = AppLocalizations.of(context); return TabBar( isScrollable: true, - tabs: const [ - Tab(text: 'اطلاعات کلی'), - Tab(text: 'قیمت و موجودی'), - Tab(text: 'مالیات'), + tabs: [ + Tab(text: t.productGeneralInfo), + Tab(text: t.pricingAndInventory), + Tab(text: t.tax), ], ); } @@ -132,6 +126,7 @@ class _ProductFormDialogState extends State { return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: ProductBasicInfoSection( + businessId: widget.businessId, formData: _controller.formData, onChanged: _controller.updateFormData, categories: _controller.categories, @@ -147,7 +142,18 @@ class _ProductFormDialogState extends State { child: ProductPricingInventorySection( formData: _controller.formData, onChanged: _controller.updateFormData, - units: const [], + units: _controller.units, + priceLists: _controller.priceLists, + currencies: _controller.currencies, + draftPriceItems: _controller.draftPriceItems, + onAddOrUpdatePriceItem: (item) { + _controller.addOrUpdateDraftPriceItem(item); + _controller.updateFormData(_controller.formData); + }, + onDeletePriceItem: (item) { + _controller.removeDraftPriceItem(item); + _controller.updateFormData(_controller.formData); + }, ), ); } @@ -208,6 +214,7 @@ class _ProductFormDialogState extends State { } Future _handleSubmit() async { + final t = AppLocalizations.of(context); if (!_controller.validateForm(_formKey)) { return; } @@ -226,10 +233,10 @@ class _ProductFormDialogState extends State { } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(_controller.errorMessage ?? 'خطای نامشخص'), + content: Text(_controller.errorMessage ?? t.error), backgroundColor: Colors.red, action: SnackBarAction( - label: 'تلاش مجدد', + label: t.retry, textColor: Colors.white, onPressed: _handleSubmit, ), diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart new file mode 100644 index 0000000..4aebfe2 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_import_dialog.dart @@ -0,0 +1,333 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +import '../person/file_picker_bridge.dart'; +import '../../core/api_client.dart'; +import '../data_table/helpers/file_saver.dart'; + +class ProductImportDialog extends StatefulWidget { + final int businessId; + + const ProductImportDialog({super.key, required this.businessId}); + + @override + State createState() => _ProductImportDialogState(); +} + +class _ProductImportDialogState extends State { + final TextEditingController _pathCtrl = TextEditingController(); + bool _dryRun = true; + String _matchBy = 'code'; + String _conflictPolicy = 'upsert'; + bool _loading = false; + Map? _result; + PickedFileData? _selectedFile; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _isInitialized = true; + }); + } + }); + } + + @override + void dispose() { + _pathCtrl.dispose(); + super.dispose(); + } + + Future _pickFile() async { + if (!_isInitialized) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(t.loading)), + ); + } + return; + } + + try { + final picked = await FilePickerBridge.pickExcel(); + if (picked != null) { + setState(() { + _selectedFile = picked; + _pathCtrl.text = picked.name; + }); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.pickFileError}: $e')), + ); + } + } + } + + Future _downloadTemplate() async { + try { + setState(() => _loading = true); + final api = ApiClient(); + final res = await api.post( + '/products/business/${widget.businessId}/import/template', + responseType: ResponseType.bytes, + ); + String filename = 'products_import_template.xlsx'; + final cd = res.headers.value('content-disposition'); + if (cd != null) { + try { + final parts = cd.split(';').map((e) => e.trim()); + for (final p in parts) { + if (p.toLowerCase().startsWith('filename=')) { + var name = p.substring('filename='.length).trim(); + if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) { + name = name.substring(1, name.length - 1); + } + if (name.isNotEmpty) filename = name; + break; + } + } + } catch (_) {} + } + await FileSaver.saveBytes((res.data as List), filename); + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.templateDownloaded}: $filename')), + ); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.templateDownloadError}: $e')), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _runImport({required bool dryRun}) async { + if (_selectedFile == null) { + await _pickFile(); + if (_selectedFile == null) return; + } + final filename = _selectedFile!.name; + final bytes = _selectedFile!.bytes; + + try { + setState(() { + _loading = true; + _result = null; + }); + final form = FormData.fromMap({ + 'file': MultipartFile.fromBytes(bytes, filename: filename), + 'dry_run': dryRun.toString(), + 'match_by': _matchBy, + 'conflict_policy': _conflictPolicy, + }); + final api = ApiClient(); + final res = await api.post>( + '/products/business/${widget.businessId}/import/excel', + data: form, + options: Options(contentType: 'multipart/form-data'), + ); + setState(() { + _result = res.data; + }); + if (!dryRun) { + if (mounted) Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + final t = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${t.importError}: $e')), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return AlertDialog( + title: Text(t.importFromExcel), + content: SizedBox( + width: 560, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row(children: [ + Expanded( + child: TextField( + controller: _pathCtrl, + readOnly: true, + decoration: InputDecoration( + labelText: t.selectedFile, + hintText: t.noFileSelected, + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: (_loading || !_isInitialized) ? null : _pickFile, + icon: const Icon(Icons.attach_file), + label: Text(t.chooseFile), + ), + ]), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _matchBy, + isDense: true, + items: [ + DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')), + DropdownMenuItem(value: 'name', child: Text('${t.matchBy}: ${t.title}')), + ], + onChanged: (v) => setState(() => _matchBy = v ?? 'code'), + decoration: const InputDecoration(isDense: true), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + value: _conflictPolicy, + isDense: true, + items: [ + DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')), + DropdownMenuItem(value: 'update', child: Text('${t.conflictPolicy}: ${t.policyUpdateExisting}')), + DropdownMenuItem(value: 'upsert', child: Text('${t.conflictPolicy}: ${t.policyUpsert}')), + ], + onChanged: (v) => setState(() => _conflictPolicy = v ?? 'upsert'), + decoration: const InputDecoration(isDense: true), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _dryRun, + onChanged: (v) => setState(() => _dryRun = v ?? true), + ), + Text(t.dryRunValidateOnly), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: _loading ? null : _downloadTemplate, + icon: const Icon(Icons.download), + label: Text(t.downloadTemplate), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : () => _runImport(dryRun: _dryRun), + icon: _loading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow), + label: Text(_dryRun ? t.reviewDryRun : t.import), + ), + const SizedBox(width: 8), + if (_dryRun) + FilledButton.tonalIcon( + onPressed: _loading ? null : () async { + setState(() => _dryRun = false); + await _runImport(dryRun: false); + }, + icon: const Icon(Icons.cloud_upload), + label: Text(t.importReal), + ), + ], + ), + if (_result != null) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Text('${t.result}:', style: Theme.of(context).textTheme.titleSmall), + ), + const SizedBox(height: 8), + _ResultSummary(result: _result!), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(false), + child: Text(t.close), + ), + ], + ); + } +} + +class _ResultSummary extends StatelessWidget { + final Map result; + const _ResultSummary({required this.result}); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final data = result['data'] as Map?; + final summary = (data?['summary'] as Map?) ?? {}; + final errors = (data?['errors'] as List?)?.cast>() ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 12, + runSpacing: 4, + children: [ + _chip(t.total, summary['total']), + _chip(t.valid, summary['valid']), + _chip(t.invalid, summary['invalid']), + _chip(t.inserted, summary['inserted']), + _chip(t.updated, summary['updated']), + _chip(t.skipped, summary['skipped']), + _chip(t.dryRun, summary['dry_run'] == true ? t.yes : t.no), + ], + ), + const SizedBox(height: 8), + if (errors.isNotEmpty) + SizedBox( + height: 160, + child: ListView.builder( + itemCount: errors.length, + itemBuilder: (context, i) { + final e = errors[i]; + return ListTile( + dense: true, + leading: const Icon(Icons.error_outline, color: Colors.red), + title: Text('${t.row} ${e['row']}'), + subtitle: Text(((e['errors'] as List?)?.join(', ')) ?? ''), + ); + }, + ), + ), + ], + ); + } + + Widget _chip(String label, Object? value) { + return Chip(label: Text('$label: ${value ?? '-'}')); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart index 8e85e85..d95dc0c 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_basic_info_section.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; +import '../../category/category_picker_field.dart'; import '../../../models/product_form_data.dart'; import '../../../utils/product_form_validator.dart'; class ProductBasicInfoSection extends StatelessWidget { + final int businessId; final ProductFormData formData; final ValueChanged onChanged; final List> categories; @@ -13,6 +15,7 @@ class ProductBasicInfoSection extends StatelessWidget { const ProductBasicInfoSection({ super.key, + required this.businessId, required this.formData, required this.onChanged, required this.categories, @@ -41,7 +44,7 @@ class ProductBasicInfoSection extends StatelessWidget { TextFormField( initialValue: formData.code, - decoration: InputDecoration(labelText: t.code + ' (اختیاری)'), + decoration: InputDecoration(labelText: '${t.code} (اختیاری)'), validator: ProductFormValidator.validateCode, onChanged: (value) => _updateFormData(formData.copyWith(code: value.trim().isEmpty ? null : value.trim())), ), @@ -70,16 +73,12 @@ class ProductBasicInfoSection extends StatelessWidget { ), const SizedBox(height: 20), - DropdownButtonFormField( - value: formData.categoryId, - items: categories - .map((category) => DropdownMenuItem( - value: category['id'] as int, - child: Text((category['label'] ?? '').toString()), - )) - .toList(), + CategoryPickerField( + businessId: businessId, + categoriesTree: categories, + initialValue: formData.categoryId, + label: t.categories, onChanged: (value) => _updateFormData(formData.copyWith(categoryId: value)), - decoration: InputDecoration(labelText: t.categories), ), ], ), @@ -93,7 +92,7 @@ class ProductBasicInfoSection extends StatelessWidget { TextFormField( initialValue: formData.code, - decoration: InputDecoration(labelText: t.code + ' (اختیاری)'), + decoration: InputDecoration(labelText: '${t.code} (اختیاری)'), validator: ProductFormValidator.validateCode, onChanged: (value) => _updateFormData(formData.copyWith(code: value.trim().isEmpty ? null : value.trim())), ), @@ -115,16 +114,12 @@ class ProductBasicInfoSection extends StatelessWidget { ), const SizedBox(height: 20), - DropdownButtonFormField( - value: formData.categoryId, - items: categories - .map((category) => DropdownMenuItem( - value: category['id'] as int, - child: Text((category['label'] ?? '').toString()), - )) - .toList(), + CategoryPickerField( + businessId: businessId, + categoriesTree: categories, + initialValue: formData.categoryId, + label: t.categories, onChanged: (value) => _updateFormData(formData.copyWith(categoryId: value)), - decoration: InputDecoration(labelText: t.categories), ), const SizedBox(height: 20), _buildUnitsSection(context), @@ -132,7 +127,7 @@ class ProductBasicInfoSection extends StatelessWidget { if (attributes.isNotEmpty) ...[ const SizedBox(height: 32), - Text('ویژگی‌ها', style: Theme.of(context).textTheme.titleSmall), + Text(t.productAttributes, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 12), Wrap( spacing: 8, @@ -169,12 +164,13 @@ class ProductBasicInfoSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('واحدها', style: Theme.of(context).textTheme.titleSmall), + Text(AppLocalizations.of(context).unitsTitle, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), Row( children: [ Expanded(child: _buildUnitTextField( - label: 'واحد اصلی', + context: context, + label: AppLocalizations.of(context).mainUnit, isRequired: true, initialText: _unitNameById(formData.mainUnitId) ?? 'عدد', onChanged: (text) { @@ -184,7 +180,8 @@ class ProductBasicInfoSection extends StatelessWidget { )), const SizedBox(width: 12), Expanded(child: _buildUnitTextField( - label: 'واحد فرعی', + context: context, + label: AppLocalizations.of(context).secondaryUnit, isRequired: false, initialText: _unitNameById(formData.secondaryUnitId) ?? '', onChanged: (text) { @@ -195,8 +192,8 @@ class ProductBasicInfoSection extends StatelessWidget { const SizedBox(width: 12), Expanded( child: TextFormField( - initialValue: formData.unitConversionFactor?.toString(), - decoration: const InputDecoration(labelText: 'ضریب تبدیل واحد'), + initialValue: formData.unitConversionFactor.toString(), + decoration: InputDecoration(labelText: AppLocalizations.of(context).unitConversionFactor), keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\\d*\\.?\\d{0,2}$')), @@ -212,6 +209,7 @@ class ProductBasicInfoSection extends StatelessWidget { } Widget _buildUnitTextField({ + required BuildContext context, required String label, required bool isRequired, required String initialText, @@ -221,7 +219,7 @@ class ProductBasicInfoSection extends StatelessWidget { initialValue: initialText, decoration: InputDecoration(labelText: label), keyboardType: TextInputType.text, - validator: isRequired ? (v) => (v == null || v.trim().isEmpty) ? '$label الزامی است' : null : null, + validator: isRequired ? (v) => (v == null || v.trim().isEmpty) ? '${AppLocalizations.of(context).required}' : null : null, onChanged: onChanged, ); } @@ -250,11 +248,12 @@ class ProductBasicInfoSection extends StatelessWidget { } Widget _buildItemTypeSelector(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'نوع', + t.itemType, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), @@ -265,8 +264,8 @@ class ProductBasicInfoSection extends StatelessWidget { Expanded( child: _buildItemTypeCard( context: context, - title: 'کالا', - subtitle: 'محصولات فیزیکی', + title: t.products, + subtitle: t.productPhysicalDesc, icon: Icons.inventory_2_outlined, value: 'کالا', isSelected: formData.itemType == 'کالا', @@ -276,8 +275,8 @@ class ProductBasicInfoSection extends StatelessWidget { Expanded( child: _buildItemTypeCard( context: context, - title: 'خدمت', - subtitle: 'خدمات و سرویس‌ها', + title: t.services, + subtitle: t.serviceDesc, icon: Icons.handyman_outlined, value: 'خدمت', isSelected: formData.itemType == 'خدمت', diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart index f0511a6..ffdd2d2 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_pricing_inventory_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; +import '../../../utils/number_formatters.dart'; import '../../../models/product_form_data.dart'; import '../../../utils/product_form_validator.dart'; @@ -7,12 +9,22 @@ class ProductPricingInventorySection extends StatelessWidget { final ProductFormData formData; final ValueChanged onChanged; final List> units; + final List> priceLists; + final List> currencies; + final List> draftPriceItems; + final void Function(Map item) onAddOrUpdatePriceItem; + final void Function(Map item) onDeletePriceItem; const ProductPricingInventorySection({ super.key, required this.formData, required this.onChanged, required this.units, + required this.priceLists, + required this.currencies, + required this.draftPriceItems, + required this.onAddOrUpdatePriceItem, + required this.onDeletePriceItem, }); @override @@ -20,22 +32,25 @@ class ProductPricingInventorySection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildInventorySection(), + _buildInventorySection(context), const SizedBox(height: 24), _buildPricingSection(context), + const SizedBox(height: 24), + _buildPerPriceListPricing(context), ], ); } - Widget _buildInventorySection() { + Widget _buildInventorySection(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( value: formData.trackInventory, onChanged: (value) => _updateFormData(formData.copyWith(trackInventory: value)), - title: const Text('کنترل موجودی'), + title: Text(t.inventoryControl), ), if (formData.trackInventory) ...[ const SizedBox(height: 16), @@ -44,12 +59,12 @@ class ProductPricingInventorySection extends StatelessWidget { Expanded( child: TextFormField( initialValue: formData.reorderPoint?.toString(), - decoration: const InputDecoration(labelText: 'نقطه سفارش مجدد'), + decoration: InputDecoration(labelText: t.reorderPointRepeat), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: 'نقطه سفارش مجدد'), + validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: t.reorderPointRepeat), onChanged: (value) => _updateFormData(formData.copyWith(reorderPoint: int.tryParse(value))), ), ), @@ -57,12 +72,12 @@ class ProductPricingInventorySection extends StatelessWidget { Expanded( child: TextFormField( initialValue: formData.minOrderQty?.toString(), - decoration: const InputDecoration(labelText: 'کمینه مقدار سفارش'), + decoration: InputDecoration(labelText: t.minOrderQty), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: 'کمینه مقدار سفارش'), + validator: (value) => ProductFormValidator.validateQuantity(value, fieldName: t.minOrderQty), onChanged: (value) => _updateFormData(formData.copyWith(minOrderQty: int.tryParse(value))), ), ), @@ -70,7 +85,7 @@ class ProductPricingInventorySection extends StatelessWidget { Expanded( child: TextFormField( initialValue: formData.leadTimeDays?.toString(), - decoration: const InputDecoration(labelText: 'زمان تحویل (روز)'), + decoration: InputDecoration(labelText: t.leadTimeDays), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, @@ -87,43 +102,40 @@ class ProductPricingInventorySection extends StatelessWidget { } Widget _buildPricingSection(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('قیمت‌گذاری', style: Theme.of(context).textTheme.titleSmall), + Text(t.pricing, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), TextFormField( initialValue: formData.baseSalesPrice?.toString(), - decoration: const InputDecoration(labelText: 'قیمت فروش'), + decoration: InputDecoration(labelText: t.salesPrice), keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), - ], - validator: (value) => ProductFormValidator.validatePrice(value, fieldName: 'قیمت فروش'), - onChanged: (value) => _updateFormData(formData.copyWith(baseSalesPrice: num.tryParse(value))), + inputFormatters: [ThousandsSeparatorInputFormatter()], + validator: (value) => ProductFormValidator.validatePrice(value, fieldName: t.salesPrice), + onChanged: (value) => _updateFormData(formData.copyWith(baseSalesPrice: num.tryParse(value.replaceAll(',', '')))), ), const SizedBox(height: 16), TextFormField( initialValue: formData.baseSalesNote, - decoration: const InputDecoration(labelText: 'توضیح قیمت فروش'), + decoration: InputDecoration(labelText: t.salesPriceNote), maxLines: 2, onChanged: (value) => _updateFormData(formData.copyWith(baseSalesNote: value.trim().isEmpty ? null : value)), ), const SizedBox(height: 16), TextFormField( initialValue: formData.basePurchasePrice?.toString(), - decoration: const InputDecoration(labelText: 'قیمت خرید'), + decoration: InputDecoration(labelText: t.purchasePrice), keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), - ], - validator: (value) => ProductFormValidator.validatePrice(value, fieldName: 'قیمت خرید'), - onChanged: (value) => _updateFormData(formData.copyWith(basePurchasePrice: num.tryParse(value))), + inputFormatters: [ThousandsSeparatorInputFormatter()], + validator: (value) => ProductFormValidator.validatePrice(value, fieldName: t.purchasePrice), + onChanged: (value) => _updateFormData(formData.copyWith(basePurchasePrice: num.tryParse(value.replaceAll(',', '')))), ), const SizedBox(height: 16), TextFormField( initialValue: formData.basePurchaseNote, - decoration: const InputDecoration(labelText: 'توضیح قیمت خرید'), + decoration: InputDecoration(labelText: t.purchasePriceNote), maxLines: 2, onChanged: (value) => _updateFormData(formData.copyWith(basePurchaseNote: value.trim().isEmpty ? null : value)), ), @@ -131,6 +143,212 @@ class ProductPricingInventorySection extends StatelessWidget { ); } + Widget _buildPerPriceListPricing(BuildContext context) { + final t = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.pricesInPriceLists, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: () async { + if (priceLists.isEmpty) { + _showNoPriceListsWarning(context); + return; + } + await _openEditorDialog(context); + }, + icon: const Icon(Icons.add), + label: Text(t.addPrice), + ), + ), + const SizedBox(height: 12), + ...draftPriceItems.map((it) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: ListTile( + title: Text(_resolvePriceListTitle(it['price_list_id'])), + subtitle: Text('${t.currency}: ${it['currency_id'] ?? '-'} | ${t.price}: ${it['price']}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () async { + if (priceLists.isEmpty) { + _showNoPriceListsWarning(context); + return; + } + await _openEditorDialog(context, existing: it); + }, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => onDeletePriceItem(it), + ), + ], + ), + ), + ); + }).toList(), + ], + ); + } + + String _resolvePriceListTitle(dynamic id) { + if (id == null) return '-'; + for (final pl in priceLists) { + if (pl['id'] == id) return (pl['name'] ?? '').toString(); + } + return 'لیست ${id.toString()}'; + } + + void _showNoPriceListsWarning(BuildContext context) { + final t = AppLocalizations.of(context); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Icon(Icons.warning, color: Colors.orange[700]), + const SizedBox(width: 8), + Text(t.noPriceListsTitle), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.noPriceListsMessage), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.info, color: Colors.blue[700], size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + t.noPriceListsHint, + style: TextStyle( + color: Colors.blue[700], + fontSize: 14, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(t.gotIt), + ), + ], + ), + ); + } + + Future _openEditorDialog(BuildContext context, {Map? existing}) async { + final formKey = GlobalKey(); + int? priceListId = (existing?['price_list_id'] as num?)?.toInt(); + int? currencyId = (existing?['currency_id'] as num?)?.toInt(); + + // Set first price list as default if none provided and price lists exist + if (priceListId == null && priceLists.isNotEmpty) { + priceListId = (priceLists.first['id'] as num).toInt(); + } + + // Default select business default currency if none provided + if (currencyId == null && currencies.isNotEmpty) { + try { + final def = currencies.firstWhere((c) => (c['is_default'] == true)); + currencyId = (def['id'] as num?)?.toInt() ?? currencyId; + } catch (_) { + // If no explicit default flagged, keep null to force selection + } + } + num price = (existing?['price'] as num?) ?? 0; + + final t = AppLocalizations.of(context); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(existing == null ? t.addPriceTitle : t.editPriceTitle), + content: SizedBox( + width: 560, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: priceListId, + items: priceLists + .map((pl) => DropdownMenuItem( + value: (pl['id'] as num).toInt(), + child: Text((pl['name'] ?? '').toString()), + )) + .toList(), + onChanged: (v) => priceListId = v, + decoration: InputDecoration(labelText: t.priceList), + validator: (v) => v == null ? t.required : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: currencyId, + items: currencies + .map((c) => DropdownMenuItem( + value: (c['id'] as num).toInt(), + child: Text('${c['title'] ?? c['name']} (${c['code']})'), + )) + .toList(), + onChanged: (v) => currencyId = v, + decoration: InputDecoration(labelText: t.currency), + validator: (v) => v == null ? t.required : null, + ), + const SizedBox(height: 12), + TextFormField( + initialValue: price.toString(), + decoration: InputDecoration(labelText: t.price), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ThousandsSeparatorInputFormatter()], + validator: (v) => (num.tryParse((v ?? '').replaceAll(',', '')) == null) ? t.invalid : null, + onChanged: (v) => price = num.tryParse((v).replaceAll(',', '')) ?? 0, + ), + ], + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(AppLocalizations.of(ctx).cancel)), + FilledButton( + onPressed: () { + if (!(formKey.currentState?.validate() ?? false)) return; + final payload = { + 'price_list_id': priceListId, + 'currency_id': currencyId, + 'price': price, + }..removeWhere((k, v) => v == null); + onAddOrUpdatePriceItem(payload); + Navigator.of(ctx).pop(true); + }, + child: Text(AppLocalizations.of(ctx).save), + ), + ], + ), + ); + } + void _updateFormData(ProductFormData newData) { onChanged(newData); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart index 00fb941..d661680 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_tax_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import '../../../models/product_form_data.dart'; import '../../../utils/product_form_validator.dart'; @@ -19,16 +20,17 @@ class ProductTaxSection extends StatelessWidget { @override Widget build(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('مالیات', style: Theme.of(context).textTheme.titleSmall), + Text(t.taxTitle, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 16), _buildTaxCodeTypeUnitRow(context), const SizedBox(height: 24), - _buildSalesTaxSection(), + _buildSalesTaxSection(context), const SizedBox(height: 24), - _buildPurchaseTaxSection(), + _buildPurchaseTaxSection(context), ], ); } @@ -40,56 +42,58 @@ class ProductTaxSection extends StatelessWidget { if (isDesktop) { return Row( children: [ - Expanded(child: _buildTaxCodeField()), + Expanded(child: _buildTaxCodeField(context)), const SizedBox(width: 12), - Expanded(child: _buildTaxTypeDropdown()), + Expanded(child: _buildTaxTypeDropdown(context)), const SizedBox(width: 12), - Expanded(child: _buildTaxUnitDropdown()), + Expanded(child: _buildTaxUnitDropdown(context)), ], ); } return Column( children: [ - _buildTaxCodeField(), + _buildTaxCodeField(context), const SizedBox(height: 12), - _buildTaxTypeDropdown(), + _buildTaxTypeDropdown(context), const SizedBox(height: 12), - _buildTaxUnitDropdown(), + _buildTaxUnitDropdown(context), ], ); }, ); } - Widget _buildTaxCodeField() { + Widget _buildTaxCodeField(BuildContext context) { + final t = AppLocalizations.of(context); return TextFormField( initialValue: formData.taxCode, - decoration: const InputDecoration(labelText: 'کُد مالیاتی'), + decoration: InputDecoration(labelText: t.taxCode), onChanged: (value) => _updateFormData( formData.copyWith(taxCode: value.trim().isEmpty ? null : value), ), ); } - Widget _buildSalesTaxSection() { + Widget _buildSalesTaxSection(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( value: formData.isSalesTaxable, onChanged: (value) => _updateFormData(formData.copyWith(isSalesTaxable: value)), - title: const Text('مشمول مالیات فروش'), + title: Text(t.isSalesTaxable), ), if (formData.isSalesTaxable) ...[ const SizedBox(height: 16), TextFormField( initialValue: formData.salesTaxRate?.toString(), - decoration: const InputDecoration(labelText: 'نرخ مالیات فروش (%)'), + decoration: InputDecoration(labelText: t.salesTaxRate), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: 'نرخ مالیات فروش'), + validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.salesTaxRate), onChanged: (value) => _updateFormData(formData.copyWith(salesTaxRate: num.tryParse(value))), ), ], @@ -97,25 +101,26 @@ class ProductTaxSection extends StatelessWidget { ); } - Widget _buildPurchaseTaxSection() { + Widget _buildPurchaseTaxSection(BuildContext context) { + final t = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( value: formData.isPurchaseTaxable, onChanged: (value) => _updateFormData(formData.copyWith(isPurchaseTaxable: value)), - title: const Text('مشمول مالیات خرید'), + title: Text(t.isPurchaseTaxable), ), if (formData.isPurchaseTaxable) ...[ const SizedBox(height: 16), TextFormField( initialValue: formData.purchaseTaxRate?.toString(), - decoration: const InputDecoration(labelText: 'نرخ مالیات خرید (%)'), + decoration: InputDecoration(labelText: t.purchaseTaxRate), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], - validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: 'نرخ مالیات خرید'), + validator: (value) => ProductFormValidator.validateTaxRate(value, fieldName: t.purchaseTaxRate), onChanged: (value) => _updateFormData(formData.copyWith(purchaseTaxRate: num.tryParse(value))), ), ], @@ -123,8 +128,9 @@ class ProductTaxSection extends StatelessWidget { ); } - Widget _buildTaxTypeDropdown() { + Widget _buildTaxTypeDropdown(BuildContext context) { if (taxTypes.isNotEmpty) { + final t = AppLocalizations.of(context); return DropdownButtonFormField( value: formData.taxTypeId, items: taxTypes @@ -134,21 +140,23 @@ class ProductTaxSection extends StatelessWidget { )) .toList(), onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: value)), - decoration: const InputDecoration(labelText: 'نوع مالیات'), + decoration: InputDecoration(labelText: t.taxType), ); } else { + final t = AppLocalizations.of(context); return TextFormField( initialValue: formData.taxTypeId?.toString(), - decoration: const InputDecoration(labelText: 'شناسه نوع مالیات'), + decoration: InputDecoration(labelText: t.taxTypeId), keyboardType: TextInputType.number, onChanged: (value) => _updateFormData(formData.copyWith(taxTypeId: int.tryParse(value))), ); } } - Widget _buildTaxUnitDropdown() { + Widget _buildTaxUnitDropdown(BuildContext context) { final List> effectiveTaxUnits = taxUnits.isNotEmpty ? taxUnits : _fallbackTaxUnits(); if (effectiveTaxUnits.isNotEmpty) { + final t = AppLocalizations.of(context); return DropdownButtonFormField( value: formData.taxUnitId, items: effectiveTaxUnits @@ -158,12 +166,13 @@ class ProductTaxSection extends StatelessWidget { )) .toList(), onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: value)), - decoration: const InputDecoration(labelText: 'واحد مالیاتی'), + decoration: InputDecoration(labelText: t.taxUnit), ); } else { + final t = AppLocalizations.of(context); return TextFormField( initialValue: formData.taxUnitId?.toString(), - decoration: const InputDecoration(labelText: 'شناسه واحد مالیاتی'), + decoration: InputDecoration(labelText: t.taxUnitId), keyboardType: TextInputType.number, onChanged: (value) => _updateFormData(formData.copyWith(taxUnitId: int.tryParse(value))), ); diff --git a/hesabixUI/hesabix_ui/test/product_form_test.dart b/hesabixUI/hesabix_ui/test/product_form_test.dart index 541b364..e57544f 100644 --- a/hesabixUI/hesabix_ui/test/product_form_test.dart +++ b/hesabixUI/hesabix_ui/test/product_form_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hesabix_ui/models/product_form_data.dart'; import 'package:hesabix_ui/utils/product_form_validator.dart';