diff --git a/hesabixAPI/API_README.md b/hesabixAPI/API_README.md new file mode 100644 index 0000000..d9b9d73 --- /dev/null +++ b/hesabixAPI/API_README.md @@ -0,0 +1,325 @@ +# Hesabix API Documentation + +## 🔗 دسترسی به مستندات + +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **OpenAPI Schema**: http://localhost:8000/openapi.json + +## 🚀 شروع سریع + +### 1. نصب و راه‌اندازی + +```bash +# کلون کردن پروژه +git clone https://github.com/hesabix/api.git +cd hesabix-api + +# نصب dependencies +pip install -r requirements.txt + +# راه‌اندازی دیتابیس +alembic upgrade head + +# اجرای سرور +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 2. اولین درخواست + +```bash +# بررسی وضعیت API +curl http://localhost:8000/api/v1/health + +# دریافت کپچا +curl -X POST http://localhost:8000/api/v1/auth/captcha + +# ثبت‌نام +curl -X POST http://localhost:8000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" \ + -d '{ + "first_name": "احمد", + "last_name": "احمدی", + "email": "ahmad@example.com", + "password": "password123", + "captcha_id": "captcha_id_from_previous_step", + "captcha_code": "12345" + }' +``` + +## 🔐 احراز هویت + +### کلیدهای API + +تمام endpoint های محافظت شده نیاز به کلید API دارند: + +```bash +Authorization: Bearer sk_your_api_key_here +``` + +### نحوه دریافت کلید + +1. **ثبت‌نام**: `POST /api/v1/auth/register` +2. **ورود**: `POST /api/v1/auth/login` +3. **کلیدهای شخصی**: `POST /api/v1/auth/api-keys` + +### مثال کامل + +```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/auth/me \ + -H "Authorization: Bearer sk_1234567890abcdef" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" +``` + +## 🌍 چندزبانه + +### هدرهای زبان + +```bash +Accept-Language: fa # فارسی (پیش‌فرض) +Accept-Language: en # انگلیسی +Accept-Language: fa-IR # فارسی ایران +Accept-Language: en-US # انگلیسی آمریکا +``` + +## 📅 تقویم + +### هدرهای تقویم + +```bash +X-Calendar-Type: jalali # تقویم شمسی (پیش‌فرض) +X-Calendar-Type: gregorian # تقویم میلادی +``` + +## 🛡️ مجوزهای دسترسی + +### مجوزهای اپلیکیشن + +- `user_management`: مدیریت کاربران +- `superadmin`: دسترسی کامل +- `business_management`: مدیریت کسب و کارها +- `system_settings`: تنظیمات سیستم + +### endpoint های محافظت شده + +- `/api/v1/users/*` → نیاز به `user_management` +- `/api/v1/auth/me` → نیاز به احراز هویت +- `/api/v1/auth/api-keys/*` → نیاز به احراز هویت + +## 📊 فرمت پاسخ‌ها + +### پاسخ موفق + +```json +{ + "success": true, + "message": "عملیات با موفقیت انجام شد", + "data": { + // داده‌های اصلی + } +} +``` + +### پاسخ خطا + +```json +{ + "success": false, + "message": "پیام خطا", + "error_code": "ERROR_CODE", + "details": { + // جزئیات خطا + } +} +``` + +### کدهای خطا + +| کد | معنی | +|----|------| +| 200 | موفقیت | +| 400 | خطا در اعتبارسنجی | +| 401 | احراز هویت نشده | +| 403 | دسترسی غیرمجاز | +| 404 | منبع یافت نشد | +| 422 | خطا در اعتبارسنجی | +| 500 | خطای سرور | + +## 🔒 امنیت + +### کپچا + +برای عملیات حساس: + +```bash +# دریافت کپچا +curl -X POST http://localhost:8000/api/v1/auth/captcha + +# استفاده در ثبت‌نام/ورود +{ + "captcha_id": "captcha_id_from_previous_step", + "captcha_code": "12345" +} +``` + +### رمزگذاری + +- رمزهای عبور: bcrypt +- کلیدهای API: SHA-256 + +## 📝 مثال‌های کاربردی + +### مدیریت کاربران + +```bash +# لیست کاربران (نیاز به مجوز usermanager) +curl -X GET http://localhost:8000/api/v1/users \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" + +# جستجوی پیشرفته کاربران +curl -X POST http://localhost:8000/api/v1/users/search \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Content-Type: application/json" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" \ + -d '{ + "take": 10, + "skip": 0, + "search": "احمد", + "search_fields": ["first_name", "last_name", "email"], + "filters": [ + { + "property": "is_active", + "operator": "=", + "value": true + } + ] + }' + +# دریافت اطلاعات یک کاربر +curl -X GET http://localhost:8000/api/v1/users/1 \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" + +# آمار کاربران +curl -X GET http://localhost:8000/api/v1/users/stats/summary \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" +``` + +### مدیریت معرفی‌ها + +```bash +# آمار معرفی‌ها +curl -X GET "http://localhost:8000/api/v1/auth/referrals/stats?start=2024-01-01&end=2024-12-31" \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" + +# لیست معرفی‌ها +curl -X POST http://localhost:8000/api/v1/auth/referrals/list \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Content-Type: application/json" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" \ + -d '{ + "take": 10, + "skip": 0, + "sort_by": "created_at", + "sort_desc": true + }' + +# خروجی PDF +curl -X POST http://localhost:8000/api/v1/auth/referrals/export/pdf \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Content-Type: application/json" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" \ + -d '{ + "take": 100, + "skip": 0 + }' \ + --output referrals.pdf + +# خروجی Excel +curl -X POST http://localhost:8000/api/v1/auth/referrals/export/excel \ + -H "Authorization: Bearer sk_your_api_key" \ + -H "Content-Type: application/json" \ + -H "Accept-Language: fa" \ + -H "X-Calendar-Type: jalali" \ + -d '{ + "take": 100, + "skip": 0 + }' \ + --output referrals.xlsx +``` + +## 🛠️ توسعه + +### ساختار پروژه + +``` +hesabixAPI/ +├── app/ +│ ├── core/ # تنظیمات اصلی +│ ├── services/ # سرویس‌ها +│ └── main.py # نقطه ورود +├── adapters/ +│ ├── api/v1/ # API endpoints +│ └── db/ # دیتابیس +├── migrations/ # مهاجرت‌های دیتابیس +└── tests/ # تست‌ها +``` + +### اجرای تست‌ها + +```bash +pytest tests/ +``` + +### مهاجرت دیتابیس + +```bash +# ایجاد migration جدید +alembic revision --autogenerate -m "description" + +# اعمال migrations +alembic upgrade head + +# بازگشت به migration قبلی +alembic downgrade -1 +``` + +## 📞 پشتیبانی + +- **ایمیل**: support@hesabix.com +- **مستندات**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **GitHub**: https://github.com/hesabix/api + +## 📄 مجوز + +این پروژه تحت مجوز MIT منتشر شده است. برای جزئیات بیشتر به فایل [LICENSE](LICENSE) مراجعه کنید. diff --git a/hesabixAPI/adapters/api/v1/auth.py b/hesabixAPI/adapters/api/v1/auth.py index c9bec09..2854aca 100644 --- a/hesabixAPI/adapters/api/v1/auth.py +++ b/hesabixAPI/adapters/api/v1/auth.py @@ -1,7 +1,7 @@ -from __future__ import annotations +# Removed __future__ annotations to fix OpenAPI schema generation import datetime -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Query from fastapi.responses import Response from sqlalchemy.orm import Session @@ -10,7 +10,12 @@ 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 +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 @@ -18,7 +23,29 @@ from app.services.api_key_service import list_personal_keys, create_personal_key router = APIRouter(prefix="/auth", tags=["auth"]) -@router.post("/captcha", summary="Generate numeric captcha") +@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({ @@ -28,7 +55,49 @@ def generate_captcha(db: Session = Depends(get_db)) -> dict: }) -@router.get("/me", summary="Get current user info") +@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) @@ -37,7 +106,61 @@ def get_current_user_info( return success_response(ctx.to_dict(), request) -@router.post("/register", summary="Register new user") +@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, @@ -66,7 +189,61 @@ def register(request: Request, payload: RegisterRequest, db: Session = Depends(g return success_response(formatted_data, request) -@router.post("/login", summary="Login with email or mobile") +@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 @@ -94,32 +271,213 @@ def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db) return success_response(formatted_data, request) -@router.post("/forgot-password", summary="Create password reset token") +@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="Reset password with token") +@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="List personal API keys") +@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="Create personal API key") +@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="Change user password") +@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) @@ -135,14 +493,75 @@ def change_password_endpoint(request: Request, payload: ChangePasswordRequest, c return success_response({"ok": True}) -@router.delete("/api-keys/{key_id}", summary="Revoke API key") +@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="Referral stats for current user") -def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str | None = None, end: str | None = None) -> dict: +@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 @@ -150,7 +569,45 @@ def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_ return success_response(stats) -@router.post("/referrals/list", summary="Referral list with advanced filtering") +@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, @@ -229,7 +686,26 @@ def get_referral_list_advanced( }, request) -@router.post("/referrals/export/pdf", summary="Export referrals to PDF") +@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, @@ -320,7 +796,26 @@ def export_referrals_pdf( ) -@router.post("/referrals/export/excel", summary="Export referrals to Excel") +@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, diff --git a/hesabixAPI/adapters/api/v1/businesses.py b/hesabixAPI/adapters/api/v1/businesses.py new file mode 100644 index 0000000..d6c3f23 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/businesses.py @@ -0,0 +1,310 @@ +# 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, + QueryInfo +) +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, + 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.get("", + 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": "2024-01-01T00:00:00Z" + } + ], + "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, + query_info: QueryInfo, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """لیست کسب و کارهای کاربر""" + owner_id = ctx.get_user_id() + query_dict = query_info.dict() + businesses = get_businesses_by_owner(db, owner_id, query_dict) + formatted_data = format_datetime_fields(businesses, request) + return success_response(formatted_data, request, "لیست کسب و کارها دریافت شد") + + +@router.get("/{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, + "address": "تهران، خیابان ولیعصر", + "phone": "02112345678", + "created_at": "2024-01-01T00:00:00Z" + } + } + } + } + }, + 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.get("/summary/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/adapters/api/v1/health.py b/hesabixAPI/adapters/api/v1/health.py index df8baf6..6f1cb59 100644 --- a/hesabixAPI/adapters/api/v1/health.py +++ b/hesabixAPI/adapters/api/v1/health.py @@ -1,8 +1,30 @@ from fastapi import APIRouter +from .schemas import SuccessResponse router = APIRouter(prefix="/health", tags=["health"]) -@router.get("", summary="Health check") +@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/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py index 07f806b..dd5964b 100644 --- a/hesabixAPI/adapters/api/v1/schemas.py +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -1,7 +1,6 @@ -from __future__ import annotations - -from typing import Any +from typing import Any, List, Optional, Union from pydantic import BaseModel, EmailStr, Field +from enum import Enum class FilterItem(BaseModel): @@ -11,13 +10,13 @@ class FilterItem(BaseModel): class QueryInfo(BaseModel): - sort_by: str | None = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی") + 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: str | None = Field(default=None, description="عبارت جستجو") - search_fields: list[str] | None = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد") - filters: list[FilterItem] | None = Field(default=None, 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): @@ -26,19 +25,19 @@ class CaptchaSolve(BaseModel): class RegisterRequest(CaptchaSolve): - first_name: str | None = Field(default=None, max_length=100) - last_name: str | None = Field(default=None, max_length=100) - email: EmailStr | None = None - mobile: str | None = Field(default=None, max_length=32) + 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: str | None = Field(default=None, max_length=100) - referrer_code: str | None = Field(default=None, min_length=4, max_length=32) + 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: str | None = Field(default=None, max_length=100) + device_id: Optional[str] = Field(default=None, max_length=100) class ForgotPasswordRequest(CaptchaSolve): @@ -57,8 +56,171 @@ class ChangePasswordRequest(BaseModel): class CreateApiKeyRequest(BaseModel): - name: str | None = Field(default=None, max_length=100) - scopes: str | None = Field(default=None, max_length=500) - expires_at: str | None = None # ISO string; parse server-side if provided + 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="کد پستی") + + +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="تاریخ آخرین بروزرسانی") + + +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="تعداد بر اساس زمینه فعالیت") diff --git a/hesabixAPI/adapters/api/v1/users.py b/hesabixAPI/adapters/api/v1/users.py index 3e837a8..06b9742 100644 --- a/hesabixAPI/adapters/api/v1/users.py +++ b/hesabixAPI/adapters/api/v1/users.py @@ -1,25 +1,81 @@ -from __future__ import annotations +# 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 +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.get("", summary="لیست کاربران با فیلتر پیشرفته") +@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 = Depends(), + query_info: QueryInfo, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db) -) -> dict: +): """ دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی @@ -91,13 +147,136 @@ def list_users( return success_response(response_data, request) -@router.get("/{user_id}", summary="دریافت اطلاعات یک کاربر") +@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) -) -> dict: +): """دریافت اطلاعات یک کاربر بر اساس ID""" repo = UserRepository(db) user = repo.get_by_id(user_id) @@ -112,12 +291,51 @@ def get_user( return success_response(formatted_user, request) -@router.get("/stats/summary", summary="آمار کلی کاربران") +@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) -) -> dict: +): """دریافت آمار کلی کاربران""" repo = UserRepository(db) diff --git a/hesabixAPI/app/core/permissions/__init__.py b/hesabixAPI/app/core/permissions/__init__.py deleted file mode 100644 index 5a046c5..0000000 --- a/hesabixAPI/app/core/permissions/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -from .permissions import ( - require_app_permission, - require_business_permission, - require_any_permission, - require_superadmin, - require_business_access, - require_sales_write, - require_sales_delete, - require_sales_approve, - require_purchases_write, - require_accounting_write, - require_inventory_write, - require_reports_export, - require_settings_manage_users, - require_user_management, - require_business_management, - require_system_settings, -) - -__all__ = [ - "require_app_permission", - "require_business_permission", - "require_any_permission", - "require_superadmin", - "require_business_access", - "require_sales_write", - "require_sales_delete", - "require_sales_approve", - "require_purchases_write", - "require_accounting_write", - "require_inventory_write", - "require_reports_export", - "require_settings_manage_users", - "require_user_management", - "require_business_management", - "require_system_settings", -] diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index a96093c..7835ca2 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -6,6 +6,7 @@ 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 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 @@ -20,6 +21,204 @@ def create_app() -> 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( @@ -62,13 +261,55 @@ def create_app() -> FastAPI: 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) register_error_handlers(application) - @application.get("/") + @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"] = { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API Key", + "description": "کلید API برای احراز هویت. فرمت: Bearer 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 نیاز به احراز هویت دارند + if "/auth/" in path or "/users" in path: + details["security"] = [{"BearerAuth": []}] + + application.openapi_schema = openapi_schema + return application.openapi_schema + + application.openapi = custom_openapi + return application diff --git a/hesabixAPI/app/services/business_service.py b/hesabixAPI/app/services/business_service.py new file mode 100644 index 0000000..75a11f3 --- /dev/null +++ b/hesabixAPI/app/services/business_service.py @@ -0,0 +1,186 @@ +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.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) + + # تبدیل 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, + 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 + ) + + # تبدیل به 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 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""" + 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, + "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.isoformat(), + "updated_at": business.updated_at.isoformat() + } diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 31d998b..28df8eb 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -4,6 +4,7 @@ adapters/__init__.py adapters/api/__init__.py adapters/api/v1/__init__.py adapters/api/v1/auth.py +adapters/api/v1/businesses.py adapters/api/v1/health.py adapters/api/v1/schemas.py adapters/api/v1/users.py @@ -37,9 +38,9 @@ app/core/responses.py app/core/security.py app/core/settings.py app/core/smart_normalizer.py -app/core/permissions/__init__.py app/services/api_key_service.py app/services/auth_service.py +app/services/business_service.py app/services/captcha_service.py app/services/query_service.py app/services/pdf/__init__.py diff --git a/hesabixUI/hesabix_ui/lib/models/business_models.dart b/hesabixUI/hesabix_ui/lib/models/business_models.dart new file mode 100644 index 0000000..cae500d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/business_models.dart @@ -0,0 +1,314 @@ +enum BusinessType { + company('شرکت'), + shop('مغازه'), + store('فروشگاه'), + union('اتحادیه'), + club('باشگاه'), + institute('موسسه'), + individual('شخصی'); + + const BusinessType(this.displayName); + final String displayName; +} + +enum BusinessField { + manufacturing('تولیدی'), + commercial('بازرگانی'), + service('خدماتی'), + other('سایر'); + + const BusinessField(this.displayName); + final String displayName; +} + +class BusinessData { + // مرحله 1: اطلاعات پایه + String name; + BusinessType? businessType; + BusinessField? businessField; + + // مرحله 2: اطلاعات تماس + String? address; + String? phone; + String? mobile; + String? postalCode; + + // مرحله 3: اطلاعات قانونی + String? nationalId; + String? registrationNumber; + String? economicId; + + // مرحله 4: اطلاعات جغرافیایی + String? country; + String? province; + String? city; + + BusinessData({ + this.name = '', + this.businessType, + this.businessField, + this.address, + this.phone, + this.mobile, + this.postalCode, + this.nationalId, + this.registrationNumber, + this.economicId, + this.country, + this.province, + this.city, + }); + + // تبدیل به Map برای ارسال به API + Map toJson() { + return { + 'name': name, + 'business_type': businessType?.name, + 'business_field': businessField?.name, + 'address': address, + 'phone': phone, + 'mobile': mobile, + 'postal_code': postalCode, + 'national_id': nationalId, + 'registration_number': registrationNumber, + 'economic_id': economicId, + 'country': country, + 'province': province, + 'city': city, + }; + } + + // کپی کردن با تغییرات + BusinessData copyWith({ + String? name, + BusinessType? businessType, + BusinessField? businessField, + String? address, + String? phone, + String? mobile, + String? postalCode, + String? nationalId, + String? registrationNumber, + String? economicId, + String? country, + String? province, + String? city, + }) { + return BusinessData( + name: name ?? this.name, + businessType: businessType ?? this.businessType, + businessField: businessField ?? this.businessField, + address: address ?? this.address, + phone: phone ?? this.phone, + mobile: mobile ?? this.mobile, + postalCode: postalCode ?? this.postalCode, + nationalId: nationalId ?? this.nationalId, + registrationNumber: registrationNumber ?? this.registrationNumber, + economicId: economicId ?? this.economicId, + country: country ?? this.country, + province: province ?? this.province, + city: city ?? this.city, + ); + } + + // بررسی اعتبار مرحله 1 + bool isStep1Valid() { + return name.isNotEmpty && businessType != null && businessField != null; + } + + // بررسی اعتبار مرحله 2 (اختیاری) + bool isStep2Valid() { + // اعتبارسنجی موبایل اگر وارد شده باشد + if (mobile != null && mobile!.isNotEmpty) { + if (!_isValidMobile(mobile!)) { + return false; + } + } + + // اعتبارسنجی تلفن ثابت اگر وارد شده باشد + if (phone != null && phone!.isNotEmpty) { + if (!_isValidPhone(phone!)) { + return false; + } + } + + return true; + } + + // بررسی اعتبار مرحله 3 (اختیاری) + bool isStep3Valid() { + // اعتبارسنجی کد ملی اگر وارد شده باشد + if (nationalId != null && nationalId!.isNotEmpty) { + if (!_isValidNationalId(nationalId!)) { + return false; + } + } + + return true; + } + + // بررسی اعتبار مرحله 4 (اختیاری) + bool isStep4Valid() { + return true; // همه فیلدها اختیاری هستند + } + + // بررسی اعتبار کل فرم + bool isFormValid() { + return isStep1Valid() && isStep2Valid() && isStep3Valid() && isStep4Valid(); + } + + // اعتبارسنجی شماره موبایل ایرانی + bool _isValidMobile(String mobile) { + // حذف فاصله‌ها و کاراکترهای اضافی + String cleanMobile = mobile.replaceAll(RegExp(r'[\s\-\(\)]'), ''); + + // بررسی فرمت‌های مختلف موبایل ایرانی + RegExp mobileRegex = RegExp(r'^(\+98|0)?9\d{9}$'); + + if (!mobileRegex.hasMatch(cleanMobile)) { + return false; + } + + // بررسی طول نهایی (باید 11 رقم باشد) + String finalMobile = cleanMobile.startsWith('+98') + ? cleanMobile.substring(3) + : cleanMobile.startsWith('0') + ? cleanMobile + : '0$cleanMobile'; + + return finalMobile.length == 11 && finalMobile.startsWith('09'); + } + + // اعتبارسنجی شماره تلفن ثابت ایرانی + bool _isValidPhone(String phone) { + // حذف فاصله‌ها و کاراکترهای اضافی + String cleanPhone = phone.replaceAll(RegExp(r'[\s\-\(\)]'), ''); + + // بررسی فرمت‌های مختلف تلفن ثابت ایرانی + RegExp phoneRegex = RegExp(r'^(\+98|0)?[1-9]\d{7,8}$'); + + if (!phoneRegex.hasMatch(cleanPhone)) { + return false; + } + + // بررسی طول نهایی (باید 8-11 رقم باشد) + String finalPhone = cleanPhone.startsWith('+98') + ? cleanPhone.substring(3) + : cleanPhone.startsWith('0') + ? cleanPhone + : '0$cleanPhone'; + + return finalPhone.length >= 8 && finalPhone.length <= 11; + } + + // اعتبارسنجی کد ملی ایرانی + bool _isValidNationalId(String nationalId) { + // حذف فاصله‌ها و کاراکترهای اضافی + String cleanId = nationalId.replaceAll(RegExp(r'[\s\-]'), ''); + + // بررسی طول (باید 10 رقم باشد) + if (cleanId.length != 10) { + return false; + } + + // بررسی اینکه همه کاراکترها عدد باشند + if (!RegExp(r'^\d{10}$').hasMatch(cleanId)) { + return false; + } + + // بررسی الگوریتم کد ملی + int sum = 0; + for (int i = 0; i < 9; i++) { + sum += int.parse(cleanId[i]) * (10 - i); + } + + int remainder = sum % 11; + int checkDigit = remainder < 2 ? remainder : 11 - remainder; + + return checkDigit == int.parse(cleanId[9]); + } + + // دریافت پیام خطای اعتبارسنجی + String? getValidationError(String field) { + switch (field) { + case 'mobile': + if (mobile != null && mobile!.isNotEmpty && !_isValidMobile(mobile!)) { + return 'شماره موبایل نامعتبر است. مثال: 09123456789'; + } + break; + case 'phone': + if (phone != null && phone!.isNotEmpty && !_isValidPhone(phone!)) { + return 'شماره تلفن ثابت نامعتبر است. مثال: 02112345678'; + } + break; + case 'nationalId': + if (nationalId != null && nationalId!.isNotEmpty && !_isValidNationalId(nationalId!)) { + return 'کد ملی نامعتبر است. مثال: 1234567890'; + } + break; + } + return null; + } +} + +class BusinessResponse { + final int id; + final String name; + final String businessType; + final String businessField; + final int ownerId; + final String? address; + final String? phone; + final String? mobile; + final String? nationalId; + final String? registrationNumber; + final String? economicId; + final String? country; + final String? province; + final String? city; + final String? postalCode; + final DateTime createdAt; + final DateTime updatedAt; + + BusinessResponse({ + required this.id, + required this.name, + required this.businessType, + required this.businessField, + required this.ownerId, + this.address, + this.phone, + this.mobile, + this.nationalId, + this.registrationNumber, + this.economicId, + this.country, + this.province, + this.city, + this.postalCode, + required this.createdAt, + required this.updatedAt, + }); + + factory BusinessResponse.fromJson(Map json) { + return BusinessResponse( + id: json['id'], + name: json['name'], + businessType: json['business_type'], + businessField: json['business_field'], + ownerId: json['owner_id'], + address: json['address'], + phone: json['phone'], + mobile: json['mobile'], + nationalId: json['national_id'], + registrationNumber: json['registration_number'], + economicId: json['economic_id'], + country: json['country'], + province: json['province'], + city: json['city'], + postalCode: json['postal_code'], + createdAt: DateTime.parse(json['created_at']), + updatedAt: DateTime.parse(json['updated_at']), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart index 1b0242c..5a205b4 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/new_business_page.dart @@ -1,24 +1,1019 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../models/business_models.dart'; +import '../../services/business_api_service.dart'; -class NewBusinessPage extends StatelessWidget { +class NewBusinessPage extends StatefulWidget { const NewBusinessPage({super.key}); + @override + State createState() => _NewBusinessPageState(); +} + +class _NewBusinessPageState extends State { + final PageController _pageController = PageController(); + final BusinessData _businessData = BusinessData(); + int _currentStep = 0; + bool _isLoading = false; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _nextStep() { + if (_currentStep < 4) { + setState(() { + _currentStep++; + }); + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + void _previousStep() { + if (_currentStep > 0) { + setState(() { + _currentStep--; + }); + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + + void _goToStep(int step) { + setState(() { + _currentStep = step; + }); + _pageController.animateToPage( + step, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + bool _canGoToNextStep() { + switch (_currentStep) { + case 0: + return _businessData.isStep1Valid(); + case 1: + return _businessData.isStep2Valid(); + case 2: + return _businessData.isStep3Valid(); + case 3: + return _businessData.isStep4Valid(); + default: + return false; + } + } + + Future _submitBusiness() async { + if (!_businessData.isFormValid()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('لطفاً تمام فیلدهای اجباری را پر کنید'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await BusinessApiService.createBusiness(_businessData); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کسب و کار با موفقیت ایجاد شد'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در ایجاد کسب و کار: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + return Scaffold( + appBar: AppBar( + title: Text(t.newBusiness), + centerTitle: true, + ), + body: Column( children: [ - Text(t.newBusiness, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text('${t.newBusiness} - sample page'), + // Progress indicator + Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Column( + children: [ + // Progress bar + Row( + children: List.generate(5, (index) { + final isActive = index <= _currentStep; + final isCurrent = index == _currentStep; + + return Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 2), + height: 6, + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).primaryColor + : Colors.grey[300], + borderRadius: BorderRadius.circular(3), + boxShadow: isCurrent + ? [ + BoxShadow( + color: Theme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 4, + spreadRadius: 1, + ), + ] + : null, + ), + ), + ); + }), + ), + const SizedBox(height: 8), + // Progress text + Text( + 'مرحله ${_currentStep + 1} از 5', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + // Step indicator + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildStepIndicator(0, 'اطلاعات پایه'), + _buildStepIndicator(1, 'اطلاعات تماس'), + _buildStepIndicator(2, 'اطلاعات قانونی'), + _buildStepIndicator(3, 'اطلاعات جغرافیایی'), + _buildStepIndicator(4, 'تأیید'), + ], + ), + ), + + // Form content with scroll + Expanded( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height - + kToolbarHeight - + 200, // برای progress indicator، step indicator و navigation buttons + ), + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (index) { + setState(() { + _currentStep = index; + }); + }, + children: [ + _buildStep1(), + _buildStep2(), + _buildStep3(), + _buildStep4(), + _buildStep5(), + ], + ), + ), + ), + ), + + // Navigation buttons + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.1), + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavigationButton( + text: 'قبلی', + icon: Icons.arrow_back_ios, + onPressed: _currentStep > 0 ? _previousStep : null, + isPrimary: false, + ), + Row( + children: [ + if (_currentStep < 4) ...[ + _buildNavigationButton( + text: 'بعدی', + icon: Icons.arrow_forward_ios, + onPressed: _canGoToNextStep() ? _nextStep : null, + isPrimary: true, + ), + ] else ...[ + _buildNavigationButton( + text: 'ایجاد کسب و کار', + icon: Icons.check, + onPressed: _isLoading ? null : _submitBusiness, + isPrimary: true, + isLoading: _isLoading, + ), + ], + ], + ), + ], + ), + ), ], ), ); } -} + Widget _buildStepIndicator(int step, String title) { + final isActive = step <= _currentStep; + final isCurrent = step == _currentStep; + + return GestureDetector( + onTap: () => _goToStep(step), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).primaryColor.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive + ? Theme.of(context).primaryColor + : Colors.grey[300]!, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 24, + height: 24, + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).primaryColor + : Colors.grey[300], + shape: BoxShape.circle, + boxShadow: isCurrent + ? [ + BoxShadow( + color: Theme.of(context).primaryColor.withOpacity(0.3), + blurRadius: 6, + spreadRadius: 2, + ), + ] + : null, + ), + child: Center( + child: isActive + ? Icon( + Icons.check, + size: 16, + color: Colors.white, + ) + : Text( + '${step + 1}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + color: isActive + ? Theme.of(context).primaryColor + : Colors.grey[600], + fontSize: 12, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ); + } + Widget _buildNavigationButton({ + required String text, + required IconData icon, + required VoidCallback? onPressed, + required bool isPrimary, + bool isLoading = false, + }) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 48, + constraints: const BoxConstraints(minWidth: 120), + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: isPrimary + ? Theme.of(context).primaryColor + : Theme.of(context).colorScheme.surface, + foregroundColor: isPrimary + ? Colors.white + : Theme.of(context).colorScheme.onSurface, + elevation: isPrimary ? 2 : 0, + shadowColor: isPrimary + ? Theme.of(context).primaryColor.withOpacity(0.3) + : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: isPrimary + ? BorderSide.none + : BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.3), + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + animationDuration: const Duration(milliseconds: 200), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isLoading + ? SizedBox( + key: const ValueKey('loading'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + isPrimary ? Colors.white : Theme.of(context).primaryColor, + ), + ), + ) + : Row( + key: ValueKey('content_$text'), + mainAxisSize: MainAxisSize.min, + children: [ + if (isPrimary) ...[ + Text( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon( + icon, + size: 18, + ), + ] else ...[ + Icon( + icon, + size: 18, + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildStep1() { + return SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات پایه کسب و کار', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + + // نام کسب و کار + TextFormField( + decoration: const InputDecoration( + labelText: 'نام کسب و کار *', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.name = value; + }); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'نام کسب و کار اجباری است'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // نوع کسب و کار + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'نوع کسب و کار *', + border: OutlineInputBorder(), + ), + value: _businessData.businessType, + items: BusinessType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + onChanged: (value) { + setState(() { + _businessData.businessType = value; + }); + }, + validator: (value) { + if (value == null) { + return 'نوع کسب و کار اجباری است'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // زمینه فعالیت + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'زمینه فعالیت *', + border: OutlineInputBorder(), + ), + value: _businessData.businessField, + items: BusinessField.values.map((field) { + return DropdownMenuItem( + value: field, + child: Text(field.displayName), + ); + }).toList(), + onChanged: (value) { + setState(() { + _businessData.businessField = value; + }); + }, + validator: (value) { + if (value == null) { + return 'زمینه فعالیت اجباری است'; + } + return null; + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStep2() { + return SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات تماس', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + + // آدرس - تمام عرض + TextFormField( + decoration: const InputDecoration( + labelText: 'آدرس', + border: OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) { + setState(() { + _businessData.address = value; + }); + }, + ), + const SizedBox(height: 16), + + // فیلدهای تماس در دو ستون + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return Row( + children: [ + Expanded( + child: TextFormField( + decoration: InputDecoration( + labelText: 'تلفن ثابت', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('phone'), + helperText: 'مثال: 02112345678', + ), + keyboardType: TextInputType.phone, + onChanged: (value) { + setState(() { + _businessData.phone = value; + }); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: InputDecoration( + labelText: 'موبایل', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('mobile'), + helperText: 'مثال: 09123456789', + ), + keyboardType: TextInputType.phone, + onChanged: (value) { + setState(() { + _businessData.mobile = value; + }); + }, + ), + ), + ], + ); + } else { + return Column( + children: [ + TextFormField( + decoration: InputDecoration( + labelText: 'تلفن ثابت', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('phone'), + helperText: 'مثال: 02112345678', + ), + keyboardType: TextInputType.phone, + onChanged: (value) { + setState(() { + _businessData.phone = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: InputDecoration( + labelText: 'موبایل', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('mobile'), + helperText: 'مثال: 09123456789', + ), + keyboardType: TextInputType.phone, + onChanged: (value) { + setState(() { + _businessData.mobile = value; + }); + }, + ), + ], + ); + } + }, + ), + const SizedBox(height: 16), + + // کد پستی + TextFormField( + decoration: const InputDecoration( + labelText: 'کد پستی', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + setState(() { + _businessData.postalCode = value; + }); + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStep3() { + return SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات قانونی', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + + // فیلدهای قانونی در دو ستون + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + decoration: InputDecoration( + labelText: 'کد ملی', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('nationalId'), + helperText: 'مثال: 1234567890', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + setState(() { + _businessData.nationalId = value; + }); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: const InputDecoration( + labelText: 'شماره ثبت', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.text, + onChanged: (value) { + setState(() { + _businessData.registrationNumber = value; + }); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'شناسه اقتصادی', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.text, + onChanged: (value) { + setState(() { + _businessData.economicId = value; + }); + }, + ), + ], + ); + } else { + return Column( + children: [ + TextFormField( + decoration: InputDecoration( + labelText: 'کد ملی', + border: const OutlineInputBorder(), + errorText: _businessData.getValidationError('nationalId'), + helperText: 'مثال: 1234567890', + ), + keyboardType: TextInputType.number, + onChanged: (value) { + setState(() { + _businessData.nationalId = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'شماره ثبت', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.text, + onChanged: (value) { + setState(() { + _businessData.registrationNumber = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'شناسه اقتصادی', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.text, + onChanged: (value) { + setState(() { + _businessData.economicId = value; + }); + }, + ), + ], + ); + } + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStep4() { + return SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات جغرافیایی', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + + // فیلدهای جغرافیایی در دو ستون + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + decoration: const InputDecoration( + labelText: 'کشور', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.country = value; + }); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + decoration: const InputDecoration( + labelText: 'استان', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.province = value; + }); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'شهر', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.city = value; + }); + }, + ), + ], + ); + } else { + return Column( + children: [ + TextFormField( + decoration: const InputDecoration( + labelText: 'کشور', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.country = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'استان', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.province = value; + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + decoration: const InputDecoration( + labelText: 'شهر', + border: OutlineInputBorder(), + ), + onChanged: (value) { + setState(() { + _businessData.city = value; + }); + }, + ), + ], + ); + } + }, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStep5() { + return SingleChildScrollView( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'تأیید اطلاعات', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + + // نمایش خلاصه اطلاعات + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).dividerColor.withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSummaryItem('نام کسب و کار', _businessData.name), + _buildSummaryItem('نوع کسب و کار', _businessData.businessType?.displayName ?? ''), + _buildSummaryItem('زمینه فعالیت', _businessData.businessField?.displayName ?? ''), + if (_businessData.address?.isNotEmpty == true) + _buildSummaryItem('آدرس', _businessData.address!), + if (_businessData.phone?.isNotEmpty == true) + _buildSummaryItem('تلفن ثابت', _businessData.phone!), + if (_businessData.mobile?.isNotEmpty == true) + _buildSummaryItem('موبایل', _businessData.mobile!), + if (_businessData.nationalId?.isNotEmpty == true) + _buildSummaryItem('کد ملی', _businessData.nationalId!), + if (_businessData.registrationNumber?.isNotEmpty == true) + _buildSummaryItem('شماره ثبت', _businessData.registrationNumber!), + if (_businessData.economicId?.isNotEmpty == true) + _buildSummaryItem('شناسه اقتصادی', _businessData.economicId!), + if (_businessData.country?.isNotEmpty == true) + _buildSummaryItem('کشور', _businessData.country!), + if (_businessData.province?.isNotEmpty == true) + _buildSummaryItem('استان', _businessData.province!), + if (_businessData.city?.isNotEmpty == true) + _buildSummaryItem('شهر', _businessData.city!), + if (_businessData.postalCode?.isNotEmpty == true) + _buildSummaryItem('کد پستی', _businessData.postalCode!), + ], + ), + ), + const SizedBox(height: 24), + + // پیام تأیید + Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'آیا از صحت اطلاعات وارد شده اطمینان دارید؟', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSummaryItem(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/services/business_api_service.dart b/hesabixUI/hesabix_ui/lib/services/business_api_service.dart new file mode 100644 index 0000000..b36a354 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/business_api_service.dart @@ -0,0 +1,104 @@ +import '../core/api_client.dart'; +import '../models/business_models.dart'; + +class BusinessApiService { + static const String _basePath = '/api/v1/businesses'; + static final ApiClient _apiClient = ApiClient(); + + // ایجاد کسب و کار جدید + static Future createBusiness(BusinessData businessData) async { + final response = await _apiClient.post( + _basePath, + data: businessData.toJson(), + ); + + if (response.data['success'] == true) { + return BusinessResponse.fromJson(response.data['data']); + } else { + throw Exception(response.data['message'] ?? 'خطا در ایجاد کسب و کار'); + } + } + + // دریافت لیست کسب و کارها + static Future> getBusinesses({ + int page = 1, + int perPage = 10, + String? search, + String? sortBy, + bool sortDesc = true, + }) async { + final queryParams = { + 'take': perPage, + 'skip': (page - 1) * perPage, + }; + + if (search != null && search.isNotEmpty) { + queryParams['search'] = search; + } + + if (sortBy != null) { + queryParams['sort_by'] = sortBy; + queryParams['sort_desc'] = sortDesc; + } + + final response = await _apiClient.post( + '$_basePath/search', + data: queryParams, + ); + + if (response.data['success'] == true) { + final List items = response.data['data']['items']; + return items.map((item) => BusinessResponse.fromJson(item)).toList(); + } else { + throw Exception(response.data['message'] ?? 'خطا در دریافت لیست کسب و کارها'); + } + } + + // دریافت جزئیات یک کسب و کار + static Future getBusiness(int businessId) async { + final response = await _apiClient.get('$_basePath/$businessId'); + + if (response.data['success'] == true) { + return BusinessResponse.fromJson(response.data['data']); + } else { + throw Exception(response.data['message'] ?? 'خطا در دریافت جزئیات کسب و کار'); + } + } + + // ویرایش کسب و کار + static Future updateBusiness( + int businessId, + BusinessData businessData, + ) async { + final response = await _apiClient.put( + '$_basePath/$businessId', + data: businessData.toJson(), + ); + + if (response.data['success'] == true) { + return BusinessResponse.fromJson(response.data['data']); + } else { + throw Exception(response.data['message'] ?? 'خطا در ویرایش کسب و کار'); + } + } + + // حذف کسب و کار + static Future deleteBusiness(int businessId) async { + final response = await _apiClient.delete('$_basePath/$businessId'); + + if (response.data['success'] != true) { + throw Exception(response.data['message'] ?? 'خطا در حذف کسب و کار'); + } + } + + // دریافت آمار کسب و کارها + static Future> getBusinessStats() async { + final response = await _apiClient.get('$_basePath/summary/stats'); + + if (response.data['success'] == true) { + return response.data['data']; + } else { + throw Exception(response.data['message'] ?? 'خطا در دریافت آمار کسب و کارها'); + } + } +}