Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/docs/DOCUMENT_NUMBERING_SETTINGS_SCENARIO.md
2026-04-14 19:34:55 +03:30

44 KiB
Executable file

سناریو: تنظیمات شماره‌گذاری اسناد

خلاصه

این سناریو نحوه پیاده‌سازی صفحه تنظیمات شماره‌گذاری اسناد را در بخش تنظیمات کسب و کار توضیح می‌دهد. کاربران با دسترسی مناسب می‌توانند نحوه شماره‌گذاری هر نوع سند را به صورت جداگانه تعیین کنند و در صورت عدم تعیین، از حالت پیش‌فرض استفاده می‌شود.


1. ساختار دیتابیس (Backend)

1.1. ایجاد جدول business_document_numbering_settings

CREATE TABLE business_document_numbering_settings (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    business_id INTEGER NOT NULL,
    document_type VARCHAR(50) NOT NULL,  -- نوع سند (invoice_sales, receipt, payment, transfer, ...)
    
    -- تنظیمات شماره‌گذاری
    prefix VARCHAR(20),                  -- پیشوند (مثلاً INV, RC, PY, TR)
    include_date BOOLEAN DEFAULT 1,      -- آیا تاریخ در شماره باشد؟
    calendar_type VARCHAR(10) DEFAULT 'gregorian',  -- نوع تقویم: gregorian (میلادی) یا jalali (شمسی)
    date_format VARCHAR(20),             -- فرمت تاریخ (YYYYMMDD, YYMMDD, YYYY-MM-DD, YYYY/MM/DD, ...)
    separator VARCHAR(5) DEFAULT '-',    -- جداکننده (مثلاً -, _, /)
    start_number INTEGER DEFAULT 1,       -- شماره شروع
    number_padding INTEGER DEFAULT 4,    -- تعداد صفرهای پیش‌رو (4 = 0001, 5 = 00001)
    reset_period VARCHAR(20),            -- دوره ریست: daily, monthly, yearly, never
    
    -- تنظیمات پیشرفته
    custom_format VARCHAR(100),           -- فرمت سفارشی (مثلاً {prefix}-{date}-{number})
    is_active BOOLEAN DEFAULT 1,         -- فعال/غیرفعال
    
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE(business_id, document_type),
    FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE
);

1.2. انواع اسناد پشتیبانی شده

  • فاکتورها:

    • invoice_sales - فاکتور فروش
    • invoice_sales_return - برگشت از فروش
    • invoice_purchase - فاکتور خرید
    • invoice_purchase_return - برگشت از خرید
    • invoice_direct_consumption - مصرف مستقیم
    • invoice_production - تولید
    • invoice_waste - ضایعات
  • اسناد مالی:

    • receipt - دریافت
    • payment - پرداخت
    • transfer - انتقال
    • expense - هزینه
    • income - درآمد
    • manual - سند دستی
    • opening_balance - تراز افتتاحیه
  • چک:

    • check_endorse - پاسخگویی چک
    • check_clear - وصول چک
    • check_pay - پرداخت چک
    • check_return - برگشت چک
    • check_bounce - برگشت خوردن چک
    • check_deposit - واریز به حساب
    • check_delete - حذف چک

2. Backend API

2.1. مدل SQLAlchemy

مسیر: hesabixAPI/adapters/db/models/document_numbering.py

from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base

class BusinessDocumentNumberingSetting(Base):
    __tablename__ = "business_document_numbering_settings"
    __table_args__ = (
        UniqueConstraint('business_id', 'document_type', 
                        name='uq_doc_numbering_business_type'),
    )
    
    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    business_id: Mapped[int] = mapped_column(
        Integer, ForeignKey("businesses.id", ondelete="CASCADE"), 
        nullable=False, index=True
    )
    document_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
    
    prefix: Mapped[str | None] = mapped_column(String(20), nullable=True)
    include_date: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
    calendar_type: Mapped[str] = mapped_column(String(10), default="gregorian", server_default="gregorian")
    date_format: Mapped[str | None] = mapped_column(String(20), nullable=True)
    separator: Mapped[str] = mapped_column(String(5), default="-", server_default="-")
    start_number: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
    number_padding: Mapped[int] = mapped_column(Integer, default=4, server_default="4")
    reset_period: Mapped[str | None] = mapped_column(String(20), nullable=True)
    
    custom_format: Mapped[str | None] = mapped_column(String(100), nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
    
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    updated_at: Mapped[datetime] = mapped_column(
        DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
    )
    
    business = relationship("Business", backref="document_numbering_settings")

2.2. Schema (Pydantic)

مسیر: hesabixAPI/adapters/api/v1/schemas.py

class DocumentNumberingSettingRequest(BaseModel):
    document_type: str
    prefix: Optional[str] = None
    include_date: bool = True
    calendar_type: str = "gregorian"  # gregorian (میلادی) یا jalali (شمسی)
    date_format: Optional[str] = None
    separator: str = "-"
    start_number: int = 1
    number_padding: int = 4
    reset_period: Optional[str] = None  # daily, monthly, yearly, never
    custom_format: Optional[str] = None
    is_active: bool = 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

2.3. API Endpoints

مسیر: hesabixAPI/adapters/api/v1/document_numbering.py

@router.get("/businesses/{business_id}/document-numbering-settings")
async def get_document_numbering_settings(
    business_id: int,
    ctx: AuthContext = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """
    دریافت تمام تنظیمات شماره‌گذاری اسناد یک کسب و کار
    """
    # بررسی دسترسی: settings.join
    if not ctx.has_business_permission("settings", "join"):
        raise HTTPException(403, "دسترسی غیرمجاز")
    
    settings = db.query(BusinessDocumentNumberingSetting).filter(
        BusinessDocumentNumberingSetting.business_id == business_id
    ).all()
    
    return [DocumentNumberingSettingResponse.from_orm(s) for s in settings]

@router.get("/businesses/{business_id}/document-numbering-settings/{document_type}")
async def get_document_numbering_setting(
    business_id: int,
    document_type: str,
    ctx: AuthContext = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """
    دریافت تنظیمات شماره‌گذاری برای یک نوع سند خاص
    """
    if not ctx.has_business_permission("settings", "join"):
        raise HTTPException(403, "دسترسی غیرمجاز")
    
    setting = db.query(BusinessDocumentNumberingSetting).filter(
        and_(
            BusinessDocumentNumberingSetting.business_id == business_id,
            BusinessDocumentNumberingSetting.document_type == document_type
        )
    ).first()
    
    if not setting:
        # برگرداندن تنظیمات پیش‌فرض
        return _get_default_setting(document_type)
    
    return DocumentNumberingSettingResponse.from_orm(setting)

@router.post("/businesses/{business_id}/document-numbering-settings")
async def create_document_numbering_setting(
    business_id: int,
    data: DocumentNumberingSettingRequest,
    ctx: AuthContext = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """
    ایجاد یا به‌روزرسانی تنظیمات شماره‌گذاری
    """
    if not ctx.has_business_permission("settings", "join"):
        raise HTTPException(403, "دسترسی غیرمجاز")
    
    existing = db.query(BusinessDocumentNumberingSetting).filter(
        and_(
            BusinessDocumentNumberingSetting.business_id == business_id,
            BusinessDocumentNumberingSetting.document_type == data.document_type
        )
    ).first()
    
    if existing:
        # به‌روزرسانی
        for key, value in data.dict(exclude_unset=True).items():
            setattr(existing, key, value)
        existing.updated_at = datetime.utcnow()
        db.commit()
        db.refresh(existing)
        return DocumentNumberingSettingResponse.from_orm(existing)
    else:
        # ایجاد جدید
        new_setting = BusinessDocumentNumberingSetting(
            business_id=business_id,
            **data.dict()
        )
        db.add(new_setting)
        db.commit()
        db.refresh(new_setting)
        return DocumentNumberingSettingResponse.from_orm(new_setting)

@router.delete("/businesses/{business_id}/document-numbering-settings/{document_type}")
async def delete_document_numbering_setting(
    business_id: int,
    document_type: str,
    ctx: AuthContext = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """
    حذف تنظیمات شماره‌گذاری (بازگشت به پیش‌فرض)
    """
    if not ctx.has_business_permission("settings", "join"):
        raise HTTPException(403, "دسترسی غیرمجاز")
    
    setting = db.query(BusinessDocumentNumberingSetting).filter(
        and_(
            BusinessDocumentNumberingSetting.business_id == business_id,
            BusinessDocumentNumberingSetting.document_type == document_type
        )
    ).first()
    
    if setting:
        db.delete(setting)
        db.commit()
    
    return {"message": "تنظیمات حذف شد و به حالت پیش‌فرض بازگشت"}

def _get_default_setting(document_type: str) -> dict:
    """
    برگرداندن تنظیمات پیش‌فرض برای هر نوع سند
    """
    defaults = {
        "invoice_sales": {"prefix": "INV", "include_date": True, "calendar_type": "gregorian", "date_format": "YYYYMMDD"},
        "invoice_sales_return": {"prefix": "INV-RET", "include_date": True, "calendar_type": "gregorian", "date_format": "YYYYMMDD"},
        "receipt": {"prefix": "RC", "include_date": True, "calendar_type": "gregorian", "date_format": "YYYYMMDD"},
        "payment": {"prefix": "PY", "include_date": True, "calendar_type": "gregorian", "date_format": "YYYYMMDD"},
        "transfer": {"prefix": "TR", "include_date": True, "calendar_type": "gregorian", "date_format": "YYYYMMDD"},
        # ... سایر انواع
    }
    
    default = defaults.get(document_type, {
        "prefix": "DOC",
        "include_date": True,
        "calendar_type": "gregorian",
        "date_format": "YYYYMMDD",
        "separator": "-",
        "start_number": 1,
        "number_padding": 4,
        "reset_period": "never"
    })
    
    return default

2.4. سرویس تولید شماره سند

مسیر: hesabixAPI/app/services/document_numbering_service.py

from app.core.calendar import CalendarConverter
from datetime import datetime, date

def generate_document_code(
    db: Session,
    business_id: int,
    document_type: str,
    document_date: date
) -> str:
    """
    تولید شماره سند بر اساس تنظیمات کسب و کار یا پیش‌فرض
    """
    # دریافت تنظیمات از دیتابیس
    setting = db.query(BusinessDocumentNumberingSetting).filter(
        and_(
            BusinessDocumentNumberingSetting.business_id == business_id,
            BusinessDocumentNumberingSetting.document_type == document_type,
            BusinessDocumentNumberingSetting.is_active == True
        )
    ).first()
    
    # اگر تنظیمات وجود نداشت، از پیش‌فرض استفاده کن
    if not setting:
        setting = _get_default_setting_for_type(document_type)
    
    # تولید شماره بر اساس تنظیمات
    prefix = setting.prefix or "DOC"
    separator = setting.separator or "-"
    
    # بخش تاریخ
    date_part = ""
    if setting.include_date:
        date_format = setting.date_format or "YYYYMMDD"
        calendar_type = setting.calendar_type or "gregorian"
        date_part = _format_date(document_date, date_format, calendar_type)
    
    # بخش شماره
    number_part = _get_next_number(
        db, business_id, document_type, 
        setting.start_number, setting.number_padding,
        setting.reset_period, document_date,
        setting.calendar_type or "gregorian"
    )
    
    # ترکیب نهایی
    if date_part:
        return f"{prefix}{separator}{date_part}{separator}{number_part}"
    else:
        return f"{prefix}{separator}{number_part}"

def _format_date(document_date: date, date_format: str, calendar_type: str) -> str:
    """
    فرمت‌بندی تاریخ بر اساس نوع تقویم (شمسی یا میلادی)
    
    Args:
        document_date: تاریخ میلادی سند
        date_format: فرمت مورد نظر (مثلاً YYYYMMDD, YYYY/MM/DD)
        calendar_type: نوع تقویم ('gregorian' یا 'jalali')
    
    Returns:
        رشته فرمت‌بندی شده تاریخ
    """
    # تبدیل date به datetime
    dt = datetime.combine(document_date, datetime.min.time())
    
    # تبدیل به تقویم مورد نظر
    if calendar_type == "jalali":
        cal_data = CalendarConverter.to_jalali(dt)
        year = cal_data["year"]
        month = cal_data["month"]
        day = cal_data["day"]
    else:  # gregorian
        cal_data = CalendarConverter.to_gregorian(dt)
        year = cal_data["year"]
        month = cal_data["month"]
        day = cal_data["day"]
    
    # فرمت‌بندی بر اساس الگو
    formatted = date_format
    
    # جایگزینی سال
    formatted = formatted.replace("YYYY", f"{year:04d}")
    formatted = formatted.replace("YY", f"{year % 100:02d}")
    
    # جایگزینی ماه
    formatted = formatted.replace("MM", f"{month:02d}")
    formatted = formatted.replace("M", f"{month}")
    
    # جایگزینی روز
    formatted = formatted.replace("DD", f"{day:02d}")
    formatted = formatted.replace("D", f"{day}")
    
    return formatted

def _get_next_number(
    db: Session,
    business_id: int,
    document_type: str,
    start_number: int,
    padding: int,
    reset_period: str | None,
    document_date: date,
    calendar_type: str = "gregorian"
) -> str:
    """
    دریافت شماره بعدی بر اساس دوره ریست
    
    نکته: اگر calendar_type == "jalali" باشد، باید محدوده را بر اساس تقویم شمسی محاسبه کنیم
    """
    from app.core.calendar import CalendarConverter
    from datetime import datetime
    
    # تعیین محدوده جستجو بر اساس reset_period
    if reset_period == "daily":
        if calendar_type == "jalali":
            # برای شمسی، باید تمام اسنادی که در همان روز شمسی هستند را پیدا کنیم
            # ابتدا تاریخ میلادی را به شمسی تبدیل می‌کنیم
            dt = datetime.combine(document_date, datetime.min.time())
            jalali = CalendarConverter.to_jalali(dt)
            # سپس تمام تاریخ‌های میلادی که در همان روز شمسی هستند را پیدا می‌کنیم
            # این کار پیچیده است، پس بهتر است از query بر اساس document_date استفاده کنیم
            # و در زمان مقایسه، تاریخ شمسی را بررسی کنیم
            date_from = document_date
            date_to = document_date
        else:
            date_from = document_date
            date_to = document_date
    elif reset_period == "monthly":
        if calendar_type == "jalali":
            # برای شمسی، باید تمام اسنادی که در همان ماه شمسی هستند را پیدا کنیم
            dt = datetime.combine(document_date, datetime.min.time())
            jalali = CalendarConverter.to_jalali(dt)
            # محاسبه محدوده بر اساس ماه شمسی
            # این کار نیاز به تبدیل تاریخ‌های ابتدا و انتهای ماه شمسی به میلادی دارد
            # برای سادگی، از همان منطق میلادی استفاده می‌کنیم اما در query فیلتر می‌کنیم
            date_from = document_date.replace(day=1)
            date_to = (date_from + timedelta(days=32)).replace(day=1) - timedelta(days=1)
        else:
            date_from = document_date.replace(day=1)
            date_to = (date_from + timedelta(days=32)).replace(day=1) - timedelta(days=1)
    elif reset_period == "yearly":
        if calendar_type == "jalali":
            # برای شمسی، باید تمام اسنادی که در همان سال شمسی هستند را پیدا کنیم
            dt = datetime.combine(document_date, datetime.min.time())
            jalali = CalendarConverter.to_jalali(dt)
            # محاسبه محدوده بر اساس سال شمسی
            date_from = document_date.replace(month=1, day=1)
            date_to = document_date.replace(month=12, day=31)
        else:
            date_from = document_date.replace(month=1, day=1)
            date_to = document_date.replace(month=12, day=31)
    else:  # never
        date_from = None
        date_to = None
    
    # جستجوی آخرین سند
    query = db.query(Document).filter(
        and_(
            Document.business_id == business_id,
            Document.document_type == document_type
        )
    )
    
    if date_from and date_to:
        if calendar_type == "jalali" and reset_period in ["daily", "monthly", "yearly"]:
            # برای تقویم شمسی، باید اسناد را بر اساس تاریخ شمسی فیلتر کنیم
            # این کار نیاز به تبدیل تاریخ میلادی هر سند به شمسی دارد
            # برای بهینه‌سازی، می‌توانیم از محدوده میلادی تقریبی استفاده کنیم
            # یا تمام اسناد را بگیریم و در Python فیلتر کنیم
            # راه حل بهتر: استفاده از محدوده میلادی و سپس فیلتر در Python
            query = query.filter(
                and_(
                    Document.document_date >= date_from,
                    Document.document_date <= date_to
                )
            )
            # سپس در Python، اسناد را بر اساس تاریخ شمسی فیلتر می‌کنیم
            docs = query.all()
            dt = datetime.combine(document_date, datetime.min.time())
            target_jalali = CalendarConverter.to_jalali(dt)
            
            filtered_docs = []
            for doc in docs:
                doc_dt = datetime.combine(doc.document_date, datetime.min.time())
                doc_jalali = CalendarConverter.to_jalali(doc_dt)
                
                if reset_period == "daily":
                    if (doc_jalali["year"] == target_jalali["year"] and
                        doc_jalali["month"] == target_jalali["month"] and
                        doc_jalali["day"] == target_jalali["day"]):
                        filtered_docs.append(doc)
                elif reset_period == "monthly":
                    if (doc_jalali["year"] == target_jalali["year"] and
                        doc_jalali["month"] == target_jalali["month"]):
                        filtered_docs.append(doc)
                elif reset_period == "yearly":
                    if doc_jalali["year"] == target_jalali["year"]:
                        filtered_docs.append(doc)
            
            last_doc = max(filtered_docs, key=lambda d: d.code) if filtered_docs else None
        else:
            # برای میلادی یا never، از فیلتر ساده استفاده می‌کنیم
            query = query.filter(
                and_(
                    Document.document_date >= date_from,
                    Document.document_date <= date_to
                )
            )
            last_doc = query.order_by(Document.code.desc()).first()
    else:
        last_doc = query.order_by(Document.code.desc()).first()
    
    if last_doc:
        try:
            # استخراج شماره از کد آخرین سند
            parts = last_doc.code.split(separator)
            last_num = int(parts[-1])
            next_num = last_num + 1
        except:
            next_num = start_number
    else:
        next_num = start_number
    
    return f"{next_num:0{padding}d}"

2.5. به‌روزرسانی سرویس‌های موجود

تغییرات در invoice_service.py:

# قبل:
doc_code = _build_invoice_code(db, business_id, invoice_type)

# بعد:
from app.services.document_numbering_service import generate_document_code
doc_code = generate_document_code(db, business_id, invoice_type, document_date)

تغییرات مشابه در:

  • receipt_payment_service.py
  • transfer_service.py
  • expense_income_service.py
  • سایر سرویس‌های ایجاد سند

3. Frontend (Flutter)

3.1. مدل‌ها

مسیر: hesabixUI/hesabix_ui/lib/models/document_numbering_models.dart

class DocumentNumberingSetting {
  final int? id;
  final int businessId;
  final String documentType;
  final String? prefix;
  final bool includeDate;
  final String calendarType; // gregorian یا jalali
  final String? dateFormat;
  final String separator;
  final int startNumber;
  final int numberPadding;
  final String? resetPeriod; // daily, monthly, yearly, never
  final String? customFormat;
  final bool isActive;
  final DateTime createdAt;
  final DateTime updatedAt;

  DocumentNumberingSetting({
    this.id,
    required this.businessId,
    required this.documentType,
    this.prefix,
    this.includeDate = true,
    this.calendarType = 'gregorian',
    this.dateFormat,
    this.separator = '-',
    this.startNumber = 1,
    this.numberPadding = 4,
    this.resetPeriod,
    this.customFormat,
    this.isActive = true,
    required this.createdAt,
    required this.updatedAt,
  });

  factory DocumentNumberingSetting.fromJson(Map<String, dynamic> json) {
    return DocumentNumberingSetting(
      id: json['id'],
      businessId: json['business_id'],
      documentType: json['document_type'],
      prefix: json['prefix'],
      includeDate: json['include_date'] ?? true,
      calendarType: json['calendar_type'] ?? 'gregorian',
      dateFormat: json['date_format'],
      separator: json['separator'] ?? '-',
      startNumber: json['start_number'] ?? 1,
      numberPadding: json['number_padding'] ?? 4,
      resetPeriod: json['reset_period'],
      customFormat: json['custom_format'],
      isActive: json['is_active'] ?? true,
      createdAt: DateTime.parse(json['created_at']),
      updatedAt: DateTime.parse(json['updated_at']),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      if (id != null) 'id': id,
      'business_id': businessId,
      'document_type': documentType,
      'prefix': prefix,
      'include_date': includeDate,
      'calendar_type': calendarType,
      'date_format': dateFormat,
      'separator': separator,
      'start_number': startNumber,
      'number_padding': numberPadding,
      'reset_period': resetPeriod,
      'custom_format': customFormat,
      'is_active': isActive,
    };
  }
}

3.2. سرویس API

مسیر: hesabixUI/hesabix_ui/lib/services/document_numbering_api_service.dart

class DocumentNumberingApiService {
  static final ApiClient _apiClient = ApiClient();

  static Future<List<DocumentNumberingSetting>> getSettings(int businessId) async {
    final response = await _apiClient.get(
      '/businesses/$businessId/document-numbering-settings',
    );
    final List<dynamic> data = response.data;
    return data.map((json) => DocumentNumberingSetting.fromJson(json)).toList();
  }

  static Future<DocumentNumberingSetting> getSetting(
    int businessId,
    String documentType,
  ) async {
    final response = await _apiClient.get(
      '/businesses/$businessId/document-numbering-settings/$documentType',
    );
    return DocumentNumberingSetting.fromJson(response.data);
  }

  static Future<DocumentNumberingSetting> saveSetting(
    int businessId,
    DocumentNumberingSetting setting,
  ) async {
    final response = await _apiClient.post(
      '/businesses/$businessId/document-numbering-settings',
      data: setting.toJson(),
    );
    return DocumentNumberingSetting.fromJson(response.data);
  }

  static Future<void> deleteSetting(int businessId, String documentType) async {
    await _apiClient.delete(
      '/businesses/$businessId/document-numbering-settings/$documentType',
    );
  }
}

3.3. صفحه تنظیمات

مسیر: hesabixUI/hesabix_ui/lib/pages/business/document_numbering_settings_page.dart

class DocumentNumberingSettingsPage extends StatefulWidget {
  final int businessId;
  const DocumentNumberingSettingsPage({super.key, required this.businessId});

  @override
  State<DocumentNumberingSettingsPage> createState() => _DocumentNumberingSettingsPageState();
}

class _DocumentNumberingSettingsPageState extends State<DocumentNumberingSettingsPage> {
  bool _loading = true;
  List<DocumentNumberingSetting> _settings = [];
  Map<String, String> _documentTypeNames = {
    'invoice_sales': 'فاکتور فروش',
    'invoice_sales_return': 'برگشت از فروش',
    'invoice_purchase': 'فاکتور خرید',
    'invoice_purchase_return': 'برگشت از خرید',
    'receipt': 'دریافت',
    'payment': 'پرداخت',
    'transfer': 'انتقال',
    // ... سایر انواع
  };

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    setState(() => _loading = true);
    try {
      final settings = await DocumentNumberingApiService.getSettings(widget.businessId);
      setState(() {
        _settings = settings;
        _loading = false;
      });
    } catch (e) {
      setState(() => _loading = false);
      if (mounted) {
        SnackBarHelper.show(context, message: 'خطا در بارگذاری: $e');
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final t = AppLocalizations.of(context);
    final cs = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: Text('تنظیمات شماره‌گذاری اسناد'),
        backgroundColor: cs.surface,
        foregroundColor: cs.onSurface,
      ),
      body: _loading
          ? Center(child: CircularProgressIndicator())
          : ListView.builder(
              padding: EdgeInsets.all(16),
              itemCount: _documentTypeNames.length,
              itemBuilder: (context, index) {
                final documentType = _documentTypeNames.keys.elementAt(index);
                final documentName = _documentTypeNames[documentType]!;
                final setting = _settings.firstWhere(
                  (s) => s.documentType == documentType,
                  orElse: () => _getDefaultSetting(documentType),
                );

                return Card(
                  margin: EdgeInsets.only(bottom: 12),
                  child: ListTile(
                    title: Text(documentName),
                    subtitle: Text(_formatPreview(setting)),
                    trailing: Icon(Icons.arrow_forward_ios, size: 16),
                    onTap: () => _showEditDialog(documentType, setting),
                  ),
                );
              },
            ),
    );
  }

  DocumentNumberingSetting _getDefaultSetting(String documentType) {
    // برگرداندن تنظیمات پیش‌فرض
    return DocumentNumberingSetting(
      businessId: widget.businessId,
      documentType: documentType,
      prefix: _getDefaultPrefix(documentType),
      includeDate: true,
      calendarType: 'gregorian',
      dateFormat: 'YYYYMMDD',
      separator: '-',
      startNumber: 1,
      numberPadding: 4,
      resetPeriod: 'never',
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
    );
  }

  String _getDefaultPrefix(String documentType) {
    final prefixes = {
      'invoice_sales': 'INV',
      'receipt': 'RC',
      'payment': 'PY',
      'transfer': 'TR',
      // ...
    };
    return prefixes[documentType] ?? 'DOC';
  }

  String _formatPreview(DocumentNumberingSetting setting) {
    final today = DateTime.now();
    String datePart = '';
    
    if (setting.includeDate) {
      if (setting.calendarType == 'jalali') {
        // تبدیل به شمسی با استفاده از shamsi_date package
        import 'package:shamsi_date/shamsi_date.dart';
        final jalali = Jalali.fromDateTime(today);
        datePart = _formatJalaliDate(jalali, setting.dateFormat ?? 'YYYYMMDD');
      } else {
        // میلادی
        datePart = _formatGregorianDate(today, setting.dateFormat ?? 'YYYYMMDD');
      }
    }
    
    final numberPart = '1'.padLeft(setting.numberPadding, '0');
    
    if (datePart.isNotEmpty) {
      return '${setting.prefix}${setting.separator}$datePart${setting.separator}$numberPart';
    }
    return '${setting.prefix}${setting.separator}$numberPart';
  }

  String _formatGregorianDate(DateTime date, String format) {
    String result = format;
    result = result.replaceAll('YYYY', date.year.toString().padLeft(4, '0'));
    result = result.replaceAll('YY', (date.year % 100).toString().padLeft(2, '0'));
    result = result.replaceAll('MM', date.month.toString().padLeft(2, '0'));
    result = result.replaceAll('M', date.month.toString());
    result = result.replaceAll('DD', date.day.toString().padLeft(2, '0'));
    result = result.replaceAll('D', date.day.toString());
    return result;
  }

  String _formatJalaliDate(Jalali jalali, String format) {
    String result = format;
    result = result.replaceAll('YYYY', jalali.year.toString().padLeft(4, '0'));
    result = result.replaceAll('YY', (jalali.year % 100).toString().padLeft(2, '0'));
    result = result.replaceAll('MM', jalali.month.toString().padLeft(2, '0'));
    result = result.replaceAll('M', jalali.month.toString());
    result = result.replaceAll('DD', jalali.day.toString().padLeft(2, '0'));
    result = result.replaceAll('D', jalali.day.toString());
    return result;
  }

  Future<void> _showEditDialog(
    String documentType,
    DocumentNumberingSetting setting,
  ) async {
    // نمایش دیالوگ ویرایش با فیلدهای:
    // - پیشوند
    // - شامل تاریخ (چک‌باکس)
    // - نوع تقویم (dropdown: میلادی، شمسی)
    // - فرمت تاریخ (dropdown که بر اساس نوع تقویم تغییر می‌کند)
    //   * برای میلادی: YYYYMMDD, YYMMDD, YYYY-MM-DD, YYYY/MM/DD, YY-MM-DD, YY/MM/DD
    //   * برای شمسی: YYYYMMDD, YYMMDD, YYYY/MM/DD, YYYY-MM-DD, YY/MM/DD, YY-MM-DD
    // - جداکننده
    // - شماره شروع
    // - تعداد صفرهای پیش‌رو
    // - دوره ریست
    // - فعال/غیرفعال
    // - پیش‌نمایش زنده (که با تغییر هر فیلد به‌روزرسانی می‌شود)
    // ...
    
    // مثال کد برای تغییر فرمت تاریخ بر اساس نوع تقویم:
    /*
    List<String> getDateFormats(String calendarType) {
      if (calendarType == 'jalali') {
        return [
          'YYYYMMDD',
          'YYMMDD',
          'YYYY/MM/DD',
          'YYYY-MM-DD',
          'YY/MM/DD',
          'YY-MM-DD',
        ];
      } else {
        return [
          'YYYYMMDD',
          'YYMMDD',
          'YYYY-MM-DD',
          'YYYY/MM/DD',
          'YY-MM-DD',
          'YY/MM/DD',
        ];
      }
    }
    */
  }
}

3.4. اضافه کردن لینک در صفحه تنظیمات

تغییرات در settings_page.dart:

_buildSettingItem(
  context,
  title: 'شماره‌گذاری اسناد',
  subtitle: 'تنظیم نحوه شماره‌گذاری انواع اسناد',
  icon: Icons.numbers,
  onTap: () => context.go('/business/${widget.businessId}/settings/document-numbering'),
),

3.5. Routing

تغییرات در main.dart:

GoRoute(
  path: '/business/:business_id/settings/document-numbering',
  name: 'business_settings_document_numbering',
  pageBuilder: (context, state) {
    final businessId = int.parse(state.pathParameters['business_id']!);
    if (!_authStore!.hasBusinessPermission('settings', 'join')) {
      return NoTransitionPage(child: PermissionGuard.buildAccessDeniedPage());
    }
    return NoTransitionPage(
      child: DocumentNumberingSettingsPage(businessId: businessId),
    );
  },
),

4. جریان کار (User Flow)

4.1. دسترسی به صفحه

  1. کاربر وارد بخش تنظیمات کسب و کار می‌شود (/business/{id}/settings)
  2. در بخش تنظیمات عمومی، گزینه "شماره‌گذاری اسناد" را می‌بیند
  3. با کلیک روی آن، به صفحه تنظیمات شماره‌گذاری منتقل می‌شود

4.2. مشاهده تنظیمات

  1. صفحه لیست تمام انواع اسناد را نمایش می‌دهد
  2. برای هر نوع سند، یک پیش‌نمایش از فرمت شماره‌گذاری نمایش داده می‌شود
  3. اگر تنظیمات سفارشی وجود داشته باشد، با آیکون یا رنگ متفاوت نمایش داده می‌شود

4.3. ویرایش تنظیمات

  1. کاربر روی یک نوع سند کلیک می‌کند
  2. دیالوگ ویرایش باز می‌شود با فیلدهای:
    • پیشوند (prefix): مثلاً INV, RC, PY
    • شامل تاریخ: چک‌باکس
    • نوع تقویم: dropdown (میلادی، شمسی)
    • فرمت تاریخ: dropdown (بسته به نوع تقویم):
      • میلادی: YYYYMMDD, YYMMDD, YYYY-MM-DD, YYYY/MM/DD, YY-MM-DD
      • شمسی: YYYYMMDD, YYMMDD, YYYY/MM/DD, YYYY-MM-DD, YY/MM/DD
    • جداکننده: input (پیش‌فرض: -)
    • شماره شروع: عدد (پیش‌فرض: 1)
    • تعداد صفرهای پیش‌رو: عدد (پیش‌فرض: 4)
    • دوره ریست: dropdown (روزانه، ماهانه، سالانه، هرگز)
    • فعال/غیرفعال: چک‌باکس
  3. کاربر تغییرات را اعمال می‌کند
  4. با کلیک روی "ذخیره"، تنظیمات در دیتابیس ذخیره می‌شود
  5. پیش‌نمایش شماره سند به صورت زنده به‌روزرسانی می‌شود

4.4. بازگشت به پیش‌فرض

  1. کاربر می‌تواند تنظیمات سفارشی را حذف کند
  2. با حذف، سیستم به تنظیمات پیش‌فرض بازمی‌گردد

4.5. استفاده در ایجاد سند

  1. هنگام ایجاد یک سند جدید (مثلاً فاکتور فروش)
  2. سیستم تنظیمات مربوط به invoice_sales را از دیتابیس می‌خواند
  3. اگر تنظیمات وجود نداشت، از پیش‌فرض استفاده می‌کند
  4. شماره سند بر اساس تنظیمات تولید می‌شود
  5. شماره در فیلد مربوطه نمایش داده می‌شود

5. تنظیمات پیش‌فرض

5.1. فاکتورها

  • پیشوند: INV
  • شامل تاریخ: بله
  • نوع تقویم: میلادی (gregorian)
  • فرمت تاریخ: YYYYMMDD
  • جداکننده: -
  • شماره شروع: 1
  • تعداد صفرها: 4
  • دوره ریست: هرگز
  • نمونه میلادی: INV-20241120-0001
  • نمونه شمسی: INV-14031001-0001 (با فرمت YYYYMMDD)

5.2. دریافت/پرداخت

  • دریافت:
    • پیشوند: RC
    • نوع تقویم: میلادی (پیش‌فرض)
    • نمونه میلادی: RC-20241120-0001
    • نمونه شمسی: RC-14031001-0001
  • پرداخت:
    • پیشوند: PY
    • نوع تقویم: میلادی (پیش‌فرض)
    • نمونه میلادی: PY-20241120-0001
    • نمونه شمسی: PY-14031001-0001

5.3. انتقال

  • پیشوند: TR
  • نوع تقویم: میلادی (پیش‌فرض)
  • نمونه میلادی: TR-20241120-0001
  • نمونه شمسی: TR-14031001-0001

5.4. فرمت‌های تاریخ پشتیبانی شده

تقویم میلادی:

  • YYYYMMDD → 20241120
  • YYMMDD → 241120
  • YYYY-MM-DD → 2024-11-20
  • YYYY/MM/DD → 2024/11/20
  • YY-MM-DD → 24-11-20
  • YY/MM/DD → 24/11/20

تقویم شمسی:

  • YYYYMMDD → 14031001
  • YYMMDD → 031001
  • YYYY/MM/DD → 1403/10/01
  • YYYY-MM-DD → 1403-10-01
  • YY/MM/DD → 03/10/01
  • YY-MM-DD → 03-10-01

6. نکات پیاده‌سازی

6.1. استفاده از تقویم شمسی

  • برای تبدیل تاریخ میلادی به شمسی از CalendarConverter.to_jalali() استفاده می‌شود
  • برای تبدیل تاریخ شمسی به میلادی از CalendarConverter.to_gregorian() استفاده می‌شود
  • تاریخ سند در دیتابیس همیشه به صورت میلادی ذخیره می‌شود
  • فقط در زمان نمایش و تولید شماره سند، تبدیل به تقویم مورد نظر انجام می‌شود
  • هنگام استفاده از تقویم شمسی برای دوره ریست (reset_period):
    • برای daily: شماره‌گذاری هر روز شمسی از 1 شروع می‌شود
    • برای monthly: شماره‌گذاری هر ماه شمسی از 1 شروع می‌شود
    • برای yearly: شماره‌گذاری هر سال شمسی از 1 شروع می‌شود
    • برای never: شماره‌گذاری پیوسته است و ریست نمی‌شود
  • در محاسبه reset_period، باید تاریخ میلادی سند را به شمسی تبدیل کرده و سپس محدوده را محاسبه کنیم

6.2. سازگاری با داده‌های موجود

  • اسناد موجود در دیتابیس تغییر نمی‌کنند
  • فقط اسناد جدید از تنظیمات جدید استفاده می‌کنند
  • در صورت تغییر فرمت، ممکن است شماره‌های تکراری ایجاد نشود (بسته به reset_period)

6.3. اعتبارسنجی

  • پیشوند نباید خالی باشد
  • تعداد صفرهای پیش‌رو باید بین 1 تا 10 باشد
  • شماره شروع باید مثبت باشد
  • نوع تقویم باید gregorian یا jalali باشد
  • فرمت تاریخ باید معتبر باشد و با نوع تقویم سازگار باشد
  • در فرمت شمسی، استفاده از / برای جداکننده تاریخ توصیه می‌شود

6.4. دسترسی‌ها

  • فقط کاربرانی که دسترسی settings.join دارند می‌توانند تنظیمات را تغییر دهند
  • سایر کاربران فقط می‌توانند مشاهده کنند (اگر نیاز باشد)

6.5. بهینه‌سازی

  • تنظیمات را در cache نگه دارید تا در هر بار ایجاد سند، query به دیتابیس نزنید
  • از index روی business_id و document_type استفاده کنید

7. Migration

def upgrade():
    op.create_table(
        'business_document_numbering_settings',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('business_id', sa.Integer(), nullable=False),
        sa.Column('document_type', sa.String(50), nullable=False),
        sa.Column('prefix', sa.String(20), nullable=True),
        sa.Column('include_date', sa.Boolean(), nullable=False, server_default='1'),
        sa.Column('calendar_type', sa.String(10), nullable=False, server_default='gregorian'),
        sa.Column('date_format', sa.String(20), nullable=True),
        sa.Column('separator', sa.String(5), nullable=False, server_default='-'),
        sa.Column('start_number', sa.Integer(), nullable=False, server_default='1'),
        sa.Column('number_padding', sa.Integer(), nullable=False, server_default='4'),
        sa.Column('reset_period', sa.String(20), nullable=True),
        sa.Column('custom_format', sa.String(100), nullable=True),
        sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
        sa.Column('created_at', sa.DateTime(), nullable=False),
        sa.Column('updated_at', sa.DateTime(), nullable=False),
        sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('business_id', 'document_type', name='uq_doc_numbering_business_type')
    )
    op.create_index('ix_doc_numbering_business', 'business_document_numbering_settings', ['business_id'])
    op.create_index('ix_doc_numbering_type', 'business_document_numbering_settings', ['document_type'])

8. تست

8.1. تست Backend

  • تست ایجاد تنظیمات
  • تست به‌روزرسانی تنظیمات
  • تست حذف تنظیمات
  • تست تولید شماره سند با تنظیمات سفارشی
  • تست تولید شماره سند با پیش‌فرض
  • تست دوره‌های ریست (روزانه، ماهانه، سالانه)

8.2. تست Frontend

  • تست نمایش لیست تنظیمات
  • تست ویرایش تنظیمات
  • تست پیش‌نمایش فرمت
  • تست اعتبارسنجی فیلدها
  • تست دسترسی‌ها

9. مثال‌های استفاده از تقویم شمسی

9.1. مثال 1: فاکتور فروش با تاریخ شمسی

  • نوع سند: invoice_sales
  • پیشوند: INV
  • نوع تقویم: jalali (شمسی)
  • فرمت تاریخ: YYYY/MM/DD
  • جداکننده: /
  • شماره: 0001
  • نتیجه: INV/1403/10/01/0001

9.2. مثال 2: دریافت با تاریخ شمسی بدون جداکننده

  • نوع سند: receipt
  • پیشوند: RC
  • نوع تقویم: jalali (شمسی)
  • فرمت تاریخ: YYYYMMDD
  • جداکننده: -
  • شماره: 0001
  • نتیجه: RC-14031001-0001

9.3. مثال 3: پرداخت با تاریخ میلادی

  • نوع سند: payment
  • پیشوند: PY
  • نوع تقویم: gregorian (میلادی)
  • فرمت تاریخ: YYYY-MM-DD
  • جداکننده: -
  • شماره: 0001
  • نتیجه: PY-2024-11-20-0001

9.4. مثال 4: دوره ریست ماهانه با تقویم شمسی

  • نوع سند: invoice_sales
  • نوع تقویم: jalali
  • دوره ریست: monthly
  • توضیح: شماره‌گذاری در ابتدای هر ماه شمسی از 1 شروع می‌شود
  • مثال:
    • INV-1403/10/01-0001 (اول مهر)
    • INV-1403/10/15-0002 (پانزدهم مهر)
    • INV-1403/11/01-0001 (اول آبان - ریست شده)

10. مستندات کاربری

  • راهنمای استفاده از صفحه تنظیمات شماره‌گذاری
  • توضیح هر فیلد و تأثیر آن بر شماره سند
  • مثال‌های مختلف فرمت‌های شماره‌گذاری برای هر دو تقویم
  • نحوه انتخاب تقویم شمسی یا میلادی
  • نحوه بازگشت به پیش‌فرض