progress in application

This commit is contained in:
Hesabix 2025-10-14 23:16:28 +03:30
parent 76ab27aa24
commit 37f4e0b6b4
34 changed files with 5959 additions and 26 deletions

View file

@ -0,0 +1,545 @@
# 📝 سیستم دریافت و پرداخت (Receipt & Payment System)
## 📌 مقدمه
سیستم دریافت و پرداخت یک سیستم حسابداری است که برای ثبت تراکنش‌های مالی بین کسب‌وکار و اشخاص (مشتریان و تامین‌کنندگان) استفاده می‌شود.
## 🎯 هدف
این سیستم برای ثبت دو نوع سند طراحی شده است:
1. **دریافت (Receipt)**: دریافت وجه از اشخاص (مشتریان)
2. **پرداخت (Payment)**: پرداخت به اشخاص (تامین‌کنندگان/فروشندگان)
## 📊 ساختار داده
### سند (Document)
هر سند دریافت یا پرداخت شامل موارد زیر است:
```json
{
"id": 123,
"code": "RC-20250115-0001",
"business_id": 1,
"document_type": "receipt", // یا "payment"
"document_date": "2025-01-15",
"currency_id": 1,
"created_by_user_id": 5,
"person_lines": [
{
"person_id": 10,
"person_name": "علی احمدی",
"amount": 1000000,
"description": "تسویه حساب"
}
],
"account_lines": [
{
"account_id": 456,
"account_name": "صندوق",
"amount": 1000000,
"description": ""
}
]
}
```
### خطوط سند (Document Lines)
هر سند شامل دو نوع خط است:
1. **خطوط اشخاص (Person Lines)**: تراکنش‌های مربوط به اشخاص
2. **خطوط حساب‌ها (Account Lines)**: تراکنش‌های مربوط به حساب‌ها (صندوق، بانک، چک، ...)
## 🧮 منطق حسابداری
### 1⃣ دریافت وجه از اشخاص (Receipt)
**سناریو**: دریافت ۱,۰۰۰,۰۰۰ تومان از مشتری "علی احمدی" به صندوق
#### ثبت در حساب‌ها:
```
صندوق (10202) بدهکار: 1,000,000
حساب دریافتنی - علی احمدی (10401) بستانکار: 1,000,000
```
#### منطق:
- **صندوق**: بدهکار می‌شود (چون دارایی افزایش یافته)
- **حساب دریافتنی شخص**: بستانکار می‌شود (چون بدهی مشتری کم شده)
#### کد نمونه (Frontend):
```dart
await service.createReceipt(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 10,
'person_name': 'علی احمدی',
'amount': 1000000,
'description': 'تسویه حساب',
}
],
accountLines: [
{
'account_id': 456, // شناسه حساب صندوق
'amount': 1000000,
'description': '',
}
],
);
```
---
### 2⃣ پرداخت به اشخاص (Payment)
**سناریو**: پرداخت ۵۰۰,۰۰۰ تومان به تامین‌کننده "رضا محمدی" از بانک
#### ثبت در حساب‌ها:
```
حساب پرداختنی - رضا محمدی (20201) بدهکار: 500,000
بانک (10203) بستانکار: 500,000
```
#### منطق:
- **حساب پرداختنی شخص**: بدهکار می‌شود (چون بدهی ما به تامین‌کننده کم شده)
- **بانک**: بستانکار می‌شود (چون دارایی کاهش یافته)
#### کد نمونه (Frontend):
```dart
await service.createPayment(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 20,
'person_name': 'رضا محمدی',
'amount': 500000,
'description': 'پرداخت بدهی',
}
],
accountLines: [
{
'account_id': 789, // شناسه حساب بانک
'amount': 500000,
'description': 'انتقال بانکی',
}
],
);
```
---
## 🔧 نحوه استفاده از API
### 1. ایجاد سند دریافت/پرداخت
**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments/create`
**Request Body:**
```json
{
"document_type": "receipt",
"document_date": "2025-01-15",
"currency_id": 1,
"person_lines": [
{
"person_id": 10,
"person_name": "علی احمدی",
"amount": 1000000,
"description": "تسویه حساب"
}
],
"account_lines": [
{
"account_id": 456,
"amount": 1000000,
"description": ""
}
],
"extra_info": {}
}
```
**Response:**
```json
{
"success": true,
"message": "RECEIPT_PAYMENT_CREATED",
"data": {
"id": 123,
"code": "RC-20250115-0001",
"business_id": 1,
"document_type": "receipt",
"document_date": "2025-01-15",
"person_lines": [...],
"account_lines": [...]
}
}
```
### 2. دریافت لیست اسناد
**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments`
**Request Body:**
```json
{
"skip": 0,
"take": 20,
"sort_by": "document_date",
"sort_desc": true,
"document_type": "receipt",
"from_date": "2025-01-01",
"to_date": "2025-01-31",
"search": ""
}
```
### 3. دریافت جزئیات یک سند
**Endpoint:** `GET /api/v1/receipts-payments/{document_id}`
### 4. حذف سند
**Endpoint:** `DELETE /api/v1/receipts-payments/{document_id}`
---
## 📱 نحوه استفاده در Flutter
### 1. Import کردن سرویس:
```dart
import 'package:hesabix_ui/services/receipt_payment_service.dart';
```
### 2. ایجاد instance:
```dart
final service = ReceiptPaymentService(apiClient);
```
### 3. ایجاد سند دریافت:
```dart
try {
final result = await service.createReceipt(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 10,
'person_name': 'علی احمدی',
'amount': 1000000,
'description': 'تسویه حساب',
}
],
accountLines: [
{
'account_id': 456,
'amount': 1000000,
'description': '',
}
],
);
print('سند با موفقیت ثبت شد: ${result['code']}');
} catch (e) {
print('خطا در ثبت سند: $e');
}
```
---
## 🗂️ انواع حساب‌های مورد استفاده
| کد حساب | نام حساب | نوع | توضیحات |
|---------|----------|-----|---------|
| `10401` | حساب دریافتنی | `4` | طلب از مشتریان |
| `20201` | حساب پرداختنی | `9` | بدهی به تامین‌کنندگان |
| `10202` | صندوق | `1` | صندوق |
| `10203` | بانک | `3` | حساب بانکی |
| `10403` | اسناد دریافتنی | `5` | چک دریافتی |
| `20202` | اسناد پرداختنی | `10` | چک پرداختی |
---
## ✅ قوانین و محدودیت‌ها
### 1. تعادل سند:
- مجموع مبالغ **person_lines** باید برابر مجموع مبالغ **account_lines** باشد
- در غیر این صورت خطای `UNBALANCED_AMOUNTS` برگردانده می‌شود
### 2. اعتبارسنجی:
- حداقل یک خط برای اشخاص الزامی است
- حداقل یک خط برای حساب‌ها الزامی است
- تمام مبالغ باید مثبت باشند
- ارز باید معتبر باشد
### 3. ایجاد خودکار حساب شخص:
- اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد می‌شود
- کد حساب: `{parent_code}-{person_id}`
- برای دریافت: `10401-{person_id}`
- برای پرداخت: `20201-{person_id}`
---
## 🔄 جریان کار (Workflow)
```mermaid
graph TD
A[شروع] --> B[کاربر وارد صفحه دریافت/پرداخت می‌شود]
B --> C[انتخاب نوع: دریافت یا پرداخت]
C --> D[کلیک بر روی دکمه افزودن]
D --> E[باز شدن دیالوگ]
E --> F[وارد کردن اطلاعات اشخاص]
F --> G[وارد کردن اطلاعات حساب‌ها]
G --> H{تعادل برقرار است؟}
H -->|خیر| I[نمایش اختلاف]
I --> F
H -->|بله| J[فعال شدن دکمه ذخیره]
J --> K[کلیک بر روی ذخیره]
K --> L[ارسال به سرور]
L --> M{موفق؟}
M -->|بله| N[نمایش پیام موفقیت]
M -->|خیر| O[نمایش پیام خطا]
N --> P[بستن دیالوگ]
O --> E
P --> Q[به‌روزرسانی لیست]
Q --> R[پایان]
```
---
## 🧪 مثال‌های کاربردی
### مثال 1: دریافت نقدی از مشتری
```dart
await service.createReceipt(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 10,
'person_name': 'شرکت ABC',
'amount': 5000000,
'description': 'دریافت بابت فاکتور شماره 123',
}
],
accountLines: [
{
'account_id': 456, // صندوق
'amount': 5000000,
}
],
);
```
**نتیجه در حساب‌ها:**
```
صندوق (10202) بدهکار: 5,000,000
حساب دریافتنی - شرکت ABC بستانکار: 5,000,000
```
---
### مثال 2: دریافت با چک از مشتری
```dart
await service.createReceipt(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 15,
'person_name': 'علی رضایی',
'amount': 3000000,
'description': 'دریافت بابت فاکتور 456',
}
],
accountLines: [
{
'account_id': 789, // اسناد دریافتنی (چک)
'amount': 3000000,
'description': 'چک شماره 12345678',
}
],
);
```
**نتیجه در حساب‌ها:**
```
اسناد دریافتنی (10403) بدهکار: 3,000,000
حساب دریافتنی - علی رضایی بستانکار: 3,000,000
```
---
### مثال 3: دریافت مختلط (نقد + چک)
```dart
await service.createReceipt(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 20,
'person_name': 'محمد حسینی',
'amount': 10000000,
'description': 'تسویه کامل',
}
],
accountLines: [
{
'account_id': 456, // صندوق
'amount': 4000000,
'description': 'نقد',
},
{
'account_id': 789, // چک دریافتنی
'amount': 6000000,
'description': 'چک شماره 87654321',
}
],
);
```
**نتیجه در حساب‌ها:**
```
صندوق (10202) بدهکار: 4,000,000
اسناد دریافتنی (10403) بدهکار: 6,000,000
حساب دریافتنی - محمد حسینی بستانکار: 10,000,000
```
---
### مثال 4: پرداخت نقدی به تامین‌کننده
```dart
await service.createPayment(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 30,
'person_name': 'شرکت XYZ',
'amount': 8000000,
'description': 'پرداخت بابت خرید کالا',
}
],
accountLines: [
{
'account_id': 456, // صندوق
'amount': 8000000,
}
],
);
```
**نتیجه در حساب‌ها:**
```
حساب پرداختنی - شرکت XYZ بدهکار: 8,000,000
صندوق (10202) بستانکار: 8,000,000
```
---
### مثال 5: پرداخت به چند تامین‌کننده
```dart
await service.createPayment(
businessId: 1,
documentDate: DateTime.now(),
currencyId: 1,
personLines: [
{
'person_id': 35,
'person_name': 'تامین‌کننده A',
'amount': 2000000,
},
{
'person_id': 40,
'person_name': 'تامین‌کننده B',
'amount': 3000000,
}
],
accountLines: [
{
'account_id': 890, // بانک
'amount': 5000000,
}
],
);
```
**نتیجه در حساب‌ها:**
```
حساب پرداختنی - تامین‌کننده A بدهکار: 2,000,000
حساب پرداختنی - تامین‌کننده B بدهکار: 3,000,000
بانک (10203) بستانکار: 5,000,000
```
---
## 🐛 خطاهای رایج و راه‌حل
| کد خطا | توضیحات | راه‌حل |
|--------|---------|--------|
| `INVALID_DOCUMENT_TYPE` | نوع سند نامعتبر | از "receipt" یا "payment" استفاده کنید |
| `CURRENCY_REQUIRED` | ارز الزامی است | currency_id را ارسال کنید |
| `PERSON_LINES_REQUIRED` | حداقل یک خط شخص الزامی | person_lines را پر کنید |
| `ACCOUNT_LINES_REQUIRED` | حداقل یک خط حساب الزامی | account_lines را پر کنید |
| `UNBALANCED_AMOUNTS` | عدم تعادل مبالغ | مجموع person_lines و account_lines باید برابر باشد |
| `PERSON_NOT_FOUND` | شخص یافت نشد | شناسه شخص را بررسی کنید |
| `ACCOUNT_NOT_FOUND` | حساب یافت نشد | شناسه حساب را بررسی کنید |
---
## 📝 نکات مهم
1. **تعادل سند**: همیشه مطمئن شوید که مجموع مبالغ اشخاص با مجموع مبالغ حساب‌ها برابر است.
2. **ایجاد خودکار حساب**: اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد می‌شود.
3. **کد سند**: کد سند به صورت خودکار با فرمت زیر تولید می‌شود:
- دریافت: `RC-YYYYMMDD-NNNN`
- پرداخت: `PY-YYYYMMDD-NNNN`
4. **منطق حسابداری**:
- **دریافت**: شخص بستانکار، حساب (صندوق/بانک) بدهکار
- **پرداخت**: شخص بدهکار، حساب (صندوق/بانک) بستانکار
5. **چند شخص/چند حساب**: می‌توانید در یک سند چند شخص و چند حساب داشته باشید.
---
## 📚 منابع مرتبط
- [مستندات API](/hesabixAPI/README.md)
- [راهنمای استفاده از Flutter](/hesabixUI/hesabix_ui/README.md)
- [ساختار حساب‌ها](/docs/ACCOUNTS_STRUCTURE.md)
---
**تاریخ ایجاد**: 2025-01-13
**نسخه**: 1.0.0
**توسعه‌دهنده**: تیم Hesabix

View file

@ -0,0 +1,163 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.core.permissions import require_business_management_dep, require_business_access
from adapters.api.v1.schemas import QueryInfo
from adapters.api.v1.schema_models.check import (
CheckCreateRequest,
CheckUpdateRequest,
)
from app.services.check_service import (
create_check,
update_check,
delete_check,
get_check_by_id,
list_checks,
)
router = APIRouter(prefix="/checks", tags=["checks"])
@router.post(
"/businesses/{business_id}/checks",
summary="لیست چک‌های کسب‌وکار",
description="دریافت لیست چک‌ها با جستجو/فیلتر",
)
@require_business_access("business_id")
async def list_checks_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
query_dict: Dict[str, Any] = {
"take": query_info.take,
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search,
"search_fields": query_info.search_fields,
"filters": query_info.filters,
}
# additional params: person_id (accept from query params or body)
# from query params
if request.query_params.get("person_id"):
try:
query_dict["person_id"] = int(request.query_params.get("person_id"))
except Exception:
pass
# from request body (DataTable additionalParams)
try:
body_json = await request.json()
if isinstance(body_json, dict) and body_json.get("person_id") is not None:
try:
query_dict["person_id"] = int(body_json.get("person_id"))
except Exception:
pass
except Exception:
pass
result = list_checks(db, business_id, query_dict)
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
return success_response(data=result, request=request, message="CHECKS_LIST_FETCHED")
@router.post(
"/businesses/{business_id}/checks/create",
summary="ایجاد چک",
description="ایجاد چک جدید برای کسب‌وکار",
)
@require_business_access("business_id")
async def create_check_endpoint(
request: Request,
business_id: int,
body: CheckCreateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
created = create_check(db, business_id, payload)
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
@router.get(
"/checks/{check_id}",
summary="جزئیات چک",
description="دریافت جزئیات چک",
)
async def get_check_endpoint(
request: Request,
check_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
result = get_check_by_id(db, check_id)
if not result:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DETAILS")
@router.put(
"/checks/{check_id}",
summary="ویرایش چک",
description="ویرایش اطلاعات چک",
)
async def update_check_endpoint(
request: Request,
check_id: int,
body: CheckUpdateRequest = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
result = update_check(db, check_id, payload)
if result is None:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_UPDATED")
@router.delete(
"/checks/{check_id}",
summary="حذف چک",
description="حذف یک چک",
)
async def delete_check_endpoint(
request: Request,
check_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
result = get_check_by_id(db, check_id)
if result:
try:
biz_id = int(result.get("business_id"))
except Exception:
biz_id = None
if biz_id is not None and not ctx.can_access_business(biz_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
ok = delete_check(db, check_id)
if not ok:
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
return success_response(data=None, request=request, message="CHECK_DELETED")

View file

@ -0,0 +1,203 @@
"""
API endpoints برای دریافت و پرداخت (Receipt & Payment)
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.core.permissions import require_business_management_dep, require_business_access
from adapters.api.v1.schemas import QueryInfo
from app.services.receipt_payment_service import (
create_receipt_payment,
get_receipt_payment,
list_receipts_payments,
delete_receipt_payment,
)
router = APIRouter(tags=["receipts-payments"])
@router.post(
"/businesses/{business_id}/receipts-payments",
summary="لیست اسناد دریافت و پرداخت",
description="دریافت لیست اسناد دریافت و پرداخت با فیلتر و جستجو",
)
@require_business_access("business_id")
async def list_receipts_payments_endpoint(
request: Request,
business_id: int,
query_info: QueryInfo = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
لیست اسناد دریافت و پرداخت
پارامترهای اضافی در body:
- document_type: "receipt" یا "payment" (اختیاری)
- from_date: تاریخ شروع (اختیاری)
- to_date: تاریخ پایان (اختیاری)
"""
query_dict: Dict[str, Any] = {
"take": query_info.take,
"skip": query_info.skip,
"sort_by": query_info.sort_by,
"sort_desc": query_info.sort_desc,
"search": query_info.search,
}
# دریافت پارامترهای اضافی از body
try:
body_json = await request.json()
if isinstance(body_json, dict):
for key in ["document_type", "from_date", "to_date"]:
if key in body_json:
query_dict[key] = body_json[key]
except Exception:
pass
result = list_receipts_payments(db, business_id, query_dict)
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
return success_response(
data=result,
request=request,
message="RECEIPTS_PAYMENTS_LIST_FETCHED"
)
@router.post(
"/businesses/{business_id}/receipts-payments/create",
summary="ایجاد سند دریافت یا پرداخت",
description="ایجاد سند دریافت یا پرداخت جدید",
)
@require_business_access("business_id")
async def create_receipt_payment_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
ایجاد سند دریافت یا پرداخت
Body باید شامل موارد زیر باشد:
{
"document_type": "receipt" | "payment",
"document_date": "2025-01-15T10:30:00",
"currency_id": 1,
"person_lines": [
{
"person_id": 123,
"person_name": "علی احمدی",
"amount": 1000000,
"description": "توضیحات (اختیاری)"
}
],
"account_lines": [
{
"account_id": 456,
"amount": 1000000,
"transaction_type": "bank" | "cash_register" | "petty_cash" | "check",
"transaction_date": "2025-01-15T10:30:00",
"commission": 5000, // اختیاری
"description": "توضیحات (اختیاری)",
// اطلاعات اضافی بر اساس نوع تراکنش:
"bank_id": "123", // برای نوع bank
"bank_name": "بانک ملی",
"cash_register_id": "456", // برای نوع cash_register
"cash_register_name": "صندوق اصلی",
"petty_cash_id": "789", // برای نوع petty_cash
"petty_cash_name": "تنخواهگردان فروش",
"check_id": "101", // برای نوع check
"check_number": "123456"
}
],
"extra_info": {} // اختیاری
}
"""
created = create_receipt_payment(db, business_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(created, request),
request=request,
message="RECEIPT_PAYMENT_CREATED"
)
@router.get(
"/receipts-payments/{document_id}",
summary="جزئیات سند دریافت/پرداخت",
description="دریافت جزئیات یک سند دریافت یا پرداخت",
)
async def get_receipt_payment_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت جزئیات سند"""
result = get_receipt_payment(db, document_id)
if not result:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Receipt/Payment document not found",
http_status=404
)
# بررسی دسترسی
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="RECEIPT_PAYMENT_DETAILS"
)
@router.delete(
"/receipts-payments/{document_id}",
summary="حذف سند دریافت/پرداخت",
description="حذف یک سند دریافت یا پرداخت",
)
async def delete_receipt_payment_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""حذف سند"""
# دریافت سند برای بررسی دسترسی
result = get_receipt_payment(db, document_id)
if result:
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
ok = delete_receipt_payment(db, document_id)
if not ok:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Receipt/Payment document not found",
http_status=404
)
return success_response(
data=None,
request=request,
message="RECEIPT_PAYMENT_DELETED"
)

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator
class CheckCreateRequest(BaseModel):
type: Literal['received', 'transferred']
person_id: Optional[int] = Field(default=None, ge=1)
issue_date: str
due_date: str
check_number: str = Field(..., min_length=1, max_length=50)
sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16)
bank_name: Optional[str] = Field(default=None, max_length=255)
branch_name: Optional[str] = Field(default=None, max_length=255)
amount: float = Field(..., gt=0)
currency_id: int = Field(..., ge=1)
@field_validator('sayad_code')
@classmethod
def validate_sayad(cls, v: Optional[str]):
if v is None:
return v
if not v.isdigit():
raise ValueError('شناسه صیاد باید فقط عددی باشد')
return v
class CheckUpdateRequest(BaseModel):
type: Optional[Literal['received', 'transferred']] = None
person_id: Optional[int] = Field(default=None, ge=1)
issue_date: Optional[str] = None
due_date: Optional[str] = None
check_number: Optional[str] = Field(default=None, min_length=1, max_length=50)
sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16)
bank_name: Optional[str] = Field(default=None, max_length=255)
branch_name: Optional[str] = Field(default=None, max_length=255)
amount: Optional[float] = Field(default=None, gt=0)
currency_id: Optional[int] = Field(default=None, ge=1)
@field_validator('sayad_code')
@classmethod
def validate_sayad(cls, v: Optional[str]):
if v is None:
return v
if not v.isdigit():
raise ValueError('شناسه صیاد باید فقط عددی باشد')
return v
class CheckResponse(BaseModel):
id: int
business_id: int
type: str
person_id: Optional[int]
person_name: Optional[str]
issue_date: str
due_date: str
check_number: str
sayad_code: Optional[str]
bank_name: Optional[str]
branch_name: Optional[str]
amount: float
currency_id: int
currency: Optional[str]
created_at: str
updated_at: str
class Config:
from_attributes = True

View file

@ -39,3 +39,4 @@ from .tax_unit import TaxUnit # noqa: F401
from .tax_type import TaxType # noqa: F401
from .bank_account import BankAccount # noqa: F401
from .petty_cash import PettyCash # noqa: F401
from .check import Check # noqa: F401

View file

@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from sqlalchemy import (
String,
Integer,
DateTime,
ForeignKey,
UniqueConstraint,
Numeric,
Enum as SQLEnum,
Index,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from adapters.db.session import Base
class CheckType(str, Enum):
RECEIVED = "received"
TRANSFERRED = "transferred"
class Check(Base):
__tablename__ = "checks"
__table_args__ = (
# پیشنهاد: یکتا بودن شماره چک در سطح کسب‌وکار
UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'),
# پیشنهاد: یکتا بودن شناسه صیاد در سطح کسب‌وکار (چند NULL مجاز است)
UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'),
Index('ix_checks_business_type', 'business_id', 'type'),
Index('ix_checks_business_person', 'business_id', 'person_id'),
Index('ix_checks_business_issue_date', 'business_id', 'issue_date'),
Index('ix_checks_business_due_date', 'business_id', 'due_date'),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
type: Mapped[CheckType] = mapped_column(SQLEnum(CheckType, name="check_type"), nullable=False, index=True)
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
issue_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
due_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
check_number: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
sayad_code: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
bank_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# روابط
business = relationship("Business", backref="checks")
person = relationship("Person", lazy="joined")
currency = relationship("Currency")

View file

@ -15,6 +15,13 @@ class DocumentLine(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True)
bank_account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
cash_register_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("cash_registers.id", ondelete="SET NULL"), nullable=True, index=True)
petty_cash_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("petty_cash.id", ondelete="SET NULL"), nullable=True, index=True)
check_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("checks.id", ondelete="SET NULL"), nullable=True, index=True)
quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True, default=0)
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -26,5 +33,11 @@ class DocumentLine(Base):
# Relationships
document = relationship("Document", back_populates="lines")
account = relationship("Account", back_populates="document_lines")
person = relationship("Person")
product = relationship("Product")
bank_account = relationship("BankAccount")
cash_register = relationship("CashRegister")
petty_cash = relationship("PettyCash")
check = relationship("Check")

View file

@ -30,6 +30,7 @@ from adapters.api.v1.support.priorities import router as support_priorities_rout
from adapters.api.v1.support.statuses import router as support_statuses_router
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
from adapters.api.v1.admin.email_config import router as admin_email_config_router
from adapters.api.v1.receipts_payments import router as receipts_payments_router
from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -299,10 +300,13 @@ def create_app() -> FastAPI:
application.include_router(persons_router, prefix=settings.api_v1_prefix)
application.include_router(customers_router, prefix=settings.api_v1_prefix)
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.checks import router as checks_router
application.include_router(checks_router, prefix=settings.api_v1_prefix)
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
# Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")

View file

@ -0,0 +1,286 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.check import Check, CheckType
from adapters.db.models.person import Person
from adapters.db.models.currency import Currency
from app.core.responses import ApiError
def _parse_iso(dt: str) -> datetime:
try:
return datetime.fromisoformat(dt.replace('Z', '+00:00'))
except Exception:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
ctype = str(data.get('type', '')).lower()
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
person_id = data.get('person_id')
if ctype == CheckType.RECEIVED.value and not person_id:
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
issue_date = _parse_iso(str(data.get('issue_date')))
due_date = _parse_iso(str(data.get('due_date')))
if due_date < issue_date:
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
sayad = data.get('sayad_code')
if sayad is not None:
s = str(sayad).strip()
if s and (len(s) != 16 or not s.isdigit()):
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
amount = data.get('amount')
try:
amount_val = float(amount)
except Exception:
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
if amount_val <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
check_number = str(data.get('check_number', '')).strip()
if not check_number:
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
# یونیک بودن در سطح کسب‌وکار
exists = db.query(Check).filter(and_(Check.business_id == business_id, Check.check_number == check_number)).first()
if exists is not None:
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
if sayad:
exists_sayad = db.query(Check).filter(and_(Check.business_id == business_id, Check.sayad_code == sayad)).first()
if exists_sayad is not None:
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
obj = Check(
business_id=business_id,
type=CheckType(ctype),
person_id=int(person_id) if person_id else None,
issue_date=issue_date,
due_date=due_date,
check_number=check_number,
sayad_code=str(sayad).strip() if sayad else None,
bank_name=(str(data.get('bank_name')).strip() if data.get('bank_name') else None),
branch_name=(str(data.get('branch_name')).strip() if data.get('branch_name') else None),
amount=amount_val,
currency_id=int(data.get('currency_id')),
)
db.add(obj)
db.commit()
db.refresh(obj)
return check_to_dict(db, obj)
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first()
return check_to_dict(db, obj) if obj else None
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
return None
if 'type' in data and data['type'] is not None:
ctype = str(data['type']).lower()
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
obj.type = CheckType(ctype)
if 'person_id' in data:
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
if 'issue_date' in data and data['issue_date'] is not None:
obj.issue_date = _parse_iso(str(data['issue_date']))
if 'due_date' in data and data['due_date'] is not None:
obj.due_date = _parse_iso(str(data['due_date']))
if obj.due_date < obj.issue_date:
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
if 'check_number' in data and data['check_number'] is not None:
new_num = str(data['check_number']).strip()
if not new_num:
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
exists = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.check_number == new_num, Check.id != obj.id)).first()
if exists is not None:
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
obj.check_number = new_num
if 'sayad_code' in data:
s = data['sayad_code']
if s is not None:
s = str(s).strip()
if s and (len(s) != 16 or not s.isdigit()):
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
if s:
exists_sayad = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.sayad_code == s, Check.id != obj.id)).first()
if exists_sayad is not None:
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
obj.sayad_code = s if s else None
for field in ["bank_name", "branch_name"]:
if field in data:
setattr(obj, field, (str(data[field]).strip() if data[field] is not None else None))
if 'amount' in data and data['amount'] is not None:
try:
amount_val = float(data['amount'])
except Exception:
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
if amount_val <= 0:
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
obj.amount = amount_val
if 'currency_id' in data and data['currency_id'] is not None:
obj.currency_id = int(data['currency_id'])
db.commit()
db.refresh(obj)
return check_to_dict(db, obj)
def delete_check(db: Session, check_id: int) -> bool:
obj = db.query(Check).filter(Check.id == check_id).first()
if obj is None:
return False
db.delete(obj)
db.commit()
return True
def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
q = db.query(Check).filter(Check.business_id == business_id)
# جستجو
if query.get("search") and query.get("search_fields"):
term = f"%{query['search']}%"
conditions = []
for f in query["search_fields"]:
if f == "check_number":
conditions.append(Check.check_number.ilike(term))
elif f == "sayad_code":
conditions.append(Check.sayad_code.ilike(term))
elif f == "bank_name":
conditions.append(Check.bank_name.ilike(term))
elif f == "branch_name":
conditions.append(Check.branch_name.ilike(term))
elif f == "person_name":
# join به persons
q = q.join(Person, Check.person_id == Person.id, isouter=True)
conditions.append(Person.alias_name.ilike(term))
if conditions:
from sqlalchemy import or_
q = q.filter(or_(*conditions))
# فیلترها
if query.get("filters"):
from app.core.calendar import CalendarConverter
for flt in query["filters"]:
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
if not prop or not op:
continue
if prop == 'type' and op == '=':
q = q.filter(Check.type == val)
elif prop == 'currency' and op == '=':
try:
q = q.filter(Check.currency_id == int(val))
except Exception:
pass
elif prop == 'person_id' and op == '=':
try:
q = q.filter(Check.person_id == int(val))
except Exception:
pass
elif prop in ('issue_date', 'due_date'):
# انتظار: فیلترهای بازه با اپراتورهای ">=" و "<=" از DataTable
try:
if isinstance(val, str) and val:
# ورودی تاریخ ممکن است بر اساس هدر تقویم باشد؛ در این لایه فرض بر ISO است (از فرانت ارسال می‌شود)
dt = _parse_iso(val)
col = getattr(Check, prop)
if op == ">=":
q = q.filter(col >= dt)
elif op == "<=":
q = q.filter(col <= dt)
except Exception:
pass
# additional params: person_id
person_param = query.get('person_id')
if person_param:
try:
q = q.filter(Check.person_id == int(person_param))
except Exception:
pass
# مرتب‌سازی
sort_by = query.get("sort_by") or "created_at"
sort_desc = bool(query.get("sort_desc", True))
col = getattr(Check, sort_by, Check.created_at)
q = q.order_by(col.desc() if sort_desc else col.asc())
# صفحه‌بندی
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
items = q.offset(skip).limit(take).all()
return {
"items": [check_to_dict(db, i) for i in items],
"pagination": {
"total": total,
"page": (skip // take) + 1,
"per_page": take,
"total_pages": (total + take - 1) // take,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
"query_info": query,
}
def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]:
if obj is None:
return None
person_name = None
if obj.person_id:
p = db.query(Person).filter(Person.id == obj.person_id).first()
person_name = getattr(p, 'alias_name', None)
currency_title = None
try:
c = db.query(Currency).filter(Currency.id == obj.currency_id).first()
currency_title = c.title or c.code if c else None
except Exception:
pass
return {
"id": obj.id,
"business_id": obj.business_id,
"type": obj.type.value,
"person_id": obj.person_id,
"person_name": person_name,
"issue_date": obj.issue_date.isoformat(),
"due_date": obj.due_date.isoformat(),
"check_number": obj.check_number,
"sayad_code": obj.sayad_code,
"bank_name": obj.bank_name,
"branch_name": obj.branch_name,
"amount": float(obj.amount),
"currency_id": obj.currency_id,
"currency": currency_title,
"created_at": obj.created_at.isoformat(),
"updated_at": obj.updated_at.isoformat(),
}

View file

@ -0,0 +1,534 @@
"""
سرویس دریافت و پرداخت (Receipt & Payment)
این سرویس برای ثبت اسناد دریافت و پرداخت استفاده میشود که شامل:
- دریافت وجه از اشخاص (مشتریان)
- پرداخت به اشخاص (تامینکنندگان)
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.person import Person
from adapters.db.models.currency import Currency
from adapters.db.models.user import User
from app.core.responses import ApiError
# نوع‌های سند
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
DOCUMENT_TYPE_PAYMENT = "payment" # پرداخت
# نوع‌های حساب (از migration)
ACCOUNT_TYPE_RECEIVABLE = "person" # حساب دریافتنی
ACCOUNT_TYPE_PAYABLE = "person" # حساب پرداختنی
ACCOUNT_TYPE_CASH = "cash_register" # صندوق
ACCOUNT_TYPE_BANK = "bank" # بانک
ACCOUNT_TYPE_CHECK_RECEIVED = "check" # اسناد دریافتنی (چک دریافتی)
ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرداختی)
def _parse_iso_date(dt: str | datetime | date) -> date:
"""تبدیل تاریخ به فرمت date"""
if isinstance(dt, date):
return dt
if isinstance(dt, datetime):
return dt.date()
try:
parsed = datetime.fromisoformat(str(dt).replace('Z', '+00:00'))
return parsed.date()
except Exception:
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
def _get_or_create_person_account(
db: Session,
business_id: int,
person_id: int,
is_receivable: bool
) -> Account:
"""
ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی)
Args:
business_id: شناسه کسبوکار
person_id: شناسه شخص
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
Returns:
Account: حساب شخص
"""
person = db.query(Person).filter(
and_(Person.id == person_id, Person.business_id == business_id)
).first()
if not person:
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
# کد حساب والد
parent_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(
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(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""
ایجاد سند دریافت یا پرداخت
Args:
business_id: شناسه کسبوکار
user_id: شناسه کاربر ایجادکننده
data: اطلاعات سند شامل:
- document_type: "receipt" یا "payment"
- document_date: تاریخ سند
- currency_id: شناسه ارز
- person_lines: لیست تراکنشهای اشخاص [{"person_id": int, "amount": float, "description": str?}, ...]
- account_lines: لیست تراکنشهای حسابها [{"account_id": int, "amount": float, "description": str?}, ...]
- extra_info: اطلاعات اضافی (اختیاری)
Returns:
Dict: اطلاعات سند ایجاد شده
"""
# اعتبارسنجی نوع سند
document_type = str(data.get("document_type", "")).lower()
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)
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
# اعتبارسنجی تاریخ
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
# اعتبارسنجی ارز
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
# اعتبارسنجی خطوط اشخاص
person_lines = data.get("person_lines", [])
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)
# اعتبارسنجی خطوط حساب‌ها
account_lines = data.get("account_lines", [])
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)
# محاسبه مجموع مبالغ
person_total = sum(float(line.get("amount", 0)) for line in person_lines)
account_total = sum(float(line.get("amount", 0)) for line in account_lines)
# بررسی تعادل مبالغ
if abs(person_total - account_total) > 0.01: # tolerance برای خطای ممیز شناور
raise ApiError(
"UNBALANCED_AMOUNTS",
f"Person total ({person_total}) must equal account total ({account_total})",
http_status=400
)
# تولید کد سند
# فرمت: RP-YYYYMMDD-NNNN (RP = Receipt/Payment)
today = datetime.now().date()
prefix = f"{'RC' if is_receipt else 'PY'}-{today.strftime('%Y%m%d')}"
last_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%")
)
).order_by(Document.code.desc()).first()
if last_doc:
try:
last_num = int(last_doc.code.split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
doc_code = f"{prefix}-{next_num:04d}"
# ایجاد سند
document = Document(
business_id=business_id,
code=doc_code,
document_type=document_type,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
extra_info=data.get("extra_info"),
)
db.add(document)
db.flush() # برای دریافت document.id
# ایجاد خطوط سند برای اشخاص
for person_line in person_lines:
person_id = person_line.get("person_id")
if not person_id:
continue
amount = Decimal(str(person_line.get("amount", 0)))
if amount <= 0:
continue
description = person_line.get("description", "").strip() or None
# دریافت یا ایجاد حساب شخص
# در دریافت: حساب دریافتنی (receivable)
# در پرداخت: حساب پرداختنی (payable)
person_account = _get_or_create_person_account(
db,
business_id,
int(person_id),
is_receivable=is_receipt
)
# ایجاد خط سند برای شخص
# در دریافت: شخص بستانکار (credit)
# در پرداخت: شخص بدهکار (debit)
line = DocumentLine(
document_id=document.id,
account_id=person_account.id,
debit=amount if not is_receipt else Decimal(0),
credit=amount if is_receipt else Decimal(0),
description=description,
extra_info={
"person_id": int(person_id),
"person_name": person_line.get("person_name"),
}
)
db.add(line)
# ایجاد خطوط سند برای حساب‌ها
for account_line in account_lines:
account_id = account_line.get("account_id")
if not account_id:
continue
amount = Decimal(str(account_line.get("amount", 0)))
if amount <= 0:
continue
description = account_line.get("description", "").strip() or None
transaction_type = account_line.get("transaction_type")
transaction_date = account_line.get("transaction_date")
commission = account_line.get("commission")
# بررسی وجود حساب
account = db.query(Account).filter(
and_(
Account.id == int(account_id),
or_(
Account.business_id == business_id,
Account.business_id == None # حساب‌های عمومی
)
)
).first()
if not account:
raise ApiError(
"ACCOUNT_NOT_FOUND",
f"Account with id {account_id} not found",
http_status=404
)
# ایجاد اطلاعات اضافی برای خط سند
extra_info = {}
if transaction_type:
extra_info["transaction_type"] = transaction_type
if transaction_date:
extra_info["transaction_date"] = transaction_date
if commission:
extra_info["commission"] = float(commission)
# اطلاعات اضافی بر اساس نوع تراکنش
if transaction_type == "bank":
if account_line.get("bank_id"):
extra_info["bank_id"] = account_line.get("bank_id")
if account_line.get("bank_name"):
extra_info["bank_name"] = account_line.get("bank_name")
elif transaction_type == "cash_register":
if account_line.get("cash_register_id"):
extra_info["cash_register_id"] = account_line.get("cash_register_id")
if account_line.get("cash_register_name"):
extra_info["cash_register_name"] = account_line.get("cash_register_name")
elif transaction_type == "petty_cash":
if account_line.get("petty_cash_id"):
extra_info["petty_cash_id"] = account_line.get("petty_cash_id")
if account_line.get("petty_cash_name"):
extra_info["petty_cash_name"] = account_line.get("petty_cash_name")
elif transaction_type == "check":
if account_line.get("check_id"):
extra_info["check_id"] = account_line.get("check_id")
if account_line.get("check_number"):
extra_info["check_number"] = account_line.get("check_number")
# ایجاد خط سند برای حساب
# در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد
line = DocumentLine(
document_id=document.id,
account_id=account.id,
debit=amount if is_receipt else Decimal(0),
credit=amount if not is_receipt else Decimal(0),
description=description,
extra_info=extra_info if extra_info else None,
)
db.add(line)
# ذخیره تغییرات
db.commit()
db.refresh(document)
return document_to_dict(db, document)
def get_receipt_payment(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
"""دریافت جزئیات یک سند دریافت/پرداخت"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return None
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
return None
return document_to_dict(db, document)
def list_receipts_payments(
db: Session,
business_id: int,
query: Dict[str, Any]
) -> Dict[str, Any]:
"""لیست اسناد دریافت و پرداخت"""
q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type.in_([DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT])
)
)
# فیلتر بر اساس نوع
doc_type = query.get("document_type")
if doc_type:
q = q.filter(Document.document_type == doc_type)
# فیلتر بر اساس تاریخ
from_date = query.get("from_date")
to_date = query.get("to_date")
if from_date:
try:
from_dt = _parse_iso_date(from_date)
q = q.filter(Document.document_date >= from_dt)
except Exception:
pass
if to_date:
try:
to_dt = _parse_iso_date(to_date)
q = q.filter(Document.document_date <= to_dt)
except Exception:
pass
# جستجو
search = query.get("search")
if search:
q = q.filter(Document.code.ilike(f"%{search}%"))
# مرتب‌سازی
sort_by = query.get("sort_by", "document_date")
sort_desc = query.get("sort_desc", True)
if hasattr(Document, sort_by):
col = getattr(Document, sort_by)
q = q.order_by(col.desc() if sort_desc else col.asc())
else:
q = q.order_by(Document.document_date.desc())
# صفحه‌بندی
skip = int(query.get("skip", 0))
take = int(query.get("take", 20))
total = q.count()
items = q.offset(skip).limit(take).all()
return {
"items": [document_to_dict(db, doc) for doc in items],
"pagination": {
"total": total,
"page": (skip // take) + 1,
"per_page": take,
"total_pages": (total + take - 1) // take,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
"query_info": query,
}
def delete_receipt_payment(db: Session, document_id: int) -> bool:
"""حذف سند دریافت/پرداخت"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return False
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
return False
db.delete(document)
db.commit()
return True
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
"""تبدیل سند به دیکشنری"""
# دریافت خطوط سند
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
# جداسازی خطوط اشخاص و حساب‌ها
person_lines = []
account_lines = []
for line in lines:
account = db.query(Account).filter(Account.id == line.account_id).first()
if not account:
continue
line_dict = {
"id": line.id,
"account_id": line.account_id,
"account_name": account.name,
"account_code": account.code,
"account_type": account.account_type,
"debit": float(line.debit),
"credit": float(line.credit),
"amount": float(line.debit if line.debit > 0 else line.credit),
"description": line.description,
"extra_info": line.extra_info,
}
# اضافه کردن اطلاعات اضافی از extra_info
if line.extra_info:
if "transaction_type" in line.extra_info:
line_dict["transaction_type"] = line.extra_info["transaction_type"]
if "transaction_date" in line.extra_info:
line_dict["transaction_date"] = line.extra_info["transaction_date"]
if "commission" in line.extra_info:
line_dict["commission"] = line.extra_info["commission"]
if "bank_id" in line.extra_info:
line_dict["bank_id"] = line.extra_info["bank_id"]
if "bank_name" in line.extra_info:
line_dict["bank_name"] = line.extra_info["bank_name"]
if "cash_register_id" in line.extra_info:
line_dict["cash_register_id"] = line.extra_info["cash_register_id"]
if "cash_register_name" in line.extra_info:
line_dict["cash_register_name"] = line.extra_info["cash_register_name"]
if "petty_cash_id" in line.extra_info:
line_dict["petty_cash_id"] = line.extra_info["petty_cash_id"]
if "petty_cash_name" in line.extra_info:
line_dict["petty_cash_name"] = line.extra_info["petty_cash_name"]
if "check_id" in line.extra_info:
line_dict["check_id"] = line.extra_info["check_id"]
if "check_number" in line.extra_info:
line_dict["check_number"] = line.extra_info["check_number"]
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
if line.extra_info and line.extra_info.get("person_id"):
person_lines.append(line_dict)
else:
account_lines.append(line_dict)
# دریافت اطلاعات کاربر ایجادکننده
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None
# دریافت اطلاعات ارز
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
currency_code = currency.code if currency else None
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"document_type": document.document_type,
"document_date": document.document_date.isoformat(),
"registered_at": document.registered_at.isoformat(),
"currency_id": document.currency_id,
"currency_code": currency_code,
"created_by_user_id": document.created_by_user_id,
"created_by_name": created_by_name,
"is_proforma": document.is_proforma,
"extra_info": document.extra_info,
"person_lines": person_lines,
"account_lines": account_lines,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
}

View file

@ -11,14 +11,17 @@ adapters/api/v1/business_users.py
adapters/api/v1/businesses.py
adapters/api/v1/cash_registers.py
adapters/api/v1/categories.py
adapters/api/v1/checks.py
adapters/api/v1/currencies.py
adapters/api/v1/customers.py
adapters/api/v1/health.py
adapters/api/v1/invoices.py
adapters/api/v1/persons.py
adapters/api/v1/petty_cash.py
adapters/api/v1/price_lists.py
adapters/api/v1/product_attributes.py
adapters/api/v1/products.py
adapters/api/v1/receipts_payments.py
adapters/api/v1/schemas.py
adapters/api/v1/tax_types.py
adapters/api/v1/tax_units.py
@ -28,6 +31,7 @@ adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/bank_account.py
adapters/api/v1/schema_models/check.py
adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py
adapters/api/v1/schema_models/person.py
@ -52,6 +56,7 @@ adapters/db/models/business_permission.py
adapters/db/models/captcha.py
adapters/db/models/cash_register.py
adapters/db/models/category.py
adapters/db/models/check.py
adapters/db/models/currency.py
adapters/db/models/document.py
adapters/db/models/document_line.py
@ -118,6 +123,7 @@ app/services/business_dashboard_service.py
app/services/business_service.py
app/services/captcha_service.py
app/services/cash_register_service.py
app/services/check_service.py
app/services/email_service.py
app/services/file_storage_service.py
app/services/person_service.py
@ -126,6 +132,7 @@ app/services/price_list_service.py
app/services/product_attribute_service.py
app/services/product_service.py
app/services/query_service.py
app/services/receipt_payment_service.py
app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py
app/services/pdf/modules/__init__.py
@ -180,6 +187,9 @@ migrations/versions/20251002_000101_add_bank_accounts_table.py
migrations/versions/20251003_000201_add_cash_registers_table.py
migrations/versions/20251003_010501_add_name_to_cash_registers.py
migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
migrations/versions/20251011_000901_add_checks_table.py
migrations/versions/20251011_010001_replace_accounts_chart_seed.py
migrations/versions/20251012_000101_update_accounts_account_type_to_english.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py

View file

@ -0,0 +1,77 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20251011_000901_add_checks_table'
down_revision: Union[str, None] = '1f0abcdd7300'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ایجاد ایمن جدول و ایندکس‌ها در صورت نبود
bind = op.get_bind()
inspector = sa.inspect(bind)
# ایجاد type در صورت نیاز
try:
op.execute("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_NAME='checks' LIMIT 1")
except Exception:
pass
if 'checks' not in inspector.get_table_names():
# Enum برای نوع چک
try:
# برخی درایورها ایجاد Enum را قبل از استفاده می‌خواهند
sa.Enum('received', 'transferred', name='check_type')
except Exception:
pass
op.create_table(
'checks',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.Enum('received', 'transferred', name='check_type'), nullable=False),
sa.Column('person_id', sa.Integer(), sa.ForeignKey('persons.id', ondelete='SET NULL'), nullable=True),
sa.Column('issue_date', sa.DateTime(), nullable=False),
sa.Column('due_date', sa.DateTime(), nullable=False),
sa.Column('check_number', sa.String(length=50), nullable=False),
sa.Column('sayad_code', sa.String(length=16), nullable=True),
sa.Column('bank_name', sa.String(length=255), nullable=True),
sa.Column('branch_name', sa.String(length=255), nullable=True),
sa.Column('amount', sa.Numeric(18, 2), nullable=False),
sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'),
sa.UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'),
)
# ایجاد ایندکس‌ها اگر وجود ندارند
try:
existing_indexes = {idx['name'] for idx in inspector.get_indexes('checks')}
if 'ix_checks_business_type' not in existing_indexes:
op.create_index('ix_checks_business_type', 'checks', ['business_id', 'type'])
if 'ix_checks_business_person' not in existing_indexes:
op.create_index('ix_checks_business_person', 'checks', ['business_id', 'person_id'])
if 'ix_checks_business_issue_date' not in existing_indexes:
op.create_index('ix_checks_business_issue_date', 'checks', ['business_id', 'issue_date'])
if 'ix_checks_business_due_date' not in existing_indexes:
op.create_index('ix_checks_business_due_date', 'checks', ['business_id', 'due_date'])
except Exception:
pass
def downgrade() -> None:
# Drop indices
op.drop_index('ix_checks_business_due_date', table_name='checks')
op.drop_index('ix_checks_business_issue_date', table_name='checks')
op.drop_index('ix_checks_business_person', table_name='checks')
op.drop_index('ix_checks_business_type', table_name='checks')
# Drop table
op.drop_table('checks')
# Drop enum type (if supported)
try:
op.execute("DROP TYPE check_type")
except Exception:
pass

View file

@ -0,0 +1,269 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
"""
Replace accounts chart seed with the provided list (public accounts only)
Revision ID: 20251011_010001_replace_accounts_chart_seed
Revises: 20251006_000001_add_tax_types_table_and_product_fks
Create Date: 2025-10-11 01:00:01.000001
"""
# revision identifiers, used by Alembic.
revision = '20251011_010001_replace_accounts_chart_seed'
down_revision = '20251011_000901_add_checks_table'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# لیست کامل از کاربر (فقط فیلدهای لازم برای جدول accounts نگه داشته شده)
# نگاشت: id => extId (صرفاً برای حلقه والد/فرزند). در جدول id خودکار است
items = [
{"id": 2452, "level": 1, "code": "1", "name": "دارایی ها", "parentId": 0, "accountType": 0},
{"id": 2453, "level": 2, "code": "101", "name": "دارایی های جاری", "parentId": 2452, "accountType": 0},
{"id": 2454, "level": 3, "code": "102", "name": "موجودی نقد و بانک", "parentId": 2453, "accountType": 0},
{"id": 2455, "level": 4, "code": "10201", "name": "تنخواه گردان", "parentId": 2454, "accountType": 2},
{"id": 2456, "level": 4, "code": "10202", "name": "صندوق", "parentId": 2454, "accountType": 1},
{"id": 2457, "level": 4, "code": "10203", "name": "بانک", "parentId": 2454, "accountType": 3},
{"id": 2458, "level": 4, "code": "10204", "name": "وجوه در راه", "parentId": 2454, "accountType": 0},
{"id": 2459, "level": 3, "code": "103", "name": "سپرده های کوتاه مدت", "parentId": 2453, "accountType": 0},
{"id": 2460, "level": 4, "code": "10301", "name": "سپرده شرکت در مناقصه و مزایده", "parentId": 2459, "accountType": 0},
{"id": 2461, "level": 4, "code": "10302", "name": "ضمانت نامه بانکی", "parentId": 2459, "accountType": 0},
{"id": 2462, "level": 4, "code": "10303", "name": "سایر سپرده ها", "parentId": 2459, "accountType": 0},
{"id": 2463, "level": 3, "code": "104", "name": "حساب های دریافتنی", "parentId": 2453, "accountType": 0},
{"id": 2464, "level": 4, "code": "10401", "name": "حساب های دریافتنی", "parentId": 2463, "accountType": 4},
{"id": 2465, "level": 4, "code": "10402", "name": "ذخیره مطالبات مشکوک الوصول", "parentId": 2463, "accountType": 0},
{"id": 2466, "level": 4, "code": "10403", "name": "اسناد دریافتنی", "parentId": 2463, "accountType": 5},
{"id": 2467, "level": 4, "code": "10404", "name": "اسناد در جریان وصول", "parentId": 2463, "accountType": 6},
{"id": 2468, "level": 3, "code": "105", "name": "سایر حساب های دریافتنی", "parentId": 2453, "accountType": 0},
{"id": 2469, "level": 4, "code": "10501", "name": "وام کارکنان", "parentId": 2468, "accountType": 0},
{"id": 2470, "level": 4, "code": "10502", "name": "سایر حساب های دریافتنی", "parentId": 2468, "accountType": 0},
{"id": 2471, "level": 3, "code": "10101", "name": "پیش پرداخت ها", "parentId": 2453, "accountType": 0},
{"id": 2472, "level": 3, "code": "10102", "name": "موجودی کالا", "parentId": 2453, "accountType": 7},
{"id": 2473, "level": 3, "code": "10103", "name": "ملزومات", "parentId": 2453, "accountType": 0},
{"id": 2474, "level": 3, "code": "10104", "name": "مالیات بر ارزش افزوده خرید", "parentId": 2453, "accountType": 8},
{"id": 2475, "level": 2, "code": "106", "name": "دارایی های غیر جاری", "parentId": 2452, "accountType": 0},
{"id": 2476, "level": 3, "code": "107", "name": "دارایی های ثابت", "parentId": 2475, "accountType": 0},
{"id": 2477, "level": 4, "code": "10701", "name": "زمین", "parentId": 2476, "accountType": 0},
{"id": 2478, "level": 4, "code": "10702", "name": "ساختمان", "parentId": 2476, "accountType": 0},
{"id": 2479, "level": 4, "code": "10703", "name": "وسائط نقلیه", "parentId": 2476, "accountType": 0},
{"id": 2480, "level": 4, "code": "10704", "name": "اثاثیه اداری", "parentId": 2476, "accountType": 0},
{"id": 2481, "level": 3, "code": "108", "name": "استهلاک انباشته", "parentId": 2475, "accountType": 0},
{"id": 2482, "level": 4, "code": "10801", "name": "استهلاک انباشته ساختمان", "parentId": 2481, "accountType": 0},
{"id": 2483, "level": 4, "code": "10802", "name": "استهلاک انباشته وسائط نقلیه", "parentId": 2481, "accountType": 0},
{"id": 2484, "level": 4, "code": "10803", "name": "استهلاک انباشته اثاثیه اداری", "parentId": 2481, "accountType": 0},
{"id": 2485, "level": 3, "code": "109", "name": "سپرده های بلندمدت", "parentId": 2475, "accountType": 0},
{"id": 2486, "level": 3, "code": "110", "name": "سایر دارائی ها", "parentId": 2475, "accountType": 0},
{"id": 2487, "level": 4, "code": "11001", "name": "حق الامتیازها", "parentId": 2486, "accountType": 0},
{"id": 2488, "level": 4, "code": "11002", "name": "نرم افزارها", "parentId": 2486, "accountType": 0},
{"id": 2489, "level": 4, "code": "11003", "name": "سایر دارایی های نامشهود", "parentId": 2486, "accountType": 0},
{"id": 2490, "level": 1, "code": "2", "name": "بدهی ها", "parentId": 0, "accountType": 0},
{"id": 2491, "level": 2, "code": "201", "name": "بدهیهای جاری", "parentId": 2490, "accountType": 0},
{"id": 2492, "level": 3, "code": "202", "name": "حساب ها و اسناد پرداختنی", "parentId": 2491, "accountType": 0},
{"id": 2493, "level": 4, "code": "20201", "name": "حساب های پرداختنی", "parentId": 2492, "accountType": 9},
{"id": 2494, "level": 4, "code": "20202", "name": "اسناد پرداختنی", "parentId": 2492, "accountType": 10},
{"id": 2495, "level": 3, "code": "203", "name": "سایر حساب های پرداختنی", "parentId": 2491, "accountType": 0},
{"id": 2496, "level": 4, "code": "20301", "name": "ذخیره مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 40},
{"id": 2497, "level": 4, "code": "20302", "name": "مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 12},
{"id": 2498, "level": 4, "code": "20303", "name": "مالیات حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 0},
{"id": 2499, "level": 4, "code": "20304", "name": "حق بیمه پرداختنی", "parentId": 2495, "accountType": 0},
{"id": 2500, "level": 4, "code": "20305", "name": "حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 42},
{"id": 2501, "level": 4, "code": "20306", "name": "عیدی و پاداش پرداختنی", "parentId": 2495, "accountType": 0},
{"id": 2502, "level": 4, "code": "20307", "name": "سایر هزینه های پرداختنی", "parentId": 2495, "accountType": 0},
{"id": 2503, "level": 3, "code": "204", "name": "پیش دریافت ها", "parentId": 2491, "accountType": 0},
{"id": 2504, "level": 4, "code": "20401", "name": "پیش دریافت فروش", "parentId": 2503, "accountType": 0},
{"id": 2505, "level": 4, "code": "20402", "name": "سایر پیش دریافت ها", "parentId": 2503, "accountType": 0},
{"id": 2506, "level": 3, "code": "20101", "name": "مالیات بر ارزش افزوده فروش", "parentId": 2491, "accountType": 11},
{"id": 2507, "level": 2, "code": "205", "name": "بدهیهای غیر جاری", "parentId": 2490, "accountType": 0},
{"id": 2508, "level": 3, "code": "206", "name": "حساب ها و اسناد پرداختنی بلندمدت", "parentId": 2507, "accountType": 0},
{"id": 2509, "level": 4, "code": "20601", "name": "حساب های پرداختنی بلندمدت", "parentId": 2508, "accountType": 0},
{"id": 2510, "level": 4, "code": "20602", "name": "اسناد پرداختنی بلندمدت", "parentId": 2508, "accountType": 0},
{"id": 2511, "level": 3, "code": "20501", "name": "وام پرداختنی", "parentId": 2507, "accountType": 0},
{"id": 2512, "level": 3, "code": "20502", "name": "ذخیره مزایای پایان خدمت کارکنان", "parentId": 2507, "accountType": 0},
{"id": 2513, "level": 1, "code": "3", "name": "حقوق صاحبان سهام", "parentId": 0, "accountType": 0},
{"id": 2514, "level": 2, "code": "301", "name": "سرمایه", "parentId": 2513, "accountType": 0},
{"id": 2515, "level": 3, "code": "30101", "name": "سرمایه اولیه", "parentId": 2514, "accountType": 13},
{"id": 2516, "level": 3, "code": "30102", "name": "افزایش یا کاهش سرمایه", "parentId": 2514, "accountType": 14},
{"id": 2517, "level": 3, "code": "30103", "name": "اندوخته قانونی", "parentId": 2514, "accountType": 15},
{"id": 2518, "level": 3, "code": "30104", "name": "برداشت ها", "parentId": 2514, "accountType": 16},
{"id": 2519, "level": 3, "code": "30105", "name": "سهم سود و زیان", "parentId": 2514, "accountType": 17},
{"id": 2520, "level": 3, "code": "30106", "name": "سود یا زیان انباشته (سنواتی)", "parentId": 2514, "accountType": 18},
{"id": 2521, "level": 1, "code": "4", "name": "بهای تمام شده کالای فروخته شده", "parentId": 0, "accountType": 0},
{"id": 2522, "level": 2, "code": "40001", "name": "بهای تمام شده کالای فروخته شده", "parentId": 2521, "accountType": 19},
{"id": 2523, "level": 2, "code": "40002", "name": "برگشت از خرید", "parentId": 2521, "accountType": 20},
{"id": 2524, "level": 2, "code": "40003", "name": "تخفیفات نقدی خرید", "parentId": 2521, "accountType": 21},
{"id": 2525, "level": 1, "code": "5", "name": "فروش", "parentId": 0, "accountType": 0},
{"id": 2526, "level": 2, "code": "50001", "name": "فروش کالا", "parentId": 2525, "accountType": 22},
{"id": 2527, "level": 2, "code": "50002", "name": "برگشت از فروش", "parentId": 2525, "accountType": 23},
{"id": 2528, "level": 2, "code": "50003", "name": "تخفیفات نقدی فروش", "parentId": 2525, "accountType": 24},
{"id": 2529, "level": 1, "code": "6", "name": "درآمد", "parentId": 0, "accountType": 0},
{"id": 2530, "level": 2, "code": "601", "name": "درآمد های عملیاتی", "parentId": 2529, "accountType": 0},
{"id": 2531, "level": 3, "code": "60101", "name": "درآمد حاصل از فروش خدمات", "parentId": 2530, "accountType": 25},
{"id": 2532, "level": 3, "code": "60102", "name": "برگشت از خرید خدمات", "parentId": 2530, "accountType": 26},
{"id": 2533, "level": 3, "code": "60103", "name": "درآمد اضافه کالا", "parentId": 2530, "accountType": 27},
{"id": 2534, "level": 3, "code": "60104", "name": "درآمد حمل کالا", "parentId": 2530, "accountType": 28},
{"id": 2535, "level": 2, "code": "602", "name": "درآمد های غیر عملیاتی", "parentId": 2529, "accountType": 0},
{"id": 2536, "level": 3, "code": "60201", "name": "درآمد حاصل از سرمایه گذاری", "parentId": 2535, "accountType": 0},
{"id": 2537, "level": 3, "code": "60202", "name": "درآمد سود سپرده ها", "parentId": 2535, "accountType": 0},
{"id": 2538, "level": 3, "code": "60203", "name": "سایر درآمد ها", "parentId": 2535, "accountType": 0},
{"id": 2539, "level": 3, "code": "60204", "name": "درآمد تسعیر ارز", "parentId": 2535, "accountType": 36},
{"id": 2540, "level": 1, "code": "7", "name": "هزینه ها", "parentId": 0, "accountType": 0},
{"id": 2541, "level": 2, "code": "701", "name": "هزینه های پرسنلی", "parentId": 2540, "accountType": 0},
{"id": 2542, "level": 3, "code": "702", "name": "هزینه حقوق و دستمزد", "parentId": 2541, "accountType": 0},
{"id": 2543, "level": 4, "code": "70201", "name": "حقوق پایه", "parentId": 2542, "accountType": 0},
{"id": 2544, "level": 4, "code": "70202", "name": "اضافه کار", "parentId": 2542, "accountType": 0},
{"id": 2545, "level": 4, "code": "70203", "name": "حق شیفت و شب کاری", "parentId": 2542, "accountType": 0},
{"id": 2546, "level": 4, "code": "70204", "name": "حق نوبت کاری", "parentId": 2542, "accountType": 0},
{"id": 2547, "level": 4, "code": "70205", "name": "حق ماموریت", "parentId": 2542, "accountType": 0},
{"id": 2548, "level": 4, "code": "70206", "name": "فوق العاده مسکن و خاروبار", "parentId": 2542, "accountType": 0},
{"id": 2549, "level": 4, "code": "70207", "name": "حق اولاد", "parentId": 2542, "accountType": 0},
{"id": 2550, "level": 4, "code": "70208", "name": "عیدی و پاداش", "parentId": 2542, "accountType": 0},
{"id": 2551, "level": 4, "code": "70209", "name": "بازخرید سنوات خدمت کارکنان", "parentId": 2542, "accountType": 0},
{"id": 2552, "level": 4, "code": "70210", "name": "بازخرید مرخصی", "parentId": 2542, "accountType": 0},
{"id": 2553, "level": 4, "code": "70211", "name": "بیمه سهم کارفرما", "parentId": 2542, "accountType": 0},
{"id": 2554, "level": 4, "code": "70212", "name": "بیمه بیکاری", "parentId": 2542, "accountType": 0},
{"id": 2555, "level": 4, "code": "70213", "name": "حقوق مزایای متفرقه", "parentId": 2542, "accountType": 0},
{"id": 2556, "level": 3, "code": "703", "name": "سایر هزینه های کارکنان", "parentId": 2541, "accountType": 0},
{"id": 2557, "level": 4, "code": "70301", "name": "سفر و ماموریت", "parentId": 2556, "accountType": 0},
{"id": 2558, "level": 4, "code": "70302", "name": "ایاب و ذهاب", "parentId": 2556, "accountType": 0},
{"id": 2559, "level": 4, "code": "70303", "name": "سایر هزینه های کارکنان", "parentId": 2556, "accountType": 0},
{"id": 2560, "level": 2, "code": "704", "name": "هزینه های عملیاتی", "parentId": 2540, "accountType": 0},
{"id": 2561, "level": 3, "code": "70401", "name": "خرید خدمات", "parentId": 2560, "accountType": 30},
{"id": 2562, "level": 3, "code": "70402", "name": "برگشت از فروش خدمات", "parentId": 2560, "accountType": 29},
{"id": 2563, "level": 3, "code": "70403", "name": "هزینه حمل کالا", "parentId": 2560, "accountType": 31},
{"id": 2564, "level": 3, "code": "70404", "name": "تعمیر و نگهداری اموال و اثاثیه", "parentId": 2560, "accountType": 0},
{"id": 2565, "level": 3, "code": "70405", "name": "هزینه اجاره محل", "parentId": 2560, "accountType": 0},
{"id": 2566, "level": 3, "code": "705", "name": "هزینه های عمومی", "parentId": 2560, "accountType": 0},
{"id": 2567, "level": 4, "code": "70501", "name": "هزینه آب و برق و گاز و تلفن", "parentId": 2566, "accountType": 0},
{"id": 2568, "level": 4, "code": "70502", "name": "هزینه پذیرایی و آبدارخانه", "parentId": 2566, "accountType": 0},
{"id": 2569, "level": 3, "code": "70406", "name": "هزینه ملزومات مصرفی", "parentId": 2560, "accountType": 0},
{"id": 2570, "level": 3, "code": "70407", "name": "هزینه کسری و ضایعات کالا", "parentId": 2560, "accountType": 32},
{"id": 2571, "level": 3, "code": "70408", "name": "بیمه دارایی های ثابت", "parentId": 2560, "accountType": 0},
{"id": 2572, "level": 2, "code": "706", "name": "هزینه های استهلاک", "parentId": 2540, "accountType": 0},
{"id": 2573, "level": 3, "code": "70601", "name": "هزینه استهلاک ساختمان", "parentId": 2572, "accountType": 0},
{"id": 2574, "level": 3, "code": "70602", "name": "هزینه استهلاک وسائط نقلیه", "parentId": 2572, "accountType": 0},
{"id": 2575, "level": 3, "code": "70603", "name": "هزینه استهلاک اثاثیه", "parentId": 2572, "accountType": 0},
{"id": 2576, "level": 2, "code": "707", "name": "هزینه های بازاریابی و توزیع و فروش", "parentId": 2540, "accountType": 0},
{"id": 2577, "level": 3, "code": "70701", "name": "هزینه آگهی و تبلیغات", "parentId": 2576, "accountType": 0},
{"id": 2578, "level": 3, "code": "70702", "name": "هزینه بازاریابی و پورسانت", "parentId": 2576, "accountType": 0},
{"id": 2579, "level": 3, "code": "70703", "name": "سایر هزینه های توزیع و فروش", "parentId": 2576, "accountType": 0},
{"id": 2580, "level": 2, "code": "708", "name": "هزینه های غیرعملیاتی", "parentId": 2540, "accountType": 0},
{"id": 2581, "level": 3, "code": "709", "name": "هزینه های بانکی", "parentId": 2580, "accountType": 0},
{"id": 2582, "level": 4, "code": "70901", "name": "سود و کارمزد وامها", "parentId": 2581, "accountType": 0},
{"id": 2583, "level": 4, "code": "70902", "name": "کارمزد خدمات بانکی", "parentId": 2581, "accountType": 33},
{"id": 2584, "level": 4, "code": "70903", "name": "جرائم دیرکرد بانکی", "parentId": 2581, "accountType": 0},
{"id": 2585, "level": 3, "code": "70801", "name": "هزینه تسعیر ارز", "parentId": 2580, "accountType": 37},
{"id": 2586, "level": 3, "code": "70802", "name": "هزینه مطالبات سوخت شده", "parentId": 2580, "accountType": 0},
{"id": 2587, "level": 1, "code": "8", "name": "سایر حساب ها", "parentId": 0, "accountType": 0},
{"id": 2588, "level": 2, "code": "801", "name": "حساب های انتظامی", "parentId": 2587, "accountType": 0},
{"id": 2589, "level": 3, "code": "80101", "name": "حساب های انتظامی", "parentId": 2588, "accountType": 0},
{"id": 2590, "level": 3, "code": "80102", "name": "طرف حساب های انتظامی", "parentId": 2588, "accountType": 0},
{"id": 2591, "level": 2, "code": "802", "name": "حساب های کنترلی", "parentId": 2587, "accountType": 0},
{"id": 2592, "level": 3, "code": "80201", "name": "کنترل کسری و اضافه کالا", "parentId": 2591, "accountType": 34},
{"id": 2593, "level": 2, "code": "803", "name": "حساب خلاصه سود و زیان", "parentId": 2587, "accountType": 0},
{"id": 2594, "level": 3, "code": "80301", "name": "خلاصه سود و زیان", "parentId": 2593, "accountType": 35},
{"id": 2595, "level": 5, "code": "70503", "name": "هزینه آب", "parentId": 2567, "accountType": 0},
{"id": 2596, "level": 5, "code": "70504", "name": "هزینه برق", "parentId": 2567, "accountType": 0},
{"id": 2597, "level": 5, "code": "70505", "name": "هزینه گاز", "parentId": 2567, "accountType": 0},
{"id": 2598, "level": 5, "code": "70506", "name": "هزینه تلفن", "parentId": 2567, "accountType": 0},
{"id": 2600, "level": 4, "code": "20503", "name": "وام از بانک ملت", "parentId": 2511, "accountType": 0},
{"id": 2601, "level": 4, "code": "10405", "name": "سود تحقق نیافته فروش اقساطی", "parentId": 2463, "accountType": 39},
{"id": 2602, "level": 3, "code": "60205", "name": "سود فروش اقساطی", "parentId": 2535, "accountType": 38},
{"id": 2603, "level": 4, "code": "70214", "name": "حق تاهل", "parentId": 2542, "accountType": 0},
{"id": 2604, "level": 4, "code": "20504", "name": "وام از بانک پارسیان", "parentId": 2511, "accountType": 0},
{"id": 2605, "level": 3, "code": "10105", "name": "مساعده", "parentId": 2453, "accountType": 0},
{"id": 2606, "level": 3, "code": "60105", "name": "تعمیرات لوازم آشپزخانه", "parentId": 2530, "accountType": 0},
{"id": 2607, "level": 4, "code": "10705", "name": "کامپیوتر", "parentId": 2476, "accountType": 0},
{"id": 2608, "level": 3, "code": "60206", "name": "درامد حاصل از فروش ضایعات", "parentId": 2535, "accountType": 0},
{"id": 2609, "level": 3, "code": "60207", "name": "سود فروش دارایی", "parentId": 2535, "accountType": 0},
{"id": 2610, "level": 3, "code": "70803", "name": "زیان فروش دارایی", "parentId": 2580, "accountType": 0},
{"id": 2611, "level": 3, "code": "10106", "name": "موجودی کالای در جریان ساخت", "parentId": 2453, "accountType": 41},
{"id": 2612, "level": 3, "code": "20102", "name": "سربار تولید پرداختنی", "parentId": 2491, "accountType": 43},
{"id": 2613, "level": 4, "code": "70507", "name": "هزینه جدید", "parentId": 2566, "accountType": 0},
]
# ۱) حذف حساب‌های عمومی موجود که در لیست جدید نیستند
existing_codes = set(r[0] for r in conn.execute(sa.text("SELECT code FROM accounts WHERE business_id IS NULL")).fetchall())
new_codes = {row["code"] for row in items}
to_delete = tuple(sorted(existing_codes - new_codes))
if to_delete:
# حذف امن بر اساس کد و فقط عمومی
del_sql = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
for c in to_delete:
conn.execute(del_sql, {"code": c})
# ۲) درج/به‌روزرسانی حساب‌ها به‌همراه نگاشت والدین
ext_to_internal: dict[int, int] = {}
select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1")
insert_q = sa.text(
"""
INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at)
VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW())
"""
)
update_q = sa.text(
"""
UPDATE accounts
SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW()
WHERE id = :id
"""
)
for item in items:
parent_internal = None
if item.get("parentId") and item["parentId"] in ext_to_internal:
parent_internal = ext_to_internal[item["parentId"]]
res = conn.execute(select_existing, {"code": item["code"]})
row = res.fetchone()
if row is None:
result = conn.execute(
insert_q,
{
"name": item["name"],
"account_type": str(item.get("accountType", 0)),
"code": item["code"],
"parent_id": parent_internal,
},
)
new_id = result.lastrowid if hasattr(result, "lastrowid") else None
if new_id is None:
# fallback: انتخاب مجدد بر اساس code
res2 = conn.execute(select_existing, {"code": item["code"]})
row2 = res2.fetchone()
if row2:
new_id = row2[0]
if new_id is not None:
ext_to_internal[item["id"]] = int(new_id)
else:
acc_id = int(row[0])
conn.execute(
update_q,
{
"id": acc_id,
"name": item["name"],
"account_type": str(item.get("accountType", 0)),
"parent_id": parent_internal,
},
)
ext_to_internal[item["id"]] = acc_id
def downgrade() -> None:
# در downgrade صرفاً کدهایی که در این میگریشن اضافه/بروز شده‌اند حذف می‌شوند
conn = op.get_bind()
codes = [
"1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102","70507"
]
delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
for code in codes:
conn.execute(delete_q, {"code": code})

View file

@ -0,0 +1,107 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
"""
Normalize accounts.account_type to English values and add constraint
Revision ID: 20251012_000101_update_accounts_account_type_to_english
Revises: 20251011_010001_replace_accounts_chart_seed
Create Date: 2025-10-12 00:01:01.000001
"""
# revision identifiers, used by Alembic.
revision = '20251012_000101_update_accounts_account_type_to_english'
down_revision = '20251011_010001_replace_accounts_chart_seed'
branch_labels = None
depends_on = None
ALLOWED_TYPES = (
"bank",
"cash_register",
"petty_cash",
"check",
"person",
"product",
"service",
"accounting_document",
)
def upgrade() -> None:
conn = op.get_bind()
# نگاشت مقادیر عددی/قدیمی به مقادیر انگلیسی جدید
mapping_updates: list[tuple[str, tuple[str, ...]]] = [
("bank", ("3",)),
("cash_register", ("1",)),
("petty_cash", ("2",)),
("check", ("5", "6", "10")),
("person", ("4", "9")),
("product", ("7",)),
("service", ("25", "26", "29", "30", "31")),
]
for new_val, old_vals in mapping_updates:
for old_val in old_vals:
conn.execute(
sa.text(
"UPDATE accounts SET account_type = :new_val WHERE account_type = :old_val"
),
{"new_val": new_val, "old_val": old_val},
)
# سایر مقادیر ناشناخته را به accounting_document تنظیم کن
placeholders = ", ".join([":v" + str(i) for i in range(len(ALLOWED_TYPES))])
params = {("v" + str(i)): v for i, v in enumerate(ALLOWED_TYPES)}
conn.execute(
sa.text(
f"UPDATE accounts SET account_type = 'accounting_document' WHERE account_type NOT IN ({placeholders})"
),
params,
)
# افزودن چک‌کانسترینت برای اطمینان از مقادیر مجاز (در صورت نبود)
# برخی پایگاه‌ها CHECK را نادیده می‌گیرند؛ این بخش ایمن با try/except است
try:
op.create_check_constraint(
"ck_accounts_account_type_allowed",
"accounts",
"account_type IN ('bank', 'cash_register', 'petty_cash', 'check', 'person', 'product', 'service', 'accounting_document')",
)
except Exception:
# اگر از قبل وجود داشته باشد، نادیده بگیر
pass
def downgrade() -> None:
# حذف چک‌کانسترینت
op.drop_constraint("ck_accounts_account_type_allowed", "accounts", type_="check")
conn = op.get_bind()
# نگاشت معکوس ساده برای بازگشت به مقادیر عددی پایه
reverse_mapping: list[tuple[str, str]] = [
("bank", "3"),
("cash_register", "1"),
("petty_cash", "2"),
("check", "5"),
("person", "4"),
("product", "7"),
("service", "25"),
("accounting_document", "0"),
]
for eng_val, legacy_val in reverse_mapping:
conn.execute(
sa.text(
"UPDATE accounts SET account_type = :legacy WHERE account_type = :eng"
),
{"legacy": legacy_val, "eng": eng_val},
)

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251014_000201_add_person_id_to_document_lines'
down_revision = '20250927_000017_add_account_id_to_document_lines'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.add_column(sa.Column('person_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_document_lines_person_id_persons', 'persons', ['person_id'], ['id'], ondelete='SET NULL')
batch_op.create_index('ix_document_lines_person_id', ['person_id'])
def downgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.drop_index('ix_document_lines_person_id')
batch_op.drop_constraint('fk_document_lines_person_id_persons', type_='foreignkey')
batch_op.drop_column('person_id')

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251014_000301_add_product_id_to_document_lines'
down_revision = '20251014_000201_add_person_id_to_document_lines'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.add_column(sa.Column('product_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_document_lines_product_id_products', 'products', ['product_id'], ['id'], ondelete='SET NULL')
batch_op.create_index('ix_document_lines_product_id', ['product_id'])
def downgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.drop_index('ix_document_lines_product_id')
batch_op.drop_constraint('fk_document_lines_product_id_products', type_='foreignkey')
batch_op.drop_column('product_id')

View file

@ -0,0 +1,49 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251014_000401_add_payment_refs_to_document_lines'
down_revision = '20251014_000301_add_product_id_to_document_lines'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
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')
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
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_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL')
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
batch_op.create_index('ix_document_lines_check_id', ['check_id'])
def downgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.drop_index('ix_document_lines_check_id')
batch_op.drop_index('ix_document_lines_petty_cash_id')
batch_op.drop_index('ix_document_lines_cash_register_id')
batch_op.drop_index('ix_document_lines_bank_account_id')
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey')
batch_op.drop_column('check_id')
batch_op.drop_column('petty_cash_id')
batch_op.drop_column('cash_register_id')
batch_op.drop_column('bank_account_id')

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20251014_000501_add_quantity_to_document_lines'
down_revision = '20251014_000401_add_payment_refs_to_document_lines'
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.add_column(sa.Column('quantity', sa.Numeric(18, 6), nullable=True, server_default=sa.text('0')))
def downgrade() -> None:
with op.batch_alter_table('document_lines') as batch_op:
batch_op.drop_column('quantity')

View file

@ -0,0 +1,24 @@
"""merge heads
Revision ID: 7ecb63029764
Revises: 20250106_000004, 20251012_000101_update_accounts_account_type_to_english, 20251014_000501_add_quantity_to_document_lines
Create Date: 2025-10-14 12:36:58.259190
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7ecb63029764'
down_revision = ('20250106_000004', '20251012_000101_update_accounts_account_type_to_english', '20251014_000501_add_quantity_to_document_lines')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View file

@ -1091,5 +1091,14 @@
"pettyCashExportExcel": "Export petty cash to Excel",
"pettyCashExportPdf": "Export petty cash to PDF",
"pettyCashReport": "Petty Cash Report"
,
"accountTypeBank": "Bank",
"accountTypeCashRegister": "Cash Register",
"accountTypePettyCash": "Petty Cash",
"accountTypeCheck": "Check",
"accountTypePerson": "Person",
"accountTypeProduct": "Product",
"accountTypeService": "Service",
"accountTypeAccountingDocument": "Accounting Document"
}

View file

@ -1073,6 +1073,14 @@
"pettyCashDetails": "جزئیات تنخواه گردان",
"pettyCashExportExcel": "خروجی Excel تنخواه گردان‌ها",
"pettyCashExportPdf": "خروجی PDF تنخواه گردان‌ها",
"pettyCashReport": "گزارش تنخواه گردان‌ها"
"pettyCashReport": "گزارش تنخواه گردان‌ها",
"accountTypeBank": "بانک",
"accountTypeCashRegister": "صندوق",
"accountTypePettyCash": "تنخواه گردان",
"accountTypeCheck": "چک",
"accountTypePerson": "شخص",
"accountTypeProduct": "کالا",
"accountTypeService": "خدمات",
"accountTypeAccountingDocument": "سند حسابداری"
}

View file

@ -5719,6 +5719,54 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Petty Cash Report'**
String get pettyCashReport;
/// No description provided for @accountTypeBank.
///
/// In en, this message translates to:
/// **'Bank'**
String get accountTypeBank;
/// No description provided for @accountTypeCashRegister.
///
/// In en, this message translates to:
/// **'Cash Register'**
String get accountTypeCashRegister;
/// No description provided for @accountTypePettyCash.
///
/// In en, this message translates to:
/// **'Petty Cash'**
String get accountTypePettyCash;
/// No description provided for @accountTypeCheck.
///
/// In en, this message translates to:
/// **'Check'**
String get accountTypeCheck;
/// No description provided for @accountTypePerson.
///
/// In en, this message translates to:
/// **'Person'**
String get accountTypePerson;
/// No description provided for @accountTypeProduct.
///
/// In en, this message translates to:
/// **'Product'**
String get accountTypeProduct;
/// No description provided for @accountTypeService.
///
/// In en, this message translates to:
/// **'Service'**
String get accountTypeService;
/// No description provided for @accountTypeAccountingDocument.
///
/// In en, this message translates to:
/// **'Accounting Document'**
String get accountTypeAccountingDocument;
}
class _AppLocalizationsDelegate

View file

@ -2897,4 +2897,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pettyCashReport => 'Petty Cash Report';
@override
String get accountTypeBank => 'Bank';
@override
String get accountTypeCashRegister => 'Cash Register';
@override
String get accountTypePettyCash => 'Petty Cash';
@override
String get accountTypeCheck => 'Check';
@override
String get accountTypePerson => 'Person';
@override
String get accountTypeProduct => 'Product';
@override
String get accountTypeService => 'Service';
@override
String get accountTypeAccountingDocument => 'Accounting Document';
}

View file

@ -2876,4 +2876,28 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get pettyCashReport => 'گزارش تنخواه گردان‌ها';
@override
String get accountTypeBank => 'بانک';
@override
String get accountTypeCashRegister => 'صندوق';
@override
String get accountTypePettyCash => 'تنخواه گردان';
@override
String get accountTypeCheck => 'چک';
@override
String get accountTypePerson => 'شخص';
@override
String get accountTypeProduct => 'کالا';
@override
String get accountTypeService => 'خدمات';
@override
String get accountTypeAccountingDocument => 'سند حسابداری';
}

View file

@ -38,6 +38,7 @@ import 'pages/business/cash_registers_page.dart';
import 'pages/business/petty_cash_page.dart';
import 'pages/business/checks_page.dart';
import 'pages/business/check_form_page.dart';
import 'pages/business/receipts_payments_page.dart';
import 'pages/error_404_page.dart';
import 'core/locale_controller.dart';
import 'core/calendar_controller.dart';
@ -795,6 +796,26 @@ class _MyAppState extends State<MyApp> {
},
),
// Checks: list, new, edit
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: ReceiptsPaymentsPage(
businessId: businessId,
calendarController: _calendarController!,
authStore: _authStore!,
apiClient: ApiClient(),
),
);
},
),
GoRoute(
path: 'checks',
name: 'business_checks',
@ -827,6 +848,7 @@ class _MyAppState extends State<MyApp> {
child: CheckFormPage(
businessId: businessId,
authStore: _authStore!,
calendarController: _calendarController!,
),
);
},
@ -847,6 +869,7 @@ class _MyAppState extends State<MyApp> {
businessId: businessId,
authStore: _authStore!,
checkId: checkId,
calendarController: _calendarController!,
),
);
},

View file

@ -2,6 +2,45 @@ import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart';
class AccountNode {
final String id;
final String code;
final String name;
final String? accountType;
final List<AccountNode> children;
final bool hasChildren;
const AccountNode({
required this.id,
required this.code,
required this.name,
this.accountType,
this.children = const [],
this.hasChildren = false,
});
factory AccountNode.fromJson(Map<String, dynamic> json) {
final rawChildren = (json['children'] as List?) ?? const [];
final parsedChildren = rawChildren
.map((c) => AccountNode.fromJson(Map<String, dynamic>.from(c as Map)))
.toList();
return AccountNode(
id: (json['id']?.toString() ?? json['code']?.toString() ?? UniqueKey().toString()),
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
accountType: json['account_type']?.toString(),
children: parsedChildren,
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
);
}
}
class _VisibleNode {
final AccountNode node;
final int level;
const _VisibleNode(this.node, this.level);
}
class AccountsPage extends StatefulWidget {
final int businessId;
const AccountsPage({super.key, required this.businessId});
@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget {
class _AccountsPageState extends State<AccountsPage> {
bool _loading = true;
String? _error;
List<dynamic> _tree = const [];
List<AccountNode> _roots = const [];
final Set<String> _expandedIds = <String>{};
@override
void initState() {
@ -26,7 +66,11 @@ class _AccountsPageState extends State<AccountsPage> {
try {
final api = ApiClient();
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
setState(() { _tree = res.data['data']['items'] ?? []; });
final items = (res.data['data']['items'] as List?) ?? const [];
final parsed = items
.map((n) => AccountNode.fromJson(Map<String, dynamic>.from(n as Map)))
.toList();
setState(() { _roots = parsed; });
} catch (e) {
setState(() { _error = e.toString(); });
} finally {
@ -34,17 +78,86 @@ class _AccountsPageState extends State<AccountsPage> {
}
}
Widget _buildNode(Map<String, dynamic> node) {
final children = (node['children'] as List?) ?? const [];
if (children.isEmpty) {
return ListTile(
title: Text('${node['code']} - ${node['name']}'),
);
List<_VisibleNode> _buildVisibleNodes() {
final List<_VisibleNode> result = <_VisibleNode>[];
void dfs(AccountNode node, int level) {
result.add(_VisibleNode(node, level));
if (_expandedIds.contains(node.id)) {
for (final child in node.children) {
dfs(child, level + 1);
}
}
}
for (final r in _roots) {
dfs(r, 0);
}
return result;
}
void _toggleExpand(AccountNode node) {
setState(() {
if (_expandedIds.contains(node.id)) {
_expandedIds.remove(node.id);
} else {
if (node.hasChildren) {
_expandedIds.add(node.id);
}
}
});
}
String _localizedAccountType(AppLocalizations t, String? value) {
if (value == null || value.isEmpty) return '-';
final ln = t.localeName;
if (ln.startsWith('fa')) {
switch (value) {
case 'bank':
return t.accountTypeBank;
case 'cash_register':
return t.accountTypeCashRegister;
case 'petty_cash':
return t.accountTypePettyCash;
case 'check':
return t.accountTypeCheck;
case 'person':
return t.accountTypePerson;
case 'product':
return t.accountTypeProduct;
case 'service':
return t.accountTypeService;
case 'accounting_document':
return t.accountTypeAccountingDocument;
default:
return value;
}
}
// English and other locales: humanize
String humanize(String v) {
return v
.split('_')
.map((p) => p.isEmpty ? p : (p[0].toUpperCase() + p.substring(1)))
.join(' ');
}
switch (value) {
case 'bank':
return t.accountTypeBank;
case 'cash_register':
return t.accountTypeCashRegister;
case 'petty_cash':
return t.accountTypePettyCash;
case 'check':
return t.accountTypeCheck;
case 'person':
return t.accountTypePerson;
case 'product':
return t.accountTypeProduct;
case 'service':
return t.accountTypeService;
case 'accounting_document':
return t.accountTypeAccountingDocument;
default:
return humanize(value);
}
return ExpansionTile(
title: Text('${node['code']} - ${node['name']}'),
children: children.map<Widget>((c) => _buildNode(Map<String, dynamic>.from(c))).toList(),
);
}
@override
@ -52,13 +165,65 @@ class _AccountsPageState extends State<AccountsPage> {
final t = AppLocalizations.of(context);
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) return Center(child: Text(_error!));
final visible = _buildVisibleNodes();
return Scaffold(
appBar: AppBar(title: Text(t.chartOfAccounts)),
body: RefreshIndicator(
onRefresh: _fetch,
child: ListView(
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
),
body: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
const SizedBox(width: 28), // expander space
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(flex: 5, child: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(flex: 3, child: Text(t.type, style: const TextStyle(fontWeight: FontWeight.w600))),
],
),
),
Expanded(
child: RefreshIndicator(
onRefresh: _fetch,
child: ListView.builder(
itemCount: visible.length,
itemBuilder: (context, index) {
final item = visible[index];
final node = item.node;
final level = item.level;
final isExpanded = _expandedIds.contains(node.id);
final canExpand = node.hasChildren;
return InkWell(
onTap: canExpand ? () => _toggleExpand(node) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
SizedBox(width: 12.0 * level),
SizedBox(
width: 28,
child: canExpand
? IconButton(
padding: EdgeInsets.zero,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
onPressed: () => _toggleExpand(node),
)
: const SizedBox.shrink(),
),
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
Expanded(flex: 5, child: Text(node.name)),
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
],
),
),
);
},
),
),
),
],
),
);
}

View file

@ -1,21 +1,349 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/auth_store.dart';
import '../../core/calendar_controller.dart';
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../widgets/date_input_field.dart';
import '../../widgets/banking/currency_picker_widget.dart';
import '../../widgets/permission/access_denied_page.dart';
import '../../services/check_service.dart';
class CheckFormPage extends StatelessWidget {
class CheckFormPage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
final int? checkId; // null => new, not null => edit
final CalendarController? calendarController;
const CheckFormPage({
super.key,
required this.businessId,
required this.authStore,
this.checkId,
this.calendarController,
});
@override
State<CheckFormPage> createState() => _CheckFormPageState();
}
class _CheckFormPageState extends State<CheckFormPage> {
final _service = CheckService();
String? _type; // 'received' | 'transferred'
DateTime? _issueDate;
DateTime? _dueDate;
int? _currencyId;
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
final _checkNumberCtrl = TextEditingController();
final _sayadCtrl = TextEditingController();
final _bankCtrl = TextEditingController();
final _branchCtrl = TextEditingController();
final _amountCtrl = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
_type = 'received';
_currencyId = widget.authStore.selectedCurrencyId;
_issueDate = DateTime.now();
_dueDate = DateTime.now();
if (widget.checkId != null) {
_loadData();
}
}
Future<void> _loadData() async {
setState(() => _loading = true);
try {
final data = await _service.getById(widget.checkId!);
setState(() {
_type = (data['type'] as String?) ?? 'received';
_checkNumberCtrl.text = (data['check_number'] ?? '') as String;
_sayadCtrl.text = (data['sayad_code'] ?? '') as String;
_bankCtrl.text = (data['bank_name'] ?? '') as String;
_branchCtrl.text = (data['branch_name'] ?? '') as String;
final amount = data['amount'];
_amountCtrl.text = amount == null ? '' : amount.toString();
final issue = data['issue_date'] as String?;
final due = data['due_date'] as String?;
_issueDate = issue != null ? DateTime.tryParse(issue) : _issueDate;
_dueDate = due != null ? DateTime.tryParse(due) : _dueDate;
_currencyId = (data['currency_id'] is int) ? data['currency_id'] as int : _currencyId;
// person_id exists but PersonComboboxWidget needs model; leave unselected for now
});
} catch (_) {
// ignore load errors for now
} finally {
if (mounted) setState(() => _loading = false);
}
}
String? _validate() {
if (_type != 'received' && _type != 'transferred') return 'نوع چک الزامی است';
if (_type == 'received' && _selectedPerson == null) return 'انتخاب شخص برای چک دریافتی الزامی است';
if ((_checkNumberCtrl.text.trim()).isEmpty) return 'شماره چک الزامی است';
if (_sayadCtrl.text.trim().isNotEmpty && _sayadCtrl.text.trim().length != 16) return 'شناسه صیاد باید 16 رقم باشد';
if (_issueDate == null) return 'تاریخ صدور الزامی است';
if (_dueDate == null) return 'تاریخ سررسید الزامی است';
if (_issueDate != null && _dueDate != null && _dueDate!.isBefore(_issueDate!)) return 'تاریخ سررسید نمی‌تواند قبل از تاریخ صدور باشد';
final amount = num.tryParse(_amountCtrl.text.replaceAll(',', '').trim());
if (amount == null || amount <= 0) return 'مبلغ باید عددی بزرگتر از صفر باشد';
if (_currencyId == null) return 'واحد پول الزامی است';
return null;
}
Future<void> _save() async {
final error = _validate();
if (error != null) {
_showError(error);
return;
}
setState(() => _loading = true);
try {
final payload = <String, dynamic>{
'type': _type,
if (_selectedPerson != null) 'person_id': (_selectedPerson as dynamic).id,
'issue_date': _issueDate!.toIso8601String(),
'due_date': _dueDate!.toIso8601String(),
'check_number': _checkNumberCtrl.text.trim(),
if (_sayadCtrl.text.trim().isNotEmpty) 'sayad_code': _sayadCtrl.text.trim(),
if (_bankCtrl.text.trim().isNotEmpty) 'bank_name': _bankCtrl.text.trim(),
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
'currency_id': _currencyId,
};
if (widget.checkId == null) {
await _service.create(businessId: widget.businessId, payload: payload);
} else {
await _service.update(id: widget.checkId!, payload: payload);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.checkId == null ? 'چک ثبت شد' : 'چک ویرایش شد'),
),
);
Navigator.of(context).maybePop();
} catch (e) {
_showError('خطا در ذخیره: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
@override
void dispose() {
_checkNumberCtrl.dispose();
_sayadCtrl.dispose();
_bankCtrl.dispose();
_branchCtrl.dispose();
_amountCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const SizedBox.expand();
final t = AppLocalizations.of(context);
final isEdit = widget.checkId != null;
if (!widget.authStore.canWriteSection('checks')) {
return AccessDeniedPage(message: t.accessDenied);
}
return Scaffold(
appBar: AppBar(
title: Text(isEdit ? t.edit : t.add),
actions: [
if (_loading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
),
],
),
body: IgnorePointer(
ignoring: _loading,
child: AbsorbPointer(
absorbing: _loading,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_loading) const LinearProgressIndicator(),
// نوع چک
DropdownButtonFormField<String>(
value: _type,
items: const [
DropdownMenuItem(value: 'received', child: Text('دریافتی')),
DropdownMenuItem(value: 'transferred', child: Text('واگذار شده')),
],
onChanged: (val) => setState(() => _type = val),
decoration: const InputDecoration(
labelText: 'نوع چک *',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
// شخص (برای دریافتی)
if (_type == 'received') ...[
PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: _selectedPerson,
onChanged: (p) => setState(() => _selectedPerson = p),
isRequired: true,
label: 'شخص (برای چک دریافتی)',
hintText: 'جست‌وجو و انتخاب شخص',
),
const SizedBox(height: 12),
],
// تاریخها
Row(
children: [
Expanded(
child: DateInputField(
value: _issueDate,
labelText: 'تاریخ صدور *',
hintText: 'انتخاب تاریخ صدور',
calendarController: widget.calendarController!,
onChanged: (d) => setState(() => _issueDate = d),
),
),
const SizedBox(width: 12),
Expanded(
child: DateInputField(
value: _dueDate,
labelText: 'تاریخ سررسید *',
hintText: 'انتخاب تاریخ سررسید',
calendarController: widget.calendarController!,
onChanged: (d) => setState(() => _dueDate = d),
),
),
],
),
const SizedBox(height: 12),
// شماره چک و شناسه صیاد
Row(
children: [
Expanded(
child: TextFormField(
controller: _checkNumberCtrl,
decoration: const InputDecoration(
labelText: 'شماره چک *',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _sayadCtrl,
decoration: const InputDecoration(
labelText: 'شناسه صیاد',
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 12),
// بانک و شعبه
Row(
children: [
Expanded(
child: TextFormField(
controller: _bankCtrl,
decoration: const InputDecoration(
labelText: 'بانک صادرکننده',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _branchCtrl,
decoration: const InputDecoration(
labelText: 'شعبه',
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 12),
// مبلغ و ارز
Row(
children: [
Expanded(
child: TextFormField(
controller: _amountCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'مبلغ *',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _currencyId,
onChanged: (id) => setState(() => _currencyId = id),
label: 'واحد پول',
hintText: 'انتخاب واحد پول',
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _loading ? null : _save,
icon: const Icon(Icons.save),
label: Text(t.save),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: _loading ? null : () => Navigator.of(context).maybePop(),
child: Text(t.cancel),
),
],
),
],
),
),
),
),
),
),
);
}
}

View file

@ -1,7 +1,14 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth_store.dart';
import '../../widgets/data_table/data_table_widget.dart';
import '../../widgets/data_table/data_table_config.dart';
import '../../widgets/permission/permission_widgets.dart';
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../models/person_model.dart';
class ChecksPage extends StatelessWidget {
class ChecksPage extends StatefulWidget {
final int businessId;
final AuthStore authStore;
@ -11,9 +18,152 @@ class ChecksPage extends StatelessWidget {
required this.authStore,
});
@override
State<ChecksPage> createState() => _ChecksPageState();
}
class _ChecksPageState extends State<ChecksPage> {
final GlobalKey _tableKey = GlobalKey();
Person? _selectedPerson;
void _refresh() {
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
}
@override
Widget build(BuildContext context) {
return const SizedBox.expand();
final t = AppLocalizations.of(context);
if (!widget.authStore.canReadSection('checks')) {
return AccessDeniedPage(message: t.accessDenied);
}
return Scaffold(
body: DataTableWidget<Map<String, dynamic>>(
key: _tableKey,
config: _buildConfig(t, context),
fromJson: (json) => json,
),
);
}
DataTableConfig<Map<String, dynamic>> _buildConfig(AppLocalizations t, BuildContext context) {
return DataTableConfig<Map<String, dynamic>>(
endpoint: '/api/v1/checks/businesses/${widget.businessId}/checks',
title: (t.localeName == 'fa') ? 'چک‌ها' : 'Checks',
excelEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/excel',
pdfEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/pdf',
getExportParams: () => {'business_id': widget.businessId, if (_selectedPerson != null) 'person_id': _selectedPerson!.id},
additionalParams: { if (_selectedPerson != null) 'person_id': _selectedPerson!.id },
showBackButton: true,
onBack: () => Navigator.of(context).maybePop(),
showTableIcon: false,
showRowNumbers: true,
enableRowSelection: true,
enableMultiRowSelection: true,
showColumnSearch: true,
showActiveFilters: true,
showClearFiltersButton: true,
columns: [
TextColumn(
'type',
'نوع',
width: ColumnWidth.small,
filterType: ColumnFilterType.multiSelect,
filterOptions: const [
FilterOption(value: 'received', label: 'دریافتی'),
FilterOption(value: 'transferred', label: 'واگذار شده'),
],
formatter: (row) => (row['type'] == 'received') ? 'دریافتی' : (row['type'] == 'transferred' ? 'واگذار شده' : '-'),
),
TextColumn('person_name', 'شخص', width: ColumnWidth.large,
formatter: (row) => (row['person_name'] ?? '-'),
),
DateColumn(
'issue_date',
'تاریخ صدور',
width: ColumnWidth.medium,
filterType: ColumnFilterType.dateRange,
formatter: (row) => (row['issue_date'] ?? '-'),
),
DateColumn(
'due_date',
'تاریخ سررسید',
width: ColumnWidth.medium,
filterType: ColumnFilterType.dateRange,
formatter: (row) => (row['due_date'] ?? '-'),
),
TextColumn('check_number', 'شماره چک', width: ColumnWidth.medium,
formatter: (row) => (row['check_number'] ?? '-'),
),
TextColumn('sayad_code', 'شناسه صیاد', width: ColumnWidth.medium,
formatter: (row) => (row['sayad_code'] ?? '-'),
),
TextColumn('bank_name', 'بانک', width: ColumnWidth.medium,
formatter: (row) => (row['bank_name'] ?? '-'),
),
TextColumn('branch_name', 'شعبه', width: ColumnWidth.medium,
formatter: (row) => (row['branch_name'] ?? '-'),
),
NumberColumn('amount', 'مبلغ', width: ColumnWidth.medium,
formatter: (row) => (row['amount']?.toString() ?? '-'),
),
TextColumn('currency', 'ارز', width: ColumnWidth.small,
formatter: (row) => (row['currency'] ?? '-'),
),
ActionColumn('actions', t.actions, actions: [
DataTableAction(
icon: Icons.edit,
label: t.edit,
onTap: (row) {
final id = row is Map<String, dynamic> ? row['id'] : null;
if (id is int) {
context.go('/business/${widget.businessId}/checks/$id/edit');
}
},
),
]),
],
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
filterFields: ['type','currency','issue_date','due_date'],
defaultPageSize: 20,
customHeaderActions: [
// فیلتر شخص
SizedBox(
width: 280,
child: PersonComboboxWidget(
businessId: widget.businessId,
selectedPerson: _selectedPerson,
onChanged: (p) {
setState(() { _selectedPerson = p; });
_refresh();
},
isRequired: false,
label: 'شخص',
hintText: 'جست‌وجوی شخص',
),
),
const SizedBox(width: 8),
PermissionButton(
section: 'checks',
action: 'add',
authStore: widget.authStore,
child: Tooltip(
message: t.add,
child: IconButton(
onPressed: () => context.go('/business/${widget.businessId}/checks/new'),
icon: const Icon(Icons.add),
),
),
),
],
onRowTap: (row) {
final id = row is Map<String, dynamic> ? row['id'] : null;
if (id is int) {
context.go('/business/${widget.businessId}/checks/$id/edit');
}
},
);
}
}

View file

@ -0,0 +1,691 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/calendar_controller.dart';
import '../../core/date_utils.dart' show HesabixDateUtils;
import '../../utils/number_formatters.dart' show formatWithThousands;
import '../../widgets/invoice/person_combobox_widget.dart';
import '../../widgets/invoice/invoice_transactions_widget.dart';
import '../../widgets/date_input_field.dart';
import '../../models/invoice_transaction.dart';
import '../../models/invoice_type_model.dart';
import '../../models/person_model.dart';
import '../../models/business_dashboard_models.dart';
import '../../widgets/banking/currency_picker_widget.dart';
import '../../core/auth_store.dart';
import '../../core/api_client.dart';
import '../../services/receipt_payment_service.dart';
class ReceiptsPaymentsPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
const ReceiptsPaymentsPage({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
});
@override
State<ReceiptsPaymentsPage> createState() => _ReceiptsPaymentsPageState();
}
class _ReceiptsPaymentsPageState extends State<ReceiptsPaymentsPage> {
int _tabIndex = 0;
final List<_BulkSettlementDraft> _drafts = <_BulkSettlementDraft>[];
@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: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
t.receiptsAndPayments,
style: Theme.of(context).textTheme.titleLarge,
),
),
FilledButton.icon(
onPressed: () async {
final draft = await showDialog<_BulkSettlementDraft>(
context: context,
builder: (_) => _BulkSettlementDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isReceipt: _tabIndex == 0,
businessInfo: widget.authStore.currentBusiness,
apiClient: widget.apiClient,
),
);
if (draft != null) {
setState(() {
_drafts.removeWhere((d) => d.id == draft.id);
_drafts.add(draft);
});
}
},
icon: const Icon(Icons.add),
label: Text(t.add),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<int>(
segments: [
ButtonSegment<int>(value: 0, label: Text(t.receipts), icon: const Icon(Icons.download_done_outlined)),
ButtonSegment<int>(value: 1, label: Text(t.payments), icon: const Icon(Icons.upload_outlined)),
],
selected: {_tabIndex},
onSelectionChanged: (set) => setState(() => _tabIndex = set.first),
),
),
const SizedBox(height: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _DraftsList(
businessId: widget.businessId,
drafts: _drafts.where((d) => d.isReceipt == (_tabIndex == 0)).toList(),
onEdit: (d) async {
final updated = await showDialog<_BulkSettlementDraft>(
context: context,
builder: (_) => _BulkSettlementDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isReceipt: d.isReceipt,
initial: d,
apiClient: widget.apiClient,
),
);
if (updated != null) {
setState(() {
final idx = _drafts.indexWhere((x) => x.id == updated.id);
if (idx >= 0) {
_drafts[idx] = updated;
} else {
_drafts.add(updated);
}
});
}
},
onDelete: (d) {
setState(() => _drafts.removeWhere((x) => x.id == d.id));
},
),
),
),
],
),
),
);
}
}
class _DraftsList extends StatelessWidget {
final int businessId;
final List<_BulkSettlementDraft> drafts;
final ValueChanged<_BulkSettlementDraft> onEdit;
final ValueChanged<_BulkSettlementDraft> onDelete;
const _DraftsList({
required this.businessId,
required this.drafts,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Card(
margin: const EdgeInsets.all(8),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(child: Text(t.receiptsAndPayments, style: Theme.of(context).textTheme.titleMedium)),
],
),
),
const Divider(height: 1),
Expanded(
child: drafts.isEmpty
? Center(child: Text(t.noDataFound))
: ListView.builder(
itemCount: drafts.length,
itemBuilder: (ctx, i) {
final d = drafts[i];
final sumPersons = d.personLines.fold<double>(0, (p, e) => p + e.amount);
final sumCenters = d.centerTransactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
return ListTile(
title: Text('${formatWithThousands(sumPersons)} | ${formatWithThousands(sumCenters)}'),
subtitle: Text('${HesabixDateUtils.formatForDisplay(d.documentDate, true)}${d.isReceipt ? t.receipts : t.payments}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => onEdit(d)),
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => onDelete(d)),
],
),
);
},
),
),
],
),
);
}
}
class _BulkSettlementDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final bool isReceipt;
final BusinessWithPermission? businessInfo;
final _BulkSettlementDraft? initial;
final ApiClient apiClient;
const _BulkSettlementDialog({
required this.businessId,
required this.calendarController,
required this.isReceipt,
this.businessInfo,
this.initial,
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 = widget.initial?.documentDate ?? DateTime.now();
_isReceipt = widget.initial?.isReceipt ?? widget.isReceipt;
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
if (widget.initial != null) {
_personLines.addAll(widget.initial!.personLines);
_centerTransactions.addAll(widget.initial!.centerTransactions);
}
}
@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,
},
}).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, null);
// نمایش پیام موفقیت
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 _BulkSettlementDraft {
final String id;
final bool isReceipt;
final DateTime documentDate;
final List<_PersonLine> personLines;
final List<InvoiceTransaction> centerTransactions;
_BulkSettlementDraft({
required this.id,
required this.isReceipt,
required this.documentDate,
required this.personLines,
required this.centerTransactions,
});
}
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

@ -0,0 +1,72 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
class CheckService {
final ApiClient _client;
CheckService({ApiClient? client}) : _client = client ?? ApiClient();
Future<Map<String, dynamic>> list({required int businessId, required Map<String, dynamic> queryInfo}) async {
try {
final res = await _client.post<Map<String, dynamic>>(
'/api/v1/checks/businesses/$businessId/checks',
data: queryInfo,
);
final data = res.data ?? <String, dynamic>{};
data['items'] ??= <dynamic>[];
return data;
} catch (e) {
return {
'items': <dynamic>[],
'pagination': {
'total': 0,
'page': 1,
'per_page': queryInfo['take'] ?? 10,
'total_pages': 0,
'has_next': false,
'has_prev': false,
},
'query_info': queryInfo,
};
}
}
Future<Map<String, dynamic>> getById(int id) async {
final res = await _client.get<Map<String, dynamic>>('/api/v1/checks/checks/$id');
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> create({required int businessId, required Map<String, dynamic> payload}) async {
final res = await _client.post<Map<String, dynamic>>(
'/api/v1/checks/businesses/$businessId/checks/create',
data: payload,
);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<Map<String, dynamic>> update({required int id, required Map<String, dynamic> payload}) async {
final res = await _client.put<Map<String, dynamic>>('/api/v1/checks/checks/$id', data: payload);
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
}
Future<void> delete(int id) async {
await _client.delete<Map<String, dynamic>>('/api/v1/checks/checks/$id');
}
Future<Response<List<int>>> exportExcel({required int businessId, required Map<String, dynamic> body}) async {
return await _client.post<List<int>>(
'/api/v1/checks/businesses/$businessId/checks/export/excel',
data: body,
options: Options(responseType: ResponseType.bytes),
);
}
Future<Response<List<int>>> exportPdf({required int businessId, required Map<String, dynamic> body}) async {
return await _client.post<List<int>>(
'/api/v1/checks/businesses/$businessId/checks/export/pdf',
data: body,
options: Options(responseType: ResponseType.bytes),
);
}
}

View file

@ -0,0 +1,185 @@
import '../core/api_client.dart';
/// سرویس دریافت و پرداخت
class ReceiptPaymentService {
final ApiClient _apiClient;
ReceiptPaymentService(this._apiClient);
/// ایجاد سند دریافت یا پرداخت
///
/// [businessId] شناسه کسبوکار
/// [documentType] نوع سند: "receipt" یا "payment"
/// [documentDate] تاریخ سند
/// [currencyId] شناسه ارز
/// [personLines] لیست تراکنشهای اشخاص
/// [accountLines] لیست تراکنشهای حسابها
/// [extraInfo] اطلاعات اضافی (اختیاری)
Future<Map<String, dynamic>> createReceiptPayment({
required int businessId,
required String documentType,
required DateTime documentDate,
required int currencyId,
required List<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
Map<String, dynamic>? extraInfo,
}) async {
final response = await _apiClient.post(
'/businesses/$businessId/receipts-payments/create',
data: {
'document_type': documentType,
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
'person_lines': personLines,
'account_lines': accountLines,
if (extraInfo != null) 'extra_info': extraInfo,
},
);
return response.data['data'] as Map<String, dynamic>;
}
/// دریافت لیست اسناد دریافت و پرداخت
///
/// [businessId] شناسه کسبوکار
/// [documentType] فیلتر بر اساس نوع سند (اختیاری)
/// [fromDate] فیلتر تاریخ از (اختیاری)
/// [toDate] فیلتر تاریخ تا (اختیاری)
/// [skip] تعداد رکورد برای رد کردن
/// [take] تعداد رکورد برای دریافت
/// [search] عبارت جستجو (اختیاری)
Future<Map<String, dynamic>> listReceiptsPayments({
required int businessId,
String? documentType,
DateTime? fromDate,
DateTime? toDate,
int skip = 0,
int take = 20,
String? search,
String? sortBy,
bool sortDesc = true,
}) async {
final body = {
'skip': skip,
'take': take,
'sort_desc': sortDesc,
if (sortBy != null) 'sort_by': sortBy,
if (search != null && search.isNotEmpty) 'search': search,
if (documentType != null) 'document_type': documentType,
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
if (toDate != null) 'to_date': toDate.toIso8601String(),
};
final response = await _apiClient.post(
'/businesses/$businessId/receipts-payments',
data: body,
);
return response.data['data'] as Map<String, dynamic>;
}
/// دریافت جزئیات یک سند دریافت/پرداخت
///
/// [documentId] شناسه سند
Future<Map<String, dynamic>> getReceiptPayment(int documentId) async {
final response = await _apiClient.get(
'/receipts-payments/$documentId',
);
return response.data['data'] as Map<String, dynamic>;
}
/// حذف سند دریافت/پرداخت
///
/// [documentId] شناسه سند
Future<void> deleteReceiptPayment(int documentId) async {
await _apiClient.delete(
'/receipts-payments/$documentId',
);
}
/// ایجاد سند دریافت
///
/// این متد یک wrapper ساده برای createReceiptPayment است
Future<Map<String, dynamic>> createReceipt({
required int businessId,
required DateTime documentDate,
required int currencyId,
required List<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
Map<String, dynamic>? extraInfo,
}) {
return createReceiptPayment(
businessId: businessId,
documentType: 'receipt',
documentDate: documentDate,
currencyId: currencyId,
personLines: personLines,
accountLines: accountLines,
extraInfo: extraInfo,
);
}
/// ایجاد سند پرداخت
///
/// این متد یک wrapper ساده برای createReceiptPayment است
Future<Map<String, dynamic>> createPayment({
required int businessId,
required DateTime documentDate,
required int currencyId,
required List<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
Map<String, dynamic>? extraInfo,
}) {
return createReceiptPayment(
businessId: businessId,
documentType: 'payment',
documentDate: documentDate,
currencyId: currencyId,
personLines: personLines,
accountLines: accountLines,
extraInfo: extraInfo,
);
}
/// دریافت لیست فقط دریافتها
Future<Map<String, dynamic>> listReceipts({
required int businessId,
DateTime? fromDate,
DateTime? toDate,
int skip = 0,
int take = 20,
String? search,
}) {
return listReceiptsPayments(
businessId: businessId,
documentType: 'receipt',
fromDate: fromDate,
toDate: toDate,
skip: skip,
take: take,
search: search,
);
}
/// دریافت لیست فقط پرداختها
Future<Map<String, dynamic>> listPayments({
required int businessId,
DateTime? fromDate,
DateTime? toDate,
int skip = 0,
int take = 20,
String? search,
}) {
return listReceiptsPayments(
businessId: businessId,
documentType: 'payment',
fromDate: fromDate,
toDate: toDate,
skip: skip,
take: take,
search: search,
);
}
}

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'helpers/file_saver.dart';
// // import 'dart:html' as html; // Not available on Linux // Not available on Linux
// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux
import 'package:flutter/material.dart';
import 'package:data_table_2/data_table_2.dart';
import 'package:dio/dio.dart';
@ -698,13 +698,18 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
await FileSaver.saveBytes(bytes, filename);
}
// Platform-specific download functions for Linux
// Platform-specific download functions for Linux
Future<void> _downloadPdf(dynamic data, String filename) async {
await _saveBytesToDownloads(data, filename);
// For Linux desktop, we'll save to Downloads folder
print('Download PDF: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
}
Future<void> _downloadExcel(dynamic data, String filename) async {
await _saveBytesToDownloads(data, filename);
// For Linux desktop, we'll save to Downloads folder
print('Download Excel: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
}

File diff suppressed because it is too large Load diff

View file

@ -54,7 +54,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
),
const SizedBox(width: 8),
Text(
'تراکنش‌های فاکتور',
'تراکنش‌ها',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),