Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/schemas.py

1465 lines
58 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from typing import Any, Dict, List, Optional, Union, Generic, TypeVar, Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator, validator
from enum import Enum
from datetime import datetime, date
T = TypeVar('T')
class FilterOperator(str, Enum):
"""
عملگرهای موجود برای فیلتر
### عملگرهای مقایسه:
- `EQUAL` (=): برابر با
- `NOT_EQUAL` (!=): نابرابر با
- `GREATER` (>): بزرگتر از
- `GREATER_EQUAL` (>=): بزرگتر یا مساوی
- `LESS` (<): کوچکتر از
- `LESS_EQUAL` (<=): کوچکتر یا مساوی
### عملگرهای رشته‌ای:
- `CONTAINS` (*): شامل (هر جایی در متن)
- `STARTS_WITH` (*?): شروع با
- `ENDS_WITH` (?*): پایان با
### عملگرهای آرایه:
- `IN` (in): موجود در لیست
- `NOT_IN` (not_in): موجود نیست در لیست
### عملگرهای null:
- `IS_NULL` (is_null): مقدار خالی است
- `IS_NOT_NULL` (is_not_null): مقدار خالی نیست
"""
EQUAL = "="
NOT_EQUAL = "!="
GREATER = ">"
GREATER_EQUAL = ">="
LESS = "<"
LESS_EQUAL = "<="
CONTAINS = "*"
STARTS_WITH = "*?"
ENDS_WITH = "?*"
IN = "in"
NOT_IN = "not_in"
IS_NULL = "is_null"
IS_NOT_NULL = "is_not_null"
class FilterItem(BaseModel):
"""
آیتم فیلتر برای جستجوی پیشرفته
### مثال‌های کاربردی:
**فیلتر عددی:**
```json
{"property": "total_amount", "operator": ">=", "value": 1000000}
```
**فیلتر رشته‌ای:**
```json
{"property": "name", "operator": "*", "value": "احمد"}
```
**فیلتر آرایه:**
```json
{"property": "status", "operator": "in", "value": ["active", "pending"]}
```
**فیلتر null:**
```json
{"property": "deleted_at", "operator": "is_null", "value": null}
```
"""
property: str = Field(
...,
description="نام فیلد مورد نظر برای اعمال فیلتر",
example="total_amount"
)
operator: str = Field(
...,
description="نوع عملگر: =, !=, >, >=, <, <=, *, *?, ?*, in, not_in, is_null, is_not_null",
example=">="
)
value: Any = Field(
...,
description="مقدار مورد نظر - برای in و not_in باید آرایه باشد، برای is_null و is_not_null می‌تواند null باشد",
example=1000000
)
class Config:
json_schema_extra = {
"examples": [
{
"summary": "فیلتر عددی",
"value": {
"property": "total_amount",
"operator": ">=",
"value": 1000000
}
},
{
"summary": "فیلتر رشته‌ای",
"value": {
"property": "description",
"operator": "*",
"value": "خرید"
}
},
{
"summary": "فیلتر آرایه",
"value": {
"property": "source_type",
"operator": "in",
"value": ["bank_account", "cash_register"]
}
}
]
}
class SortItem(BaseModel):
"""یک سطح مرتب‌سازی در لیست چندستونه (کلید sort در بدنهٔ JSON)."""
by: str = Field(..., description="نام فیلد برای مرتب‌سازی", example="document_date")
desc: bool = Field(default=False, description="true = نزولی، false = صعودی")
class QueryInfo(BaseModel):
"""
پارامترهای جستجو، فیلتر، مرتب‌سازی و صفحه‌بندی
### قابلیت‌ها:
- **مرتب‌سازی**: تک‌ستونه با sort_by / sort_desc (سازگار با کلاینت قدیمی) یا چندستونه با آرایه sort
- **صفحه‌بندی**: با take و skip
- **جستجو**: در چندین فیلد همزمان
- **فیلتر پیشرفته**: با عملگرهای مختلف
### مثال کامل:
```json
{
"take": 20,
"skip": 0,
"sort_by": "created_at",
"sort_desc": true,
"search": "احمد",
"search_fields": ["first_name", "last_name", "email"],
"filters": [
{"property": "is_active", "operator": "=", "value": true},
{"property": "created_at", "operator": ">=", "value": "2024-01-01"}
]
}
```
"""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"take": 20,
"skip": 0,
"sort_by": "created_at",
"sort_desc": True,
"search": "احمد",
"search_fields": ["first_name", "last_name"],
"filters": [
{
"property": "is_active",
"operator": "=",
"value": True,
}
],
}
},
)
sort_by: Optional[str] = Field(
default=None,
description="نام فیلد مورد نظر برای مرتب‌سازی (مثال: created_at, name, total_amount)",
example="created_at"
)
sort_desc: bool = Field(
default=False,
description="نوع مرتب‌سازی: false = صعودی (A-Z, 1-9), true = نزولی (Z-A, 9-1)",
example=True
)
sort: Optional[List[SortItem]] = Field(
default=None,
description=(
"مرتب‌سازی چندسطحی. اگر ارسال شود و حداقل یک عضو معتبر داشته باشد، "
"اولویت با این فهرست است؛ در غیر این صورت از sort_by / sort_desc استفاده می‌شود."
),
)
take: int = Field(
default=10,
ge=1,
le=100,
description="تعداد رکورد در هر صفحه (حداقل 1، حداکثر 100)",
example=20
)
skip: int = Field(
default=0,
ge=0,
description="تعداد رکوردی که از ابتدا رد می‌شود (برای صفحه‌بندی)",
example=0
)
search: Optional[str] = Field(
default=None,
description="عبارت جستجو - در تمام فیلدهای search_fields یا فیلدهای پیش‌فرض جستجو می‌شود",
example="احمد"
)
search_fields: Optional[List[str]] = Field(
default=None,
alias="searchFields",
description="فیلدهای مورد نظر برای جستجو. اگر ارسال نشود، فیلدهای پیش‌فرض استفاده می‌شود",
example=["first_name", "last_name", "email"]
)
category_ids: Optional[List[int]] = Field(
default=None,
alias="categoryIds",
description="فیلتر بر اساس شناسه دسته‌بندی کالا/خدمت (چند انتخاب با OR)",
example=[1, 2],
)
filters: Optional[List[FilterItem]] = Field(
default=None,
description="آرایه‌ای از فیلترهای پیشرفته. تمام فیلترها با AND به هم متصل می‌شوند",
example=[
{"property": "is_active", "operator": "=", "value": True},
{"property": "total_amount", "operator": ">=", "value": 1000000}
]
)
include_inventory: bool = Field(
default=False,
description="محاسبه و اضافه کردن فیلدهای موجودی انبار و اطلاعات مالی (فقط برای محصولات)",
example=False
)
inventory_as_of_date: Optional[str] = Field(
default=None,
description="تاریخ محاسبه موجودی (فرمت: YYYY-MM-DD یا YYYY/MM/DD). پیش‌فرض: امروز",
example="2024-01-15"
)
@validator('take')
def validate_take(cls, v):
if v < 1:
raise ValueError('take باید حداقل 1 باشد')
if v > 100:
raise ValueError('take نمی‌تواند بیشتر از 100 باشد')
return v
class DocumentListQuery(QueryInfo):
"""
QueryInfo به‌همراه فیلترهای تخت رایج endpointهای لیست (سند، فاکتور، دریافت/پرداخت، …).
فیلدهای `filters[]` و فیلدهای تخت می‌توانند همزمان ارسال شوند.
"""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"take": 20,
"skip": 0,
"sort_by": "document_date",
"sort_desc": True,
"document_type": "invoice_sales",
"from_date": "2024-01-01",
"to_date": "2024-12-31",
"search": "تهران",
"filters": [
{"property": "description", "operator": "*", "value": "تهران"},
],
}
},
)
document_type: Optional[str] = Field(
default=None,
description="نوع سند (مثلاً invoice_sales، receipt، payment، transfer، expense)",
)
from_date: Optional[str] = Field(default=None, description="از تاریخ (ISO یا YYYY-MM-DD)")
to_date: Optional[str] = Field(default=None, description="تا تاریخ")
currency_id: Optional[int] = Field(default=None, description="شناسه ارز")
is_proforma: Optional[bool] = Field(default=None, description="پیش‌فاکتور (true) یا قطعی (false)")
fiscal_year_id: Optional[int] = Field(default=None, description="سال مالی؛ در صورت نبود از هدر X-Fiscal-Year-ID")
project_id: Optional[int] = Field(default=None, description="شناسه پروژه")
person_id: Optional[int] = Field(default=None, description="شناسه شخص / طرف‌حساب")
account_id: Optional[int] = Field(default=None, description="شناسه حساب (هزینه/درآمد)")
bank_account_id: Optional[int] = Field(default=None, description="فیلتر انتقال — حساب بانکی")
cash_register_id: Optional[int] = Field(default=None, description="فیلتر انتقال — صندوق")
petty_cash_id: Optional[int] = Field(default=None, description="فیلتر انتقال — تنخواه")
tag_ids: Optional[List[int]] = Field(default=None, description="برچسب‌های فاکتور (لیست شناسه)")
tag_match: Optional[str] = Field(default=None, description="any یا all برای tag_ids")
is_installment_sale: Optional[bool] = Field(default=None, description="فاکتور اقساطی")
class InvoiceListQuery(DocumentListQuery):
"""همان DocumentListQuery با مثال Swagger مخصوص فاکتور."""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"take": 30,
"skip": 0,
"document_type": "invoice_sales",
"from_date": "2024-01-01",
"to_date": "2024-12-31",
"filters": [
{"property": "total_amount", "operator": ">=", "value": 5000000},
],
}
},
)
class KardexListQuery(QueryInfo):
"""QueryInfo + فیلترهای تخت گزارش کاردکس."""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"take": 50,
"skip": 0,
"from_date": "2024-01-01",
"to_date": "2024-12-31",
"person_ids": [1, 2],
"match_mode": "any",
}
},
)
from_date: Optional[str] = None
to_date: Optional[str] = None
fiscal_year_id: Optional[int] = None
person_ids: Optional[List[int]] = None
product_ids: Optional[List[int]] = None
bank_account_ids: Optional[List[int]] = None
cash_register_ids: Optional[List[int]] = None
petty_cash_ids: Optional[List[int]] = None
account_ids: Optional[List[int]] = None
check_ids: Optional[List[int]] = None
warehouse_ids: Optional[List[int]] = None
match_mode: Optional[str] = Field(default="any", description="any | all")
result_scope: Optional[str] = Field(default="lines_matching", description="دامنه نتیجه کاردکس")
include_running_balance: Optional[bool] = False
currency_id: Optional[int] = Field(
default=None,
description="فیلتر ارز سند؛ خالی = همه ارزها (مبالغ می‌توانند معادل پایه باشند)",
)
amounts_in_base: Optional[bool] = Field(
default=None,
description="اگر true (یا ارز خالی) مبالغ از debit_base/credit_base نمایش داده شوند",
)
class WarehouseDocListQuery(QueryInfo):
"""QueryInfo + فیلترهای حواله انبار."""
model_config = ConfigDict(
populate_by_name=True,
json_schema_extra={
"example": {
"take": 20,
"skip": 0,
"doc_type": "issue",
"status": "posted",
"filters": [{"property": "code", "operator": "*", "value": "WH"}],
}
},
)
doc_type: Optional[Union[str, List[str]]] = None
status: Optional[Union[str, List[str]]] = None
warehouse_id: Optional[int] = None
warehouse_ids: Optional[List[int]] = None
from_date: Optional[str] = None
to_date: Optional[str] = None
fiscal_year_id: Optional[int] = None
class CaptchaSolve(BaseModel):
captcha_id: str = Field(..., min_length=8)
captcha_code: str = Field(..., min_length=3, max_length=8)
class RegisterRequest(CaptchaSolve):
first_name: Optional[str] = Field(default=None, max_length=100)
last_name: Optional[str] = Field(default=None, max_length=100)
email: Optional[EmailStr] = None
mobile: Optional[str] = Field(default=None, max_length=32)
password: str = Field(..., min_length=8, max_length=128)
device_id: Optional[str] = Field(default=None, max_length=100)
referrer_code: Optional[str] = Field(default=None, min_length=4, max_length=32)
class LoginRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
password: str = Field(..., min_length=8, max_length=128)
device_id: Optional[str] = Field(default=None, max_length=100)
class ForgotPasswordRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255)
class SendLoginOtpRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255, description="ایمیل یا شماره موبایل")
channel: str = Field(..., description="کانال ارسال: sms, email, telegram")
session_id: Optional[str] = Field(default=None, description="شناسه session موجود (برای تغییر کانال)")
class AvailableChannelsRequest(CaptchaSolve):
identifier: str = Field(..., min_length=3, max_length=255, description="ایمیل یا شماره موبایل")
class ResetPasswordRequest(CaptchaSolve):
token: str = Field(..., min_length=16)
new_password: str = Field(..., min_length=8, max_length=128)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=8, max_length=128)
new_password: str = Field(..., min_length=8, max_length=128)
confirm_password: str = Field(..., min_length=8, max_length=128)
class UpdateMobileRequest(CaptchaSolve):
mobile: str = Field(..., min_length=10, max_length=32, description="شماره موبایل جدید")
force_unverified: bool = Field(default=False, description="اجازه تغییر شماره ثبت شده اما تایید نشده")
send_verification_sms: bool = Field(
default=True,
description="پس از ذخیرهٔ شماره، کد تایید به همان موبایل (از همان کپچا) ارسال شود",
)
class SendMobileVerificationRequest(CaptchaSolve):
"""همراه با کپچا (بدن درخواست JSON) — از query param خالی برای جلوگیری از سوءاستفاده"""
mobile: str = Field(..., min_length=10, max_length=32, description="شماره موبایل برای ارسال کد تایید")
class UpdateEmailRequest(CaptchaSolve):
email: EmailStr = Field(..., description="ایمیل جدید")
force_unverified: bool = Field(default=False, description="اجازه تغییر ایمیل ثبت شده اما تایید نشده")
class CreateApiKeyRequest(BaseModel):
name: Optional[str] = Field(default=None, max_length=100, description="نام کلید API")
scopes: Optional[str] = Field(default=None, max_length=500, description="محدوده دسترسی (JSON string)")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا (ISO format)")
ip_whitelist: Optional[str] = Field(default=None, max_length=1000, description="لیست IP های مجاز (جدا شده با کاما)")
# 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="تاریخ آخرین بروزرسانی")
signature_file_id: Optional[str] = Field(default=None, 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="محدوده دسترسی")
ip: Optional[str] = Field(default=None, description="لیست IP های مجاز")
user_agent: Optional[str] = Field(default=None, description="اطلاعات مرورگر")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا")
last_used_at: Optional[str] = Field(default=None, description="آخرین استفاده")
created_at: str = Field(..., description="تاریخ ایجاد")
revoked_at: Optional[str] = Field(default=None, description="تاریخ لغو")
is_active: bool = Field(..., description="وضعیت فعال بودن")
class UpdateApiKeyRequest(BaseModel):
name: Optional[str] = Field(default=None, max_length=100, description="نام کلید API")
scopes: Optional[str] = Field(default=None, max_length=500, description="محدوده دسترسی (JSON string)")
expires_at: Optional[str] = Field(default=None, description="تاریخ انقضا (ISO format)")
ip_whitelist: Optional[str] = Field(default=None, max_length=1000, description="لیست IP های مجاز (جدا شده با کاما)")
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="درصد کاربران فعال")
# Bulk Operations Request Models
class BulkActivateRequest(BaseModel):
"""درخواست فعال‌سازی دسته‌ای کاربران"""
user_ids: List[int] = Field(
...,
min_items=1,
description="لیست شناسه‌های کاربران برای فعال‌سازی",
example=[1, 2, 3]
)
class Config:
json_schema_extra = {
"example": {
"user_ids": [1, 2, 3, 4, 5]
}
}
class BulkSuspendRequest(BaseModel):
"""درخواست تعلیق دسته‌ای کاربران"""
user_ids: List[int] = Field(
...,
min_items=1,
description="لیست شناسه‌های کاربران برای تعلیق",
example=[1, 2, 3]
)
class Config:
json_schema_extra = {
"example": {
"user_ids": [1, 2, 3, 4, 5]
}
}
class BulkResetPasswordRequest(BaseModel):
"""درخواست بازنشانی رمز عبور دسته‌ای کاربران"""
user_ids: List[int] = Field(
...,
min_items=1,
description="لیست شناسه‌های کاربران برای بازنشانی رمز عبور",
example=[1, 2, 3]
)
send_notification: bool = Field(
default=True,
description="برای هر کاربری که توکن ساخته شد، اعلان auth.password_reset ارسال شود",
)
class Config:
json_schema_extra = {
"example": {
"user_ids": [1, 2, 3, 4, 5],
"send_notification": True,
}
}
class AdminSetUserPasswordRequest(BaseModel):
"""تنظیم رمز توسط مدیر: مستقیم (انتخابی) یا تولید تصادفی (یک‌بار نمایش)."""
model_config = ConfigDict(extra="forbid")
mode: Literal["direct", "random"] = Field(
default="direct",
description="direct: new_password + confirm_password؛ random: تولید در سرور",
)
new_password: Optional[str] = Field(
default=None,
min_length=8,
max_length=128,
description="الزام در حالت direct",
)
confirm_password: Optional[str] = Field(
default=None,
min_length=8,
max_length=128,
description="الزام در حالت direct",
)
@model_validator(mode="after")
def _validate_mode(self) -> "AdminSetUserPasswordRequest":
if self.mode == "direct":
if not self.new_password or not self.confirm_password:
raise ValueError("برای حالت direct، new_password و confirm_password الزامی است")
return self
# User Detail Response Models
class UserBusinessResponse(BaseModel):
"""اطلاعات کسب‌وکار کاربر"""
id: int = Field(..., description="شناسه کسب‌وکار")
name: str = Field(..., description="نام کسب‌وکار")
field: Optional[str] = Field(default=None, description="زمینه فعالیت")
role: str = Field(..., description="نقش کاربر در کسب‌وکار: owner, admin, operator, supervisor, user")
status: str = Field(..., description="وضعیت کسب‌وکار")
created_at: str = Field(..., description="تاریخ عضویت")
class UserSessionResponse(BaseModel):
"""اطلاعات نشست کاربر"""
id: int = Field(..., description="شناسه نشست")
device: str = Field(..., description="نام دستگاه یا مرورگر")
ip: Optional[str] = Field(default=None, description="آدرس IP")
last_active_at: str = Field(..., description="آخرین زمان فعالیت")
created_at: str = Field(..., description="تاریخ ایجاد نشست")
class UserAuditLogResponse(BaseModel):
"""اطلاعات لاگ فعالیت کاربر"""
id: int = Field(..., description="شناسه لاگ")
action: str = Field(..., description="نوع عملیات")
description: Optional[str] = Field(default=None, description="توضیحات")
category: Optional[str] = Field(default=None, description="دسته‌بندی")
entity_type: Optional[str] = Field(default=None, description="نوع موجودیت")
entity_id: Optional[int] = Field(default=None, description="شناسه موجودیت")
created_at: str = Field(..., description="تاریخ ایجاد")
class UserDetailResponse(UserResponse):
"""اطلاعات کامل کاربر شامل کسب‌وکارها، نشست‌ها و لاگ‌ها"""
businesses: Optional[List[UserBusinessResponse]] = Field(
default=None,
description="لیست کسب‌وکارهای کاربر"
)
sessions: Optional[List[UserSessionResponse]] = Field(
default=None,
description="لیست نشست‌های فعال کاربر"
)
audit_logs: Optional[List[UserAuditLogResponse]] = Field(
default=None,
description="آخرین فعالیت‌های کاربر (حداکثر 50 مورد)"
)
class Config:
json_schema_extra = {
"example": {
"id": 1,
"email": "user@example.com",
"mobile": "09123456789",
"first_name": "احمد",
"last_name": "احمدی",
"is_active": True,
"referral_code": "ABC123",
"app_permissions": {"user_management": True},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"signature_file_id": "550e8400-e29b-41d4-a716-446655440000",
"businesses": [
{
"id": 1,
"name": "شرکت نمونه",
"field": "بازرگانی",
"role": "owner",
"status": "active",
"created_at": "2024-01-01T00:00:00Z"
}
],
"sessions": [
{
"id": 1,
"device": "Chrome on Windows",
"ip": "192.168.1.1",
"last_active_at": "2024-01-15T10:30:00Z",
"created_at": "2024-01-01T00:00:00Z"
}
],
"audit_logs": [
{
"id": 1,
"action": "login",
"description": "ورود به سیستم",
"category": "authentication",
"entity_type": "user",
"entity_id": 1,
"created_at": "2024-01-15T10:30:00Z"
}
]
}
}
# Bulk Operations Response Models
class BulkOperationResponse(BaseModel):
"""پاسخ عملیات دسته‌ای"""
updated_count: int = Field(..., description="تعداد رکوردهای به‌روز شده")
total_requested: int = Field(..., description="تعداد کل درخواست‌ها")
class BulkResetPasswordResponse(BaseModel):
"""پاسخ بازنشانی رمز عبور دسته‌ای"""
tokens_created: int = Field(..., description="تعداد توکن‌های ایجاد شده")
total_requested: int = Field(..., description="تعداد کل درخواست‌ها")
# Business Schemas
class BusinessType(str, Enum):
COMPANY = "شرکت"
SHOP = "مغازه"
STORE = "فروشگاه"
UNION = "اتحادیه"
CLUB = "باشگاه"
INSTITUTE = "موسسه"
INDIVIDUAL = "شخصی"
class BusinessField(str, Enum):
MANUFACTURING = "تولیدی"
COMMERCIAL = "بازرگانی"
SERVICE = "خدماتی"
OTHER = "سایر"
class BusinessCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="نام کسب و کار")
business_type: BusinessType = Field(..., description="نوع کسب و کار")
business_field: BusinessField = Field(..., description="زمینه فعالیت")
address: Optional[str] = Field(default=None, max_length=1000, description="آدرس")
phone: Optional[str] = Field(default=None, max_length=20, description="تلفن ثابت")
mobile: Optional[str] = Field(default=None, max_length=20, description="موبایل")
national_id: Optional[str] = Field(default=None, max_length=20, description="کد ملی")
registration_number: Optional[str] = Field(default=None, max_length=50, description="شماره ثبت")
economic_id: Optional[str] = Field(default=None, max_length=50, description="شناسه اقتصادی")
country: Optional[str] = Field(default=None, max_length=100, description="کشور")
province: Optional[str] = Field(default=None, max_length=100, description="استان")
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه")
default_currency_id: int = Field(..., description="شناسه ارز پیشفرض (الزامی)")
currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده")
# تنظیمات اعتبار مشتریان
default_credit_limit: Optional[float] = Field(default=None, description="سقف اعتبار پیشفرض اشخاص")
check_credit_enabled_by_default: Optional[bool] = Field(default=False, description="بررسی اعتبار مشتریان به صورت پیشفرض (خاموش)")
include_sample_data: Optional[bool] = Field(
default=False,
description="در صورت true، پس از ایجاد کسب‌وکار دادهٔ نمونه (مشتری، کالا، انبار، ...) درج می‌شود؛ فقط برای ایجاد معقول است نه ایمپورت پشتیبان",
)
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="کد پستی")
default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض (فقط برای کسب‌وکارهایی که ارز پیش‌فرض ندارند)")
# تنظیمات اعتبار مشتریان
default_credit_limit: Optional[float] = Field(default=None, description="سقف اعتبار پیشفرض اشخاص")
check_credit_enabled_by_default: Optional[bool] = Field(default=None, description="بررسی اعتبار مشتریان به صورت پیشفرض")
public_catalog_show_contact: Optional[bool] = Field(
default=None,
description="نمایش تلفن/موبایل در API عمومی کاتالوگ (شبکهٔ انتشار کالا)",
)
public_catalog_show_base_sales_price: Optional[bool] = Field(
default=None,
description="نمایش قیمت فروش پایه در API عمومی کاتالوگ",
)
# تنظیمات محاسبه سود فاکتور
invoice_profit_calculation_method: Optional[str] = Field(default=None, description="روش محاسبه سود فاکتور: automatic, manual, disabled")
invoice_profit_calculation_basis: Optional[str] = Field(
default=None,
description=(
"مبنای محاسبه سود: purchase_price, cost_price, average_cost, fifo, fifo_jbfn (FIFO با پیش‌خور از خریدهای بعدی)، "
"lifo, weighted_average, moving_weighted_average (WMA دائمی), standard_cost, actual_cost"
),
)
invoice_profit_include_overhead: Optional[bool] = Field(default=None, description="آیا هزینه‌های سربار در محاسبه سود لحاظ شود؟")
invoice_profit_overhead_type: Optional[str] = Field(default=None, description="نوع هزینه‌های سربار: none, production_overhead, all_overhead, custom_percent")
invoice_profit_overhead_percent: Optional[float] = Field(default=None, ge=0, le=100, description="درصد هزینه‌های سربار (0-100) - فقط برای custom_percent")
invoice_profit_calculation_type: Optional[str] = Field(default=None, description="نوع محاسبه سود: gross (ناخالص), net (خالص), both (هر دو)")
invoice_profit_ledger_recognition_basis: Optional[str] = Field(
default=None,
description="زمان شناسایی بهای تمام‌شده قطعی در دفتر: warehouse_document_posting | sales_invoice_document",
)
invoice_profit_fifo_shortage_mode: Optional[str] = Field(
default=None,
description="سیاست کسری در مبنای سود FIFO/LIFO/moving_weighted_average: perpetual_mixed | average_purchase_on_shortage",
)
# به‌روزرسانی قیمت پایه کالا از فاکتور قطعی
invoice_sync_update_sales_price_enabled: Optional[bool] = Field(
default=None,
description="به‌روزرسانی خودکار قیمت فروش پایه از فاکتور فروش قطعی",
)
invoice_sync_update_purchase_price_enabled: Optional[bool] = Field(
default=None,
description="به‌روزرسانی خودکار قیمت خرید پایه از فاکتور خرید قطعی",
)
invoice_sync_sales_price_basis: Optional[str] = Field(
default=None,
description="مبنای محاسبه: unit_price | net_after_line_discount | net_with_tax | cost_price",
)
invoice_sync_purchase_price_basis: Optional[str] = Field(
default=None,
description="مبنای محاسبه: unit_price | net_after_line_discount | net_with_tax | cost_price",
)
invoice_warehouse_release_mode: Optional[str] = Field(
default=None,
description="حواله پس از ثبت فاکتور: none (بدون حواله)، draft (پیش‌نویس)، posted (قطعی فوری)",
)
invoice_purchase_accounting_mode: Optional[str] = Field(
default=None,
description="ثبت حسابداری خرید: direct_inventory | grni_two_step | grni_legacy",
)
invoice_missing_line_warehouse_policy: Optional[str] = Field(
default=None,
description="reject (جلوگیری با پیام راهنما) | use_default_warehouse (پر کردن خودکار از انبار پیش‌فرض کسب‌وکار)",
)
invoice_default_warehouse_id: Optional[int] = Field(
default=None,
description="انبار پیش‌فرض برای ردیف‌های انبارداری بدون انبار وقتی سیاست use_default_warehouse است",
)
invoice_default_warehouse_fill_document_header: Optional[bool] = Field(
default=None,
description="هنگام پر کردن خودکار خطوط، اگر انبار سطح فاکتور خالی بود همان انبار پیش‌فرض روی سربرگ هم ست شود",
)
allow_negative_inventory_for_bulk: Optional[bool] = Field(
default=None,
description="اجازه قطعی حواله با کسری برای کالاهای فله‌ای (غیر یونیک) با کنترل موجودی",
)
allow_negative_inventory_for_unique: Optional[bool] = Field(
default=None,
description="اجازه قطعی حواله با کسری برای کالاهای یونیک با کنترل موجودی",
)
warehouse_transfer_require_positive_stock: Optional[bool] = Field(
default=None,
description="اگر true باشد، حواله انتقال همیشه کنترل کسری کامل دارد",
)
goods_expense_income_workflow_mode: Optional[str] = Field(
default=None,
description="simple | two_step — گردش‌کار کالای هزینه/درآمد",
)
goods_expense_income_auto_post_in_simple_mode: Optional[bool] = Field(
default=None,
description="در حالت simple، قطعی خودکار در صورت داشتن مجوز post",
)
goods_expense_income_default_expense_account_code: Optional[str] = Field(
default=None,
max_length=50,
description="کد حساب پیش‌فرض کالای هزینه‌شده (مثلاً 70407)",
)
goods_expense_income_default_income_account_code: Optional[str] = Field(
default=None,
max_length=50,
description="کد حساب پیش‌فرض کالای درآمدشده (مثلاً 60103)",
)
goods_expense_income_stock_count_mode: Optional[str] = Field(
default=None,
description="goods_docs | physical_adjustment | ask",
)
goods_expense_income_allow_manual_unit_cost: Optional[bool] = Field(
default=None,
description="اجازه تغییر دستی بهای واحد",
)
goods_expense_income_require_person: Optional[bool] = Field(
default=None,
description="الزام انتخاب شخص روی سند",
)
invoice_global_discount_percent_basis: Optional[str] = Field(
default=None,
description=(
"مبنای درصد تخفیف کلی: subtotal_after_line_discount | gross_before_line_discount | "
"total_after_lines_including_tax"
),
)
invoice_global_discount_tax_mode: Optional[str] = Field(
default=None,
description="اثر تخفیف کلی بر مالیات: recalculate_tax_proportional | keep_line_taxes",
)
invoice_global_discount_max_percent: Optional[float] = Field(
default=None,
ge=0,
le=100,
description="سقف درصد تخفیف کلی نسبت به مبنا (اختیاری)",
)
invoice_global_discount_max_amount: Optional[float] = Field(
default=None,
ge=0,
description="سقف مبلغ تخفیف کلی (اختیاری)",
)
display_timezone: Optional[str] = Field(
default=None,
max_length=80,
description="منطقهٔ زمانی نمایش (IANA مثل Asia/Tehran)؛ خالی = پیش‌فرض سیستم",
)
# تسعیر ارز فاکتور: as_of_source، document_date_effective، when_no_rate
fx_revaluation_policy: Optional[Dict[str, Any]] = Field(
default=None,
description="سیاست تسعیر ارز (JSON): document_date/registered_at، start/end of day، block یا allow_without_fx",
)
@validator("display_timezone", pre=True)
def _validate_display_timezone(cls, v): # noqa: N805
if v is None:
return None
from app.services.business_timezone_service import normalize_business_display_timezone
return normalize_business_display_timezone(v)
@validator("fx_revaluation_policy", pre=True)
def _validate_fx_revaluation_policy_field(cls, v): # noqa: N805
if v is None:
return None
from app.services.invoice_fx_revaluation import validate_and_normalize_fx_revaluation_policy_payload
try:
return validate_and_normalize_fx_revaluation_policy_payload(v)
except ValueError as e:
raise ValueError(str(e))
@validator("invoice_global_discount_percent_basis")
def _validate_invoice_global_discount_percent_basis(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {
"subtotal_after_line_discount",
"gross_before_line_discount",
"total_after_lines_including_tax",
}
if v not in allowed:
raise ValueError("مبنای درصد تخفیف کلی نامعتبر است")
return v
@validator("invoice_global_discount_tax_mode")
def _validate_invoice_global_discount_tax_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"recalculate_tax_proportional", "keep_line_taxes"}
if v not in allowed:
raise ValueError("حالت مالیات تخفیف کلی نامعتبر است")
return v
@validator("invoice_warehouse_release_mode")
def _validate_invoice_warehouse_release_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip().lower()
if s in ("none", "off", "no", "disabled"):
return "none"
if s in ("posted", "final", "confirmed"):
return "posted"
if s in ("draft",):
return "draft"
raise ValueError("invoice_warehouse_release_mode نامعتبر است (none، draft یا posted)")
@validator("goods_expense_income_workflow_mode")
def _validate_gei_workflow_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip().lower()
if s not in ("simple", "two_step"):
raise ValueError("goods_expense_income_workflow_mode باید simple یا two_step باشد")
return s
@validator("goods_expense_income_stock_count_mode")
def _validate_gei_stock_count_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip().lower()
if s not in ("goods_docs", "physical_adjustment", "ask"):
raise ValueError(
"goods_expense_income_stock_count_mode باید goods_docs، physical_adjustment یا ask باشد"
)
return s
@validator(
"goods_expense_income_default_expense_account_code",
"goods_expense_income_default_income_account_code",
)
def _validate_gei_account_code(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip()
if not s:
raise ValueError("کد حساب نمی‌تواند خالی باشد")
return s
@validator("invoice_purchase_accounting_mode")
def _validate_invoice_purchase_accounting_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
from app.services.purchase_accounting_service import PURCHASE_ACCOUNTING_MODES, normalize_purchase_accounting_mode
n = normalize_purchase_accounting_mode(v)
if n not in PURCHASE_ACCOUNTING_MODES:
raise ValueError(
"invoice_purchase_accounting_mode نامعتبر است "
"(direct_inventory، grni_two_step یا grni_legacy)"
)
return n
@validator("invoice_missing_line_warehouse_policy")
def _validate_invoice_missing_line_warehouse_policy(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip().lower()
if s in ("reject", "block", "deny", "forbid"):
return "reject"
if s in ("use_default_warehouse", "default_warehouse", "auto_default"):
return "use_default_warehouse"
raise ValueError("invoice_missing_line_warehouse_policy نامعتبر است (reject یا use_default_warehouse)")
@validator("invoice_sync_sales_price_basis", "invoice_sync_purchase_price_basis")
def _validate_invoice_sync_basis(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"unit_price", "net_after_line_discount", "net_with_tax", "cost_price"}
if v not in allowed:
raise ValueError("مبنای همگام‌سازی قیمت نامعتبر است")
return v
@validator("invoice_profit_calculation_method")
def _validate_invoice_profit_calculation_method(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"automatic", "manual", "disabled"}
value = str(v).strip().lower()
if value not in allowed:
raise ValueError("روش محاسبه سود فاکتور نامعتبر است")
return value
@validator("invoice_profit_calculation_basis")
def _validate_invoice_profit_calculation_basis(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {
"purchase_price",
"cost_price",
"average_cost",
"fifo",
"fifo_jbfn",
"lifo",
"weighted_average",
"moving_weighted_average",
"standard_cost",
"actual_cost",
}
value = str(v).strip().lower()
if value in ("wma", "moving_wavg", "mwa"):
value = "moving_weighted_average"
if value not in allowed:
raise ValueError("مبنای محاسبه سود نامعتبر است")
return value
@validator("invoice_profit_overhead_type")
def _validate_invoice_profit_overhead_type(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"none", "production_overhead", "all_overhead", "custom_percent"}
value = str(v).strip().lower()
if value not in allowed:
raise ValueError("نوع هزینه سربار نامعتبر است")
return value
@validator("invoice_profit_calculation_type")
def _validate_invoice_profit_calculation_type(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"gross", "net", "both"}
value = str(v).strip().lower()
if value not in allowed:
raise ValueError("نوع محاسبه سود نامعتبر است")
return value
@validator("invoice_profit_ledger_recognition_basis")
def _validate_invoice_profit_ledger_recognition_basis(cls, v): # noqa: N805
if v is None or v == "":
return None
allowed = {"warehouse_document_posting", "sales_invoice_document"}
value = str(v).strip().lower()
if value not in allowed:
raise ValueError(
"مبنای شناسایی قطعی نامعتبر است "
"(warehouse_document_posting یا sales_invoice_document)"
)
return value
@validator("invoice_profit_fifo_shortage_mode")
def _validate_invoice_profit_fifo_shortage_mode(cls, v): # noqa: N805
if v is None or v == "":
return None
s = str(v).strip().lower()
if s in ("avg", "average", "avg_shortage"):
s = "average_purchase_on_shortage"
allowed = {"perpetual_mixed", "average_purchase_on_shortage"}
if s not in allowed:
raise ValueError(
"invoice_profit_fifo_shortage_mode نامعتبر است (perpetual_mixed یا average_purchase_on_shortage)"
)
return s
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="کد پستی")
logo_file_id: Optional[str] = Field(default=None, description="شناسه فایل لوگوی کسب‌وکار (در صورت تنظیم)")
stamp_file_id: Optional[str] = Field(default=None, description="شناسه فایل مهر/امضای کسب‌وکار (در صورت تنظیم)")
# تنظیمات اعتبار مشتریان
default_credit_limit: Optional[float] = Field(default=None, description="سقف اعتبار پیشفرض اشخاص")
check_credit_enabled_by_default: bool = Field(default=False, description="بررسی اعتبار مشتریان به صورت پیشفرض")
public_catalog_show_contact: bool = Field(
default=False,
description="نمایش تلفن/موبایل در API عمومی کاتالوگ",
)
public_catalog_show_base_sales_price: bool = Field(
default=True,
description="نمایش قیمت فروش پایه در API عمومی کاتالوگ",
)
# تنظیمات محاسبه سود فاکتور
invoice_profit_calculation_method: Optional[str] = Field(default=None, description="روش محاسبه سود فاکتور")
invoice_profit_calculation_basis: Optional[str] = Field(default=None, description="مبنای محاسبه سود")
invoice_profit_include_overhead: Optional[bool] = Field(default=None, description="آیا هزینه‌های سربار در محاسبه سود لحاظ می‌شود")
invoice_profit_overhead_type: Optional[str] = Field(default=None, description="نوع هزینه‌های سربار")
invoice_profit_overhead_percent: Optional[float] = Field(default=None, description="درصد هزینه‌های سربار")
invoice_profit_calculation_type: Optional[str] = Field(default=None, description="نوع محاسبه سود")
invoice_profit_ledger_recognition_basis: Optional[str] = Field(
default=None,
description="شناسایی بهای تمام‌شده قطعی دفتر: با حواله یا با فاکتور",
)
invoice_profit_fifo_shortage_mode: str = Field(
default="perpetual_mixed",
description="سیاست کسری در محاسبه سود بر مبنای FIFO/LIFO/moving_weighted_average",
)
invoice_sync_update_sales_price_enabled: bool = Field(default=False, description="همگام‌سازی قیمت فروش از فاکتور")
invoice_sync_update_purchase_price_enabled: bool = Field(default=False, description="همگام‌سازی قیمت خرید از فاکتور")
invoice_sync_sales_price_basis: Optional[str] = Field(default=None, description="مبنای قیمت فروش")
invoice_sync_purchase_price_basis: Optional[str] = Field(default=None, description="مبنای قیمت خرید")
invoice_warehouse_release_mode: str = Field(
default="draft",
description="حواله پس از ثبت فاکتور: none، draft، posted",
)
invoice_purchase_accounting_mode: str = Field(
default="direct_inventory",
description="ثبت حسابداری خرید: direct_inventory، grni_two_step، grni_legacy",
)
invoice_missing_line_warehouse_policy: str = Field(
default="reject",
description="reject | use_default_warehouse",
)
invoice_default_warehouse_id: Optional[int] = Field(
default=None,
description="انبار پیش‌فرض برای ردیف بدون انبار",
)
invoice_default_warehouse_fill_document_header: bool = Field(
default=True,
description="پر کردن انبار سطح فاکتور هنگام حالت خودکار",
)
allow_negative_inventory_for_bulk: bool = Field(
default=False,
description="خروج با موجودی منفی برای کالاهای فله‌ای هنگام قطعی حواله",
)
allow_negative_inventory_for_unique: bool = Field(
default=False,
description="خروج با موجودی منفی برای کالاهای یونیک هنگام قطعی حواله",
)
warehouse_transfer_require_positive_stock: bool = Field(
default=True,
description="انتقال بین انبار همیشه نیاز به موجودی کافی",
)
invoice_global_discount_percent_basis: str = Field(
default="subtotal_after_line_discount",
description="مبنای درصد تخفیف کلی",
)
invoice_global_discount_tax_mode: str = Field(
default="recalculate_tax_proportional",
description="اثر تخفیف کلی بر مالیات",
)
invoice_global_discount_max_percent: Optional[float] = Field(
default=None,
description="سقف درصد تخفیف کلی",
)
invoice_global_discount_max_amount: Optional[float] = Field(
default=None,
description="سقف مبلغ تخفیف کلی",
)
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض")
currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسب‌وکار")
class BusinessListResponse(BaseModel):
items: List[BusinessResponse] = Field(..., description="لیست کسب و کارها")
pagination: PaginationInfo = Field(..., description="اطلاعات صفحه‌بندی")
query_info: dict = Field(..., description="اطلاعات جستجو و فیلتر")
class BusinessSummaryResponse(BaseModel):
total_businesses: int = Field(..., description="تعداد کل کسب و کارها")
by_type: dict = Field(..., description="تعداد بر اساس نوع")
by_field: dict = Field(..., description="تعداد بر اساس زمینه فعالیت")
class PaginatedResponse(BaseModel, Generic[T]):
"""پاسخ صفحه‌بندی شده برای لیست‌ها"""
items: List[T] = Field(..., description="آیتم‌های صفحه")
total: int = Field(..., description="تعداد کل آیتم‌ها")
page: int = Field(..., description="شماره صفحه فعلی")
limit: int = Field(..., description="تعداد آیتم در هر صفحه")
total_pages: int = Field(..., description="تعداد کل صفحات")
@classmethod
def create(cls, items: List[T], total: int, page: int, limit: int) -> 'PaginatedResponse[T]':
"""ایجاد پاسخ صفحه‌بندی شده"""
total_pages = (total + limit - 1) // limit
return cls(
items=items,
total=total,
page=page,
limit=limit,
total_pages=total_pages
)
# Fiscal Year Schemas
class FiscalYearCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255, description="عنوان سال مالی")
start_date: date = Field(..., description="تاریخ شروع سال مالی")
end_date: date = Field(..., description="تاریخ پایان سال مالی")
is_last: bool = Field(default=True, description="آیا آخرین سال مالی فعال است؟")
# Business User Schemas
class BusinessUserSchema(BaseModel):
id: int
business_id: int
user_id: int
user_name: str
user_email: str
user_phone: Optional[str] = None
role: str
status: str
added_at: datetime
last_active: Optional[datetime] = None
permissions: dict
membership_expires_at: Optional[datetime] = None
membership_unlimited: bool = True
membership_active: bool = True
class Config:
from_attributes = True
class AddUserRequest(BaseModel):
email_or_phone: str
membership_expires_at: Optional[datetime] = Field(
default=None,
description="پایان عضویت؛ خالی یا null یعنی نامحدود.",
)
@model_validator(mode="after")
def validate_membership_expires_at(self) -> "AddUserRequest":
if self.membership_expires_at is None:
return self
from app.core.business_membership import to_naive_utc
exp = to_naive_utc(self.membership_expires_at)
if exp <= datetime.utcnow():
raise ValueError("membership_expires_at must be in the future")
self.membership_expires_at = exp
return self
class Config:
json_schema_extra = {
"example": {
"email_or_phone": "user@example.com",
"membership_expires_at": None,
}
}
class AddUserResponse(BaseModel):
success: bool
message: str
user: Optional[BusinessUserSchema] = None
class UpdatePermissionsRequest(BaseModel):
permissions: dict
apply_membership_expiry: bool = Field(
default=False,
description="اگر True باشد، مقدار membership_expires_at اعمال می‌شود (null = نامحدود).",
)
membership_expires_at: Optional[datetime] = None
@model_validator(mode="after")
def normalize_membership_expires_at(self) -> "UpdatePermissionsRequest":
if not self.apply_membership_expiry or self.membership_expires_at is None:
return self
from app.core.business_membership import to_naive_utc
self.membership_expires_at = to_naive_utc(self.membership_expires_at)
return self
class Config:
json_schema_extra = {
"example": {
"permissions": {
"sales": {
"read": True,
"write": True,
"delete": False
},
"reports": {
"read": True,
"export": True
},
"settings": {
"manage_users": True
}
},
"apply_membership_expiry": False,
"membership_expires_at": None,
}
}
class UpdatePermissionsResponse(BaseModel):
success: bool
message: str
class RemoveUserResponse(BaseModel):
success: bool
message: str
class LeaveBusinessResponse(BaseModel):
success: bool
message: str
class BusinessUsersListResponse(BaseModel):
success: bool
message: str
data: dict
calendar_type: Optional[str] = None
# Document Numbering Settings Schemas
class DocumentNumberingSettingRequest(BaseModel):
document_type: str = Field(..., description="نوع سند (invoice_sales, receipt, payment, ...)")
prefix: Optional[str] = Field(default=None, max_length=20, description="پیشوند شماره سند")
include_date: bool = Field(default=True, description="آیا تاریخ در شماره سند باشد؟")
calendar_type: str = Field(
default="gregorian",
description="نوع تقویم: gregorian (میلادی) یا jalali (شمسی)"
)
date_format: Optional[str] = Field(
default=None,
max_length=20,
description="فرمت تاریخ (YYYYMMDD, YYYY/MM/DD, ...)"
)
separator: str = Field(default="-", max_length=5, description="جداکننده")
start_number: int = Field(default=1, ge=1, description="شماره شروع")
number_padding: int = Field(default=4, ge=1, le=10, description="تعداد صفرهای پیش‌رو")
reset_period: Optional[str] = Field(
default=None,
description="دوره ریست: daily, monthly, yearly, never"
)
custom_format: Optional[str] = Field(
default=None,
max_length=100,
description="فرمت سفارشی"
)
is_active: bool = Field(default=True, description="فعال/غیرفعال")
class Config:
from_attributes = True
class DocumentNumberingSettingResponse(BaseModel):
id: int
business_id: int
document_type: str
prefix: Optional[str]
include_date: bool
calendar_type: str
date_format: Optional[str]
separator: str
start_number: int
number_padding: int
reset_period: Optional[str]
custom_format: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DocumentCodeReservationRequest(BaseModel):
document_type: str = Field(..., description="نوع سند فاکتور (invoice_sales, ...)")
document_date: str = Field(..., description="تاریخ سند به فرمت YYYY-MM-DD")
class DocumentCodeReservationResponse(BaseModel):
reservation_id: str
code: str
document_type: str
document_date: str
expires_at: Optional[str] = None
status: str
class Config:
from_attributes = True