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

1147 lines
44 KiB
Markdown
Executable file

# سناریو: تنظیمات شماره‌گذاری اسناد
## خلاصه
این سناریو نحوه پیاده‌سازی صفحه تنظیمات شماره‌گذاری اسناد را در بخش تنظیمات کسب و کار توضیح می‌دهد. کاربران با دسترسی مناسب می‌توانند نحوه شماره‌گذاری هر نوع سند را به صورت جداگانه تعیین کنند و در صورت عدم تعیین، از حالت پیش‌فرض استفاده می‌شود.
---
## 1. ساختار دیتابیس (Backend)
### 1.1. ایجاد جدول `business_document_numbering_settings`
```sql
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`
```python
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`
```python
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`
```python
@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`
```python
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`:**
```python
# قبل:
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`
```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`
```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`
```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`:**
```dart
_buildSettingItem(
context,
title: 'شماره‌گذاری اسناد',
subtitle: 'تنظیم نحوه شماره‌گذاری انواع اسناد',
icon: Icons.numbers,
onTap: () => context.go('/business/${widget.businessId}/settings/document-numbering'),
),
```
### 3.5. Routing
**تغییرات در `main.dart`:**
```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
```python
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. مستندات کاربری
- راهنمای استفاده از صفحه تنظیمات شماره‌گذاری
- توضیح هر فیلد و تأثیر آن بر شماره سند
- مثال‌های مختلف فرمت‌های شماره‌گذاری برای هر دو تقویم
- نحوه انتخاب تقویم شمسی یا میلادی
- نحوه بازگشت به پیش‌فرض