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.pytransfer_service.pyexpense_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. دسترسی به صفحه
- کاربر وارد بخش تنظیمات کسب و کار میشود (
/business/{id}/settings) - در بخش تنظیمات عمومی، گزینه "شمارهگذاری اسناد" را میبیند
- با کلیک روی آن، به صفحه تنظیمات شمارهگذاری منتقل میشود
4.2. مشاهده تنظیمات
- صفحه لیست تمام انواع اسناد را نمایش میدهد
- برای هر نوع سند، یک پیشنمایش از فرمت شمارهگذاری نمایش داده میشود
- اگر تنظیمات سفارشی وجود داشته باشد، با آیکون یا رنگ متفاوت نمایش داده میشود
4.3. ویرایش تنظیمات
- کاربر روی یک نوع سند کلیک میکند
- دیالوگ ویرایش باز میشود با فیلدهای:
- پیشوند (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 (روزانه، ماهانه، سالانه، هرگز)
- فعال/غیرفعال: چکباکس
- کاربر تغییرات را اعمال میکند
- با کلیک روی "ذخیره"، تنظیمات در دیتابیس ذخیره میشود
- پیشنمایش شماره سند به صورت زنده بهروزرسانی میشود
4.4. بازگشت به پیشفرض
- کاربر میتواند تنظیمات سفارشی را حذف کند
- با حذف، سیستم به تنظیمات پیشفرض بازمیگردد
4.5. استفاده در ایجاد سند
- هنگام ایجاد یک سند جدید (مثلاً فاکتور فروش)
- سیستم تنظیمات مربوط به
invoice_salesرا از دیتابیس میخواند - اگر تنظیمات وجود نداشت، از پیشفرض استفاده میکند
- شماره سند بر اساس تنظیمات تولید میشود
- شماره در فیلد مربوطه نمایش داده میشود
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→20241120YYMMDD→241120YYYY-MM-DD→2024-11-20YYYY/MM/DD→2024/11/20YY-MM-DD→24-11-20YY/MM/DD→24/11/20
تقویم شمسی:
YYYYMMDD→14031001YYMMDD→031001YYYY/MM/DD→1403/10/01YYYY-MM-DD→1403-10-01YY/MM/DD→03/10/01YY-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. مستندات کاربری
- راهنمای استفاده از صفحه تنظیمات شمارهگذاری
- توضیح هر فیلد و تأثیر آن بر شماره سند
- مثالهای مختلف فرمتهای شمارهگذاری برای هر دو تقویم
- نحوه انتخاب تقویم شمسی یا میلادی
- نحوه بازگشت به پیشفرض