progress in recipies

This commit is contained in:
Hesabix 2025-10-15 21:21:11 +03:30
parent 37f4e0b6b4
commit 4c9283ab98
45 changed files with 3727 additions and 401 deletions

View file

@ -0,0 +1,79 @@
# پیاده‌سازی کارمزد در بخش دریافت و پرداخت
## تغییرات اعمال شده
### 1. سرویس دریافت و پرداخت (`receipt_payment_service.py`)
#### تغییرات اصلی:
- **محاسبه مجموع کارمزدها**: مجموع کارمزدهای همه تراکنش‌ها محاسبه می‌شود
- **ایجاد خطوط کارمزد جداگانه**: برای هر کارمزد، دو خط جداگانه ایجاد می‌شود:
- خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
- خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
#### منطق کارمزد:
**در دریافت (Receipt):**
- کارمزد از حساب بانک/صندوق/تنخواهگردان کم می‌شود (Credit)
- کارمزد به حساب کارمزد خدمات بانکی اضافه می‌شود (Debit)
**در پرداخت (Payment):**
- کارمزد به حساب بانک/صندوق/تنخواهگردان اضافه می‌شود (Debit)
- کارمزد از حساب کارمزد خدمات بانکی کم می‌شود (Credit)
#### کدهای حساب:
- بانک: `10203`
- صندوق: `10202`
- تنخواهگردان: `10201`
- چک دریافتی: `10403`
- چک پرداختی: `20202`
- **کارمزد خدمات بانکی: `70902`**
### 2. نمایش خطوط کارمزد
در تابع `document_to_dict`:
- خطوط کارمزد با فلگ `is_commission_line: true` تشخیص داده می‌شوند
- خطوط کارمزد همیشه در `account_lines` نمایش داده می‌شوند
## نحوه استفاده
### فرانت‌اند:
```dart
// در InvoiceTransaction
final transaction = InvoiceTransaction(
// ... سایر فیلدها
commission: 5000, // کارمزد به ریال
);
```
### API:
```json
{
"account_lines": [
{
"transaction_type": "bank",
"amount": 1000000,
"commission": 5000,
"bank_id": "123"
}
]
}
```
## نتیجه
✅ کارمزد از فرانت به سرور ارسال می‌شود
✅ کارمزد به عنوان سطر جداگانه در `document_lines` ثبت می‌شود
✅ کارمزد برای بانک، صندوق و تنخواهگردان به صورت جداگانه ثبت می‌شود
✅ کارمزد در حساب کارمزد خدمات بانکی (کد 70902) ثبت می‌شود
✅ تعادل حسابداری حفظ می‌شود
## مثال عملی
**دریافت 1,000,000 ریال از شخص با کارمزد 5,000 ریال:**
1. خط اصلی: شخص بستانکار 1,000,000 ریال
2. خط اصلی: بانک بدهکار 1,000,000 ریال
3. خط کارمزد: بانک بستانکار 5,000 ریال (کم شدن کارمزد)
4. خط کارمزد: کارمزد خدمات بانکی بدهکار 5,000 ریال (اضافه شدن کارمزد)
**مجموع:** شخص = 1,000,000 ریال، بانک = 995,000 ریال، کارمزد خدمات بانکی = 5,000 ریال ✅

View file

@ -0,0 +1,236 @@
# پیاده‌سازی صفحه لیست دریافت و پرداخت با ویجت جدول
## 📋 خلاصه
این سند توضیح می‌دهد که چگونه بخش لیست دریافت و پرداخت از یک ListView ساده به یک ویجت جدول پیشرفته تبدیل شده است.
## 🎯 اهداف
- جایگزینی ListView ساده با DataTableWidget پیشرفته
- افزودن قابلیت‌های جستجو، فیلتر و صفحه‌بندی
- بهبود تجربه کاربری و عملکرد
- استفاده مجدد از ویجت جدول در بخش‌های دیگر
## 📁 فایل‌های ایجاد شده
### 1. مدل داده
**مسیر:** `lib/models/receipt_payment_document.dart`
#### کلاس‌های اصلی:
- `ReceiptPaymentDocument`: مدل اصلی سند دریافت/پرداخت
- `PersonLine`: مدل خط شخص در سند
- `AccountLine`: مدل خط حساب در سند
#### ویژگی‌های کلیدی:
- پشتیبانی از JSON serialization
- محاسبه خودکار مجموع مبالغ
- تشخیص نوع سند (دریافت/پرداخت)
- فرمت‌بندی مناسب برای نمایش
### 2. سرویس
**مسیر:** `lib/services/receipt_payment_list_service.dart`
#### کلاس اصلی:
- `ReceiptPaymentListService`: مدیریت API calls
#### متدهای اصلی:
- `getList()`: دریافت لیست اسناد با فیلتر
- `getById()`: دریافت جزئیات یک سند
- `delete()`: حذف یک سند
- `deleteMultiple()`: حذف چندین سند
- `getStats()`: دریافت آمار کلی
### 3. صفحه جدید
**مسیر:** `lib/pages/business/receipts_payments_list_page.dart`
#### ویژگی‌های صفحه:
- استفاده از DataTableWidget
- فیلتر نوع سند (دریافت/پرداخت/همه)
- فیلتر بازه زمانی
- جستجوی پیشرفته
- عملیات CRUD کامل
## 🔧 تنظیمات جدول
### ستون‌های تعریف شده:
1. **کد سند** (TextColumn): نمایش کد سند
2. **نوع سند** (TextColumn): دریافت/پرداخت
3. **تاریخ سند** (DateColumn): تاریخ با فرمت جلالی
4. **مبلغ کل** (NumberColumn): مجموع مبالغ
5. **تعداد اشخاص** (NumberColumn): تعداد خطوط اشخاص
6. **تعداد حساب‌ها** (NumberColumn): تعداد خطوط حساب‌ها
7. **ایجادکننده** (TextColumn): نام کاربر
8. **تاریخ ثبت** (DateColumn): زمان ثبت
9. **عملیات** (ActionColumn): دکمه‌های عملیات
### قابلیت‌های فعال:
- ✅ جستجوی کلی
- ✅ فیلتر ستونی
- ✅ فیلتر بازه زمانی
- ✅ مرتب‌سازی
- ✅ صفحه‌بندی
- ✅ انتخاب چندتایی
- ✅ دکمه refresh
- ✅ دکمه clear filters
## 🚀 نحوه استفاده
### 1. Navigation
```dart
// در routing موجود
GoRoute(
path: 'receipts-payments',
name: 'business_receipts_payments',
builder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return BusinessShell(
businessId: businessId,
authStore: _authStore!,
localeController: controller,
calendarController: _calendarController!,
themeController: themeController,
child: ReceiptsPaymentsListPage(
businessId: businessId,
calendarController: _calendarController!,
authStore: _authStore!,
apiClient: ApiClient(),
),
);
},
),
```
### 2. استفاده مستقیم
```dart
ReceiptsPaymentsListPage(
businessId: 123,
calendarController: calendarController,
authStore: authStore,
apiClient: apiClient,
)
```
## 🔄 تغییرات در Routing
### قبل:
```dart
child: ReceiptsPaymentsPage(
businessId: businessId,
calendarController: _calendarController!,
authStore: _authStore!,
apiClient: ApiClient(),
),
```
### بعد:
```dart
child: ReceiptsPaymentsListPage(
businessId: businessId,
calendarController: _calendarController!,
authStore: _authStore!,
apiClient: ApiClient(),
),
```
## 📊 API Integration
### Endpoint استفاده شده:
```
POST /businesses/{business_id}/receipts-payments
```
### پارامترهای پشتیبانی شده:
- `search`: جستجوی کلی
- `document_type`: نوع سند (receipt/payment)
- `from_date`: تاریخ شروع
- `to_date`: تاریخ پایان
- `sort_by`: فیلد مرتب‌سازی
- `sort_desc`: جهت مرتب‌سازی
- `take`: تعداد رکورد در صفحه
- `skip`: تعداد رکورد رد شده
## 🎨 UI/UX بهبودها
### قبل:
- ListView ساده
- فقط نمایش draft های محلی
- عدم وجود جستجو و فیلتر
- UI محدود
### بعد:
- DataTableWidget پیشرفته
- اتصال مستقیم به API
- جستجو و فیلتر کامل
- UI مدرن و responsive
- عملیات CRUD کامل
## 🔧 تنظیمات پیشرفته
### فیلترهای اضافی:
```dart
additionalParams: {
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
},
```
### تنظیمات جدول:
```dart
DataTableConfig<ReceiptPaymentDocument>(
endpoint: '/businesses/${widget.businessId}/receipts-payments',
searchFields: ['code', 'created_by_name'],
filterFields: ['document_type'],
dateRangeField: 'document_date',
enableRowSelection: true,
enableMultiRowSelection: true,
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
)
```
## 🚧 TODO های آینده
1. **صفحه افزودن سند جدید**
- استفاده از dialog موجود
- یکپارچه‌سازی با API
2. **صفحه جزئیات سند**
- نمایش کامل خطوط اشخاص و حساب‌ها
- امکان ویرایش
3. **عملیات گروهی**
- حذف چندتایی
- خروجی اکسل
- چاپ اسناد
4. **بهبودهای UX**
- انیمیشن‌های بهتر
- حالت‌های loading پیشرفته
- پیام‌های خطای بهتر
## 📝 نکات مهم
1. **سازگاری**: صفحه قدیمی `ReceiptsPaymentsPage` همچنان موجود است
2. **API**: از همان API موجود استفاده می‌کند
3. **مدل‌ها**: مدل‌های جدید با ساختار API سازگار هستند
4. **Performance**: صفحه‌بندی و lazy loading برای عملکرد بهتر
## 🔍 تست
### بررسی syntax:
```bash
flutter analyze lib/pages/business/receipts_payments_list_page.dart
flutter analyze lib/models/receipt_payment_document.dart
flutter analyze lib/services/receipt_payment_list_service.dart
```
### تست runtime:
1. اجرای اپلیکیشن
2. رفتن به بخش دریافت و پرداخت
3. تست فیلترها و جستجو
4. تست عملیات CRUD
## 📚 منابع
- [DataTableWidget Documentation](../hesabixUI/hesabix_ui/lib/widgets/data_table/README.md)
- [API Documentation](../hesabixAPI/adapters/api/v1/receipts_payments.py)
- [Service Implementation](../hesabixAPI/app/services/receipt_payment_service.py)

View file

@ -2,9 +2,14 @@
API endpoints برای دریافت و پرداخت (Receipt & Payment) API endpoints برای دریافت و پرداخت (Receipt & Payment)
""" """
from typing import Any, Dict from typing import Any, Dict, List
from fastapi import APIRouter, Depends, Request, Body from fastapi import APIRouter, Depends, Request, Body
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import io
import json
import datetime
import re
from adapters.db.session import get_db from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext from app.core.auth_dependency import get_current_user, AuthContext
@ -17,6 +22,7 @@ from app.services.receipt_payment_service import (
list_receipts_payments, list_receipts_payments,
delete_receipt_payment, delete_receipt_payment,
) )
from adapters.db.models.business import Business
router = APIRouter(tags=["receipts-payments"]) router = APIRouter(tags=["receipts-payments"])
@ -201,3 +207,439 @@ async def delete_receipt_payment_endpoint(
message="RECEIPT_PAYMENT_DELETED" message="RECEIPT_PAYMENT_DELETED"
) )
@router.post(
"/businesses/{business_id}/receipts-payments/export/excel",
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
description="خروجی Excel لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_receipts_payments_excel(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی Excel لیست اسناد دریافت و پرداخت"""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from app.core.i18n import negotiate_locale
# Build query dict from flat body
# For export, we limit to reasonable number to prevent memory issues
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
query_dict = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
"document_type": body.get("document_type"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
}
result = list_receipts_payments(db, business_id, query_dict)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Check if we hit the limit
if len(items) >= max_export_records:
# Add a warning row to indicate data was truncated
warning_item = {
"code": "⚠️ هشدار",
"document_type": "حداکثر ۱۰,۰۰۰ رکورد قابل export است",
"document_date": "",
"total_amount": "",
"person_lines_count": "",
"account_lines_count": "",
"created_by_name": "",
"registered_at": "",
}
items.append(warning_item)
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers based on export_columns (order + visibility)
headers: List[str] = []
keys: List[str] = []
export_columns = body.get('export_columns')
if export_columns:
for col in export_columns:
key = col.get('key')
label = col.get('label', key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
# Default columns for receipts/payments
default_columns = [
('code', 'کد سند'),
('document_type_name', 'نوع سند'),
('document_date', 'تاریخ سند'),
('total_amount', 'مبلغ کل'),
('person_names', 'اشخاص'),
('account_lines_count', 'تعداد حساب‌ها'),
('created_by_name', 'ایجادکننده'),
('registered_at', 'تاریخ ثبت'),
]
for key, label in default_columns:
if items and key in items[0]:
keys.append(key)
headers.append(label)
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Receipts & Payments"
# Locale and RTL/LTR handling
locale = negotiate_locale(request.headers.get("Accept-Language"))
if locale == 'fa':
try:
ws.sheet_view.rightToLeft = True
except Exception:
pass
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
# Write header row
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data rows
for row_idx, item in enumerate(items, 2):
for col_idx, key in enumerate(keys, 1):
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
cell = ws.cell(row=row_idx, column=col_idx, value=value)
cell.border = border
# RTL alignment for Persian text
if locale == 'fa' and isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in value):
cell.alignment = Alignment(horizontal="right")
# Auto-width columns
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
# Save to bytes
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
# Build meaningful filename
biz_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
biz_name = b.name or ""
except Exception:
biz_name = ""
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "receipts_payments"
if biz_name:
base += f"_{slugify(biz_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
content = buffer.getvalue()
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post(
"/businesses/{business_id}/receipts-payments/export/pdf",
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
description="خروجی PDF لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستون‌ها",
)
@require_business_access("business_id")
async def export_receipts_payments_pdf(
business_id: int,
request: Request,
body: Dict[str, Any] = Body(...),
auth_context: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""خروجی PDF لیست اسناد دریافت و پرداخت"""
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from app.core.i18n import negotiate_locale
from html import escape
# Build query dict from flat body
# For export, we limit to reasonable number to prevent memory issues
max_export_records = 10000
take_value = min(int(body.get("take", 1000)), max_export_records)
query_dict = {
"take": take_value,
"skip": int(body.get("skip", 0)),
"sort_by": body.get("sort_by"),
"sort_desc": bool(body.get("sort_desc", False)),
"search": body.get("search"),
"search_fields": body.get("search_fields"),
"filters": body.get("filters"),
"document_type": body.get("document_type"),
"from_date": body.get("from_date"),
"to_date": body.get("to_date"),
}
result = list_receipts_payments(db, business_id, query_dict)
items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items]
# Handle selected rows
selected_only = bool(body.get('selected_only', False))
selected_indices = body.get('selected_indices')
if selected_only and selected_indices is not None:
indices = None
if isinstance(selected_indices, str):
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = None
elif isinstance(selected_indices, list):
indices = selected_indices
if isinstance(indices, list):
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
# Prepare headers and data
headers: List[str] = []
keys: List[str] = []
export_columns = body.get('export_columns')
if export_columns:
for col in export_columns:
key = col.get('key')
label = col.get('label', key)
if key:
keys.append(str(key))
headers.append(str(label))
else:
# Default columns for receipts/payments
default_columns = [
('code', 'کد سند'),
('document_type_name', 'نوع سند'),
('document_date', 'تاریخ سند'),
('total_amount', 'مبلغ کل'),
('person_names', 'اشخاص'),
('account_lines_count', 'تعداد حساب‌ها'),
('created_by_name', 'ایجادکننده'),
('registered_at', 'تاریخ ثبت'),
]
for key, label in default_columns:
if items and key in items[0]:
keys.append(key)
headers.append(label)
# Get business name
business_name = ""
try:
b = db.query(Business).filter(Business.id == business_id).first()
if b is not None:
business_name = b.name or ""
except Exception:
business_name = ""
# Locale handling
locale = negotiate_locale(request.headers.get("Accept-Language"))
is_fa = locale == 'fa'
# Prepare data for HTML
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
title_text = "لیست اسناد دریافت و پرداخت" if is_fa else "Receipts & Payments List"
label_biz = "کسب و کار" if is_fa else "Business"
label_date = "تاریخ تولید" if is_fa else "Generated Date"
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
# Create headers HTML
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
# Create rows HTML
rows_html = []
for item in items:
row_cells = []
for key in keys:
value = item.get(key, "")
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
row_cells.append(f'<td>{escape(str(value))}</td>')
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
# Create HTML table
table_html = f"""
<!DOCTYPE html>
<html dir="{'rtl' if is_fa else 'ltr'}">
<head>
<meta charset="utf-8">
<title>{title_text}</title>
<style>
@page {{
margin: 1cm;
size: A4;
}}
body {{
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
font-size: 12px;
line-height: 1.4;
color: #333;
direction: {'rtl' if is_fa else 'ltr'};
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #366092;
}}
.title {{
font-size: 18px;
font-weight: bold;
color: #366092;
}}
.meta {{
font-size: 11px;
color: #666;
}}
.table-wrapper {{
overflow-x: auto;
margin: 20px 0;
}}
.report-table {{
width: 100%;
border-collapse: collapse;
margin: 0;
font-size: 11px;
}}
.report-table thead {{
background-color: #366092;
color: white;
}}
.report-table th {{
border: 1px solid #d7dde6;
padding: 8px 6px;
text-align: {'right' if is_fa else 'left'};
font-weight: bold;
white-space: nowrap;
}}
.report-table tbody tr:nth-child(even) {{
background-color: #f8f9fa;
}}
.report-table tbody tr:hover {{
background-color: #e9ecef;
}}
tbody td {{
border: 1px solid #d7dde6;
padding: 5px 4px;
vertical-align: top;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
text-align: {'right' if is_fa else 'left'};
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">{title_text}</div>
<div class="meta">{label_biz}: {escape(business_name)}</div>
</div>
<div class="meta">{label_date}: {escape(now)}</div>
</div>
<div class="table-wrapper">
<table class="report-table">
<thead>
<tr>{headers_html}</tr>
</thead>
<tbody>
{''.join(rows_html)}
</tbody>
</table>
</div>
<div class="footer">{footer_text}</div>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
# Build meaningful filename
def slugify(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
base = "receipts_payments"
if business_name:
base += f"_{slugify(business_name)}"
if selected_only:
base += "_selected"
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -3,6 +3,9 @@
# Import from file_storage module # Import from file_storage module
from .file_storage import * from .file_storage import *
# Import document line schemas
from .document_line import *
# Re-export from parent schemas module # Re-export from parent schemas module
import sys import sys
import os import os

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Optional
from decimal import Decimal
from pydantic import BaseModel, Field
class DocumentLineCreateRequest(BaseModel):
"""درخواست ایجاد خط سند جدید"""
account_id: Optional[int] = Field(default=None, description="شناسه حساب")
person_id: Optional[int] = Field(default=None, description="شناسه شخص")
product_id: Optional[int] = Field(default=None, description="شناسه محصول")
bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی")
cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق")
petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه گردان")
check_id: Optional[int] = Field(default=None, description="شناسه چک")
quantity: Optional[Decimal] = Field(default=0, description="تعداد کالا")
debit: Decimal = Field(default=0, description="مبلغ بدهکار")
credit: Decimal = Field(default=0, description="مبلغ بستانکار")
description: Optional[str] = Field(default=None, description="توضیحات")
extra_info: Optional[dict] = Field(default=None, description="اطلاعات اضافی")
class DocumentLineUpdateRequest(BaseModel):
"""درخواست به‌روزرسانی خط سند"""
account_id: Optional[int] = None
person_id: Optional[int] = None
product_id: Optional[int] = None
bank_account_id: Optional[int] = None
cash_register_id: Optional[int] = None
petty_cash_id: Optional[int] = None
check_id: Optional[int] = None
quantity: Optional[Decimal] = None
debit: Optional[Decimal] = None
credit: Optional[Decimal] = None
description: Optional[str] = None
extra_info: Optional[dict] = None
class DocumentLineResponse(BaseModel):
"""پاسخ خط سند"""
id: int
document_id: int
account_id: Optional[int]
person_id: Optional[int]
product_id: Optional[int]
bank_account_id: Optional[int]
cash_register_id: Optional[int]
petty_cash_id: Optional[int]
check_id: Optional[int]
quantity: Optional[Decimal]
debit: Decimal
credit: Decimal
description: Optional[str]
extra_info: Optional[dict]
created_at: str
updated_at: str
# اطلاعات مرتبط
account_name: Optional[str] = None
person_name: Optional[str] = None
product_name: Optional[str] = None
bank_account_name: Optional[str] = None
cash_register_name: Optional[str] = None
petty_cash_name: Optional[str] = None
check_number: Optional[str] = None
class Config:
from_attributes = True

View file

@ -38,5 +38,6 @@ from .product_attribute_link import ProductAttributeLink # noqa: F401
from .tax_unit import TaxUnit # noqa: F401 from .tax_unit import TaxUnit # noqa: F401
from .tax_type import TaxType # noqa: F401 from .tax_type import TaxType # noqa: F401
from .bank_account import BankAccount # noqa: F401 from .bank_account import BankAccount # noqa: F401
from .cash_register import CashRegister # noqa: F401
from .petty_cash import PettyCash # noqa: F401 from .petty_cash import PettyCash # noqa: F401
from .check import Check # noqa: F401 from .check import Check # noqa: F401

View file

@ -17,6 +17,7 @@ class Document(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
fiscal_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("fiscal_years.id", ondelete="RESTRICT"), nullable=False, index=True)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True) created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
@ -30,6 +31,7 @@ class Document(Base):
# Relationships # Relationships
business = relationship("Business", back_populates="documents") business = relationship("Business", back_populates="documents")
fiscal_year = relationship("FiscalYear", back_populates="documents")
currency = relationship("Currency", back_populates="documents") currency = relationship("Currency", back_populates="documents")
created_by = relationship("User", foreign_keys=[created_by_user_id]) created_by = relationship("User", foreign_keys=[created_by_user_id])
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan") lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")

View file

@ -22,5 +22,6 @@ class FiscalYear(Base):
# Relationships # Relationships
business = relationship("Business", back_populates="fiscal_years") business = relationship("Business", back_populates="fiscal_years")
documents = relationship("Document", back_populates="fiscal_year", cascade="all, delete-orphan")

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from datetime import datetime from datetime import datetime, date
from fastapi import HTTPException, status, Request from fastapi import HTTPException, status, Request
from .calendar import CalendarConverter, CalendarType from .calendar import CalendarConverter, CalendarType
@ -57,6 +57,22 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"] formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
else: else:
formatted_data[f"{key}_raw"] = value.isoformat() formatted_data[f"{key}_raw"] = value.isoformat()
elif isinstance(value, date):
# Convert date to datetime for processing
dt_value = datetime.combine(value, datetime.min.time())
# Format the main date field based on calendar type
if calendar_type == "jalali":
formatted_data[key] = CalendarConverter.to_jalali(dt_value)["date_only"]
else:
formatted_data[key] = value.isoformat()
# Add formatted date as additional field
formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(dt_value, calendar_type)
# Convert raw date to the same calendar type as the formatted date
if calendar_type == "jalali":
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(dt_value)["date_only"]
else:
formatted_data[f"{key}_raw"] = value.isoformat()
elif isinstance(value, (dict, list)): elif isinstance(value, (dict, list)):
formatted_data[key] = format_datetime_fields(value, request) formatted_data[key] = format_datetime_fields(value, request)
else: else:

View file

@ -11,6 +11,7 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func from sqlalchemy import and_, or_, func
@ -21,8 +22,12 @@ from adapters.db.models.account import Account
from adapters.db.models.person import Person from adapters.db.models.person import Person
from adapters.db.models.currency import Currency from adapters.db.models.currency import Currency
from adapters.db.models.user import User from adapters.db.models.user import User
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError from app.core.responses import ApiError
# تنظیم لاگر
logger = logging.getLogger(__name__)
# نوع‌های سند # نوع‌های سند
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
@ -50,14 +55,57 @@ def _parse_iso_date(dt: str | datetime | date) -> date:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400) raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
def _get_or_create_person_account( def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
"""دریافت سال مالی فعلی برای کسب‌وکار"""
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if not fiscal_year:
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
return fiscal_year
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
"""
دریافت حساب ثابت بر اساس کد
Args:
db: Session پایگاه داده
account_code: کد حساب (مثل 10201, 10202, 10203)
Returns:
Account: حساب ثابت
"""
account = db.query(Account).filter(
and_(
Account.business_id == None, # حساب‌های عمومی
Account.code == account_code
)
).first()
if not account:
raise ApiError(
"ACCOUNT_NOT_FOUND",
f"Account with code {account_code} not found",
http_status=500
)
return account
def _get_person_account(
db: Session, db: Session,
business_id: int, business_id: int,
person_id: int, person_id: int,
is_receivable: bool is_receivable: bool
) -> Account: ) -> Account:
""" """
ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی) دریافت حساب شخص (حساب دریافتنی یا پرداختنی عمومی)
Args: Args:
business_id: شناسه کسبوکار business_id: شناسه کسبوکار
@ -65,7 +113,7 @@ def _get_or_create_person_account(
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
Returns: Returns:
Account: حساب شخص Account: حساب شخص عمومی
""" """
person = db.query(Person).filter( person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id) and_(Person.id == person_id, Person.business_id == business_id)
@ -74,53 +122,11 @@ def _get_or_create_person_account(
if not person: if not person:
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404) raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
# کد حساب والد # کد حساب عمومی (بدون ایجاد حساب جداگانه)
parent_code = "10401" if is_receivable else "20201" account_code = "10401" if is_receivable else "20201"
account_type = ACCOUNT_TYPE_RECEIVABLE if is_receivable else ACCOUNT_TYPE_PAYABLE
# پیدا کردن حساب والد # استفاده از تابع کمکی
parent_account = db.query(Account).filter( return _get_fixed_account_by_code(db, account_code)
and_(
Account.business_id == None, # حساب‌های عمومی
Account.code == parent_code
)
).first()
if not parent_account:
raise ApiError(
"PARENT_ACCOUNT_NOT_FOUND",
f"Parent account with code {parent_code} not found",
http_status=500
)
# بررسی وجود حساب شخص
person_account_code = f"{parent_code}-{person_id}"
person_account = db.query(Account).filter(
and_(
Account.business_id == business_id,
Account.code == person_account_code
)
).first()
if not person_account:
# ایجاد حساب جدید برای شخص
account_name = f"{person.alias_name}"
if is_receivable:
account_name = f"طلب از {account_name}"
else:
account_name = f"بدهی به {account_name}"
person_account = Account(
business_id=business_id,
code=person_account_code,
name=account_name,
account_type=account_type,
parent_id=parent_account.id,
)
db.add(person_account)
db.flush() # برای دریافت ID
return person_account
def create_receipt_payment( def create_receipt_payment(
@ -146,12 +152,17 @@ def create_receipt_payment(
Returns: Returns:
Dict: اطلاعات سند ایجاد شده Dict: اطلاعات سند ایجاد شده
""" """
logger.info(f"=== شروع ایجاد سند دریافت/پرداخت ===")
logger.info(f"business_id: {business_id}, user_id: {user_id}")
logger.info(f"داده‌های ورودی: {data}")
# اعتبارسنجی نوع سند # اعتبارسنجی نوع سند
document_type = str(data.get("document_type", "")).lower() document_type = str(data.get("document_type", "")).lower()
logger.info(f"نوع سند: {document_type}")
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT): if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400) raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT) is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
logger.info(f"آیا دریافت است: {is_receipt}")
# اعتبارسنجی تاریخ # اعتبارسنجی تاریخ
document_date = _parse_iso_date(data.get("document_date", datetime.now())) document_date = _parse_iso_date(data.get("document_date", datetime.now()))
@ -165,13 +176,22 @@ def create_receipt_payment(
if not currency: if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
# دریافت سال مالی فعلی
logger.info(f"دریافت سال مالی فعلی برای business_id={business_id}")
fiscal_year = _get_current_fiscal_year(db, business_id)
logger.info(f"سال مالی فعلی: id={fiscal_year.id}, title={fiscal_year.title}")
# اعتبارسنجی خطوط اشخاص # اعتبارسنجی خطوط اشخاص
person_lines = data.get("person_lines", []) person_lines = data.get("person_lines", [])
logger.info(f"تعداد خطوط اشخاص: {len(person_lines)}")
logger.info(f"خطوط اشخاص: {person_lines}")
if not person_lines or not isinstance(person_lines, list): if not person_lines or not isinstance(person_lines, list):
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400) raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
# اعتبارسنجی خطوط حساب‌ها # اعتبارسنجی خطوط حساب‌ها
account_lines = data.get("account_lines", []) account_lines = data.get("account_lines", [])
logger.info(f"تعداد خطوط حساب‌ها: {len(account_lines)}")
logger.info(f"خطوط حساب‌ها: {account_lines}")
if not account_lines or not isinstance(account_lines, list): if not account_lines or not isinstance(account_lines, list):
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400) raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
@ -213,6 +233,7 @@ def create_receipt_payment(
# ایجاد سند # ایجاد سند
document = Document( document = Document(
business_id=business_id, business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code, code=doc_code,
document_type=document_type, document_type=document_type,
document_date=document_date, document_date=document_date,
@ -226,51 +247,74 @@ def create_receipt_payment(
db.flush() # برای دریافت document.id db.flush() # برای دریافت document.id
# ایجاد خطوط سند برای اشخاص # ایجاد خطوط سند برای اشخاص
for person_line in person_lines: logger.info(f"=== شروع ایجاد خطوط اشخاص ===")
for i, person_line in enumerate(person_lines):
logger.info(f"پردازش خط شخص {i+1}: {person_line}")
person_id = person_line.get("person_id") person_id = person_line.get("person_id")
logger.info(f"person_id: {person_id}")
if not person_id: if not person_id:
logger.warning(f"خط شخص {i+1}: person_id موجود نیست، رد می‌شود")
continue continue
amount = Decimal(str(person_line.get("amount", 0))) amount = Decimal(str(person_line.get("amount", 0)))
logger.info(f"مبلغ: {amount}")
if amount <= 0: if amount <= 0:
logger.warning(f"خط شخص {i+1}: مبلغ صفر یا منفی، رد می‌شود")
continue continue
description = person_line.get("description", "").strip() or None description = person_line.get("description", "").strip() or None
logger.info(f"توضیحات: {description}")
# دریافت یا ایجاد حساب شخص # دریافت حساب شخص عمومی
# در دریافت: حساب دریافتنی (receivable) # در دریافت: حساب دریافتنی (receivable) - کد 10401
# در پرداخت: حساب پرداختنی (payable) # در پرداخت: حساب پرداختنی (payable) - کد 20201
person_account = _get_or_create_person_account( logger.info(f"دریافت حساب شخص برای person_id={person_id}, is_receivable={is_receipt}")
person_account = _get_person_account(
db, db,
business_id, business_id,
int(person_id), int(person_id),
is_receivable=is_receipt is_receivable=is_receipt
) )
logger.info(f"حساب شخص پیدا شد: id={person_account.id}, code={person_account.code}, name={person_account.name}")
# ایجاد خط سند برای شخص # ایجاد خط سند برای شخص
# در دریافت: شخص بستانکار (credit) # در دریافت: شخص بستانکار (credit)
# در پرداخت: شخص بدهکار (debit) # در پرداخت: شخص بدهکار (debit)
debit_amount = amount if not is_receipt else Decimal(0)
credit_amount = amount if is_receipt else Decimal(0)
logger.info(f"مقادیر بدهکار/بستانکار: debit={debit_amount}, credit={credit_amount}")
line = DocumentLine( line = DocumentLine(
document_id=document.id, document_id=document.id,
account_id=person_account.id, account_id=person_account.id,
debit=amount if not is_receipt else Decimal(0), person_id=int(person_id),
credit=amount if is_receipt else Decimal(0), quantity=person_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description, description=description,
extra_info={ extra_info={
"person_id": int(person_id), "person_id": int(person_id),
"person_name": person_line.get("person_name"), "person_name": person_line.get("person_name"),
} }
) )
logger.info(f"خط سند شخص ایجاد شد: {line}")
db.add(line) db.add(line)
# ایجاد خطوط سند برای حساب‌ها # ایجاد خطوط سند برای حساب‌ها
for account_line in account_lines: logger.info(f"=== شروع ایجاد خطوط حساب‌ها ===")
total_commission = Decimal(0) # مجموع کارمزدها
for i, account_line in enumerate(account_lines):
logger.info(f"پردازش خط حساب {i+1}: {account_line}")
account_id = account_line.get("account_id") account_id = account_line.get("account_id")
logger.info(f"account_id: {account_id}")
if not account_id: if not account_id:
continue logger.info(f"خط حساب {i+1}: account_id موجود نیست، ادامه می‌دهد")
amount = Decimal(str(account_line.get("amount", 0))) amount = Decimal(str(account_line.get("amount", 0)))
logger.info(f"مبلغ: {amount}")
if amount <= 0: if amount <= 0:
logger.warning(f"خط حساب {i+1}: مبلغ صفر یا منفی، رد می‌شود")
continue continue
description = account_line.get("description", "").strip() or None description = account_line.get("description", "").strip() or None
@ -278,24 +322,70 @@ def create_receipt_payment(
transaction_date = account_line.get("transaction_date") transaction_date = account_line.get("transaction_date")
commission = account_line.get("commission") commission = account_line.get("commission")
# بررسی وجود حساب logger.info(f"نوع تراکنش: {transaction_type}")
account = db.query(Account).filter( logger.info(f"تاریخ تراکنش: {transaction_date}")
and_( logger.info(f"کمیسیون: {commission}")
Account.id == int(account_id),
or_( # اضافه کردن کارمزد به مجموع
Account.business_id == business_id, if commission:
Account.business_id == None # حساب‌های عمومی commission_amount = Decimal(str(commission))
total_commission += commission_amount
logger.info(f"کارمزد اضافه شد: {commission_amount}, مجموع: {total_commission}")
# تعیین حساب بر اساس transaction_type
account = None
if transaction_type == "bank":
# برای بانک، از حساب بانک استفاده کن
account_code = "10203" # بانک
logger.info(f"انتخاب حساب بانک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "cash_register":
# برای صندوق، از حساب صندوق استفاده کن
account_code = "10202" # صندوق
logger.info(f"انتخاب حساب صندوق با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "petty_cash":
# برای تنخواهگردان، از حساب تنخواهگردان استفاده کن
account_code = "10201" # تنخواه گردان
logger.info(f"انتخاب حساب تنخواهگردان با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "check":
# برای چک، بر اساس نوع سند از کد مناسب استفاده کن
if is_receipt:
account_code = "10403" # اسناد دریافتنی (چک دریافتی)
else:
account_code = "20202" # اسناد پرداختنی (چک پرداختی)
logger.info(f"انتخاب حساب چک با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif transaction_type == "person":
# برای شخص، از حساب شخص عمومی استفاده کن
account_code = "20201" # حساب‌های پرداختنی
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
account = _get_fixed_account_by_code(db, account_code)
elif account_id:
# اگر account_id مشخص باشد، از آن استفاده کن
logger.info(f"استفاده از account_id مشخص: {account_id}")
account = db.query(Account).filter(
and_(
Account.id == int(account_id),
or_(
Account.business_id == business_id,
Account.business_id == None # حساب‌های عمومی
)
) )
) ).first()
).first()
if not account: if not account:
logger.error(f"خط حساب {i+1}: حساب پیدا نشد برای transaction_type: {transaction_type}")
raise ApiError( raise ApiError(
"ACCOUNT_NOT_FOUND", "ACCOUNT_NOT_FOUND",
f"Account with id {account_id} not found", f"Account not found for transaction_type: {transaction_type}",
http_status=404 http_status=404
) )
logger.info(f"حساب پیدا شد: id={account.id}, code={account.code}, name={account.name}")
# ایجاد اطلاعات اضافی برای خط سند # ایجاد اطلاعات اضافی برای خط سند
extra_info = {} extra_info = {}
if transaction_type: if transaction_type:
@ -330,19 +420,141 @@ def create_receipt_payment(
# ایجاد خط سند برای حساب # ایجاد خط سند برای حساب
# در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد # در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد # در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد
debit_amount = amount if is_receipt else Decimal(0)
credit_amount = amount if not is_receipt else Decimal(0)
logger.info(f"مقادیر بدهکار/بستانکار برای حساب: debit={debit_amount}, credit={credit_amount}")
# تنظیم bank_account_id بر اساس bank_id ارسالی
bank_account_id = None
if transaction_type == "bank" and account_line.get("bank_id"):
try:
bank_account_id = int(account_line.get("bank_id"))
logger.info(f"bank_account_id تنظیم شد: {bank_account_id}")
except (ValueError, TypeError):
logger.warning(f"خطا در تبدیل bank_id: {account_line.get('bank_id')}")
# تنظیم person_id برای transaction_type="person"
person_id_for_line = None
if transaction_type == "person" and account_line.get("person_id"):
try:
person_id_for_line = int(account_line.get("person_id"))
logger.info(f"person_id تنظیم شد: {person_id_for_line}")
except (ValueError, TypeError):
logger.warning(f"خطا در تبدیل person_id: {account_line.get('person_id')}")
line = DocumentLine( line = DocumentLine(
document_id=document.id, document_id=document.id,
account_id=account.id, account_id=account.id,
debit=amount if is_receipt else Decimal(0), person_id=person_id_for_line,
credit=amount if not is_receipt else Decimal(0), bank_account_id=bank_account_id,
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
quantity=account_line.get("quantity"),
debit=debit_amount,
credit=credit_amount,
description=description, description=description,
extra_info=extra_info if extra_info else None, extra_info=extra_info if extra_info else None,
) )
logger.info(f"خط سند حساب ایجاد شد: {line}")
db.add(line) db.add(line)
# ایجاد خطوط کارمزد اگر کارمزدی وجود دارد
if total_commission > 0:
logger.info(f"=== ایجاد خطوط کارمزد ===")
logger.info(f"مجموع کارمزد: {total_commission}")
# ایجاد خط کارمزد برای هر تراکنش که کارمزد دارد
for i, account_line in enumerate(account_lines):
commission = account_line.get("commission")
if not commission or Decimal(str(commission)) <= 0:
continue
commission_amount = Decimal(str(commission))
transaction_type = account_line.get("transaction_type")
logger.info(f"ایجاد خط کارمزد برای تراکنش {i+1}: مبلغ={commission_amount}, نوع={transaction_type}")
# تعیین حساب کارمزد بر اساس نوع تراکنش
commission_account = None
commission_account_code = None
if transaction_type == "bank":
commission_account_code = "10203" # بانک
elif transaction_type == "cash_register":
commission_account_code = "10202" # صندوق
elif transaction_type == "petty_cash":
commission_account_code = "10201" # تنخواه گردان
elif transaction_type == "check":
if is_receipt:
commission_account_code = "10403" # اسناد دریافتنی
else:
commission_account_code = "20202" # اسناد پرداختنی
elif transaction_type == "person":
commission_account_code = "20201" # حساب‌های پرداختنی
if commission_account_code:
commission_account = _get_fixed_account_by_code(db, commission_account_code)
logger.info(f"حساب کارمزد پیدا شد: id={commission_account.id}, code={commission_account.code}, name={commission_account.name}")
# ایجاد خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
# در دریافت: کارمزد از حساب کم می‌شود (credit)
# در پرداخت: کارمزد به حساب اضافه می‌شود (debit)
commission_debit = commission_amount if not is_receipt else Decimal(0)
commission_credit = commission_amount if is_receipt else Decimal(0)
commission_line = DocumentLine(
document_id=document.id,
account_id=commission_account.id,
bank_account_id=account_line.get("bank_id"),
cash_register_id=account_line.get("cash_register_id"),
petty_cash_id=account_line.get("petty_cash_id"),
check_id=account_line.get("check_id"),
debit=commission_debit,
credit=commission_credit,
description=f"کارمزد تراکنش {transaction_type}",
extra_info={
"transaction_type": transaction_type,
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
}
)
logger.info(f"خط کارمزد حساب ایجاد شد: {commission_line}")
db.add(commission_line)
# ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
# در دریافت: کارمزد به حساب کارمزد اضافه می‌شود (debit)
# در پرداخت: کارمزد از حساب کارمزد کم می‌شود (credit)
logger.info(f"ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی")
# دریافت حساب کارمزد خدمات بانکی
commission_service_account = _get_fixed_account_by_code(db, "70902")
logger.info(f"حساب کارمزد خدمات بانکی پیدا شد: id={commission_service_account.id}, code={commission_service_account.code}, name={commission_service_account.name}")
commission_service_debit = commission_amount if is_receipt else Decimal(0)
commission_service_credit = commission_amount if not is_receipt else Decimal(0)
commission_service_line = DocumentLine(
document_id=document.id,
account_id=commission_service_account.id,
debit=commission_service_debit,
credit=commission_service_credit,
description=f"کارمزد خدمات بانکی",
extra_info={
"commission": float(commission_amount),
"is_commission_line": True,
"original_transaction_index": i,
"commission_type": "banking_service",
}
)
logger.info(f"خط کارمزد خدمات بانکی ایجاد شد: {commission_service_line}")
db.add(commission_service_line)
# ذخیره تغییرات # ذخیره تغییرات
logger.info(f"=== ذخیره تغییرات ===")
db.commit() db.commit()
db.refresh(document) db.refresh(document)
logger.info(f"سند با موفقیت ایجاد شد: id={document.id}, code={document.code}")
return document_to_dict(db, document) return document_to_dict(db, document)
@ -404,7 +616,8 @@ def list_receipts_payments(
sort_by = query.get("sort_by", "document_date") sort_by = query.get("sort_by", "document_date")
sort_desc = query.get("sort_desc", True) sort_desc = query.get("sort_desc", True)
if hasattr(Document, sort_by): # بررسی اینکه sort_by معتبر است
if sort_by and isinstance(sort_by, str) and hasattr(Document, sort_by):
col = getattr(Document, sort_by) col = getattr(Document, sort_by)
q = q.order_by(col.desc() if sort_desc else col.asc()) q = q.order_by(col.desc() if sort_desc else col.asc())
else: else:
@ -464,6 +677,13 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
line_dict = { line_dict = {
"id": line.id, "id": line.id,
"account_id": line.account_id, "account_id": line.account_id,
"person_id": line.person_id,
"product_id": line.product_id,
"bank_account_id": line.bank_account_id,
"cash_register_id": line.cash_register_id,
"petty_cash_id": line.petty_cash_id,
"check_id": line.check_id,
"quantity": float(line.quantity) if line.quantity else None,
"account_name": account.name, "account_name": account.name,
"account_code": account.code, "account_code": account.code,
"account_type": account.account_type, "account_type": account.account_type,
@ -498,9 +718,25 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
line_dict["check_id"] = line.extra_info["check_id"] line_dict["check_id"] = line.extra_info["check_id"]
if "check_number" in line.extra_info: if "check_number" in line.extra_info:
line_dict["check_number"] = line.extra_info["check_number"] line_dict["check_number"] = line.extra_info["check_number"]
if "person_name" in line.extra_info:
line_dict["person_name"] = line.extra_info["person_name"]
# اگر person_id موجود است، نام شخص را از دیتابیس دریافت کن
if line.person_id and "person_name" not in line_dict:
person = db.query(Person).filter(Person.id == line.person_id).first()
if person:
line_dict["person_name"] = person.alias_name or f"{person.first_name} {person.last_name}".strip()
else:
line_dict["person_name"] = "نامشخص"
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب # تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
if line.extra_info and line.extra_info.get("person_id"): # خطوط کارمزد را جداگانه تشخیص می‌دهیم
is_commission_line = line.extra_info and line.extra_info.get("is_commission_line", False)
if is_commission_line:
# خط کارمزد - همیشه در account_lines قرار می‌گیرد
account_lines.append(line_dict)
elif line.extra_info and line.extra_info.get("person_id"):
person_lines.append(line_dict) person_lines.append(line_dict)
else: else:
account_lines.append(line_dict) account_lines.append(line_dict)
@ -513,11 +749,28 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
currency = db.query(Currency).filter(Currency.id == document.currency_id).first() currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
currency_code = currency.code if currency else None currency_code = currency.code if currency else None
# محاسبه مبلغ کل و تعداد خطوط
total_amount = sum(line.get("amount", 0) for line in person_lines)
person_lines_count = len(person_lines)
account_lines_count = len(account_lines)
# ایجاد لیست نام اشخاص برای نمایش
person_names = []
for line in person_lines:
person_name = line.get("person_name")
if person_name and person_name not in person_names:
person_names.append(person_name)
person_names_str = ", ".join(person_names) if person_names else "نامشخص"
# تعیین نام نوع سند
document_type_name = "دریافت" if document.document_type == DOCUMENT_TYPE_RECEIPT else "پرداخت"
return { return {
"id": document.id, "id": document.id,
"code": document.code, "code": document.code,
"business_id": document.business_id, "business_id": document.business_id,
"document_type": document.document_type, "document_type": document.document_type,
"document_type_name": document_type_name,
"document_date": document.document_date.isoformat(), "document_date": document.document_date.isoformat(),
"registered_at": document.registered_at.isoformat(), "registered_at": document.registered_at.isoformat(),
"currency_id": document.currency_id, "currency_id": document.currency_id,
@ -528,6 +781,10 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
"extra_info": document.extra_info, "extra_info": document.extra_info,
"person_lines": person_lines, "person_lines": person_lines,
"account_lines": account_lines, "account_lines": account_lines,
"total_amount": total_amount,
"person_lines_count": person_lines_count,
"account_lines_count": account_lines_count,
"person_names": person_names_str,
"created_at": document.created_at.isoformat(), "created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(), "updated_at": document.updated_at.isoformat(),
} }

View file

@ -32,6 +32,7 @@ adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/bank_account.py adapters/api/v1/schema_models/bank_account.py
adapters/api/v1/schema_models/check.py adapters/api/v1/schema_models/check.py
adapters/api/v1/schema_models/document_line.py
adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py adapters/api/v1/schema_models/person.py
@ -161,12 +162,12 @@ migrations/versions/20250120_000001_add_persons_tables.py
migrations/versions/20250120_000002_add_join_permission.py migrations/versions/20250120_000002_add_join_permission.py
migrations/versions/20250915_000001_init_auth_tables.py migrations/versions/20250915_000001_init_auth_tables.py
migrations/versions/20250916_000002_add_referral_fields.py migrations/versions/20250916_000002_add_referral_fields.py
migrations/versions/20250926_000010_add_person_code_and_types.py migrations/versions/20250926_000010_add_person_code.py
migrations/versions/20250926_000011_drop_person_is_active.py migrations/versions/20250926_000011_drop_active.py
migrations/versions/20250927_000012_add_fiscal_years_table.py migrations/versions/20250927_000012_add_fiscal_years.py
migrations/versions/20250927_000013_add_currencies_and_business_currencies.py migrations/versions/20250927_000013_add_currencies.py
migrations/versions/20250927_000014_add_documents_table.py migrations/versions/20250927_000014_add_documents.py
migrations/versions/20250927_000015_add_document_lines_table.py migrations/versions/20250927_000015_add_lines.py
migrations/versions/20250927_000016_add_accounts_table.py migrations/versions/20250927_000016_add_accounts_table.py
migrations/versions/20250927_000017_add_account_id_to_document_lines.py migrations/versions/20250927_000017_add_account_id_to_document_lines.py
migrations/versions/20250927_000018_seed_currencies.py migrations/versions/20250927_000018_seed_currencies.py
@ -190,11 +191,17 @@ migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
migrations/versions/20251011_000901_add_checks_table.py migrations/versions/20251011_000901_add_checks_table.py
migrations/versions/20251011_010001_replace_accounts_chart_seed.py migrations/versions/20251011_010001_replace_accounts_chart_seed.py
migrations/versions/20251012_000101_update_accounts_account_type_to_english.py migrations/versions/20251012_000101_update_accounts_account_type_to_english.py
migrations/versions/20251014_000201_add_person_id_to_document_lines.py
migrations/versions/20251014_000301_add_product_id_to_document_lines.py
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
migrations/versions/7ecb63029764_merge_heads.py
migrations/versions/9f9786ae7191_create_tax_units_table.py migrations/versions/9f9786ae7191_create_tax_units_table.py
migrations/versions/a1443c153b47_merge_heads.py migrations/versions/a1443c153b47_merge_heads.py
migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py
migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py
migrations/versions/c302bc2f2cb8_remove_person_type_column.py migrations/versions/c302bc2f2cb8_remove_person_type_column.py
migrations/versions/caf3f4ef4b76_add_tax_units_table.py migrations/versions/caf3f4ef4b76_add_tax_units_table.py

View file

@ -3,7 +3,7 @@ import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250926_000010_add_person_code_and_types' revision = '20250926_000010_add_person_code'
down_revision = '20250916_000002' down_revision = '20250916_000002'
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View file

@ -3,8 +3,8 @@ import sqlalchemy as sa
from sqlalchemy import inspect from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250926_000011_drop_person_is_active' revision = '20250926_000011_drop_active'
down_revision = '20250926_000010_add_person_code_and_types' down_revision = '20250926_000010_add_person_code'
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View file

@ -6,8 +6,8 @@ from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250927_000012_add_fiscal_years_table' revision = '20250927_000012_add_fiscal_years'
down_revision = '20250926_000011_drop_person_is_active' down_revision = '20250926_000011_drop_active'
branch_labels = None branch_labels = None
depends_on = ('20250117_000003',) depends_on = ('20250117_000003',)

View file

@ -0,0 +1,93 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '20250927_000013_add_currencies'
down_revision = '20250927_000012_add_fiscal_years'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
# Create currencies table if it doesn't exist
if 'currencies' not in tables:
op.create_table(
'currencies',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('symbol', sa.String(length=16), nullable=False),
sa.Column('code', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Unique constraints and indexes
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
op.create_index('ix_currencies_name', 'currencies', ['name'])
# Create business_currencies association table if it doesn't exist
if 'business_currencies' not in tables:
op.create_table(
'business_currencies',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('business_id', sa.Integer(), nullable=False),
sa.Column('currency_id', sa.Integer(), nullable=False),
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.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Unique and indexes for association
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
# Add default_currency_id to businesses if not exists
if 'businesses' in tables:
cols = {c['name'] for c in inspector.get_columns('businesses')}
if 'default_currency_id' not in cols:
with op.batch_alter_table('businesses') as batch_op:
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
def downgrade() -> None:
# Drop index/foreign key/column default_currency_id if exists
with op.batch_alter_table('businesses') as batch_op:
try:
batch_op.drop_index('ix_businesses_default_currency_id')
except Exception:
pass
try:
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_column('default_currency_id')
except Exception:
pass
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
op.drop_table('business_currencies')
op.drop_index('ix_currencies_name', table_name='currencies')
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
op.drop_table('currencies')

View file

@ -1,88 +0,0 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250927_000013_add_currencies_and_business_currencies'
down_revision = '20250927_000012_add_fiscal_years_table'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create currencies table
op.create_table(
'currencies',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('symbol', sa.String(length=16), nullable=False),
sa.Column('code', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Unique constraints and indexes
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
op.create_index('ix_currencies_name', 'currencies', ['name'])
# Create business_currencies association table
op.create_table(
'business_currencies',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('business_id', sa.Integer(), nullable=False),
sa.Column('currency_id', sa.Integer(), nullable=False),
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.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Add default_currency_id to businesses if not exists
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'businesses' in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns('businesses')}
if 'default_currency_id' not in cols:
with op.batch_alter_table('businesses') as batch_op:
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
# Unique and indexes for association
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
def downgrade() -> None:
# Drop index/foreign key/column default_currency_id if exists
with op.batch_alter_table('businesses') as batch_op:
try:
batch_op.drop_index('ix_businesses_default_currency_id')
except Exception:
pass
try:
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_column('default_currency_id')
except Exception:
pass
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
op.drop_table('business_currencies')
op.drop_index('ix_currencies_name', table_name='currencies')
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
op.drop_table('currencies')

View file

@ -5,8 +5,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250927_000014_add_documents_table' revision = '20250927_000014_add_documents'
down_revision = '20250927_000013_add_currencies_and_business_currencies' down_revision = '20250927_000013_add_currencies'
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View file

@ -1,38 +0,0 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250927_000015_add_document_lines_table'
down_revision = '20250927_000014_add_documents_table'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'document_lines',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('document_id', sa.Integer(), nullable=False),
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('extra_info', sa.JSON(), nullable=True),
sa.Column('developer_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
def downgrade() -> None:
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
op.drop_table('document_lines')

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '20250927_000015_add_lines'
down_revision = '20250927_000014_add_documents'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
# Create document_lines table if it doesn't exist
if 'document_lines' not in tables:
op.create_table(
'document_lines',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('document_id', sa.Integer(), nullable=False),
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('extra_info', sa.JSON(), nullable=True),
sa.Column('developer_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Create indexes
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
def downgrade() -> None:
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
op.drop_table('document_lines')

View file

@ -6,7 +6,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '20250927_000016_add_accounts_table' revision = '20250927_000016_add_accounts_table'
down_revision = '20250927_000015_add_document_lines_table' down_revision = '20250927_000015_add_lines'
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@ -12,21 +13,47 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
# Check if document_lines table exists
if 'document_lines' not in tables:
return
# Get existing columns
cols = {c['name'] for c in inspector.get_columns('document_lines')}
with op.batch_alter_table('document_lines') as batch_op: with op.batch_alter_table('document_lines') as batch_op:
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True)) # Only add columns if they don't exist
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True)) if 'bank_account_id' not in cols:
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True)) batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True)) if 'cash_register_id' not in cols:
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
if 'petty_cash_id' not in cols:
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
if 'check_id' not in cols:
batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL') # Only create foreign keys if the referenced tables exist
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL') if 'bank_accounts' in tables and 'bank_account_id' not in cols:
batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL') batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL') if 'cash_registers' in tables and 'cash_register_id' not in cols:
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
if 'petty_cash' in tables and 'petty_cash_id' not in cols:
batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL')
if 'checks' in tables and 'check_id' not in cols:
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL')
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id']) # Only create indexes if columns were added
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id']) if 'bank_account_id' not in cols:
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id']) batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
batch_op.create_index('ix_document_lines_check_id', ['check_id']) if 'cash_register_id' not in cols:
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
if 'petty_cash_id' not in cols:
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
if 'check_id' not in cols:
batch_op.create_index('ix_document_lines_check_id', ['check_id'])
def downgrade() -> None: def downgrade() -> None:
@ -36,10 +63,23 @@ def downgrade() -> None:
batch_op.drop_index('ix_document_lines_cash_register_id') batch_op.drop_index('ix_document_lines_cash_register_id')
batch_op.drop_index('ix_document_lines_bank_account_id') batch_op.drop_index('ix_document_lines_bank_account_id')
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey') # Try to drop foreign keys, ignore if they don't exist
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey') try:
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey') batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey') except Exception:
pass
try:
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey')
except Exception:
pass
batch_op.drop_column('check_id') batch_op.drop_column('check_id')
batch_op.drop_column('petty_cash_id') batch_op.drop_column('petty_cash_id')

View file

@ -8,6 +8,7 @@ Create Date: 2025-09-20 14:02:19.543853
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '5553f8745c6e' revision = '5553f8745c6e'
@ -18,87 +19,104 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('support_categories', bind = op.get_bind()
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), inspector = inspect(bind)
sa.Column('name', sa.String(length=100), nullable=False), tables = set(inspector.get_table_names())
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False), # Only create tables if they don't exist
sa.Column('created_at', sa.DateTime(), nullable=False), if 'support_categories' not in tables:
sa.Column('updated_at', sa.DateTime(), nullable=False), op.create_table('support_categories',
sa.PrimaryKeyConstraint('id') sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
) sa.Column('name', sa.String(length=100), nullable=False),
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) sa.Column('description', sa.Text(), nullable=True),
op.create_table('support_priorities', sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('description', sa.Text(), nullable=True), sa.PrimaryKeyConstraint('id')
sa.Column('color', sa.String(length=7), nullable=True), )
sa.Column('order', sa.Integer(), nullable=False), op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False), if 'support_priorities' not in tables:
sa.PrimaryKeyConstraint('id') op.create_table('support_priorities',
) sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) sa.Column('name', sa.String(length=50), nullable=False),
op.create_table('support_statuses', sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('name', sa.String(length=50), nullable=False), sa.Column('order', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('color', sa.String(length=7), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('is_final', sa.Boolean(), nullable=False), sa.PrimaryKeyConstraint('id')
sa.Column('created_at', sa.DateTime(), nullable=False), )
sa.Column('updated_at', sa.DateTime(), nullable=False), op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
sa.PrimaryKeyConstraint('id')
) if 'support_statuses' not in tables:
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) op.create_table('support_statuses',
op.create_table('support_tickets', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False), sa.Column('description', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=False), sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('is_final', sa.Boolean(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('priority_id', sa.Integer(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('status_id', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id')
sa.Column('assigned_operator_id', sa.Integer(), nullable=True), )
sa.Column('is_internal', sa.Boolean(), nullable=False), op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
sa.Column('closed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False), if 'support_tickets' not in tables:
sa.Column('updated_at', sa.DateTime(), nullable=False), op.create_table('support_tickets',
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'), sa.Column('title', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'), sa.Column('description', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'), sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.Column('category_id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('priority_id', sa.Integer(), nullable=False),
) sa.Column('status_id', sa.Integer(), nullable=False),
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False) sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False) sa.Column('is_internal', sa.Boolean(), nullable=False),
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False) sa.Column('closed_at', sa.DateTime(), nullable=True),
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) sa.Column('created_at', sa.DateTime(), nullable=False),
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) sa.Column('updated_at', sa.DateTime(), nullable=False),
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False) sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
op.create_table('support_messages', sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
sa.Column('ticket_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
sa.Column('sender_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False), sa.PrimaryKeyConstraint('id')
sa.Column('content', sa.Text(), nullable=False), )
sa.Column('is_internal', sa.Boolean(), nullable=False), op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
sa.Column('created_at', sa.DateTime(), nullable=False), op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'), op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'), op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
sa.PrimaryKeyConstraint('id') op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
) op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False) if 'support_messages' not in tables:
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) op.create_table('support_messages',
op.alter_column('businesses', 'business_type', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), sa.Column('ticket_id', sa.Integer(), nullable=False),
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), sa.Column('sender_id', sa.Integer(), nullable=False),
existing_nullable=False) sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
op.alter_column('businesses', 'business_field', sa.Column('content', sa.Text(), nullable=False),
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), sa.Column('is_internal', sa.Boolean(), nullable=False),
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), sa.Column('created_at', sa.DateTime(), nullable=False),
existing_nullable=False) sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
# Only alter columns if businesses table exists
if 'businesses' in tables:
op.alter_column('businesses', 'business_type',
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
existing_nullable=False)
op.alter_column('businesses', 'business_field',
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
existing_nullable=False)
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -0,0 +1,33 @@
"""add_fiscal_year_to_documents
Revision ID: ac9e4b3dcffc
Revises: 7ecb63029764
Create Date: 2025-10-15 11:22:53.762056
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'ac9e4b3dcffc'
down_revision = '7ecb63029764'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only add index and foreign key for fiscal_year_id (column already exists)
op.create_index(op.f('ix_documents_fiscal_year_id'), 'documents', ['fiscal_year_id'], unique=False)
op.create_foreign_key(None, 'documents', 'fiscal_years', ['fiscal_year_id'], ['id'], ondelete='RESTRICT')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only remove fiscal_year_id from documents table
op.drop_constraint(None, 'documents', type_='foreignkey')
op.drop_index(op.f('ix_documents_fiscal_year_id'), table_name='documents')
op.drop_column('documents', 'fiscal_year_id')
# ### end Alembic commands ###

View file

@ -8,6 +8,7 @@ Create Date: 2025-09-30 14:46:58.614162
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'caf3f4ef4b76' revision = 'caf3f4ef4b76'
@ -18,49 +19,81 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.alter_column('persons', 'code', bind = op.get_bind()
existing_type=mysql.INTEGER(), inspector = inspect(bind)
comment='کد یکتا در هر کسب و کار',
existing_nullable=True) # Check if persons table exists and has the code column
op.alter_column('persons', 'person_type', if 'persons' in inspector.get_table_names():
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), cols = {c['name'] for c in inspector.get_columns('persons')}
comment='نوع شخص',
existing_nullable=False) # Only alter code column if it exists
op.alter_column('persons', 'person_types', if 'code' in cols:
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), op.alter_column('persons', 'code',
comment='لیست انواع شخص به صورت JSON', existing_type=mysql.INTEGER(),
existing_nullable=True) comment='کد یکتا در هر کسب و کار',
op.alter_column('persons', 'commission_sale_percent', existing_nullable=True)
existing_type=mysql.DECIMAL(precision=5, scale=2),
comment='درصد پورسانت از فروش', # Only alter person_type column if it exists
existing_nullable=True) if 'person_type' in cols:
op.alter_column('persons', 'commission_sales_return_percent', op.alter_column('persons', 'person_type',
existing_type=mysql.DECIMAL(precision=5, scale=2), existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
comment='درصد پورسانت از برگشت از فروش', comment='نوع شخص',
existing_nullable=True) existing_nullable=False)
op.alter_column('persons', 'commission_sales_amount',
existing_type=mysql.DECIMAL(precision=12, scale=2), # Only alter person_types column if it exists
comment='مبلغ فروش مبنا برای پورسانت', if 'person_types' in cols:
existing_nullable=True) op.alter_column('persons', 'person_types',
op.alter_column('persons', 'commission_sales_return_amount', existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
existing_type=mysql.DECIMAL(precision=12, scale=2), comment='لیست انواع شخص به صورت JSON',
comment='مبلغ برگشت از فروش مبنا برای پورسانت', existing_nullable=True)
existing_nullable=True)
op.alter_column('persons', 'commission_exclude_discounts', # Only alter commission columns if they exist
existing_type=mysql.TINYINT(display_width=1), if 'commission_sale_percent' in cols:
comment='عدم محاسبه تخفیف در پورسانت', op.alter_column('persons', 'commission_sale_percent',
existing_nullable=False, existing_type=mysql.DECIMAL(precision=5, scale=2),
existing_server_default=sa.text("'0'")) comment='درصد پورسانت از فروش',
op.alter_column('persons', 'commission_exclude_additions_deductions', existing_nullable=True)
existing_type=mysql.TINYINT(display_width=1),
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', if 'commission_sales_return_percent' in cols:
existing_nullable=False, op.alter_column('persons', 'commission_sales_return_percent',
existing_server_default=sa.text("'0'")) existing_type=mysql.DECIMAL(precision=5, scale=2),
op.alter_column('persons', 'commission_post_in_invoice_document', comment='درصد پورسانت از برگشت از فروش',
existing_type=mysql.TINYINT(display_width=1), existing_nullable=True)
comment='ثبت پورسانت در سند حسابداری فاکتور',
existing_nullable=False, if 'commission_sales_amount' in cols:
existing_server_default=sa.text("'0'")) op.alter_column('persons', 'commission_sales_amount',
existing_type=mysql.DECIMAL(precision=12, scale=2),
comment='مبلغ فروش مبنا برای پورسانت',
existing_nullable=True)
if 'commission_sales_return_amount' in cols:
op.alter_column('persons', 'commission_sales_return_amount',
existing_type=mysql.DECIMAL(precision=12, scale=2),
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
existing_nullable=True)
if 'commission_exclude_discounts' in cols:
op.alter_column('persons', 'commission_exclude_discounts',
existing_type=mysql.TINYINT(display_width=1),
comment='عدم محاسبه تخفیف در پورسانت',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
if 'commission_exclude_additions_deductions' in cols:
op.alter_column('persons', 'commission_exclude_additions_deductions',
existing_type=mysql.TINYINT(display_width=1),
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
if 'commission_post_in_invoice_document' in cols:
op.alter_column('persons', 'commission_post_in_invoice_document',
existing_type=mysql.TINYINT(display_width=1),
comment='ثبت پورسانت در سند حسابداری فاکتور',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
# Continue with other operations
op.alter_column('price_items', 'tier_name', op.alter_column('price_items', 'tier_name',
existing_type=mysql.VARCHAR(length=64), existing_type=mysql.VARCHAR(length=64),
comment='نام پله قیمت (تکی/عمده/همکار/...)', comment='نام پله قیمت (تکی/عمده/همکار/...)',

View file

@ -38,7 +38,7 @@ import 'pages/business/cash_registers_page.dart';
import 'pages/business/petty_cash_page.dart'; import 'pages/business/petty_cash_page.dart';
import 'pages/business/checks_page.dart'; import 'pages/business/checks_page.dart';
import 'pages/business/check_form_page.dart'; import 'pages/business/check_form_page.dart';
import 'pages/business/receipts_payments_page.dart'; import 'pages/business/receipts_payments_list_page.dart';
import 'pages/error_404_page.dart'; import 'pages/error_404_page.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
import 'core/calendar_controller.dart'; import 'core/calendar_controller.dart';
@ -795,7 +795,7 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
// Checks: list, new, edit // Receipts & Payments: list with data table
GoRoute( GoRoute(
path: 'receipts-payments', path: 'receipts-payments',
name: 'business_receipts_payments', name: 'business_receipts_payments',
@ -807,7 +807,7 @@ class _MyAppState extends State<MyApp> {
localeController: controller, localeController: controller,
calendarController: _calendarController!, calendarController: _calendarController!,
themeController: themeController, themeController: themeController,
child: ReceiptsPaymentsPage( child: ReceiptsPaymentsListPage(
businessId: businessId, businessId: businessId,
calendarController: _calendarController!, calendarController: _calendarController!,
authStore: _authStore!, authStore: _authStore!,

View file

@ -0,0 +1,97 @@
class AccountTreeNode {
final int id;
final String code;
final String name;
final String? accountType;
final int? parentId;
final int? level;
final List<AccountTreeNode> children;
const AccountTreeNode({
required this.id,
required this.code,
required this.name,
this.accountType,
this.parentId,
this.level,
this.children = const [],
});
factory AccountTreeNode.fromJson(Map<String, dynamic> json) {
return AccountTreeNode(
id: json['id'] as int,
code: json['code'] as String,
name: json['name'] as String,
accountType: json['account_type'] as String?,
parentId: json['parent_id'] as int?,
level: json['level'] as int?,
children: (json['children'] as List<dynamic>?)
?.map((child) => AccountTreeNode.fromJson(child as Map<String, dynamic>))
.toList() ?? [],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'name': name,
'account_type': accountType,
'parent_id': parentId,
'level': level,
'children': children.map((child) => child.toJson()).toList(),
};
}
/// بررسی میکند که آیا این حساب فرزند دارد یا نه
bool get hasChildren => children.isNotEmpty;
/// دریافت تمام حسابهای قابل انتخاب (بدون فرزند) به صورت تخت
List<AccountTreeNode> getSelectableAccounts() {
List<AccountTreeNode> selectable = [];
if (!hasChildren) {
selectable.add(this);
} else {
for (final child in children) {
selectable.addAll(child.getSelectableAccounts());
}
}
return selectable;
}
/// دریافت تمام حسابها به صورت تخت (شامل همه سطوح)
List<AccountTreeNode> getAllAccounts() {
List<AccountTreeNode> all = [this];
for (final child in children) {
all.addAll(child.getAllAccounts());
}
return all;
}
/// جستجو در درخت حسابها بر اساس نام یا کد
List<AccountTreeNode> searchAccounts(String query) {
final lowerQuery = query.toLowerCase();
return getAllAccounts().where((account) {
return account.name.toLowerCase().contains(lowerQuery) ||
account.code.toLowerCase().contains(lowerQuery);
}).toList();
}
@override
String toString() {
return '$code - $name';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AccountTreeNode && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View file

@ -0,0 +1,222 @@
/// مدل خط شخص در سند دریافت/پرداخت
class PersonLine {
final int id;
final int? personId;
final String? personName;
final double amount;
final String? description;
final Map<String, dynamic>? extraInfo;
const PersonLine({
required this.id,
this.personId,
this.personName,
required this.amount,
this.description,
this.extraInfo,
});
factory PersonLine.fromJson(Map<String, dynamic> json) {
return PersonLine(
id: json['id'] ?? 0,
personId: json['person_id'],
personName: json['person_name'],
amount: (json['amount'] ?? 0).toDouble(),
description: json['description'],
extraInfo: json['extra_info'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'person_id': personId,
'person_name': personName,
'amount': amount,
'description': description,
'extra_info': extraInfo,
};
}
}
/// مدل خط حساب در سند دریافت/پرداخت
class AccountLine {
final int id;
final int accountId;
final String accountName;
final String accountCode;
final String? accountType;
final double amount;
final String? description;
final String? transactionType;
final DateTime? transactionDate;
final double? commission;
final Map<String, dynamic>? extraInfo;
const AccountLine({
required this.id,
required this.accountId,
required this.accountName,
required this.accountCode,
this.accountType,
required this.amount,
this.description,
this.transactionType,
this.transactionDate,
this.commission,
this.extraInfo,
});
factory AccountLine.fromJson(Map<String, dynamic> json) {
return AccountLine(
id: json['id'] ?? 0,
accountId: json['account_id'] ?? 0,
accountName: json['account_name'] ?? '',
accountCode: json['account_code'] ?? '',
accountType: json['account_type'],
amount: (json['amount'] ?? 0).toDouble(),
description: json['description'],
transactionType: json['transaction_type'],
transactionDate: json['transaction_date'] != null
? DateTime.tryParse(json['transaction_date'])
: null,
commission: json['commission']?.toDouble(),
extraInfo: json['extra_info'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'account_id': accountId,
'account_name': accountName,
'account_code': accountCode,
'account_type': accountType,
'amount': amount,
'description': description,
'transaction_type': transactionType,
'transaction_date': transactionDate?.toIso8601String(),
'commission': commission,
'extra_info': extraInfo,
};
}
}
/// مدل سند دریافت/پرداخت
class ReceiptPaymentDocument {
final int id;
final String code;
final int businessId;
final String documentType; // 'receipt' or 'payment'
final DateTime documentDate;
final DateTime registeredAt;
final int currencyId;
final String? currencyCode;
final int createdByUserId;
final String? createdByName;
final bool isProforma;
final Map<String, dynamic>? extraInfo;
final List<PersonLine> personLines;
final List<AccountLine> accountLines;
final String? personNames;
final DateTime createdAt;
final DateTime updatedAt;
const ReceiptPaymentDocument({
required this.id,
required this.code,
required this.businessId,
required this.documentType,
required this.documentDate,
required this.registeredAt,
required this.currencyId,
this.currencyCode,
required this.createdByUserId,
this.createdByName,
required this.isProforma,
this.extraInfo,
required this.personLines,
required this.accountLines,
this.personNames,
required this.createdAt,
required this.updatedAt,
});
factory ReceiptPaymentDocument.fromJson(Map<String, dynamic> json) {
return ReceiptPaymentDocument(
id: json['id'] ?? 0,
code: json['code'] ?? '',
businessId: json['business_id'] ?? 0,
documentType: json['document_type'] ?? '',
documentDate: DateTime.tryParse(json['document_date'] ?? '') ?? DateTime.now(),
registeredAt: DateTime.tryParse(json['registered_at'] ?? '') ?? DateTime.now(),
currencyId: json['currency_id'] ?? 0,
currencyCode: json['currency_code'],
createdByUserId: json['created_by_user_id'] ?? 0,
createdByName: json['created_by_name'],
isProforma: json['is_proforma'] ?? false,
extraInfo: json['extra_info'],
personLines: (json['person_lines'] as List<dynamic>?)
?.map((item) => PersonLine.fromJson(item))
.toList() ?? [],
accountLines: (json['account_lines'] as List<dynamic>?)
?.map((item) => AccountLine.fromJson(item))
.toList() ?? [],
personNames: json['person_names'],
createdAt: DateTime.tryParse(json['created_at'] ?? '') ?? DateTime.now(),
updatedAt: DateTime.tryParse(json['updated_at'] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'business_id': businessId,
'document_type': documentType,
'document_date': documentDate.toIso8601String(),
'registered_at': registeredAt.toIso8601String(),
'currency_id': currencyId,
'currency_code': currencyCode,
'created_by_user_id': createdByUserId,
'created_by_name': createdByName,
'is_proforma': isProforma,
'extra_info': extraInfo,
'person_lines': personLines.map((item) => item.toJson()).toList(),
'account_lines': accountLines.map((item) => item.toJson()).toList(),
'person_names': personNames,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
/// محاسبه مجموع مبلغ کل
double get totalAmount {
return personLines.fold(0.0, (sum, line) => sum + line.amount);
}
/// تعداد خطوط اشخاص
int get personLinesCount => personLines.length;
/// تعداد خطوط حسابها
int get accountLinesCount => accountLines.length;
/// آیا سند دریافت است؟
bool get isReceipt => documentType == 'receipt';
/// آیا سند پرداخت است؟
bool get isPayment => documentType == 'payment';
/// دریافت نام نوع سند
String get documentTypeName {
switch (documentType) {
case 'receipt':
return 'دریافت';
case 'payment':
return 'پرداخت';
default:
return documentType;
}
}
}

View file

@ -14,6 +14,7 @@ import '../../widgets/category/category_tree_dialog.dart';
import '../../services/business_dashboard_service.dart'; import '../../services/business_dashboard_service.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
class BusinessShell extends StatefulWidget { class BusinessShell extends StatefulWidget {
final int businessId; final int businessId;
@ -68,7 +69,25 @@ class _BusinessShellState extends State<BusinessShell> {
super.dispose(); super.dispose();
} }
void _refreshCurrentPage() { Future<void> showAddReceiptPaymentDialog() async {
final calendarController = widget.calendarController ?? await CalendarController.load();
final result = await showDialog<bool>(
context: context,
builder: (context) => BulkSettlementDialog(
businessId: widget.businessId,
calendarController: calendarController,
isReceipt: true, // پیشفرض دریافت
businessInfo: widget.authStore.currentBusiness,
apiClient: ApiClient(),
),
);
if (result == true) {
// Refresh the receipts payments page if it's currently open
_refreshCurrentPage();
}
}
void _refreshCurrentPage() {
// Force a rebuild of the current page // Force a rebuild of the current page
setState(() { setState(() {
// This will cause the current page to rebuild // This will cause the current page to rebuild
@ -809,6 +828,9 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (child.label == t.invoice) { } else if (child.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
context.go('/business/${widget.businessId}/invoice/new'); context.go('/business/${widget.businessId}/invoice/new');
} else if (child.label == t.receiptsAndPayments) {
// Show add receipt payment dialog
showAddReceiptPaymentDialog();
} else if (child.label == t.expenseAndIncome) { } else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income // Navigate to add expense/income
} else if (child.label == t.warehouses) { } else if (child.label == t.warehouses) {
@ -951,6 +973,9 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (item.label == t.invoice) { } else if (item.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
context.go('/business/${widget.businessId}/invoice/new'); context.go('/business/${widget.businessId}/invoice/new');
} else if (item.label == t.receiptsAndPayments) {
// Show add receipt payment dialog
showAddReceiptPaymentDialog();
} else if (item.label == t.checks) { } else if (item.label == t.checks) {
// Navigate to add check // Navigate to add check
context.go('/business/${widget.businessId}/checks/new'); context.go('/business/${widget.businessId}/checks/new');
@ -1122,6 +1147,9 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (child.label == t.invoice) { } else if (child.label == t.invoice) {
// Navigate to add invoice // Navigate to add invoice
context.go('/business/${widget.businessId}/invoice/new'); context.go('/business/${widget.businessId}/invoice/new');
} else if (child.label == t.receiptsAndPayments) {
// Show add receipt payment dialog
showAddReceiptPaymentDialog();
} else if (child.label == t.expenseAndIncome) { } else if (child.label == t.expenseAndIncome) {
// Navigate to add expense/income // Navigate to add expense/income
} else if (child.label == t.warehouses) { } else if (child.label == t.warehouses) {

View file

@ -0,0 +1,926 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/receipt_payment_document.dart';
import 'package:hesabix_ui/services/receipt_payment_list_service.dart';
import 'package:hesabix_ui/services/receipt_payment_service.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/invoice_transactions_widget.dart';
import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils;
import 'package:hesabix_ui/models/invoice_transaction.dart';
import 'package:hesabix_ui/models/invoice_type_model.dart';
import 'package:hesabix_ui/models/person_model.dart';
import 'package:hesabix_ui/models/business_dashboard_models.dart';
/// صفحه لیست اسناد دریافت و پرداخت با ویجت جدول
class ReceiptsPaymentsListPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
const ReceiptsPaymentsListPage({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
});
@override
State<ReceiptsPaymentsListPage> createState() => _ReceiptsPaymentsListPageState();
}
class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
late ReceiptPaymentListService _service;
String? _selectedDocumentType;
DateTime? _fromDate;
DateTime? _toDate;
int _refreshKey = 0; // کلید برای تازهسازی جدول
@override
void initState() {
super.initState();
_service = ReceiptPaymentListService(widget.apiClient);
}
/// تازهسازی دادههای جدول
void _refreshData() {
setState(() {
_refreshKey++; // تغییر کلید باعث rebuild شدن جدول میشود
});
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// هدر صفحه
_buildHeader(t),
// فیلترها
_buildFilters(t),
// جدول دادهها
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DataTableWidget<ReceiptPaymentDocument>(
key: ValueKey(_refreshKey),
config: _buildTableConfig(t),
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
calendarController: widget.calendarController,
),
),
),
],
),
),
);
}
/// ساخت هدر صفحه
Widget _buildHeader(AppLocalizations t) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.receiptsAndPayments,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'مدیریت اسناد دریافت و پرداخت',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
FilledButton.icon(
onPressed: _onAddNew,
icon: const Icon(Icons.add),
label: Text(t.add),
),
],
),
);
}
/// ساخت بخش فیلترها
Widget _buildFilters(AppLocalizations t) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// فیلتر نوع سند
Expanded(
flex: 2,
child: SegmentedButton<String?>(
segments: [
ButtonSegment<String?>(
value: null,
label: Text('همه'),
icon: const Icon(Icons.all_inclusive),
),
ButtonSegment<String?>(
value: 'receipt',
label: Text(t.receipts),
icon: const Icon(Icons.download_done_outlined),
),
ButtonSegment<String?>(
value: 'payment',
label: Text(t.payments),
icon: const Icon(Icons.upload_outlined),
),
],
selected: {_selectedDocumentType},
onSelectionChanged: (set) {
setState(() {
_selectedDocumentType = set.first;
});
// refresh data when filter changes
_refreshData();
},
),
),
const SizedBox(width: 16),
// فیلتر تاریخ
Expanded(
flex: 3,
child: Row(
children: [
Expanded(
child: DateInputField(
value: _fromDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _fromDate = date);
_refreshData();
},
labelText: 'از تاریخ',
hintText: 'انتخاب تاریخ شروع',
),
),
const SizedBox(width: 8),
Expanded(
child: DateInputField(
value: _toDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _toDate = date);
_refreshData();
},
labelText: 'تا تاریخ',
hintText: 'انتخاب تاریخ پایان',
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () {
setState(() {
_fromDate = null;
_toDate = null;
});
_refreshData();
},
icon: const Icon(Icons.clear),
tooltip: 'پاک کردن فیلتر تاریخ',
),
],
),
),
],
),
);
}
/// ساخت تنظیمات جدول
DataTableConfig<ReceiptPaymentDocument> _buildTableConfig(AppLocalizations t) {
return DataTableConfig<ReceiptPaymentDocument>(
endpoint: '/businesses/${widget.businessId}/receipts-payments',
title: t.receiptsAndPayments,
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
getExportParams: () => {
'business_id': widget.businessId,
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
},
columns: [
// کد سند
TextColumn(
'code',
'کد سند',
width: ColumnWidth.medium,
formatter: (item) => item.code,
),
// نوع سند
TextColumn(
'document_type',
'نوع',
width: ColumnWidth.small,
formatter: (item) => item.documentTypeName,
),
// تاریخ سند
DateColumn(
'document_date',
'تاریخ سند',
width: ColumnWidth.medium,
formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali),
),
// مبلغ کل
NumberColumn(
'total_amount',
'مبلغ کل',
width: ColumnWidth.large,
formatter: (item) => formatWithThousands(item.totalAmount),
suffix: ' ریال',
),
// نام اشخاص
TextColumn(
'person_names',
'اشخاص',
width: ColumnWidth.medium,
formatter: (item) => item.personNames ?? 'نامشخص',
),
// تعداد حسابها
NumberColumn(
'account_lines_count',
'حساب‌ها',
width: ColumnWidth.small,
formatter: (item) => item.accountLinesCount.toString(),
),
// ایجادکننده
TextColumn(
'created_by_name',
'ایجادکننده',
width: ColumnWidth.medium,
formatter: (item) => item.createdByName ?? 'نامشخص',
),
// تاریخ ثبت
DateColumn(
'registered_at',
'تاریخ ثبت',
width: ColumnWidth.medium,
formatter: (item) => HesabixDateUtils.formatForDisplay(item.registeredAt, widget.calendarController.isJalali),
),
// عملیات
ActionColumn(
'actions',
'عملیات',
width: ColumnWidth.medium,
actions: [
DataTableAction(
icon: Icons.visibility,
label: 'مشاهده',
onTap: (item) => _onView(item),
),
DataTableAction(
icon: Icons.edit,
label: 'ویرایش',
onTap: (item) => _onEdit(item),
),
DataTableAction(
icon: Icons.delete,
label: 'حذف',
onTap: (item) => _onDelete(item),
isDestructive: true,
),
],
),
],
searchFields: ['code', 'created_by_name'],
filterFields: ['document_type'],
dateRangeField: 'document_date',
showSearch: true,
showFilters: true,
showPagination: true,
showColumnSearch: true,
showRefreshButton: true,
showClearFiltersButton: true,
enableRowSelection: true,
enableMultiRowSelection: true,
showExportButtons: true,
showExcelExport: true,
showPdfExport: true,
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
additionalParams: {
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
},
onRowTap: (item) => _onView(item),
onRowDoubleTap: (item) => _onEdit(item),
emptyStateMessage: 'هیچ سند دریافت یا پرداختی یافت نشد',
loadingMessage: 'در حال بارگذاری اسناد...',
errorMessage: 'خطا در بارگذاری اسناد',
);
}
/// افزودن سند جدید
void _onAddNew() async {
final result = await showDialog<bool>(
context: context,
builder: (_) => BulkSettlementDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isReceipt: true, // پیشفرض دریافت
businessInfo: widget.authStore.currentBusiness,
apiClient: widget.apiClient,
),
);
// اگر سند با موفقیت ثبت شد، جدول را تازهسازی کن
if (result == true) {
_refreshData();
}
}
/// مشاهده جزئیات سند
void _onView(ReceiptPaymentDocument document) {
// TODO: باز کردن صفحه جزئیات سند
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('مشاهده سند ${document.code}'),
),
);
}
/// ویرایش سند
void _onEdit(ReceiptPaymentDocument document) {
// TODO: باز کردن صفحه ویرایش سند
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('ویرایش سند ${document.code}'),
),
);
}
/// حذف سند
void _onDelete(ReceiptPaymentDocument document) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('تأیید حذف'),
content: Text('آیا از حذف سند ${document.code} اطمینان دارید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('انصراف'),
),
FilledButton(
onPressed: () async {
Navigator.pop(context);
await _performDelete(document);
},
child: const Text('حذف'),
),
],
),
);
}
/// انجام عملیات حذف
Future<void> _performDelete(ReceiptPaymentDocument document) async {
try {
final success = await _service.delete(document.id);
if (success) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('سند ${document.code} با موفقیت حذف شد'),
backgroundColor: Colors.green,
),
);
}
} else {
throw Exception('خطا در حذف سند');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در حذف سند: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
class BulkSettlementDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final bool isReceipt;
final BusinessWithPermission? businessInfo;
final ApiClient apiClient;
const BulkSettlementDialog({
super.key,
required this.businessId,
required this.calendarController,
required this.isReceipt,
this.businessInfo,
required this.apiClient,
});
@override
State<BulkSettlementDialog> createState() => _BulkSettlementDialogState();
}
class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
final _formKey = GlobalKey<FormState>();
late DateTime _docDate;
late bool _isReceipt;
int? _selectedCurrencyId;
final List<_PersonLine> _personLines = <_PersonLine>[];
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
@override
void initState() {
super.initState();
_docDate = DateTime.now();
_isReceipt = widget.isReceipt;
// اگر ارز پیشفرض موجود است، آن را انتخاب کن، در غیر این صورت null بگذار تا CurrencyPickerWidget خودکار انتخاب کند
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final sumPersons = _personLines.fold<double>(0, (p, e) => p + e.amount);
final sumCenters = _centerTransactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
final diff = (_isReceipt ? sumCenters - sumPersons : sumPersons - sumCenters).toDouble();
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
t.receiptsAndPayments,
style: Theme.of(context).textTheme.titleLarge,
),
),
SegmentedButton<bool>(
segments: [
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
ButtonSegment<bool>(value: false, label: Text(t.payments)),
],
selected: {_isReceipt},
onSelectionChanged: (s) => setState(() => _isReceipt = s.first),
),
const SizedBox(width: 12),
SizedBox(
width: 200,
child: DateInputField(
value: _docDate,
calendarController: widget.calendarController,
onChanged: (d) => setState(() => _docDate = d ?? DateTime.now()),
labelText: 'تاریخ سند',
hintText: 'انتخاب تاریخ',
),
),
const SizedBox(width: 12),
SizedBox(
width: 200,
child: CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _selectedCurrencyId,
onChanged: (currencyId) => setState(() => _selectedCurrencyId = currencyId),
label: 'ارز',
hintText: 'انتخاب ارز',
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: Row(
children: [
Expanded(
child: _PersonsPanel(
businessId: widget.businessId,
lines: _personLines,
onChanged: (ls) => setState(() {
_personLines.clear();
_personLines.addAll(ls);
}),
),
),
const VerticalDivider(width: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: InvoiceTransactionsWidget(
transactions: _centerTransactions,
onChanged: (txs) => setState(() {
_centerTransactions.clear();
_centerTransactions.addAll(txs);
}),
businessId: widget.businessId,
calendarController: widget.calendarController,
invoiceType: InvoiceType.sales,
),
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 16,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_TotalChip(label: t.people, value: sumPersons),
_TotalChip(label: t.accounts, value: sumCenters),
_TotalChip(label: 'اختلاف', value: diff, isError: diff != 0),
],
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.cancel),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: diff == 0 && _personLines.isNotEmpty && _centerTransactions.isNotEmpty
? _onSave
: null,
icon: const Icon(Icons.save),
label: Text(t.save),
),
],
),
),
],
),
),
),
);
}
Future<void> _onSave() async {
if (!mounted) return;
// نمایش loading
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => const Center(child: CircularProgressIndicator()),
);
try {
final service = ReceiptPaymentService(widget.apiClient);
// تبدیل personLines به فرمت مورد نیاز API
final personLinesData = _personLines.map((line) => {
'person_id': int.parse(line.personId!),
'person_name': line.personName,
'amount': line.amount,
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
}).toList();
// تبدیل centerTransactions به فرمت مورد نیاز API
final accountLinesData = _centerTransactions.map((tx) => {
'account_id': tx.accountId,
'amount': tx.amount.toDouble(),
'transaction_type': tx.type.value,
'transaction_date': tx.transactionDate.toIso8601String(),
if (tx.commission != null && tx.commission! > 0)
'commission': tx.commission!.toDouble(),
if (tx.description != null && tx.description!.isNotEmpty)
'description': tx.description,
// اطلاعات اضافی بر اساس نوع تراکنش
if (tx.type == TransactionType.bank) ...{
'bank_id': tx.bankId,
'bank_name': tx.bankName,
},
if (tx.type == TransactionType.cashRegister) ...{
'cash_register_id': tx.cashRegisterId,
'cash_register_name': tx.cashRegisterName,
},
if (tx.type == TransactionType.pettyCash) ...{
'petty_cash_id': tx.pettyCashId,
'petty_cash_name': tx.pettyCashName,
},
if (tx.type == TransactionType.check) ...{
'check_id': tx.checkId,
'check_number': tx.checkNumber,
},
if (tx.type == TransactionType.person) ...{
'person_id': tx.personId,
'person_name': tx.personName,
},
}).toList();
// ارسال به سرور
await service.createReceiptPayment(
businessId: widget.businessId,
documentType: _isReceipt ? 'receipt' : 'payment',
documentDate: _docDate,
currencyId: _selectedCurrencyId!,
personLines: personLinesData,
accountLines: accountLinesData,
);
if (!mounted) return;
// بستن dialog loading
Navigator.pop(context);
// بستن dialog اصلی با موفقیت
Navigator.pop(context, true);
// نمایش پیام موفقیت
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isReceipt
? 'سند دریافت با موفقیت ثبت شد'
: 'سند پرداخت با موفقیت ثبت شد',
),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!mounted) return;
// بستن dialog loading
Navigator.pop(context);
// نمایش خطا
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
class _PersonsPanel extends StatefulWidget {
final int businessId;
final List<_PersonLine> lines;
final ValueChanged<List<_PersonLine>> onChanged;
const _PersonsPanel({
required this.businessId,
required this.lines,
required this.onChanged,
});
@override
State<_PersonsPanel> createState() => _PersonsPanelState();
}
class _PersonsPanelState extends State<_PersonsPanel> {
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text(t.people, style: Theme.of(context).textTheme.titleMedium)),
IconButton(
onPressed: () {
final newLines = List<_PersonLine>.from(widget.lines);
newLines.add(_PersonLine.empty());
widget.onChanged(newLines);
},
icon: const Icon(Icons.add),
tooltip: t.add,
),
],
),
const SizedBox(height: 8),
Expanded(
child: widget.lines.isEmpty
? Center(child: Text(t.noDataFound))
: ListView.separated(
itemCount: widget.lines.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (ctx, i) {
final line = widget.lines[i];
return _PersonLineTile(
businessId: widget.businessId,
line: line,
onChanged: (l) {
final newLines = List<_PersonLine>.from(widget.lines);
newLines[i] = l;
widget.onChanged(newLines);
},
onDelete: () {
final newLines = List<_PersonLine>.from(widget.lines);
newLines.removeAt(i);
widget.onChanged(newLines);
},
);
},
),
),
],
),
);
}
}
class _PersonLineTile extends StatefulWidget {
final int businessId;
final _PersonLine line;
final ValueChanged<_PersonLine> onChanged;
final VoidCallback onDelete;
const _PersonLineTile({
required this.businessId,
required this.line,
required this.onChanged,
required this.onDelete,
});
@override
State<_PersonLineTile> createState() => _PersonLineTileState();
}
class _PersonLineTileState extends State<_PersonLineTile> {
final _amountController = TextEditingController();
final _descController = TextEditingController();
@override
void initState() {
super.initState();
_amountController.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0);
_descController.text = widget.line.description ?? '';
}
@override
void dispose() {
_amountController.dispose();
_descController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
children: [
Expanded(
child: PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: widget.line.personId != null
? Person(
id: int.tryParse(widget.line.personId!),
businessId: widget.businessId,
aliasName: widget.line.personName ?? '',
personTypes: const [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
)
: null,
onChanged: (opt) {
widget.onChanged(widget.line.copyWith(personId: opt?.id?.toString(), personName: opt?.displayName));
},
label: t.people,
hintText: t.search,
isRequired: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 180,
child: TextFormField(
controller: _amountController,
decoration: InputDecoration(
labelText: t.amount,
hintText: '1,000,000',
),
keyboardType: TextInputType.number,
validator: (v) {
final val = double.tryParse((v ?? '').replaceAll(',', ''));
if (val == null || val <= 0) return t.mustBePositiveNumber;
return null;
},
onChanged: (v) {
final val = double.tryParse(v.replaceAll(',', '')) ?? 0;
widget.onChanged(widget.line.copyWith(amount: val));
},
),
),
const SizedBox(width: 8),
IconButton(
onPressed: widget.onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
const SizedBox(height: 8),
TextFormField(
controller: _descController,
decoration: InputDecoration(
labelText: t.description,
),
onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())),
),
],
),
),
);
}
}
class _TotalChip extends StatelessWidget {
final String label;
final double value;
final bool isError;
const _TotalChip({required this.label, required this.value, this.isError = false});
@override
Widget build(BuildContext context) {
final ColorScheme scheme = Theme.of(context).colorScheme;
return Chip(
label: Text('$label: ${formatWithThousands(value)}'),
backgroundColor: isError ? scheme.errorContainer : scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: isError ? scheme.onErrorContainer : scheme.onSurfaceVariant),
);
}
}
class _PersonLine {
final String? personId;
final String? personName;
final double amount;
final String? description;
const _PersonLine({this.personId, this.personName, required this.amount, this.description});
factory _PersonLine.empty() => const _PersonLine(amount: 0);
_PersonLine copyWith({String? personId, String? personName, double? amount, String? description}) {
return _PersonLine(
personId: personId ?? this.personId,
personName: personName ?? this.personName,
amount: amount ?? this.amount,
description: description ?? this.description,
);
}
}

View file

@ -411,6 +411,10 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> {
'check_id': tx.checkId, 'check_id': tx.checkId,
'check_number': tx.checkNumber, 'check_number': tx.checkNumber,
}, },
if (tx.type == TransactionType.person) ...{
'person_id': tx.personId,
'person_name': tx.personName,
},
}).toList(); }).toList();
// ارسال به سرور // ارسال به سرور

View file

@ -0,0 +1,22 @@
import '../core/api_client.dart';
class AccountService {
final ApiClient _client;
AccountService({ApiClient? client}) : _client = client ?? ApiClient();
/// دریافت درخت حسابها برای یک کسب و کار
Future<Map<String, dynamic>> getAccountsTree({required int businessId}) async {
try {
final res = await _client.get<Map<String, dynamic>>(
'/api/v1/accounts/business/$businessId/tree',
);
// API پاسخ را در فیلد 'data' برمیگرداند
final responseData = res.data?['data'] as Map<String, dynamic>?;
return responseData ?? <String, dynamic>{'items': <dynamic>[]};
} catch (e) {
print('خطا در دریافت درخت حساب‌ها: $e');
return <String, dynamic>{'items': <dynamic>[]};
}
}
}

View file

@ -0,0 +1,201 @@
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/receipt_payment_document.dart';
/// پاسخ API برای لیست اسناد
class ReceiptPaymentListResponse {
final List<ReceiptPaymentDocument> items;
final int total;
final int page;
final int perPage;
final int totalPages;
final bool hasNext;
final bool hasPrev;
const ReceiptPaymentListResponse({
required this.items,
required this.total,
required this.page,
required this.perPage,
required this.totalPages,
required this.hasNext,
required this.hasPrev,
});
factory ReceiptPaymentListResponse.fromJson(Map<String, dynamic> json) {
final pagination = json['pagination'] as Map<String, dynamic>? ?? {};
return ReceiptPaymentListResponse(
items: (json['items'] as List<dynamic>?)
?.map((item) => ReceiptPaymentDocument.fromJson(item))
.toList() ?? [],
total: pagination['total'] ?? 0,
page: pagination['page'] ?? 1,
perPage: pagination['per_page'] ?? 20,
totalPages: pagination['total_pages'] ?? 0,
hasNext: pagination['has_next'] ?? false,
hasPrev: pagination['has_prev'] ?? false,
);
}
}
/// سرویس برای مدیریت لیست اسناد دریافت و پرداخت
class ReceiptPaymentListService {
final ApiClient _apiClient;
ReceiptPaymentListService(this._apiClient);
/// دریافت لیست اسناد دریافت و پرداخت
Future<ReceiptPaymentListResponse> getList({
required int businessId,
String? search,
String? documentType,
DateTime? fromDate,
DateTime? toDate,
String? sortBy,
bool? sortDesc,
int page = 1,
int limit = 20,
}) async {
try {
final queryParams = <String, dynamic>{
'take': limit,
'skip': (page - 1) * limit,
'sort_by': sortBy ?? 'document_date',
'sort_desc': sortDesc ?? true,
'search': search,
'document_type': documentType,
'from_date': fromDate?.toIso8601String(),
'to_date': toDate?.toIso8601String(),
};
// حذف پارامترهای null
queryParams.removeWhere((key, value) => value == null);
final response = await _apiClient.post(
'/businesses/$businessId/receipts-payments',
data: queryParams,
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>? ?? {};
return ReceiptPaymentListResponse.fromJson(data);
} else {
throw Exception('خطا در دریافت لیست اسناد: ${response.statusMessage}');
}
} catch (e) {
throw Exception('خطا در دریافت لیست اسناد: $e');
}
}
/// دریافت جزئیات یک سند
Future<ReceiptPaymentDocument?> getById(int documentId) async {
try {
final response = await _apiClient.get('/receipts-payments/$documentId');
if (response.statusCode == 200 && response.data != null) {
final data = response.data['data'] as Map<String, dynamic>? ?? {};
return ReceiptPaymentDocument.fromJson(data);
} else {
return null;
}
} catch (e) {
throw Exception('خطا در دریافت جزئیات سند: $e');
}
}
/// حذف یک سند
Future<bool> delete(int documentId) async {
try {
final response = await _apiClient.delete('/receipts-payments/$documentId');
return response.statusCode == 200;
} catch (e) {
throw Exception('خطا در حذف سند: $e');
}
}
/// حذف چندین سند
Future<bool> deleteMultiple(List<int> documentIds) async {
try {
// حذف تکتک اسناد
for (final id in documentIds) {
await delete(id);
}
return true;
} catch (e) {
throw Exception('خطا در حذف اسناد: $e');
}
}
/// دریافت آمار کلی
Future<Map<String, dynamic>> getStats({
required int businessId,
DateTime? fromDate,
DateTime? toDate,
}) async {
try {
final queryParams = <String, dynamic>{
'take': 1, // فقط برای آمار
'skip': 0,
'from_date': fromDate?.toIso8601String(),
'to_date': toDate?.toIso8601String(),
};
queryParams.removeWhere((key, value) => value == null);
// دریافت آمار دریافتها
final receiptsResponse = await _apiClient.post(
'/businesses/$businessId/receipts-payments',
data: {
...queryParams,
'document_type': 'receipt',
},
);
// دریافت آمار پرداختها
final paymentsResponse = await _apiClient.post(
'/businesses/$businessId/receipts-payments',
data: {
...queryParams,
'document_type': 'payment',
},
);
int receiptsCount = 0;
int paymentsCount = 0;
double receiptsTotal = 0.0;
double paymentsTotal = 0.0;
if (receiptsResponse.statusCode == 200 && receiptsResponse.data != null) {
final receiptsData = receiptsResponse.data['data'] as Map<String, dynamic>? ?? {};
receiptsCount = receiptsData['pagination']?['total'] ?? 0;
final receipts = (receiptsData['items'] as List<dynamic>?)
?.map((item) => ReceiptPaymentDocument.fromJson(item))
.toList() ?? [];
receiptsTotal = receipts.fold(0.0, (sum, doc) => sum + doc.totalAmount);
}
if (paymentsResponse.statusCode == 200 && paymentsResponse.data != null) {
final paymentsData = paymentsResponse.data['data'] as Map<String, dynamic>? ?? {};
paymentsCount = paymentsData['pagination']?['total'] ?? 0;
final payments = (paymentsData['items'] as List<dynamic>?)
?.map((item) => ReceiptPaymentDocument.fromJson(item))
.toList() ?? [];
paymentsTotal = payments.fold(0.0, (sum, doc) => sum + doc.totalAmount);
}
return {
'receipts_count': receiptsCount,
'payments_count': paymentsCount,
'receipts_total': receiptsTotal,
'payments_total': paymentsTotal,
'total_count': receiptsCount + paymentsCount,
'net_amount': receiptsTotal - paymentsTotal,
};
} catch (e) {
throw Exception('خطا در دریافت آمار: $e');
}
}
}

View file

@ -61,6 +61,16 @@ class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
setState(() { setState(() {
_currencies = currencies; _currencies = currencies;
_isLoading = false; _isLoading = false;
// اگر ارزی انتخاب نشده و ارز پیشفرض موجود است، آن را انتخاب کن
if (_selectedValue == null && currencies.isNotEmpty) {
final defaultCurrency = currencies.firstWhere(
(currency) => currency['is_default'] == true,
orElse: () => currencies.first,
);
_selectedValue = defaultCurrency['id'] as int;
widget.onChanged(_selectedValue);
}
}); });
} catch (e) { } catch (e) {
setState(() { setState(() {

View file

@ -246,6 +246,9 @@ class DataTableConfig<T> {
final String? excelEndpoint; final String? excelEndpoint;
final String? pdfEndpoint; final String? pdfEndpoint;
final Map<String, dynamic> Function()? getExportParams; final Map<String, dynamic> Function()? getExportParams;
final bool showExportButtons;
final bool showExcelExport;
final bool showPdfExport;
// Column settings configuration // Column settings configuration
final String? tableId; final String? tableId;
@ -321,6 +324,9 @@ class DataTableConfig<T> {
this.excelEndpoint, this.excelEndpoint,
this.pdfEndpoint, this.pdfEndpoint,
this.getExportParams, this.getExportParams,
this.showExportButtons = false,
this.showExcelExport = true,
this.showPdfExport = true,
this.tableId, this.tableId,
this.enableColumnSettings = true, this.enableColumnSettings = true,
this.showColumnSettingsButton = true, this.showColumnSettingsButton = true,
@ -452,6 +458,7 @@ class QueryInfo {
'take': take, 'take': take,
'skip': skip, 'skip': skip,
'sort_desc': sortDesc, 'sort_desc': sortDesc,
'sort_by': sortBy ?? 'document_date', // مقدار پیشفرض برای sort_by
}; };
if (search != null && search!.isNotEmpty) { if (search != null && search!.isNotEmpty) {
@ -461,10 +468,6 @@ class QueryInfo {
} }
} }
if (sortBy != null && sortBy!.isNotEmpty) {
json['sort_by'] = sortBy;
}
if (filters != null && filters!.isNotEmpty) { if (filters != null && filters!.isNotEmpty) {
json['filters'] = filters!.map((f) => f.toJson()).toList(); json['filters'] = filters!.map((f) => f.toJson()).toList();
} }

View file

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'helpers/file_saver.dart'; import 'package:file_saver/file_saver.dart';
// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:data_table_2/data_table_2.dart'; import 'package:data_table_2/data_table_2.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -687,29 +687,49 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
// Cross-platform save using conditional FileSaver // Cross-platform save using conditional FileSaver
Future<void> _saveBytesToDownloads(dynamic data, String filename) async { Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
List<int> bytes; Uint8List bytes;
if (data is List<int>) { if (data is List<int>) {
bytes = data; bytes = Uint8List.fromList(data);
} else if (data is Uint8List) { } else if (data is Uint8List) {
bytes = data.toList(); bytes = data;
} else { } else {
throw Exception('Unsupported binary data type: ${data.runtimeType}'); throw Exception('Unsupported binary data type: ${data.runtimeType}');
} }
await FileSaver.saveBytes(bytes, filename);
// Use file_saver package for cross-platform file saving
try {
final fileSaver = FileSaver.instance;
final extension = filename.split('.').last;
await fileSaver.saveFile(
name: filename,
bytes: bytes,
ext: extension,
);
} catch (e) {
print('Error saving file: $e');
rethrow;
}
} }
// Platform-specific download functions for Linux
// Platform-specific download functions for Linux // Platform-specific download functions for Linux
Future<void> _downloadPdf(dynamic data, String filename) async { Future<void> _downloadPdf(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder try {
print('Download PDF: $filename (Linux desktop - save to Downloads folder)'); await _saveBytesToDownloads(data, filename);
// TODO: Implement proper file saving for Linux print('✅ PDF downloaded successfully: $filename');
} catch (e) {
print('❌ Error downloading PDF: $e');
rethrow;
}
} }
Future<void> _downloadExcel(dynamic data, String filename) async { Future<void> _downloadExcel(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder try {
print('Download Excel: $filename (Linux desktop - save to Downloads folder)'); await _saveBytesToDownloads(data, filename);
// TODO: Implement proper file saving for Linux print('✅ Excel downloaded successfully: $filename');
} catch (e) {
print('❌ Error downloading Excel: $e');
rethrow;
}
} }

View file

@ -0,0 +1,488 @@
import 'package:flutter/material.dart';
import '../../models/account_tree_node.dart';
import '../../services/account_service.dart';
class AccountTreeComboboxWidget extends StatefulWidget {
final int businessId;
final AccountTreeNode? selectedAccount;
final ValueChanged<AccountTreeNode?> onChanged;
final String label;
final String hintText;
final bool isRequired;
const AccountTreeComboboxWidget({
super.key,
required this.businessId,
this.selectedAccount,
required this.onChanged,
this.label = 'حساب',
this.hintText = 'انتخاب حساب',
this.isRequired = false,
});
@override
State<AccountTreeComboboxWidget> createState() => _AccountTreeComboboxWidgetState();
}
class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
final AccountService _accountService = AccountService();
List<AccountTreeNode> _accounts = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadAccounts();
}
Future<void> _loadAccounts() async {
setState(() {
_isLoading = true;
});
try {
final response = await _accountService.getAccountsTree(businessId: widget.businessId);
final items = (response['items'] as List<dynamic>?)
?.map((item) => AccountTreeNode.fromJson(item as Map<String, dynamic>))
.toList() ?? [];
setState(() {
_accounts = items;
});
} catch (e) {
print('خطا در لود کردن حساب‌ها: $e');
setState(() {
_accounts = [];
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// لیبل
if (widget.label.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
widget.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (widget.isRequired)
Text(
' *',
style: TextStyle(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
// فیلد انتخاب
InkWell(
onTap: _isLoading ? null : _showAccountDialog,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: theme.colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.selectedAccount?.toString() ?? widget.hintText,
style: theme.textTheme.bodyMedium?.copyWith(
color: widget.selectedAccount != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
),
),
),
if (_isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(
Icons.arrow_drop_down,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
],
);
}
void _showAccountDialog() {
showDialog(
context: context,
builder: (context) => AccountSelectionDialog(
accounts: _accounts,
selectedAccount: widget.selectedAccount,
onAccountSelected: (account) {
widget.onChanged(account);
Navigator.pop(context);
},
),
);
}
}
class AccountSelectionDialog extends StatefulWidget {
final List<AccountTreeNode> accounts;
final AccountTreeNode? selectedAccount;
final ValueChanged<AccountTreeNode?> onAccountSelected;
const AccountSelectionDialog({
super.key,
required this.accounts,
this.selectedAccount,
required this.onAccountSelected,
});
@override
State<AccountSelectionDialog> createState() => _AccountSelectionDialogState();
}
class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
String _searchQuery = '';
List<AccountTreeNode> _filteredAccounts = [];
final Set<int> _expandedNodes = <int>{};
@override
void initState() {
super.initState();
_filteredAccounts = widget.accounts;
// همه گرههای سطح اول را به صورت پیشفرض باز کن
_expandedNodes.addAll(widget.accounts.map((account) => account.id));
}
void _filterAccounts(String query) {
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_filteredAccounts = widget.accounts;
} else {
_filteredAccounts = widget.accounts
.expand((account) => account.searchAccounts(query))
.where((account) => !account.hasChildren) // فقط حسابهای بدون فرزند
.toList();
}
});
}
void _expandAll() {
setState(() {
_expandedNodes.clear();
// همه گرههایی که فرزند دارند را باز کن
for (final account in widget.accounts) {
_addAllExpandableNodes(account);
}
});
}
void _collapseAll() {
setState(() {
_expandedNodes.clear();
});
}
void _addAllExpandableNodes(AccountTreeNode account) {
if (account.hasChildren) {
_expandedNodes.add(account.id);
for (final child in account.children) {
_addAllExpandableNodes(child);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: Container(
width: 600,
height: 500,
margin: const EdgeInsets.all(16),
child: Column(
children: [
// هدر
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: theme.colorScheme.onPrimary,
size: 24,
),
const SizedBox(width: 8),
Text(
'انتخاب حساب',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
color: theme.colorScheme.onPrimary,
),
],
),
),
// جستجو و دکمههای کنترل
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
children: [
TextField(
decoration: InputDecoration(
hintText: 'جستجو در حساب‌ها...',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onChanged: _filterAccounts,
),
if (_searchQuery.isEmpty) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
onPressed: _expandAll,
icon: const Icon(Icons.expand_more),
label: const Text('همه را باز کن'),
),
TextButton.icon(
onPressed: _collapseAll,
icon: const Icon(Icons.expand_less),
label: const Text('همه را ببند'),
),
],
),
],
],
),
),
// لیست حسابها
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
child: _searchQuery.isEmpty
? _buildTreeView()
: _buildSearchResults(),
),
),
// دکمهها
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('انصراف'),
),
const SizedBox(width: 8),
if (widget.selectedAccount != null)
TextButton(
onPressed: () {
widget.onAccountSelected(null);
Navigator.pop(context);
},
child: const Text('حذف انتخاب'),
),
],
),
),
],
),
),
);
}
Widget _buildTreeView() {
return ListView.builder(
itemCount: widget.accounts.length,
itemBuilder: (context, index) {
return _buildAccountNode(widget.accounts[index], 0);
},
);
}
Widget _buildSearchResults() {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _filteredAccounts.length,
itemBuilder: (context, index) {
final account = _filteredAccounts[index];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
tileColor: account.id == widget.selectedAccount?.id
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
leading: Icon(
Icons.account_balance_wallet,
color: account.id == widget.selectedAccount?.id
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(account.name),
subtitle: Text('کد: ${account.code}'),
trailing: account.id == widget.selectedAccount?.id
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: () => widget.onAccountSelected(account),
),
);
},
);
}
Widget _buildAccountNode(AccountTreeNode account, int level) {
final theme = Theme.of(context);
final isSelected = account.id == widget.selectedAccount?.id;
final canSelect = !account.hasChildren;
final isExpanded = _expandedNodes.contains(account.id);
return Column(
children: [
Container(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
child: ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 16 + (level * 24),
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
tileColor: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
leading: account.hasChildren
? IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: () {
setState(() {
if (isExpanded) {
_expandedNodes.remove(account.id);
} else {
_expandedNodes.add(account.id);
}
});
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
)
: Icon(
Icons.account_balance_wallet,
color: canSelect
? (isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant)
: theme.colorScheme.outline,
),
title: Text(
account.name,
style: TextStyle(
color: canSelect
? theme.colorScheme.onSurface
: theme.colorScheme.outline,
fontWeight: account.hasChildren ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
'کد: ${account.code}',
style: TextStyle(
color: canSelect
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.outline,
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: theme.colorScheme.primary,
)
: null,
onTap: canSelect ? () => widget.onAccountSelected(account) : null,
),
),
// نمایش فرزندان فقط اگر گره باز باشد
if (account.hasChildren && isExpanded)
...account.children.map((child) => _buildAccountNode(child, level + 1)),
// خط جداکننده بین حسابهای مختلف (فقط برای سطح اول)
if (level == 0 && account != widget.accounts.last)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
],
);
}
}

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/bank_account_service.dart'; import '../../services/bank_account_service.dart';
@ -99,7 +100,10 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
: res['items']; : res['items'];
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) { final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
final m = Map<String, dynamic>.from(e as Map); final m = Map<String, dynamic>.from(e as Map);
return BankAccountOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص')); final id = m['id']?.toString();
final name = m['name']?.toString() ?? 'نامشخص';
log('Bank account item: id=$id, name=$name');
return BankAccountOption(id ?? '', name);
}).toList(); }).toList();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../models/invoice_transaction.dart'; import '../../models/invoice_transaction.dart';
import '../../models/person_model.dart'; import '../../models/person_model.dart';
import '../../models/account_tree_node.dart';
import '../../core/date_utils.dart'; import '../../core/date_utils.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../utils/number_formatters.dart'; import '../../utils/number_formatters.dart';
@ -9,10 +10,12 @@ import '../../services/bank_account_service.dart';
import '../../services/cash_register_service.dart'; import '../../services/cash_register_service.dart';
import '../../services/petty_cash_service.dart'; import '../../services/petty_cash_service.dart';
import '../../services/person_service.dart'; import '../../services/person_service.dart';
import '../../services/account_service.dart';
import 'person_combobox_widget.dart'; import 'person_combobox_widget.dart';
import 'bank_account_combobox_widget.dart'; import 'bank_account_combobox_widget.dart';
import 'cash_register_combobox_widget.dart'; import 'cash_register_combobox_widget.dart';
import 'petty_cash_combobox_widget.dart'; import 'petty_cash_combobox_widget.dart';
import 'account_tree_combobox_widget.dart';
import '../../models/invoice_type_model.dart'; import '../../models/invoice_type_model.dart';
class InvoiceTransactionsWidget extends StatefulWidget { class InvoiceTransactionsWidget extends StatefulWidget {
@ -194,7 +197,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
const SizedBox(height: 8), const SizedBox(height: 8),
// تاریخ و مبلغ // تاریخ، مبلغ و کارمزد
Row( Row(
children: [ children: [
Expanded( Expanded(
@ -206,6 +209,12 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
), ),
), ),
), ),
Expanded(
child: _buildDetailRow(
'مبلغ:',
formatWithThousands(transaction.amount, decimalPlaces: 0),
),
),
if (transaction.commission != null) if (transaction.commission != null)
Expanded( Expanded(
child: _buildDetailRow( child: _buildDetailRow(
@ -371,6 +380,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
final CashRegisterService _cashRegisterService = CashRegisterService(); final CashRegisterService _cashRegisterService = CashRegisterService();
final PettyCashService _pettyCashService = PettyCashService(); final PettyCashService _pettyCashService = PettyCashService();
final PersonService _personService = PersonService(); final PersonService _personService = PersonService();
final AccountService _accountService = AccountService();
// فیلدهای خاص هر نوع تراکنش // فیلدهای خاص هر نوع تراکنش
String? _selectedBankId; String? _selectedBankId;
@ -378,7 +388,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
String? _selectedPettyCashId; String? _selectedPettyCashId;
String? _selectedCheckId; String? _selectedCheckId;
String? _selectedPersonId; String? _selectedPersonId;
String? _selectedAccountId; AccountTreeNode? _selectedAccount;
// لیستهای داده // لیستهای داده
List<Map<String, dynamic>> _banks = []; List<Map<String, dynamic>> _banks = [];
@ -403,12 +413,44 @@ class _TransactionDialogState extends State<TransactionDialog> {
_selectedPettyCashId = widget.transaction?.pettyCashId; _selectedPettyCashId = widget.transaction?.pettyCashId;
_selectedCheckId = widget.transaction?.checkId; _selectedCheckId = widget.transaction?.checkId;
_selectedPersonId = widget.transaction?.personId; _selectedPersonId = widget.transaction?.personId;
_selectedAccountId = widget.transaction?.accountId;
// اگر حساب انتخاب شده است، باید آن را از API دریافت کنیم
if (widget.transaction?.accountId != null) {
_loadSelectedAccount();
}
// لود کردن دادهها از دیتابیس // لود کردن دادهها از دیتابیس
_loadData(); _loadData();
} }
Future<void> _loadSelectedAccount() async {
try {
final response = await _accountService.getAccountsTree(businessId: widget.businessId);
final items = (response['items'] as List<dynamic>?)
?.map((item) => AccountTreeNode.fromJson(item as Map<String, dynamic>))
.toList() ?? [];
// جستجو برای پیدا کردن حساب انتخاب شده
final accountId = int.tryParse(widget.transaction?.accountId ?? '');
if (accountId != null) {
for (final account in items) {
final foundAccount = account.getAllAccounts().firstWhere(
(acc) => acc.id == accountId,
orElse: () => throw StateError('Account not found'),
);
if (foundAccount.id == accountId) {
setState(() {
_selectedAccount = foundAccount;
});
break;
}
}
}
} catch (e) {
print('خطا در لود کردن حساب انتخاب شده: $e');
}
}
Future<void> _loadData() async { Future<void> _loadData() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@ -794,21 +836,17 @@ class _TransactionDialogState extends State<TransactionDialog> {
} }
Widget _buildAccountFields() { Widget _buildAccountFields() {
return DropdownButtonFormField<String>( return AccountTreeComboboxWidget(
initialValue: _selectedAccountId, businessId: widget.businessId,
decoration: const InputDecoration( selectedAccount: _selectedAccount,
labelText: 'حساب *', onChanged: (account) {
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'account1', child: Text('حساب جاری')),
DropdownMenuItem(value: 'account2', child: Text('حساب پس‌انداز')),
],
onChanged: (value) {
setState(() { setState(() {
_selectedAccountId = value; _selectedAccount = account;
}); });
}, },
label: 'حساب *',
hintText: 'انتخاب حساب',
isRequired: true,
); );
} }
@ -848,8 +886,8 @@ class _TransactionDialogState extends State<TransactionDialog> {
checkNumber: _getCheckNumber(_selectedCheckId), checkNumber: _getCheckNumber(_selectedCheckId),
personId: _selectedPersonId, personId: _selectedPersonId,
personName: _getPersonName(_selectedPersonId), personName: _getPersonName(_selectedPersonId),
accountId: _selectedAccountId, accountId: _selectedAccount?.id.toString(),
accountName: _getAccountName(_selectedAccountId), accountName: _selectedAccount?.name,
transactionDate: _transactionDate, transactionDate: _transactionDate,
amount: amount, amount: amount,
commission: commission, commission: commission,
@ -903,14 +941,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
(p) => p['id']?.toString() == id, (p) => p['id']?.toString() == id,
orElse: () => <String, dynamic>{}, orElse: () => <String, dynamic>{},
); );
return person['name']?.toString(); return person['alias_name']?.toString() ?? person['name']?.toString();
} }
String? _getAccountName(String? id) {
switch (id) {
case 'account1': return 'حساب جاری';
case 'account2': return 'حساب پس‌انداز';
default: return null;
}
}
} }

View file

@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_saver_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
file_saver_plugin_register_with_registrar(file_saver_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_linux file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
) )

View file

@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation import Foundation
import file_picker import file_picker
import file_saver
import file_selector_macos import file_selector_macos
import flutter_secure_storage_macos import flutter_secure_storage_macos
import path_provider_foundation import path_provider_foundation
@ -13,6 +14,7 @@ import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))

View file

@ -137,6 +137,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "10.3.3" version: "10.3.3"
file_saver:
dependency: "direct main"
description:
name: file_saver
sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.2.14"
file_selector: file_selector:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -48,6 +48,7 @@ dependencies:
persian_datetime_picker: ^3.2.0 persian_datetime_picker: ^3.2.0
shamsi_date: ^1.1.1 shamsi_date: ^1.1.1
intl: ^0.20.0 intl: ^0.20.0
file_saver: ^0.2.7
data_table_2: ^2.5.12 data_table_2: ^2.5.12
file_picker: ^10.3.3 file_picker: ^10.3.3
file_selector: ^1.0.4 file_selector: ^1.0.4

View file

@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_saver
file_selector_windows file_selector_windows
flutter_secure_storage_windows flutter_secure_storage_windows
) )