progress in documents

This commit is contained in:
Hesabix 2025-10-27 18:47:45 +00:00
parent c88b1ccdd0
commit bd1fd1a39a
64 changed files with 13177 additions and 551 deletions

View file

@ -0,0 +1,156 @@
# پیاده‌سازی بخش لیست هزینه و درآمد
## خلاصه پیاده‌سازی
این پیاده‌سازی شامل بخش کاملی برای مدیریت اسناد هزینه و درآمد است که بر اساس الگوی موجود در بخش دریافت و پرداخت طراحی شده است.
## فایل‌های ایجاد شده
### Frontend (Flutter)
#### مدل‌ها
- `lib/models/expense_income_document.dart` - مدل اصلی سند هزینه/درآمد
- `lib/models/account_model.dart` - مدل حساب
#### سرویس‌ها
- `lib/services/expense_income_list_service.dart` - سرویس لیست و عملیات گروهی
- `lib/services/expense_income_service.dart` - سرویس CRUD
#### صفحات
- `lib/pages/business/expense_income_list_page.dart` - صفحه اصلی لیست
- `lib/pages/test/expense_income_test_page.dart` - صفحه تست
#### ویجت‌ها
- `lib/widgets/expense_income/expense_income_form_dialog.dart` - دیالوگ ایجاد/ویرایش
- `lib/widgets/expense_income/expense_income_details_dialog.dart` - دیالوگ مشاهده جزئیات
- `lib/widgets/invoice/account_combobox_widget.dart` - ویجت انتخاب حساب
### Backend (Python)
#### API Endpoints
- `hesabixAPI/adapters/api/v1/expense_income.py` - تمام endpoint های مورد نیاز
#### سرویس‌ها
- `hesabixAPI/app/services/expense_income_service.py` - منطق کسب و کار
## ویژگی‌های پیاده‌سازی شده
### Frontend
- ✅ لیست اسناد با جدول پیشرفته
- ✅ فیلتر بر اساس نوع سند (هزینه/درآمد)
- ✅ فیلتر تاریخ (از/تا)
- ✅ جستجو و صفحه‌بندی
- ✅ انتخاب چندگانه و حذف گروهی
- ✅ خروجی Excel و PDF
- ✅ دیالوگ ایجاد سند جدید
- ✅ دیالوگ ویرایش سند موجود
- ✅ دیالوگ مشاهده جزئیات
- ✅ اعتبارسنجی تعادل حساب‌ها
### Backend
- ✅ API لیست اسناد با فیلتر و صفحه‌بندی
- ✅ API ایجاد سند جدید
- ✅ API ویرایش سند موجود
- ✅ API حذف سند (تکی و گروهی)
- ✅ API مشاهده جزئیات سند
- ✅ API خروجی Excel
- ✅ API خروجی PDF
- ✅ اعتبارسنجی و مدیریت خطا
## ساختار داده
### سند هزینه/درآمد
```json
{
"id": 1,
"code": "EI-20250115-12345",
"document_type": "expense",
"document_type_name": "هزینه",
"document_date": "2025-01-15",
"currency_id": 1,
"total_amount": 1000000,
"description": "توضیحات سند",
"item_lines": [
{
"account_id": 123,
"account_name": "هزینه اداری",
"amount": 1000000,
"description": "توضیحات خط"
}
],
"counterparty_lines": [
{
"transaction_type": "bank",
"amount": 1000000,
"transaction_date": "2025-01-15T10:00:00",
"bank_account_id": 456,
"bank_account_name": "بانک ملی"
}
]
}
```
## نحوه استفاده
### اضافه کردن به منوی اصلی
```dart
// در فایل منوی اصلی
ListTile(
leading: const Icon(Icons.trending_up),
title: const Text('هزینه و درآمد'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ExpenseIncomeListPage(
businessId: businessId,
calendarController: calendarController,
authStore: authStore,
apiClient: apiClient,
),
),
);
},
)
```
### تست عملکرد
```dart
// استفاده از صفحه تست
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ExpenseIncomeTestPage(),
),
)
```
## نکات مهم
1. **تعادل حساب‌ها**: سیستم تضمین می‌کند که مجموع حساب‌های هزینه/درآمد با مجموع طرف‌حساب‌ها برابر باشد.
2. **انواع طرف‌حساب**: پشتیبانی از بانک، صندوق، تنخواهگردان، چک و شخص.
3. **کد سند**: فرمت `EI-YYYYMMDD-XXXXX` برای اسناد هزینه/درآمد.
4. **امنیت**: تمام endpoint ها نیاز به احراز هویت و مجوز مناسب دارند.
5. **چندزبانه**: پشتیبانی از تقویم شمسی و میلادی.
## مراحل بعدی
1. **تست کامل**: تست تمام سناریوهای ممکن
2. **بهینه‌سازی**: بهبود عملکرد و UX
3. **مستندسازی**: تکمیل مستندات API
4. **گزارش‌گیری**: اضافه کردن گزارش‌های پیشرفته
5. **یکپارچه‌سازی**: اتصال به سایر بخش‌های سیستم
## مشکلات احتمالی
1. **وابستگی‌ها**: ممکن است نیاز به نصب پکیج‌های اضافی باشد
2. **API**: باید مطمئن شوید که backend در حال اجرا است
3. **دسترسی**: بررسی مجوزهای کاربر برای دسترسی به بخش
## پشتیبانی
برای گزارش مشکلات یا درخواست ویژگی‌های جدید، لطفاً با تیم توسعه تماس بگیرید.

View file

@ -1,7 +1,8 @@
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel
from adapters.db.session import get_db from adapters.db.session import get_db
from adapters.api.v1.schemas import SuccessResponse from adapters.api.v1.schemas import SuccessResponse
@ -15,6 +16,15 @@ from adapters.db.models.account import Account
router = APIRouter(prefix="/accounts", tags=["accounts"]) router = APIRouter(prefix="/accounts", tags=["accounts"])
class SearchAccountsRequest(BaseModel):
"""درخواست جستجوی حساب‌ها"""
take: int = 50
skip: int = 0
search: Optional[str] = None
sort_by: Optional[str] = "code"
sort_desc: bool = False
def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]: def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
by_id: dict[int, AccountTreeNode] = {} by_id: dict[int, AccountTreeNode] = {}
roots: list[AccountTreeNode] = [] roots: list[AccountTreeNode] = []
@ -55,3 +65,135 @@ def get_accounts_tree(
return success_response({"items": [n.model_dump() for n in tree]}, request) return success_response({"items": [n.model_dump() for n in tree]}, request)
@router.get("/business/{business_id}",
summary="دریافت لیست حساب‌ها برای یک کسب و کار",
description="لیست تمام حساب‌های عمومی و حساب‌های اختصاصی کسب و کار (بدون ساختار درختی)",
)
@require_business_access("business_id")
def get_accounts_list(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت لیست ساده حساب‌ها"""
rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
items = [
{
"id": r.id,
"code": r.code,
"name": r.name,
"account_type": r.account_type,
"parent_id": r.parent_id,
"business_id": r.business_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
return success_response({"items": items}, request)
@router.get("/business/{business_id}/account/{account_id}",
summary="دریافت جزئیات یک حساب خاص",
description="دریافت اطلاعات کامل یک حساب بر اساس ID",
)
@require_business_access("business_id")
def get_account_by_id(
request: Request,
business_id: int,
account_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""دریافت یک حساب خاص"""
account = db.query(Account).filter(
Account.id == account_id,
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).first()
if not account:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="حساب یافت نشد")
account_data = {
"id": account.id,
"code": account.code,
"name": account.name,
"account_type": account.account_type,
"parent_id": account.parent_id,
"business_id": account.business_id,
"created_at": account.created_at.isoformat() if account.created_at else None,
"updated_at": account.updated_at.isoformat() if account.updated_at else None,
}
return success_response(account_data, request)
@router.post("/business/{business_id}",
summary="جستجو و فیلتر حساب‌ها",
description="جستجو در حساب‌ها با قابلیت فیلتر، مرتب‌سازی و صفحه‌بندی",
)
@require_business_access("business_id")
def search_accounts(
request: Request,
business_id: int,
search_request: SearchAccountsRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db)
) -> dict:
"""جستجوی حساب‌ها با فیلتر"""
query = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
)
# اعمال جستجو
if search_request.search:
search_term = f"%{search_request.search}%"
query = query.filter(
(Account.code.ilike(search_term)) | (Account.name.ilike(search_term))
)
# شمارش کل
total = query.count()
# مرتب‌سازی
if search_request.sort_by == "name":
order_col = Account.name
else:
order_col = Account.code
if search_request.sort_desc:
query = query.order_by(order_col.desc())
else:
query = query.order_by(order_col.asc())
# صفحه‌بندی
query = query.offset(search_request.skip).limit(search_request.take)
rows = query.all()
items = [
{
"id": r.id,
"code": r.code,
"name": r.name,
"account_type": r.account_type,
"parent_id": r.parent_id,
"business_id": r.business_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
return success_response({
"items": items,
"total": total,
"skip": search_request.skip,
"take": search_request.take,
}, request)

View file

@ -0,0 +1,121 @@
from __future__ import annotations
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, Request, Query
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.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schema_models.product_bom import (
ProductBOMCreateRequest,
ProductBOMUpdateRequest,
BOMExplosionRequest,
)
from app.services.bom_service import (
create_bom,
get_bom,
list_boms,
update_bom,
delete_bom,
explode_bom,
)
router = APIRouter(prefix="/boms", tags=["boms"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_bom_endpoint(
request: Request,
business_id: int,
payload: ProductBOMCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = create_bom(db, business_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.get("/business/{business_id}")
@require_business_access("business_id")
def list_boms_endpoint(
request: Request,
business_id: int,
product_id: int | None = Query(default=None),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_boms(db, business_id, product_id)
return success_response(data=format_datetime_fields(result, request), request=request)
@router.get("/business/{business_id}/{bom_id}")
@require_business_access("business_id")
def get_bom_endpoint(
request: Request,
business_id: int,
bom_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
item = get_bom(db, business_id, bom_id)
if not item:
raise ApiError("NOT_FOUND", "BOM not found", http_status=404)
return success_response(data=format_datetime_fields({"item": item}, request), request=request)
@router.put("/business/{business_id}/{bom_id}")
@require_business_access("business_id")
def update_bom_endpoint(
request: Request,
business_id: int,
bom_id: int,
payload: ProductBOMUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = update_bom(db, business_id, bom_id, payload)
if not result:
raise ApiError("NOT_FOUND", "BOM not found", http_status=404)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.delete("/business/{business_id}/{bom_id}")
@require_business_access("business_id")
def delete_bom_endpoint(
request: Request,
business_id: int,
bom_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
ok = delete_bom(db, business_id, bom_id)
return success_response({"deleted": ok}, request)
@router.post("/business/{business_id}/explode")
@require_business_access("business_id")
def explode_bom_endpoint(
request: Request,
business_id: int,
payload: BOMExplosionRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = explode_bom(db, business_id, payload)
return success_response(data=format_datetime_fields(result, request), request=request)

View file

@ -0,0 +1,448 @@
"""
API endpoints برای مدیریت اسناد حسابداری (General Accounting Documents)
"""
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body, Query
from fastapi.responses import Response
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.permissions import require_business_access, require_business_management_dep
from app.core.responses import success_response, format_datetime_fields, ApiError
from app.services.document_service import (
list_documents,
get_document,
delete_document,
delete_multiple_documents,
get_document_types_summary,
export_documents_excel,
create_manual_document,
update_manual_document,
)
from adapters.api.v1.schema_models.document import (
CreateManualDocumentRequest,
UpdateManualDocumentRequest,
)
router = APIRouter(tags=["documents"])
@router.post(
"/businesses/{business_id}/documents",
summary="لیست اسناد حسابداری",
description="دریافت لیست تمام اسناد حسابداری (عمومی و اتوماتیک) با فیلتر و صفحه‌بندی",
)
@require_business_access("business_id")
async def list_documents_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
لیست اسناد حسابداری
Body parameters:
- document_type: نوع سند (expense, income, receipt, payment, transfer, manual)
- fiscal_year_id: شناسه سال مالی
- from_date: از تاریخ (ISO format)
- to_date: تا تاریخ (ISO format)
- currency_id: شناسه ارز
- is_proforma: پیشفاکتور یا قطعی
- search: جستجو در کد سند و توضیحات
- sort_by: فیلد مرتبسازی (document_date, code, document_type, created_at)
- sort_desc: ترتیب نزولی (true/false)
- take: تعداد رکورد (1-1000)
- skip: تعداد رکورد صرفنظر شده
"""
query_dict: Dict[str, Any] = {
"take": body.get("take", 50),
"skip": body.get("skip", 0),
"sort_by": body.get("sort_by", "document_date"),
"sort_desc": body.get("sort_desc", True),
"search": body.get("search"),
}
# فیلترهای اضافی
for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]:
if key in body:
query_dict[key] = body[key]
# سال مالی از header
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
query_dict["fiscal_year_id"] = body["fiscal_year_id"]
except Exception:
pass
result = list_documents(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="DOCUMENTS_LIST_FETCHED"
)
@router.get(
"/documents/{document_id}",
summary="جزئیات سند حسابداری",
description="دریافت جزئیات کامل یک سند شامل تمام سطرهای سند",
)
async def get_document_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت جزئیات کامل سند"""
result = get_document(db, document_id)
if not result:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"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="DOCUMENT_DETAILS_FETCHED"
)
@router.delete(
"/documents/{document_id}",
summary="حذف سند حسابداری",
description="حذف یک سند حسابداری (فقط اسناد عمومی manual قابل حذف هستند)",
)
async def delete_document_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
حذف سند حسابداری
توجه: فقط اسناد عمومی (manual) قابل حذف هستند.
اسناد اتوماتیک (expense, income, receipt, payment, ...) باید از منبع اصلی حذف شوند.
"""
# دریافت سند برای بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# حذف سند
success = delete_document(db, document_id)
return success_response(
data={"deleted": success, "document_id": document_id},
request=request,
message="DOCUMENT_DELETED"
)
@router.post(
"/documents/bulk-delete",
summary="حذف گروهی اسناد",
description="حذف گروهی اسناد حسابداری (فقط اسناد manual حذف می‌شوند)",
)
async def bulk_delete_documents_endpoint(
request: Request,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
حذف گروهی اسناد
Body:
document_ids: لیست شناسههای سند
توجه: اسناد اتوماتیک نادیده گرفته میشوند و باید از منبع اصلی حذف شوند.
"""
document_ids = body.get("document_ids", [])
if not document_ids:
raise ApiError(
"INVALID_REQUEST",
"document_ids is required",
http_status=400
)
result = delete_multiple_documents(db, document_ids)
return success_response(
data=result,
request=request,
message="DOCUMENTS_BULK_DELETED"
)
@router.get(
"/businesses/{business_id}/documents/types-summary",
summary="خلاصه آماری انواع اسناد",
description="دریافت خلاصه آماری تعداد هر نوع سند",
)
@require_business_access("business_id")
async def get_document_types_summary_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت خلاصه آماری انواع اسناد"""
summary = get_document_types_summary(db, business_id)
total = sum(summary.values())
return success_response(
data={"summary": summary, "total": total},
request=request,
message="DOCUMENT_TYPES_SUMMARY_FETCHED"
)
@router.post(
"/businesses/{business_id}/documents/export/excel",
summary="خروجی Excel اسناد",
description="دریافت فایل Excel لیست اسناد حسابداری",
)
@require_business_access("business_id")
async def export_documents_excel_endpoint(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
خروجی Excel لیست اسناد
Body: فیلترهای مشابه لیست اسناد
"""
filters = {}
# فیلترها
for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]:
if key in body:
filters[key] = body[key]
# سال مالی از header
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
filters["fiscal_year_id"] = int(fy_header)
elif "fiscal_year_id" in body:
filters["fiscal_year_id"] = body["fiscal_year_id"]
except Exception:
pass
excel_data = export_documents_excel(db, business_id, filters)
return Response(
content=excel_data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename=documents_{business_id}.xlsx"
}
)
@router.get(
"/documents/{document_id}/pdf",
summary="PDF یک سند",
description="دریافت فایل PDF یک سند حسابداری",
)
async def get_document_pdf_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""
PDF یک سند
TODO: پیادهسازی تولید PDF برای سند
"""
# بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# TODO: تولید PDF
raise ApiError(
"NOT_IMPLEMENTED",
"PDF generation is not implemented yet",
http_status=501
)
@router.post(
"/businesses/{business_id}/documents/manual",
summary="ایجاد سند حسابداری دستی",
description="ایجاد یک سند حسابداری دستی جدید با سطرهای مورد نظر",
)
@require_business_access("business_id")
async def create_manual_document_endpoint(
request: Request,
business_id: int,
body: CreateManualDocumentRequest,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
ایجاد سند حسابداری دستی
Body:
- code: کد سند (اختیاری - خودکار تولید میشود)
- document_date: تاریخ سند
- fiscal_year_id: شناسه سال مالی (اختیاری - اگر نباشد، سال مالی فعال استفاده میشود)
- currency_id: شناسه ارز
- is_proforma: پیشفاکتور یا قطعی
- description: توضیحات سند
- lines: سطرهای سند (حداقل 2 سطر)
- extra_info: اطلاعات اضافی
نکته: اگر fiscal_year_id ارسال نشود، سیستم به ترتیب زیر عمل میکند:
1. از X-Fiscal-Year-ID header میخواند
2. سال مالی فعال (is_last=True) را انتخاب میکند
3. اگر سال مالی فعال نداشت، خطا برمیگرداند
اعتبارسنجیها:
- سند باید متوازن باشد (مجموع بدهکار = مجموع بستانکار)
- حداقل 2 سطر داشته باشد
- هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر)
"""
# دریافت سال مالی از header یا body
fiscal_year_id = body.fiscal_year_id
if not fiscal_year_id:
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
fiscal_year_id = int(fy_header)
except Exception:
pass
# اگر fiscal_year_id نبود، سال مالی فعال (is_last=True) را پیدا کن
if not fiscal_year_id:
from adapters.db.models.fiscal_year import FiscalYear
active_fy = db.query(FiscalYear).filter(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
).first()
if active_fy:
fiscal_year_id = active_fy.id
else:
raise ApiError(
"FISCAL_YEAR_REQUIRED",
"No active fiscal year found for this business. Please create a fiscal year first.",
http_status=400
)
# تبدیل Pydantic model به dict
data = body.model_dump()
data["lines"] = [line.model_dump() for line in body.lines]
# ایجاد سند
result = create_manual_document(
db=db,
business_id=business_id,
fiscal_year_id=fiscal_year_id,
user_id=ctx.get_user_id(),
data=data,
)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="MANUAL_DOCUMENT_CREATED"
)
@router.put(
"/documents/{document_id}",
summary="ویرایش سند حسابداری دستی",
description="ویرایش یک سند حسابداری دستی (فقط اسناد manual قابل ویرایش هستند)",
)
async def update_manual_document_endpoint(
request: Request,
document_id: int,
body: UpdateManualDocumentRequest,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""
ویرایش سند حسابداری دستی
Body:
- code: کد سند
- document_date: تاریخ سند
- currency_id: شناسه ارز
- is_proforma: پیشفاکتور یا قطعی
- description: توضیحات سند
- lines: سطرهای سند (اختیاری - اگر ارسال شود جایگزین سطرهای قبلی میشود)
- extra_info: اطلاعات اضافی
توجه:
- فقط اسناد manual قابل ویرایش هستند
- اسناد اتوماتیک باید از منبع اصلی ویرایش شوند
"""
# بررسی دسترسی
doc = get_document(db, document_id)
if not doc:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
# تبدیل Pydantic model به dict (فقط فیلدهای set شده)
data = body.model_dump(exclude_unset=True)
if "lines" in data and data["lines"] is not None:
data["lines"] = [line.model_dump() for line in body.lines]
# ویرایش سند
result = update_manual_document(
db=db,
document_id=document_id,
data=data,
)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="MANUAL_DOCUMENT_UPDATED"
)

View file

@ -14,6 +14,10 @@ from adapters.api.v1.schemas import QueryInfo
from app.services.expense_income_service import ( from app.services.expense_income_service import (
create_expense_income, create_expense_income,
list_expense_income, list_expense_income,
get_expense_income,
update_expense_income,
delete_expense_income,
delete_multiple_expense_income,
) )
@ -86,3 +90,241 @@ async def list_expense_income_endpoint(
return success_response(data=result, request=request, message="EXPENSE_INCOME_LIST_FETCHED") return success_response(data=result, request=request, message="EXPENSE_INCOME_LIST_FETCHED")
@router.get(
"/expense-income/{document_id}",
summary="جزئیات سند هزینه/درآمد",
description="دریافت جزئیات یک سند هزینه یا درآمد",
)
async def get_expense_income_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""دریافت جزئیات سند"""
result = get_expense_income(db, document_id)
if not result:
from app.core.responses import ApiError
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Expense/Income document not found",
http_status=404
)
# بررسی دسترسی
business_id = result.get("business_id")
if business_id and not ctx.can_access_business(business_id):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
return success_response(
data=format_datetime_fields(result, request),
request=request,
message="EXPENSE_INCOME_DETAILS"
)
@router.put(
"/expense-income/{document_id}",
summary="ویرایش سند هزینه/درآمد",
description="ویرایش یک سند هزینه یا درآمد",
)
async def update_expense_income_endpoint(
request: Request,
document_id: int,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""ویرایش سند هزینه/درآمد"""
updated = update_expense_income(db, document_id, ctx.get_user_id(), body)
return success_response(
data=format_datetime_fields(updated, request),
request=request,
message="EXPENSE_INCOME_UPDATED"
)
@router.delete(
"/expense-income/{document_id}",
summary="حذف سند هزینه/درآمد",
description="حذف یک سند هزینه یا درآمد",
)
async def delete_expense_income_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""حذف سند هزینه/درآمد"""
success = delete_expense_income(db, document_id)
if not success:
from app.core.responses import ApiError
raise ApiError("DELETE_FAILED", "Failed to delete document", http_status=500)
return success_response(
data={"deleted": True},
request=request,
message="EXPENSE_INCOME_DELETED"
)
@router.post(
"/expense-income/bulk-delete",
summary="حذف گروهی اسناد هزینه/درآمد",
description="حذف چندین سند هزینه یا درآمد",
)
async def delete_multiple_expense_income_endpoint(
request: Request,
body: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
_: None = Depends(require_business_management_dep),
):
"""حذف گروهی اسناد"""
document_ids = body.get("document_ids", [])
if not document_ids:
from app.core.responses import ApiError
raise ApiError("INVALID_REQUEST", "document_ids is required", http_status=400)
success = delete_multiple_expense_income(db, document_ids)
if not success:
from app.core.responses import ApiError
raise ApiError("DELETE_FAILED", "Failed to delete documents", http_status=500)
return success_response(
data={"deleted_count": len(document_ids)},
request=request,
message="EXPENSE_INCOME_BULK_DELETED"
)
@router.post(
"/businesses/{business_id}/expense-income/export/excel",
summary="خروجی Excel اسناد هزینه/درآمد",
description="دریافت فایل Excel لیست اسناد هزینه/درآمد",
)
@require_business_access("business_id")
async def export_expense_income_excel_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی Excel"""
from app.services.expense_income_service import export_expense_income_excel
from fastapi.responses import Response
# دریافت پارامترهای فیلتر
query_dict = {}
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
# سال مالی از هدر
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
excel_data = export_expense_income_excel(db, business_id, query_dict)
return Response(
content=excel_data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.xlsx"}
)
@router.post(
"/businesses/{business_id}/expense-income/export/pdf",
summary="خروجی PDF اسناد هزینه/درآمد",
description="دریافت فایل PDF لیست اسناد هزینه/درآمد",
)
@require_business_access("business_id")
async def export_expense_income_pdf_endpoint(
request: Request,
business_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""خروجی PDF"""
from app.services.expense_income_service import export_expense_income_pdf
from fastapi.responses import Response
# دریافت پارامترهای فیلتر
query_dict = {}
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
# سال مالی از هدر
try:
fy_header = request.headers.get("X-Fiscal-Year-ID")
if fy_header:
query_dict["fiscal_year_id"] = int(fy_header)
except Exception:
pass
pdf_data = export_expense_income_pdf(db, business_id, query_dict)
return Response(
content=pdf_data,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.pdf"}
)
@router.get(
"/expense-income/{document_id}/pdf",
summary="PDF یک سند هزینه/درآمد",
description="دریافت فایل PDF یک سند هزینه یا درآمد",
)
async def get_expense_income_pdf_endpoint(
request: Request,
document_id: int,
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
"""PDF یک سند"""
from app.services.expense_income_service import generate_expense_income_pdf
from fastapi.responses import Response
# بررسی دسترسی
doc = get_expense_income(db, document_id)
if not doc:
from app.core.responses import ApiError
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
business_id = doc.get("business_id")
if business_id and not ctx.can_access_business(business_id):
from app.core.responses import ApiError
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
pdf_data = generate_expense_income_pdf(db, document_id)
return Response(
content=pdf_data,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=expense_income_{document_id}.pdf"}
)

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form
from fastapi import UploadFile, File from fastapi import UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from adapters.db.session import get_db from adapters.db.session import get_db
@ -19,6 +20,7 @@ from app.services.person_service import (
) )
from adapters.db.models.person import Person from adapters.db.models.person import Person
from adapters.db.models.business import Business from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
router = APIRouter(prefix="/persons", tags=["persons"]) router = APIRouter(prefix="/persons", tags=["persons"])
@ -190,6 +192,26 @@ async def get_persons_endpoint(
auth_context: AuthContext = Depends(get_current_user), auth_context: AuthContext = Depends(get_current_user),
): ):
"""دریافت لیست اشخاص کسب و کار""" """دریافت لیست اشخاص کسب و کار"""
# دریافت سال مالی از header
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
# اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم
if not fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
fiscal_year_id = fiscal_year.id
query_dict = { query_dict = {
"take": query_info.take, "take": query_info.take,
"skip": query_info.skip, "skip": query_info.skip,
@ -199,7 +221,7 @@ async def get_persons_endpoint(
"search_fields": query_info.search_fields, "search_fields": query_info.search_fields,
"filters": query_info.filters, "filters": query_info.filters,
} }
result = get_persons_by_business(db, business_id, query_dict) result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id)
# فرمت کردن تاریخ‌ها # فرمت کردن تاریخ‌ها
result['items'] = [ result['items'] = [
@ -232,6 +254,26 @@ async def export_persons_excel(
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from fastapi.responses import Response from fastapi.responses import Response
# دریافت سال مالی از header
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
# اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم
if not fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
fiscal_year_id = fiscal_year.id
# Build query dict similar to list endpoint from flat body # Build query dict similar to list endpoint from flat body
query_dict = { query_dict = {
"take": int(body.get("take", 20)), "take": int(body.get("take", 20)),
@ -243,7 +285,7 @@ async def export_persons_excel(
"filters": body.get("filters"), "filters": body.get("filters"),
} }
result = get_persons_by_business(db, business_id, query_dict) result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id)
items = result.get('items', []) items = result.get('items', [])
# Format date/time fields using existing helper # Format date/time fields using existing helper
@ -381,6 +423,26 @@ async def export_persons_pdf(
from weasyprint import HTML, CSS from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration from weasyprint.text.fonts import FontConfiguration
# دریافت سال مالی از header
fiscal_year_id = None
fy_header = request.headers.get('X-Fiscal-Year-ID')
if fy_header:
try:
fiscal_year_id = int(fy_header)
except (ValueError, TypeError):
pass
# اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم
if not fiscal_year_id:
fiscal_year = db.query(FiscalYear).filter(
and_(
FiscalYear.business_id == business_id,
FiscalYear.is_last == True
)
).first()
if fiscal_year:
fiscal_year_id = fiscal_year.id
# Build query dict from flat body # Build query dict from flat body
query_dict = { query_dict = {
"take": int(body.get("take", 20)), "take": int(body.get("take", 20)),
@ -392,7 +454,7 @@ async def export_persons_pdf(
"filters": body.get("filters"), "filters": body.get("filters"),
} }
result = get_persons_by_business(db, business_id, query_dict) result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id)
items = result.get('items', []) items = result.get('items', [])
items = [format_datetime_fields(item, request) for item in items] items = [format_datetime_fields(item, request) for item in items]

View file

@ -0,0 +1,207 @@
"""
Schema Models برای اسناد حسابداری (Documents)
"""
from __future__ import annotations
from typing import Optional, List, Any, Dict
from decimal import Decimal
from datetime import date, datetime
from pydantic import BaseModel, Field
class DocumentListFilters(BaseModel):
"""فیلترهای لیست اسناد"""
document_type: Optional[str] = Field(default=None, description="نوع سند")
fiscal_year_id: Optional[int] = Field(default=None, description="شناسه سال مالی")
from_date: Optional[str] = Field(default=None, description="از تاریخ (ISO format)")
to_date: Optional[str] = Field(default=None, description="تا تاریخ (ISO format)")
currency_id: Optional[int] = Field(default=None, description="شناسه ارز")
is_proforma: Optional[bool] = Field(default=None, description="پیش‌فاکتور یا قطعی")
search: Optional[str] = Field(default=None, description="جستجو در کد سند و توضیحات")
sort_by: Optional[str] = Field(default="document_date", description="فیلد مرتب‌سازی")
sort_desc: bool = Field(default=True, description="ترتیب نزولی")
take: int = Field(default=50, ge=1, le=1000, description="تعداد رکورد")
skip: int = Field(default=0, ge=0, description="تعداد رکورد صرف‌نظر شده")
class DocumentLineResponse(BaseModel):
"""پاسخ خط سند"""
id: int
document_id: int
account_id: Optional[int] = None
person_id: Optional[int] = None
product_id: Optional[int] = None
bank_account_id: Optional[int] = None
cash_register_id: Optional[int] = None
petty_cash_id: Optional[int] = None
check_id: Optional[int] = None
quantity: Optional[float] = None
debit: float
credit: float
description: Optional[str] = None
extra_info: Optional[Dict[str, Any]] = None
# اطلاعات مرتبط
account_code: Optional[str] = None
account_name: Optional[str] = None
person_name: Optional[str] = None
product_name: Optional[str] = None
bank_account_name: Optional[str] = None
cash_register_name: Optional[str] = None
petty_cash_name: Optional[str] = None
check_number: Optional[str] = None
class Config:
from_attributes = True
class DocumentSummaryResponse(BaseModel):
"""پاسخ خلاصه سند (برای لیست)"""
id: int
code: str
business_id: int
fiscal_year_id: int
currency_id: int
created_by_user_id: int
registered_at: datetime
document_date: date
document_type: str
is_proforma: bool
description: Optional[str] = None
created_at: datetime
updated_at: datetime
# اطلاعات مرتبط
business_title: Optional[str] = None
fiscal_year_title: Optional[str] = None
currency_code: Optional[str] = None
currency_symbol: Optional[str] = None
created_by_name: Optional[str] = None
# محاسبات
total_debit: float
total_credit: float
lines_count: int
class Config:
from_attributes = True
class DocumentDetailResponse(BaseModel):
"""پاسخ جزئیات کامل سند (با سطرها)"""
id: int
code: str
business_id: int
fiscal_year_id: int
currency_id: int
created_by_user_id: int
registered_at: datetime
document_date: date
document_type: str
is_proforma: bool
description: Optional[str] = None
extra_info: Optional[Dict[str, Any]] = None
developer_settings: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
# اطلاعات مرتبط
business_title: Optional[str] = None
fiscal_year_title: Optional[str] = None
currency_code: Optional[str] = None
currency_symbol: Optional[str] = None
created_by_name: Optional[str] = None
# سطرهای سند
lines: List[DocumentLineResponse]
# محاسبات
total_debit: float
total_credit: float
lines_count: int
class Config:
from_attributes = True
class DocumentDeleteResponse(BaseModel):
"""پاسخ حذف سند"""
deleted: bool
document_id: int
class BulkDeleteRequest(BaseModel):
"""درخواست حذف گروهی"""
document_ids: List[int] = Field(..., description="لیست شناسه‌های سند")
class BulkDeleteResponse(BaseModel):
"""پاسخ حذف گروهی"""
deleted_count: int
total_requested: int
errors: List[Dict[str, Any]]
skipped_auto_documents: List[Dict[str, Any]]
class DocumentTypesSummaryResponse(BaseModel):
"""پاسخ خلاصه آماری انواع اسناد"""
summary: Dict[str, int]
total: int
class DocumentLineCreate(BaseModel):
"""درخواست ایجاد یک سطر سند"""
account_id: int = Field(..., description="شناسه حساب (الزامی)")
person_id: Optional[int] = Field(default=None, description="شناسه شخص (تفضیل)")
product_id: Optional[int] = Field(default=None, description="شناسه کالا (تفضیل)")
bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی (تفضیل)")
cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق (تفضیل)")
petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه (تفضیل)")
check_id: Optional[int] = Field(default=None, description="شناسه چک (تفضیل)")
quantity: Optional[float] = Field(default=None, description="مقدار/تعداد")
debit: float = Field(default=0, ge=0, description="بدهکار")
credit: float = Field(default=0, ge=0, description="بستانکار")
description: Optional[str] = Field(default=None, max_length=500, description="توضیحات سطر")
extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی")
class DocumentLineUpdate(BaseModel):
"""درخواست ویرایش یک سطر سند"""
id: Optional[int] = Field(default=None, description="شناسه سطر (برای ویرایش)")
account_id: int = Field(..., description="شناسه حساب (الزامی)")
person_id: Optional[int] = Field(default=None, description="شناسه شخص (تفضیل)")
product_id: Optional[int] = Field(default=None, description="شناسه کالا (تفضیل)")
bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی (تفضیل)")
cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق (تفضیل)")
petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه (تفضیل)")
check_id: Optional[int] = Field(default=None, description="شناسه چک (تفضیل)")
quantity: Optional[float] = Field(default=None, description="مقدار/تعداد")
debit: float = Field(default=0, ge=0, description="بدهکار")
credit: float = Field(default=0, ge=0, description="بستانکار")
description: Optional[str] = Field(default=None, max_length=500, description="توضیحات سطر")
extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی")
class CreateManualDocumentRequest(BaseModel):
"""درخواست ایجاد سند حسابداری دستی"""
code: Optional[str] = Field(default=None, max_length=50, description="کد سند (اختیاری - خودکار)")
document_date: date = Field(..., description="تاریخ سند")
fiscal_year_id: Optional[int] = Field(default=None, description="شناسه سال مالی (اختیاری - از header)")
currency_id: int = Field(..., description="شناسه ارز")
is_proforma: bool = Field(default=False, description="پیش‌فاکتور یا قطعی")
description: Optional[str] = Field(default=None, max_length=1000, description="توضیحات سند")
lines: List[DocumentLineCreate] = Field(..., min_items=2, description="سطرهای سند (حداقل 2)")
extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی")
class UpdateManualDocumentRequest(BaseModel):
"""درخواست ویرایش سند حسابداری دستی"""
code: Optional[str] = Field(default=None, max_length=50, description="کد سند")
document_date: Optional[date] = Field(default=None, description="تاریخ سند")
currency_id: Optional[int] = Field(default=None, description="شناسه ارز")
is_proforma: Optional[bool] = Field(default=None, description="پیش‌فاکتور یا قطعی")
description: Optional[str] = Field(default=None, max_length=1000, description="توضیحات سند")
lines: Optional[List[DocumentLineUpdate]] = Field(default=None, min_items=2, description="سطرهای سند")
extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی")

View file

@ -223,6 +223,10 @@ class PersonResponse(BaseModel):
commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات") commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات")
commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور") commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور")
# تراز و وضعیت مالی
balance: Optional[float] = Field(default=None, description="تراز شخص (بستانکار - بدهکار)")
status: Optional[str] = Field(default=None, description="وضعیت مالی (بستانکار/بدهکار/بالانس/بدون تراکنش)")
class Config: class Config:
from_attributes = True from_attributes = True

View file

@ -0,0 +1,126 @@
from __future__ import annotations
from typing import Optional, List
from decimal import Decimal
from pydantic import BaseModel, Field
class BomStatus(str):
DRAFT = "draft"
APPROVED = "approved"
ARCHIVED = "archived"
class BomItem(BaseModel):
line_no: int
component_product_id: int
qty_per: Decimal = Field(..., description="مقدار برای تولید 1 واحد")
uom: Optional[str] = Field(default=None, max_length=32)
wastage_percent: Optional[Decimal] = Field(default=None)
is_optional: bool = Field(default=False)
substitute_group: Optional[str] = Field(default=None, max_length=64)
suggested_warehouse_id: Optional[int] = Field(default=None)
class BomOutput(BaseModel):
line_no: int
output_product_id: int
ratio: Decimal
uom: Optional[str] = Field(default=None, max_length=32)
class BomOperation(BaseModel):
line_no: int
operation_name: str
cost_fixed: Optional[Decimal] = None
cost_per_unit: Optional[Decimal] = None
cost_uom: Optional[str] = Field(default=None, max_length=32)
work_center: Optional[str] = Field(default=None, max_length=128)
class ProductBOMCreateRequest(BaseModel):
product_id: int
version: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
is_default: bool = Field(default=False)
effective_from: Optional[str] = Field(default=None)
effective_to: Optional[str] = Field(default=None)
yield_percent: Optional[Decimal] = None
wastage_percent: Optional[Decimal] = None
status: str = Field(default=BomStatus.DRAFT)
notes: Optional[str] = Field(default=None, max_length=5000)
items: List[BomItem] = Field(default_factory=list)
outputs: List[BomOutput] = Field(default_factory=list)
operations: List[BomOperation] = Field(default_factory=list)
class ProductBOMUpdateRequest(BaseModel):
version: Optional[str] = Field(default=None, max_length=64)
name: Optional[str] = Field(default=None, max_length=255)
is_default: Optional[bool] = Field(default=None)
effective_from: Optional[str] = Field(default=None)
effective_to: Optional[str] = Field(default=None)
yield_percent: Optional[Decimal] = None
wastage_percent: Optional[Decimal] = None
status: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None, max_length=5000)
items: Optional[List[BomItem]] = None
outputs: Optional[List[BomOutput]] = None
operations: Optional[List[BomOperation]] = None
class ProductBOMResponse(BaseModel):
id: int
business_id: int
product_id: int
version: str
name: str
is_default: bool
effective_from: Optional[str] = None
effective_to: Optional[str] = None
yield_percent: Optional[Decimal] = None
wastage_percent: Optional[Decimal] = None
status: str
notes: Optional[str] = None
created_at: str
updated_at: str
items: List[BomItem] = Field(default_factory=list)
outputs: List[BomOutput] = Field(default_factory=list)
operations: List[BomOperation] = Field(default_factory=list)
class Config:
from_attributes = True
class BOMExplosionRequest(BaseModel):
product_id: Optional[int] = None
bom_id: Optional[int] = None
quantity: Decimal = Field(..., description="مقدار تولید")
date: Optional[str] = None
class Config:
json_schema_extra = {
"examples": [
{"product_id": 1, "quantity": 100},
]
}
class BOMExplosionItem(BaseModel):
component_product_id: int
required_qty: Decimal
uom: Optional[str] = None
suggested_warehouse_id: Optional[int] = None
is_optional: bool
substitute_group: Optional[str] = None
class BOMExplosionResult(BaseModel):
items: List[BOMExplosionItem]
outputs: List[BomOutput]
notes: Optional[str] = None

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class WarehouseCreateRequest(BaseModel):
code: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
is_default: bool = Field(default=False)
class WarehouseUpdateRequest(BaseModel):
code: Optional[str] = Field(default=None, max_length=64)
name: Optional[str] = Field(default=None, max_length=255)
description: Optional[str] = Field(default=None, max_length=2000)
is_default: Optional[bool] = Field(default=None)
class WarehouseResponse(BaseModel):
id: int
business_id: int
code: str
name: str
description: Optional[str] = None
is_default: bool
created_at: str
updated_at: str
class Config:
from_attributes = True

View file

@ -49,9 +49,13 @@ async def list_transfers_endpoint(
try: try:
body_json = await request.json() body_json = await request.json()
if isinstance(body_json, dict): if isinstance(body_json, dict):
# Forward simple date range params
for key in ["from_date", "to_date"]: for key in ["from_date", "to_date"]:
if key in body_json: if key in body_json:
query_dict[key] = body_json[key] query_dict[key] = body_json[key]
# Forward advanced filters from DataTable (e.g., document_date range)
if "filters" in body_json:
query_dict["filters"] = body_json.get("filters")
except Exception: except Exception:
pass pass

View file

@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Dict, Any
from fastapi import APIRouter, Depends, Request
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.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schema_models.warehouse import (
WarehouseCreateRequest,
WarehouseUpdateRequest,
)
from app.services.warehouse_service import (
create_warehouse,
list_warehouses,
get_warehouse,
update_warehouse,
delete_warehouse,
)
router = APIRouter(prefix="/warehouses", tags=["warehouses"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_warehouse_endpoint(
request: Request,
business_id: int,
payload: WarehouseCreateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = create_warehouse(db, business_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.get("/business/{business_id}")
@require_business_access("business_id")
def list_warehouses_endpoint(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
result = list_warehouses(db, business_id)
return success_response(data=format_datetime_fields(result, request), request=request)
@router.get("/business/{business_id}/{warehouse_id}")
@require_business_access("business_id")
def get_warehouse_endpoint(
request: Request,
business_id: int,
warehouse_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
item = get_warehouse(db, business_id, warehouse_id)
if not item:
raise ApiError("NOT_FOUND", "Warehouse not found", http_status=404)
return success_response(data=format_datetime_fields({"item": item}, request), request=request)
@router.put("/business/{business_id}/{warehouse_id}")
@require_business_access("business_id")
def update_warehouse_endpoint(
request: Request,
business_id: int,
warehouse_id: int,
payload: WarehouseUpdateRequest,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "write"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403)
result = update_warehouse(db, business_id, warehouse_id, payload)
if not result:
raise ApiError("NOT_FOUND", "Warehouse not found", http_status=404)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.delete("/business/{business_id}/{warehouse_id}")
@require_business_access("business_id")
def delete_warehouse_endpoint(
request: Request,
business_id: int,
warehouse_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Dict[str, Any]:
if not ctx.has_business_permission("inventory", "delete"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403)
ok = delete_warehouse(db, business_id, warehouse_id)
return success_response({"deleted": ok}, request)

View file

@ -41,3 +41,5 @@ from .bank_account import BankAccount # noqa: F401
from .cash_register import CashRegister # noqa: F401 from .cash_register import CashRegister # noqa: F401
from .petty_cash import PettyCash # noqa: F401 from .petty_cash import PettyCash # noqa: F401
from .check import Check # noqa: F401 from .check import Check # noqa: F401
from .warehouse import Warehouse # noqa: F401
from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401

View file

@ -0,0 +1,125 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
String,
Integer,
DateTime,
Text,
ForeignKey,
UniqueConstraint,
Boolean,
Numeric,
Date,
Enum as SQLEnum,
)
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class BomStatus(str):
DRAFT = "draft"
APPROVED = "approved"
ARCHIVED = "archived"
class ProductBOM(Base):
"""
سرشاخه فرمول تولید (BOM)
"""
__tablename__ = "product_boms"
__table_args__ = (
UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"),
)
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)
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True)
version: Mapped[str] = mapped_column(String(64), nullable=False, comment="نسخه فرمول")
name: Mapped[str] = mapped_column(String(255), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
effective_from: Mapped[Date | None] = mapped_column(Date, nullable=True)
effective_to: Mapped[Date | None] = mapped_column(Date, nullable=True)
yield_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="بازده کل")
wastage_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="پرت کل")
status: Mapped[str] = mapped_column(String(16), default=BomStatus.DRAFT, nullable=False, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[int | None] = mapped_column(Integer, nullable=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)
class ProductBOMItem(Base):
"""
اقلام مصرفی BOM
"""
__tablename__ = "product_bom_items"
__table_args__ = (
UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True)
line_no: Mapped[int] = mapped_column(Integer, nullable=False)
component_product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False, index=True)
qty_per: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False, comment="مقدار برای تولید 1 واحد")
uom: Mapped[str | None] = mapped_column(String(32), nullable=True)
wastage_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
is_optional: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
substitute_group: Mapped[str | None] = mapped_column(String(64), nullable=True)
suggested_warehouse_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True)
class ProductBOMOutput(Base):
"""
خروجیهای BOM (محصول اصلی و جانبی)
"""
__tablename__ = "product_bom_outputs"
__table_args__ = (
UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True)
line_no: Mapped[int] = mapped_column(Integer, nullable=False)
output_product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False, index=True)
ratio: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False, comment="نسبت خروجی نسبت به 1 واحد")
uom: Mapped[str | None] = mapped_column(String(32), nullable=True)
class ProductBOMOperation(Base):
"""
عملیات/سربار BOM
"""
__tablename__ = "product_bom_operations"
__table_args__ = (
UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True)
line_no: Mapped[int] = mapped_column(Integer, nullable=False)
operation_name: Mapped[str] = mapped_column(String(255), nullable=False)
cost_fixed: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
cost_per_unit: Mapped[float | None] = mapped_column(Numeric(18, 6), nullable=True)
cost_uom: Mapped[str | None] = mapped_column(String(32), nullable=True)
work_center: Mapped[str | None] = mapped_column(String(128), nullable=True)

View file

@ -0,0 +1,33 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from adapters.db.session import Base
class Warehouse(Base):
"""
انبارهای کسبوکار (حداقلهای موردنیاز برای BOM و اسناد تولید)
"""
__tablename__ = "warehouses"
__table_args__ = (
UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"),
)
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)
code: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="کد یکتا در هر کسب‌وکار")
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, 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)

View file

@ -0,0 +1,514 @@
"""
Repository برای مدیریت اسناد حسابداری (Documents)
"""
from __future__ import annotations
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select, and_, or_, func, desc, asc
from datetime import date
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.currency import Currency
from adapters.db.models.user import User
from adapters.db.models.account import Account
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.cash_register import CashRegister
from adapters.db.models.petty_cash import PettyCash
from adapters.db.models.check import Check
class DocumentRepository:
"""Repository برای عملیات پایگاه داده مربوط به اسناد حسابداری"""
def __init__(self, db: Session) -> None:
self.db = db
def get_document(self, document_id: int) -> Optional[Document]:
"""دریافت یک سند با شناسه"""
return self.db.query(Document).filter(Document.id == document_id).first()
def get_document_with_relations(self, document_id: int) -> Optional[Document]:
"""دریافت سند با روابط (eager loading)"""
return (
self.db.query(Document)
.options(
joinedload(Document.business),
joinedload(Document.fiscal_year),
joinedload(Document.currency),
joinedload(Document.created_by),
joinedload(Document.lines),
)
.filter(Document.id == document_id)
.first()
)
def list_documents_with_filters(
self,
business_id: int,
filters: Dict[str, Any],
) -> tuple[List[Dict[str, Any]], int]:
"""
لیست اسناد با فیلترها و صفحهبندی
Args:
business_id: شناسه کسبوکار
filters: دیکشنری فیلترها شامل:
- document_type: نوع سند
- fiscal_year_id: شناسه سال مالی
- from_date: از تاریخ
- to_date: تا تاریخ
- currency_id: شناسه ارز
- is_proforma: پیشفاکتور یا نه
- search: عبارت جستجو
- sort_by: فیلد مرتبسازی
- sort_desc: ترتیب نزولی
- take: تعداد رکورد
- skip: تعداد رکورد صرفنظر شده
Returns:
tuple: (لیست اسناد, تعداد کل)
"""
# Query پایه
query = self.db.query(
Document.id,
Document.code,
Document.business_id,
Document.fiscal_year_id,
Document.currency_id,
Document.created_by_user_id,
Document.registered_at,
Document.document_date,
Document.document_type,
Document.is_proforma,
Document.description,
Document.created_at,
Document.updated_at,
Business.name.label("business_title"),
FiscalYear.title.label("fiscal_year_title"),
Currency.code.label("currency_code"),
Currency.symbol.label("currency_symbol"),
(User.first_name + " " + User.last_name).label("created_by_name"),
).select_from(Document).join(
Business, Document.business_id == Business.id
).join(
FiscalYear, Document.fiscal_year_id == FiscalYear.id
).join(
Currency, Document.currency_id == Currency.id
).join(
User, Document.created_by_user_id == User.id
).filter(
Document.business_id == business_id
)
# اعمال فیلترها
if filters.get("document_type"):
query = query.filter(Document.document_type == filters["document_type"])
if filters.get("fiscal_year_id"):
query = query.filter(Document.fiscal_year_id == filters["fiscal_year_id"])
if filters.get("from_date"):
try:
from_date = self._parse_date(filters["from_date"])
query = query.filter(Document.document_date >= from_date)
except Exception:
pass
if filters.get("to_date"):
try:
to_date = self._parse_date(filters["to_date"])
query = query.filter(Document.document_date <= to_date)
except Exception:
pass
if filters.get("currency_id"):
query = query.filter(Document.currency_id == filters["currency_id"])
if filters.get("is_proforma") is not None:
query = query.filter(Document.is_proforma == filters["is_proforma"])
# جستجو
if filters.get("search"):
search_term = f"%{filters['search']}%"
query = query.filter(
or_(
Document.code.ilike(search_term),
Document.description.ilike(search_term),
)
)
# شمارش کل
total_count = query.count()
# مرتب‌سازی
sort_by = filters.get("sort_by", "document_date")
sort_desc = filters.get("sort_desc", True)
if sort_by == "document_date":
order_col = Document.document_date
elif sort_by == "code":
order_col = Document.code
elif sort_by == "document_type":
order_col = Document.document_type
elif sort_by == "created_at":
order_col = Document.created_at
else:
order_col = Document.document_date
if sort_desc:
query = query.order_by(desc(order_col), desc(Document.id))
else:
query = query.order_by(asc(order_col), asc(Document.id))
# صفحه‌بندی
skip = filters.get("skip", 0)
take = filters.get("take", 50)
query = query.offset(skip).limit(take)
# اجرای query
results = query.all()
# محاسبه مجموع بدهکار و بستانکار برای هر سند
documents = []
for row in results:
doc_dict = {
"id": row.id,
"code": row.code,
"business_id": row.business_id,
"fiscal_year_id": row.fiscal_year_id,
"currency_id": row.currency_id,
"created_by_user_id": row.created_by_user_id,
"registered_at": row.registered_at,
"document_date": row.document_date,
"document_type": row.document_type,
"is_proforma": row.is_proforma,
"description": row.description,
"created_at": row.created_at,
"updated_at": row.updated_at,
"business_title": row.business_title,
"fiscal_year_title": row.fiscal_year_title,
"currency_code": row.currency_code,
"currency_symbol": row.currency_symbol,
"created_by_name": row.created_by_name,
}
# محاسبه مجموع
totals = (
self.db.query(
func.sum(DocumentLine.debit).label("total_debit"),
func.sum(DocumentLine.credit).label("total_credit"),
func.count(DocumentLine.id).label("lines_count"),
)
.filter(DocumentLine.document_id == row.id)
.first()
)
doc_dict["total_debit"] = float(totals.total_debit or 0)
doc_dict["total_credit"] = float(totals.total_credit or 0)
doc_dict["lines_count"] = totals.lines_count or 0
documents.append(doc_dict)
return documents, total_count
def get_document_details(self, document_id: int) -> Optional[Dict[str, Any]]:
"""دریافت جزئیات کامل سند شامل سطرها"""
document = self.get_document_with_relations(document_id)
if not document:
return None
return self.to_dict_with_lines(document)
def delete_document(self, document_id: int) -> bool:
"""حذف سند"""
document = self.get_document(document_id)
if not document:
return False
self.db.delete(document)
self.db.commit()
return True
def to_dict(self, document: Document) -> Dict[str, Any]:
"""تبدیل Document به dictionary (بدون سطرها)"""
return {
"id": document.id,
"code": document.code,
"business_id": document.business_id,
"fiscal_year_id": document.fiscal_year_id,
"currency_id": document.currency_id,
"created_by_user_id": document.created_by_user_id,
"registered_at": document.registered_at,
"document_date": document.document_date,
"document_type": document.document_type,
"is_proforma": document.is_proforma,
"description": document.description,
"extra_info": document.extra_info,
"developer_settings": document.developer_settings,
"created_at": document.created_at,
"updated_at": document.updated_at,
}
def to_dict_with_lines(self, document: Document) -> Dict[str, Any]:
"""تبدیل Document به dictionary (با سطرها و جزئیات کامل)"""
doc_dict = self.to_dict(document)
# اضافه کردن اطلاعات روابط
if document.business:
doc_dict["business_title"] = document.business.name
if document.fiscal_year:
doc_dict["fiscal_year_title"] = document.fiscal_year.title
if document.currency:
doc_dict["currency_code"] = document.currency.code
doc_dict["currency_symbol"] = document.currency.symbol
if document.created_by:
doc_dict["created_by_name"] = (
f"{document.created_by.first_name} {document.created_by.last_name}"
)
# اضافه کردن سطرهای سند
lines = []
total_debit = 0
total_credit = 0
for line in document.lines:
line_dict = self._document_line_to_dict(line)
lines.append(line_dict)
total_debit += float(line.debit or 0)
total_credit += float(line.credit or 0)
doc_dict["lines"] = lines
doc_dict["total_debit"] = total_debit
doc_dict["total_credit"] = total_credit
doc_dict["lines_count"] = len(lines)
return doc_dict
def _document_line_to_dict(self, line: DocumentLine) -> Dict[str, Any]:
"""تبدیل DocumentLine به dictionary"""
line_dict = {
"id": line.id,
"document_id": line.document_id,
"account_id": line.account_id,
"person_id": line.person_id,
"product_id": line.product_id,
"bank_account_id": line.bank_account_id,
"cash_register_id": line.cash_register_id,
"petty_cash_id": line.petty_cash_id,
"check_id": line.check_id,
"quantity": float(line.quantity) if line.quantity else None,
"debit": float(line.debit or 0),
"credit": float(line.credit or 0),
"description": line.description,
"extra_info": line.extra_info,
"created_at": line.created_at,
"updated_at": line.updated_at,
}
# اضافه کردن اطلاعات روابط
if line.account:
line_dict["account_code"] = line.account.code
line_dict["account_name"] = line.account.name
if line.person:
line_dict["person_name"] = line.person.alias_name
if line.product:
line_dict["product_name"] = line.product.title
if line.bank_account:
line_dict["bank_account_name"] = line.bank_account.account_title
if line.cash_register:
line_dict["cash_register_name"] = line.cash_register.title
if line.petty_cash:
line_dict["petty_cash_name"] = line.petty_cash.title
if line.check:
line_dict["check_number"] = line.check.check_number
return line_dict
def _parse_date(self, date_value: Any) -> date:
"""تبدیل مقدار به date"""
if isinstance(date_value, date):
return date_value
if isinstance(date_value, str):
from datetime import datetime
return datetime.fromisoformat(date_value.split("T")[0]).date()
raise ValueError(f"Invalid date format: {date_value}")
def create_document(self, document_data: Dict[str, Any]) -> Document:
"""
ایجاد سند جدید
Args:
document_data: دیکشنری حاوی اطلاعات سند و سطرها
Returns:
Document ایجاد شده
"""
# جداسازی سطرها از اطلاعات سند
lines_data = document_data.pop("lines", [])
# ایجاد سند
document = Document(**document_data)
self.db.add(document)
self.db.flush() # برای دریافت ID سند
# ایجاد سطرهای سند
for line_data in lines_data:
line = DocumentLine(
document_id=document.id,
**line_data
)
self.db.add(line)
self.db.commit()
self.db.refresh(document)
return document
def update_document(
self,
document_id: int,
document_data: Dict[str, Any]
) -> Optional[Document]:
"""
ویرایش سند موجود
Args:
document_id: شناسه سند
document_data: دیکشنری حاوی اطلاعات جدید سند و سطرها
Returns:
Document ویرایش شده یا None
"""
document = self.get_document(document_id)
if not document:
return None
# جداسازی سطرها
lines_data = document_data.pop("lines", None)
# ویرایش فیلدهای سند
for key, value in document_data.items():
if value is not None and hasattr(document, key):
setattr(document, key, value)
# اگر سطرها ارسال شده، آن‌ها را جایگزین کن
if lines_data is not None:
# حذف سطرهای قدیمی
for old_line in document.lines:
self.db.delete(old_line)
self.db.flush()
# ایجاد سطرهای جدید
for line_data in lines_data:
line_id = line_data.pop("id", None)
line = DocumentLine(
document_id=document.id,
**line_data
)
self.db.add(line)
self.db.commit()
self.db.refresh(document)
return document
def generate_document_code(
self,
business_id: int,
document_type: str = "manual"
) -> str:
"""
تولید کد خودکار برای سند
Args:
business_id: شناسه کسبوکار
document_type: نوع سند
Returns:
کد یکتای سند
"""
from datetime import datetime
# دریافت آخرین کد سند
last_doc = (
self.db.query(Document)
.filter(
Document.business_id == business_id,
Document.document_type == document_type
)
.order_by(desc(Document.id))
.first()
)
if last_doc and last_doc.code:
try:
# استخراج عدد از آخر کد
import re
numbers = re.findall(r'\d+', last_doc.code)
if numbers:
last_number = int(numbers[-1])
return f"{document_type.upper()}-{last_number + 1:05d}"
except Exception:
pass
# اگر سند قبلی نداشت یا فرمت نامعتبر بود
year = datetime.now().year % 100 # دو رقم آخر سال
return f"{document_type.upper()}-{year}{1:04d}"
def validate_document_balance(self, lines_data: List[Dict[str, Any]]) -> tuple[bool, str]:
"""
اعتبارسنجی متوازن بودن سند
Args:
lines_data: لیست سطرهای سند
Returns:
tuple: (متوازن است؟, پیام خطا)
"""
if not lines_data or len(lines_data) < 2:
return False, "سند باید حداقل 2 سطر داشته باشد"
total_debit = sum(float(line.get("debit", 0)) for line in lines_data)
total_credit = sum(float(line.get("credit", 0)) for line in lines_data)
# تلرانس برای خطاهای اعشاری
tolerance = 0.01
if abs(total_debit - total_credit) > tolerance:
diff = total_debit - total_credit
return False, f"سند متوازن نیست. تفاوت: {diff:,.2f}"
# حداقل یک سطر باید بدهکار و یک سطر بستانکار داشته باشد
has_debit = any(float(line.get("debit", 0)) > 0 for line in lines_data)
has_credit = any(float(line.get("credit", 0)) > 0 for line in lines_data)
if not has_debit or not has_credit:
return False, "سند باید حداقل یک سطر بدهکار و یک سطر بستانکار داشته باشد"
# هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر)
for i, line in enumerate(lines_data, 1):
debit = float(line.get("debit", 0))
credit = float(line.get("credit", 0))
if debit == 0 and credit == 0:
return False, f"سطر {i} باید مقدار بدهکار یا بستانکار داشته باشد"
# نمی‌تواند هم بدهکار هم بستانکار داشته باشد
if debit > 0 and credit > 0:
return False, f"سطر {i} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد"
return True, ""

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import select, and_
from adapters.db.models.product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation
class ProductBOMRepository:
def __init__(self, db: Session) -> None:
self.db = db
# BOM header
def create_bom(self, **kwargs) -> ProductBOM:
obj = ProductBOM(**kwargs)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get_bom(self, bom_id: int, business_id: int) -> Optional[ProductBOM]:
obj = self.db.get(ProductBOM, bom_id)
if not obj or obj.business_id != business_id:
return None
return obj
def list_boms(self, business_id: int, product_id: Optional[int] = None) -> List[ProductBOM]:
stmt = select(ProductBOM).where(ProductBOM.business_id == business_id)
if product_id:
stmt = stmt.where(ProductBOM.product_id == product_id)
return [r[0] for r in self.db.execute(stmt.order_by(ProductBOM.id.desc())).all()]
def update_bom(self, bom_id: int, **kwargs) -> Optional[ProductBOM]:
obj = self.db.get(ProductBOM, bom_id)
if not obj:
return None
for k, v in kwargs.items():
if v is not None:
setattr(obj, k, v)
self.db.commit()
self.db.refresh(obj)
return obj
def delete_bom(self, bom_id: int) -> bool:
obj = self.db.get(ProductBOM, bom_id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True
# Items
def replace_items(self, bom_id: int, items: List[dict]) -> None:
self.db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom_id).delete()
for it in items:
self.db.add(ProductBOMItem(bom_id=bom_id, **it))
self.db.commit()
def replace_outputs(self, bom_id: int, outputs: List[dict]) -> None:
self.db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom_id).delete()
for out in outputs:
self.db.add(ProductBOMOutput(bom_id=bom_id, **out))
self.db.commit()
def replace_operations(self, bom_id: int, operations: List[dict]) -> None:
self.db.query(ProductBOMOperation).filter(ProductBOMOperation.bom_id == bom_id).delete()
for op in operations:
self.db.add(ProductBOMOperation(bom_id=bom_id, **op))
self.db.commit()

View file

@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import select, and_
from adapters.db.models.warehouse import Warehouse
class WarehouseRepository:
def __init__(self, db: Session) -> None:
self.db = db
def create(self, **kwargs) -> Warehouse:
obj = Warehouse(**kwargs)
self.db.add(obj)
self.db.commit()
self.db.refresh(obj)
return obj
def get(self, warehouse_id: int) -> Optional[Warehouse]:
return self.db.get(Warehouse, warehouse_id)
def list(self, business_id: int) -> List[Warehouse]:
stmt = select(Warehouse).where(Warehouse.business_id == business_id).order_by(Warehouse.id.desc())
return [r[0] for r in self.db.execute(stmt).all()]
def update(self, warehouse_id: int, **kwargs) -> Optional[Warehouse]:
obj = self.db.get(Warehouse, warehouse_id)
if not obj:
return None
for k, v in kwargs.items():
if v is not None:
setattr(obj, k, v)
self.db.commit()
self.db.refresh(obj)
return obj
def delete(self, warehouse_id: int) -> bool:
obj = self.db.get(Warehouse, warehouse_id)
if not obj:
return False
self.db.delete(obj)
self.db.commit()
return True

View file

@ -33,6 +33,8 @@ from adapters.api.v1.admin.email_config import router as admin_email_config_rout
from adapters.api.v1.receipts_payments import router as receipts_payments_router from adapters.api.v1.receipts_payments import router as receipts_payments_router
from adapters.api.v1.transfers import router as transfers_router from adapters.api.v1.transfers import router as transfers_router
from adapters.api.v1.fiscal_years import router as fiscal_years_router from adapters.api.v1.fiscal_years import router as fiscal_years_router
from adapters.api.v1.expense_income import router as expense_income_router
from adapters.api.v1.documents import router as documents_router
from app.core.i18n import negotiate_locale, Translator from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -297,6 +299,10 @@ def create_app() -> FastAPI:
application.include_router(categories_router, prefix=settings.api_v1_prefix) application.include_router(categories_router, prefix=settings.api_v1_prefix)
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix) application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
application.include_router(products_router, prefix=settings.api_v1_prefix) application.include_router(products_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.warehouses import router as warehouses_router
application.include_router(warehouses_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.boms import router as boms_router
application.include_router(boms_router, prefix=settings.api_v1_prefix)
application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
application.include_router(invoices_router, prefix=settings.api_v1_prefix) application.include_router(invoices_router, prefix=settings.api_v1_prefix)
application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix)
@ -310,8 +316,8 @@ def create_app() -> FastAPI:
application.include_router(tax_types_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) application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
application.include_router(transfers_router, prefix=settings.api_v1_prefix) application.include_router(transfers_router, prefix=settings.api_v1_prefix)
from adapters.api.v1.expense_income import router as expense_income_router
application.include_router(expense_income_router, prefix=settings.api_v1_prefix) application.include_router(expense_income_router, prefix=settings.api_v1_prefix)
application.include_router(documents_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints

View file

@ -0,0 +1,221 @@
from __future__ import annotations
from typing import Dict, Any, Optional, List
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.core.responses import ApiError
from adapters.db.models.product import Product
from adapters.db.models.product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation
from adapters.db.repositories.product_bom_repository import ProductBOMRepository
from adapters.api.v1.schema_models.product_bom import (
ProductBOMCreateRequest,
ProductBOMUpdateRequest,
BOMExplosionRequest,
)
def _to_bom_dict(bom: ProductBOM, db: Session) -> Dict[str, Any]:
items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all()
outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all()
operations = db.query(ProductBOMOperation).filter(ProductBOMOperation.bom_id == bom.id).order_by(ProductBOMOperation.line_no).all()
return {
"id": bom.id,
"business_id": bom.business_id,
"product_id": bom.product_id,
"version": bom.version,
"name": bom.name,
"is_default": bom.is_default,
"effective_from": bom.effective_from,
"effective_to": bom.effective_to,
"yield_percent": bom.yield_percent,
"wastage_percent": bom.wastage_percent,
"status": bom.status,
"notes": bom.notes,
"created_at": bom.created_at,
"updated_at": bom.updated_at,
"items": [
{
"line_no": it.line_no,
"component_product_id": it.component_product_id,
"qty_per": it.qty_per,
"uom": it.uom,
"wastage_percent": it.wastage_percent,
"is_optional": it.is_optional,
"substitute_group": it.substitute_group,
"suggested_warehouse_id": it.suggested_warehouse_id,
}
for it in items
],
"outputs": [
{
"line_no": ot.line_no,
"output_product_id": ot.output_product_id,
"ratio": ot.ratio,
"uom": ot.uom,
}
for ot in outputs
],
"operations": [
{
"line_no": op.line_no,
"operation_name": op.operation_name,
"cost_fixed": op.cost_fixed,
"cost_per_unit": op.cost_per_unit,
"cost_uom": op.cost_uom,
"work_center": op.work_center,
}
for op in operations
],
}
def create_bom(db: Session, business_id: int, payload: ProductBOMCreateRequest) -> Dict[str, Any]:
# product must belong to business
product = db.get(Product, payload.product_id)
if not product or product.business_id != business_id:
raise ApiError("INVALID_PRODUCT", "کالای انتخابی معتبر نیست", http_status=400)
repo = ProductBOMRepository(db)
bom = repo.create_bom(
business_id=business_id,
product_id=payload.product_id,
version=payload.version.strip(),
name=payload.name.strip(),
is_default=bool(payload.is_default),
effective_from=payload.effective_from,
effective_to=payload.effective_to,
yield_percent=payload.yield_percent,
wastage_percent=payload.wastage_percent,
status=payload.status,
notes=payload.notes,
)
# Replace child rows
items = [it.model_dump() for it in payload.items]
outputs = [ot.model_dump() for ot in payload.outputs]
operations = [op.model_dump() for op in payload.operations]
repo.replace_items(bom.id, items)
repo.replace_outputs(bom.id, outputs)
repo.replace_operations(bom.id, operations)
# enforce single default per product
if bom.is_default:
db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == payload.product_id, ProductBOM.id != bom.id)
).update({ProductBOM.is_default: False})
db.commit()
return {"message": "BOM_CREATED", "data": _to_bom_dict(bom, db)}
def get_bom(db: Session, business_id: int, bom_id: int) -> Optional[Dict[str, Any]]:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return None
return _to_bom_dict(bom, db)
def list_boms(db: Session, business_id: int, product_id: Optional[int] = None) -> Dict[str, Any]:
repo = ProductBOMRepository(db)
rows = repo.list_boms(business_id, product_id)
return {"items": [_to_bom_dict(b, db) for b in rows]}
def update_bom(db: Session, business_id: int, bom_id: int, payload: ProductBOMUpdateRequest) -> Optional[Dict[str, Any]]:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return None
updated = repo.update_bom(
bom_id,
version=payload.version.strip() if isinstance(payload.version, str) else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
is_default=payload.is_default if payload.is_default is not None else None,
effective_from=payload.effective_from,
effective_to=payload.effective_to,
yield_percent=payload.yield_percent,
wastage_percent=payload.wastage_percent,
status=payload.status,
notes=payload.notes,
)
if not updated:
return None
if payload.items is not None:
repo.replace_items(bom_id, [it.model_dump() for it in payload.items])
if payload.outputs is not None:
repo.replace_outputs(bom_id, [ot.model_dump() for ot in payload.outputs])
if payload.operations is not None:
repo.replace_operations(bom_id, [op.model_dump() for op in payload.operations])
if updated.is_default:
db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == updated.product_id, ProductBOM.id != updated.id)
).update({ProductBOM.is_default: False})
db.commit()
return {"message": "BOM_UPDATED", "data": _to_bom_dict(updated, db)}
def delete_bom(db: Session, business_id: int, bom_id: int) -> bool:
repo = ProductBOMRepository(db)
bom = repo.get_bom(bom_id, business_id)
if not bom:
return False
return repo.delete_bom(bom_id)
def explode_bom(db: Session, business_id: int, req: BOMExplosionRequest) -> Dict[str, Any]:
# minimal explosion without stock checks
if not req.bom_id and not req.product_id:
raise ApiError("INVALID_REQUEST", "bom_id یا product_id الزامی است", http_status=400)
bom: Optional[ProductBOM] = None
if req.bom_id:
bom = db.get(ProductBOM, req.bom_id)
if not bom or bom.business_id != business_id:
raise ApiError("NOT_FOUND", "BOM یافت نشد", http_status=404)
else:
# pick default bom for product
bom = db.query(ProductBOM).filter(
and_(ProductBOM.business_id == business_id, ProductBOM.product_id == req.product_id, ProductBOM.is_default == True)
).first()
if not bom:
raise ApiError("NOT_FOUND", "برای این کالا فرمول پیش‌فرضی تعریف نشده است", http_status=404)
items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all()
outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all()
qty = Decimal(str(req.quantity))
explosion_items: List[Dict[str, Any]] = []
for it in items:
base = Decimal(str(it.qty_per)) * qty
# apply line wastage
if it.wastage_percent:
base = base * (Decimal("1.0") + Decimal(str(it.wastage_percent)) / Decimal("100"))
explosion_items.append({
"component_product_id": it.component_product_id,
"required_qty": base,
"uom": it.uom,
"suggested_warehouse_id": it.suggested_warehouse_id,
"is_optional": it.is_optional,
"substitute_group": it.substitute_group,
})
# outputs scaling
out_scaled = []
for ot in outputs:
out_scaled.append({
"line_no": ot.line_no,
"output_product_id": ot.output_product_id,
"ratio": Decimal(str(ot.ratio)) * qty,
"uom": ot.uom,
})
return {"items": explosion_items, "outputs": out_scaled}

View file

@ -0,0 +1,536 @@
"""
سرویس مدیریت اسناد حسابداری عمومی (General Accounting Documents)
این سرویس برای مدیریت تمام اسناد حسابداری (عمومی و اتوماتیک) استفاده میشود.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime, date
import logging
from sqlalchemy.orm import Session
from adapters.db.repositories.document_repository import DocumentRepository
from app.core.responses import ApiError
logger = logging.getLogger(__name__)
def list_documents(
db: Session,
business_id: int,
query: Dict[str, Any],
) -> Dict[str, Any]:
"""
دریافت لیست اسناد حسابداری با فیلتر و صفحهبندی
Args:
db: جلسه دیتابیس
business_id: شناسه کسبوکار
query: فیلترها و تنظیمات:
- document_type: نوع سند (expense, income, receipt, payment, transfer, manual, ...)
- fiscal_year_id: شناسه سال مالی
- from_date: از تاریخ
- to_date: تا تاریخ
- currency_id: شناسه ارز
- is_proforma: پیشفاکتور یا قطعی
- search: عبارت جستجو
- sort_by: فیلد مرتبسازی
- sort_desc: ترتیب نزولی
- take: تعداد رکورد در هر صفحه
- skip: تعداد رکورد صرفنظر شده
Returns:
دیکشنری حاوی items و pagination
"""
repo = DocumentRepository(db)
# دریافت لیست اسناد
documents, total = repo.list_documents_with_filters(business_id, query)
# محاسبه pagination
take = query.get("take", 50)
skip = query.get("skip", 0)
page = (skip // take) + 1
total_pages = (total + take - 1) // take
return {
"items": documents,
"pagination": {
"total": total,
"page": page,
"per_page": take,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
}
def get_document(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
"""
دریافت جزئیات کامل یک سند شامل سطرهای سند
Args:
db: جلسه دیتابیس
document_id: شناسه سند
Returns:
دیکشنری حاوی اطلاعات کامل سند یا None
"""
repo = DocumentRepository(db)
return repo.get_document_details(document_id)
def delete_document(db: Session, document_id: int) -> bool:
"""
حذف یک سند حسابداری
توجه: فقط اسناد عمومی (manual) قابل حذف هستند
Args:
db: جلسه دیتابیس
document_id: شناسه سند
Returns:
True در صورت موفقیت، False در غیر این صورت
Raises:
ApiError: در صورت عدم وجود سند یا عدم امکان حذف
"""
repo = DocumentRepository(db)
# بررسی وجود سند
document = repo.get_document(document_id)
if not document:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Document not found",
http_status=404
)
# بررسی نوع سند - فقط اسناد manual قابل حذف هستند
if document.document_type != "manual":
raise ApiError(
"CANNOT_DELETE_AUTO_DOCUMENT",
"Cannot delete automatically generated documents. Please delete from the original source.",
http_status=400
)
# حذف سند
success = repo.delete_document(document_id)
if not success:
raise ApiError(
"DELETE_FAILED",
"Failed to delete document",
http_status=500
)
return True
def delete_multiple_documents(db: Session, document_ids: List[int]) -> Dict[str, Any]:
"""
حذف گروهی اسناد حسابداری
فقط اسناد عمومی (manual) حذف میشوند، اسناد اتوماتیک نادیده گرفته میشوند
Args:
db: جلسه دیتابیس
document_ids: لیست شناسههای سند
Returns:
دیکشنری حاوی تعداد حذف شده و خطاها
"""
repo = DocumentRepository(db)
deleted_count = 0
errors = []
skipped_auto = []
for doc_id in document_ids:
try:
document = repo.get_document(doc_id)
if not document:
errors.append({"id": doc_id, "error": "Document not found"})
continue
# بررسی نوع سند
if document.document_type != "manual":
skipped_auto.append({
"id": doc_id,
"type": document.document_type,
"code": document.code
})
continue
# حذف سند
if repo.delete_document(doc_id):
deleted_count += 1
else:
errors.append({"id": doc_id, "error": "Delete failed"})
except Exception as e:
logger.error(f"Error deleting document {doc_id}: {str(e)}")
errors.append({"id": doc_id, "error": str(e)})
return {
"deleted_count": deleted_count,
"total_requested": len(document_ids),
"errors": errors,
"skipped_auto_documents": skipped_auto,
}
def get_document_types_summary(db: Session, business_id: int) -> Dict[str, Any]:
"""
دریافت خلاصه آماری انواع اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسبوکار
Returns:
دیکشنری حاوی آمار هر نوع سند
"""
from adapters.db.models.document import Document
from sqlalchemy import func
results = (
db.query(
Document.document_type,
func.count(Document.id).label("count")
)
.filter(Document.business_id == business_id)
.group_by(Document.document_type)
.all()
)
summary = {}
for row in results:
summary[row.document_type] = row.count
return summary
def export_documents_excel(
db: Session,
business_id: int,
filters: Dict[str, Any],
) -> bytes:
"""
خروجی Excel لیست اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسبوکار
filters: فیلترها
Returns:
محتوای فایل Excel به صورت bytes
"""
try:
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
repo = DocumentRepository(db)
# دریافت تمام اسناد (بدون pagination)
filters_copy = filters.copy()
filters_copy["take"] = 10000
filters_copy["skip"] = 0
documents, _ = repo.list_documents_with_filters(business_id, filters_copy)
# ایجاد Workbook
wb = Workbook()
ws = wb.active
ws.title = "Documents"
# تنظیم راست‌چین برای فارسی
ws.sheet_view.rightToLeft = True
# هدر
headers = [
"شماره سند",
"نوع سند",
"تاریخ سند",
"سال مالی",
"ارز",
"بدهکار",
"بستانکار",
"وضعیت",
"توضیحات",
"تاریخ ثبت",
]
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF", size=12)
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal="center", vertical="center")
# داده‌ها
for row_num, doc in enumerate(documents, 2):
ws.cell(row=row_num, column=1, value=doc.get("code"))
ws.cell(row=row_num, column=2, value=doc.get("document_type"))
ws.cell(row=row_num, column=3, value=str(doc.get("document_date")))
ws.cell(row=row_num, column=4, value=doc.get("fiscal_year_title"))
ws.cell(row=row_num, column=5, value=doc.get("currency_code"))
ws.cell(row=row_num, column=6, value=doc.get("total_debit", 0))
ws.cell(row=row_num, column=7, value=doc.get("total_credit", 0))
ws.cell(row=row_num, column=8, value="پیش‌فاکتور" if doc.get("is_proforma") else "قطعی")
ws.cell(row=row_num, column=9, value=doc.get("description", ""))
ws.cell(row=row_num, column=10, value=str(doc.get("created_at")))
# تنظیم عرض ستون‌ها
ws.column_dimensions["A"].width = 15
ws.column_dimensions["B"].width = 15
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 20
ws.column_dimensions["E"].width = 10
ws.column_dimensions["F"].width = 15
ws.column_dimensions["G"].width = 15
ws.column_dimensions["H"].width = 12
ws.column_dimensions["I"].width = 30
ws.column_dimensions["J"].width = 20
# ذخیره در حافظه
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output.read()
except ImportError:
raise ApiError(
"OPENPYXL_NOT_INSTALLED",
"openpyxl library is not installed",
http_status=500
)
except Exception as e:
logger.error(f"Error generating Excel: {str(e)}")
raise ApiError(
"EXCEL_GENERATION_FAILED",
f"Failed to generate Excel file: {str(e)}",
http_status=500
)
def export_documents_pdf(
db: Session,
business_id: int,
filters: Dict[str, Any],
) -> bytes:
"""
خروجی PDF لیست اسناد
Args:
db: جلسه دیتابیس
business_id: شناسه کسبوکار
filters: فیلترها
Returns:
محتوای فایل PDF به صورت bytes
"""
# TODO: پیاده‌سازی export PDF
# می‌توان از WeasyPrint یا ReportLab استفاده کرد
raise ApiError(
"NOT_IMPLEMENTED",
"PDF export is not implemented yet",
http_status=501
)
def create_manual_document(
db: Session,
business_id: int,
fiscal_year_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""
ایجاد سند حسابداری دستی جدید
Args:
db: جلسه دیتابیس
business_id: شناسه کسبوکار
fiscal_year_id: شناسه سال مالی
user_id: شناسه کاربر ایجادکننده
data: اطلاعات سند و سطرها
Returns:
دیکشنری حاوی اطلاعات کامل سند ایجاد شده
Raises:
ApiError: در صورت بروز خطا
"""
repo = DocumentRepository(db)
# اعتبارسنجی سطرهای سند
lines_data = data.get("lines", [])
is_valid, error_msg = repo.validate_document_balance(lines_data)
if not is_valid:
raise ApiError(
"INVALID_DOCUMENT",
error_msg,
http_status=400
)
# تولید کد سند اگر وجود نداشت
code = data.get("code")
if not code:
code = repo.generate_document_code(business_id, "manual")
# تبدیل lines به فرمت مناسب repository
lines_for_db = []
for line in lines_data:
line_dict = {
"account_id": line.get("account_id"),
"person_id": line.get("person_id"),
"product_id": line.get("product_id"),
"bank_account_id": line.get("bank_account_id"),
"cash_register_id": line.get("cash_register_id"),
"petty_cash_id": line.get("petty_cash_id"),
"check_id": line.get("check_id"),
"quantity": line.get("quantity"),
"debit": line.get("debit", 0),
"credit": line.get("credit", 0),
"description": line.get("description"),
"extra_info": line.get("extra_info"),
}
lines_for_db.append(line_dict)
# آماده‌سازی داده‌های سند
document_data = {
"code": code,
"business_id": business_id,
"fiscal_year_id": fiscal_year_id,
"currency_id": data.get("currency_id"),
"created_by_user_id": user_id,
"document_date": data.get("document_date"),
"document_type": "manual",
"is_proforma": data.get("is_proforma", False),
"description": data.get("description"),
"extra_info": data.get("extra_info"),
"lines": lines_for_db,
}
try:
# ایجاد سند
document = repo.create_document(document_data)
# دریافت جزئیات کامل سند (با روابط)
return repo.get_document_details(document.id)
except Exception as e:
logger.error(f"Error creating manual document: {str(e)}")
db.rollback()
raise ApiError(
"CREATE_DOCUMENT_FAILED",
f"Failed to create document: {str(e)}",
http_status=500
)
def update_manual_document(
db: Session,
document_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""
ویرایش سند حسابداری دستی
Args:
db: جلسه دیتابیس
document_id: شناسه سند
data: اطلاعات جدید سند
Returns:
دیکشنری حاوی اطلاعات کامل سند ویرایش شده
Raises:
ApiError: در صورت بروز خطا
"""
repo = DocumentRepository(db)
# بررسی وجود سند
document = repo.get_document(document_id)
if not document:
raise ApiError(
"DOCUMENT_NOT_FOUND",
"Document not found",
http_status=404
)
# بررسی اینکه فقط اسناد manual قابل ویرایش هستند
if document.document_type != "manual":
raise ApiError(
"CANNOT_EDIT_AUTO_DOCUMENT",
"Cannot edit automatically generated documents. Please edit from the original source.",
http_status=400
)
# اگر سطرها ارسال شده، اعتبارسنجی کن
if "lines" in data and data["lines"] is not None:
lines_data = data["lines"]
is_valid, error_msg = repo.validate_document_balance(lines_data)
if not is_valid:
raise ApiError(
"INVALID_DOCUMENT",
error_msg,
http_status=400
)
# تبدیل lines به فرمت مناسب repository
lines_for_db = []
for line in lines_data:
line_dict = {
"account_id": line.get("account_id"),
"person_id": line.get("person_id"),
"product_id": line.get("product_id"),
"bank_account_id": line.get("bank_account_id"),
"cash_register_id": line.get("cash_register_id"),
"petty_cash_id": line.get("petty_cash_id"),
"check_id": line.get("check_id"),
"quantity": line.get("quantity"),
"debit": line.get("debit", 0),
"credit": line.get("credit", 0),
"description": line.get("description"),
"extra_info": line.get("extra_info"),
}
lines_for_db.append(line_dict)
data["lines"] = lines_for_db
try:
# ویرایش سند
updated_document = repo.update_document(document_id, data)
if not updated_document:
raise ApiError(
"UPDATE_FAILED",
"Failed to update document",
http_status=500
)
# دریافت جزئیات کامل سند
return repo.get_document_details(updated_document.id)
except ApiError:
raise
except Exception as e:
logger.error(f"Error updating manual document: {str(e)}")
db.rollback()
raise ApiError(
"UPDATE_DOCUMENT_FAILED",
f"Failed to update document: {str(e)}",
http_status=500
)

View file

@ -396,3 +396,362 @@ def list_expense_income(
} }
def get_expense_income(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
"""دریافت جزئیات یک سند هزینه/درآمد"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return None
return document_to_dict(db, document)
def update_expense_income(
db: Session,
document_id: int,
user_id: int,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""ویرایش سند هزینه/درآمد"""
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
# بررسی نوع سند
if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
raise ApiError("INVALID_DOCUMENT_TYPE", "Document is not expense/income", http_status=400)
is_income = document.document_type == DOCUMENT_TYPE_INCOME
# تاریخ
document_date = _parse_iso_date(data.get("document_date", document.document_date))
# ارز
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)
# سال مالی فعال
fiscal_year = _get_business_fiscal_year(db, document.business_id)
# اعتبارسنجی خطوط
item_lines: List[Dict[str, Any]] = list(data.get("item_lines") or [])
counterparty_lines: List[Dict[str, Any]] = list(data.get("counterparty_lines") or [])
if not item_lines:
raise ApiError("LINES_REQUIRED", "item_lines is required", http_status=400)
if not counterparty_lines:
raise ApiError("LINES_REQUIRED", "counterparty_lines is required", http_status=400)
sum_items = Decimal(0)
for idx, line in enumerate(item_lines):
if not line.get("account_id"):
raise ApiError("ACCOUNT_REQUIRED", f"item_lines[{idx}].account_id is required", http_status=400)
amount = Decimal(str(line.get("amount", 0)))
if amount <= 0:
raise ApiError("AMOUNT_INVALID", f"item_lines[{idx}].amount must be > 0", http_status=400)
sum_items += amount
sum_counterparties = Decimal(0)
for idx, line in enumerate(counterparty_lines):
amount = Decimal(str(line.get("amount", 0)))
if amount <= 0:
raise ApiError("AMOUNT_INVALID", f"counterparty_lines[{idx}].amount must be > 0", http_status=400)
sum_counterparties += amount
if sum_items != sum_counterparties:
raise ApiError("LINES_NOT_BALANCED", "Sum of items and counterparties must be equal", http_status=400)
# حذف خطوط قبلی
db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete()
# به‌روزرسانی اطلاعات سند
document.document_date = document_date
document.currency_id = int(currency_id)
document.fiscal_year_id = fiscal_year.id
document.description = (data.get("description") or "").strip() or None
document.extra_info = data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None
# سطرهای حساب‌های هزینه/درآمد
for line in item_lines:
account = db.query(Account).filter(
and_(
Account.id == int(line.get("account_id")),
or_(Account.business_id == document.business_id, Account.business_id == None), # noqa: E711
)
).first()
if not account:
raise ApiError("ACCOUNT_NOT_FOUND", "Item account not found", http_status=404)
amount = Decimal(str(line.get("amount", 0)))
description = (line.get("description") or "").strip() or None
debit_amount = amount if not is_income else Decimal(0)
credit_amount = amount if is_income else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=account.id,
debit=debit_amount,
credit=credit_amount,
description=description,
))
# سطرهای طرف‌حساب
for line in counterparty_lines:
amount = Decimal(str(line.get("amount", 0)))
description = (line.get("description") or "").strip() or None
commission = Decimal(str(line.get("commission", 0))) if line.get("commission") else Decimal(0)
# تعیین نوع تراکنش و حساب مربوطه
transaction_type = line.get("transaction_type", "bank")
account = None
if transaction_type == "bank":
bank_account_id = line.get("bank_account_id")
if bank_account_id:
from adapters.db.models.bank_account import BankAccount
bank_account = db.query(BankAccount).filter(BankAccount.id == int(bank_account_id)).first()
if bank_account:
account = bank_account.account
elif transaction_type == "cash_register":
cash_register_id = line.get("cash_register_id")
if cash_register_id:
from adapters.db.models.cash_register import CashRegister
cash_register = db.query(CashRegister).filter(CashRegister.id == int(cash_register_id)).first()
if cash_register:
account = cash_register.account
elif transaction_type == "petty_cash":
petty_cash_id = line.get("petty_cash_id")
if petty_cash_id:
from adapters.db.models.petty_cash import PettyCash
petty_cash = db.query(PettyCash).filter(PettyCash.id == int(petty_cash_id)).first()
if petty_cash:
account = petty_cash.account
elif transaction_type == "check":
check_id = line.get("check_id")
if check_id:
from adapters.db.models.check import Check
check = db.query(Check).filter(Check.id == int(check_id)).first()
if check:
account = check.account
elif transaction_type == "person":
person_id = line.get("person_id")
if person_id:
from adapters.db.models.person import Person
person = db.query(Person).filter(Person.id == int(person_id)).first()
if person:
# حساب شخص بر اساس نوع (دریافتنی/پرداختنی)
account = _get_person_account(db, document.business_id, int(person_id), is_income)
if not account:
# اگر حساب مشخص نشده، از حساب پیش‌فرض استفاده کن
account_code = "1111" if is_income else "2111" # نقد یا بانک
account = _get_fixed_account_by_code(db, account_code)
# مبلغ اصلی
debit_amount = amount if is_income else Decimal(0)
credit_amount = amount if not is_income else Decimal(0)
db.add(DocumentLine(
document_id=document.id,
account_id=account.id,
debit=debit_amount,
credit=credit_amount,
description=description,
extra_info={
"transaction_type": transaction_type,
"transaction_date": line.get("transaction_date"),
"commission": float(commission),
**{k: v for k, v in line.items() if k not in ["amount", "description", "commission", "transaction_type", "transaction_date"]}
}
))
# اگر کارمزد وجود دارد، خط کارمزد اضافه کن
if commission > 0:
commission_account = _get_fixed_account_by_code(db, "5111") # کارمزد
db.add(DocumentLine(
document_id=document.id,
account_id=commission_account.id,
debit=commission if is_income else Decimal(0),
credit=commission if not is_income else Decimal(0),
description=f"کارمزد {description or ''}",
extra_info={"is_commission_line": True}
))
db.commit()
db.refresh(document)
return document_to_dict(db, document)
def delete_expense_income(db: Session, document_id: int) -> bool:
"""حذف یک سند هزینه/درآمد"""
try:
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
return False
# بررسی نوع سند
if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME):
return False
# حذف خطوط سند
db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete()
# حذف سند
db.delete(document)
db.commit()
return True
except Exception as e:
logger.error(f"Error deleting expense/income document {document_id}: {e}")
db.rollback()
return False
def delete_multiple_expense_income(db: Session, document_ids: List[int]) -> bool:
"""حذف چندین سند هزینه/درآمد"""
try:
documents = db.query(Document).filter(
and_(
Document.id.in_(document_ids),
Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME])
)
).all()
if not documents:
return False
# حذف خطوط اسناد
db.query(DocumentLine).filter(DocumentLine.document_id.in_(document_ids)).delete()
# حذف اسناد
for document in documents:
db.delete(document)
db.commit()
return True
except Exception as e:
logger.error(f"Error deleting multiple expense/income documents: {e}")
db.rollback()
return False
def export_expense_income_excel(db: Session, business_id: int, query: Dict[str, Any]) -> bytes:
"""خروجی Excel اسناد هزینه/درآمد"""
# این تابع باید پیاده‌سازی شود
# فعلاً یک فایل Excel خالی برمی‌گرداند
import io
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "هزینه و درآمد"
# هدرها
headers = ["کد سند", "نوع", "تاریخ سند", "مبلغ کل", "توضیحات", "ایجادکننده", "تاریخ ثبت"]
for col, header in enumerate(headers, 1):
ws.cell(row=1, column=col, value=header)
# دریافت داده‌ها
result = list_expense_income(db, business_id, query)
items = result.get("items", [])
# اضافه کردن داده‌ها
for row, item in enumerate(items, 2):
ws.cell(row=row, column=1, value=item.get("code", ""))
ws.cell(row=row, column=2, value=item.get("document_type_name", ""))
ws.cell(row=row, column=3, value=item.get("document_date", ""))
ws.cell(row=row, column=4, value=item.get("total_amount", 0))
ws.cell(row=row, column=5, value=item.get("description", ""))
ws.cell(row=row, column=6, value=item.get("created_by_name", ""))
ws.cell(row=row, column=7, value=item.get("registered_at", ""))
# ذخیره در بایت
output = io.BytesIO()
wb.save(output)
return output.getvalue()
def export_expense_income_pdf(db: Session, business_id: int, query: Dict[str, Any]) -> bytes:
"""خروجی PDF اسناد هزینه/درآمد"""
# این تابع باید پیاده‌سازی شود
# فعلاً یک فایل PDF خالی برمی‌گرداند
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
import io
output = io.BytesIO()
c = canvas.Canvas(output, pagesize=letter)
# عنوان
c.setFont("Helvetica-Bold", 16)
c.drawString(100, 750, "گزارش هزینه و درآمد")
# دریافت داده‌ها
result = list_expense_income(db, business_id, query)
items = result.get("items", [])
# اضافه کردن داده‌ها
y = 700
c.setFont("Helvetica", 12)
for item in items[:20]: # حداکثر 20 آیتم
c.drawString(100, y, f"{item.get('code', '')} - {item.get('document_type_name', '')} - {item.get('total_amount', 0)}")
y -= 20
if y < 100:
c.showPage()
y = 750
c.save()
return output.getvalue()
def generate_expense_income_pdf(db: Session, document_id: int) -> bytes:
"""تولید PDF یک سند هزینه/درآمد"""
# این تابع باید پیاده‌سازی شود
# فعلاً یک فایل PDF خالی برمی‌گرداند
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
import io
output = io.BytesIO()
c = canvas.Canvas(output, pagesize=letter)
# دریافت سند
document = db.query(Document).filter(Document.id == document_id).first()
if not document:
raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404)
# عنوان
c.setFont("Helvetica-Bold", 16)
c.drawString(100, 750, f"سند {document.document_type_name}")
c.drawString(100, 730, f"کد: {document.code}")
c.drawString(100, 710, f"تاریخ: {document.document_date}")
c.save()
return output.getvalue()
def _get_person_account(
db: Session,
business_id: int,
person_id: int,
is_receivable: bool
) -> Account:
"""دریافت حساب شخص (دریافتنی یا پرداختنی)"""
from adapters.db.models.person import Person
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
# تعیین کد حساب بر اساس نوع
if is_receivable:
account_code = "1211" # دریافتنی‌ها
else:
account_code = "2211" # پرداختنی‌ها
return _get_fixed_account_by_code(db, account_code)

View file

@ -5,6 +5,8 @@ from app.core.responses import ApiError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func from sqlalchemy import and_, or_, func
from adapters.db.models.person import Person, PersonBankAccount, PersonType from adapters.db.models.person import Person, PersonBankAccount, PersonType
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.api.v1.schema_models.person import ( from adapters.api.v1.schema_models.person import (
PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest
) )
@ -137,11 +139,30 @@ def get_person_by_id(db: Session, person_id: int, business_id: int) -> Optional[
def get_persons_by_business( def get_persons_by_business(
db: Session, db: Session,
business_id: int, business_id: int,
query_info: Dict[str, Any] query_info: Dict[str, Any],
fiscal_year_id: Optional[int] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""دریافت لیست اشخاص با جستجو و فیلتر""" """دریافت لیست اشخاص با جستجو و فیلتر"""
query = db.query(Person).filter(Person.business_id == business_id) query = db.query(Person).filter(Person.business_id == business_id)
# بررسی نیاز به محاسبه تراز قبل از pagination
# (برای فیلتر یا مرتب‌سازی بر اساس تراز/وضعیت)
needs_balance_before_pagination = False
sort_by = query_info.get('sort_by', 'created_at')
if sort_by in ['balance', 'status']:
needs_balance_before_pagination = True
# بررسی فیلترها برای balance و status
if query_info.get('filters'):
for filter_item in query_info['filters']:
if isinstance(filter_item, dict):
field = filter_item.get('property')
else:
field = getattr(filter_item, 'property', None)
if field in ['balance', 'status']:
needs_balance_before_pagination = True
break
# اعمال جستجو # اعمال جستجو
if query_info.get('search') and query_info.get('search_fields'): if query_info.get('search') and query_info.get('search_fields'):
search_term = f"%{query_info['search']}%" search_term = f"%{query_info['search']}%"
@ -274,10 +295,11 @@ def get_persons_by_business(
# شمارش کل رکوردها # شمارش کل رکوردها
total = query.count() total = query.count()
# اعمال مرتب‌سازی # اعمال مرتب‌سازی (فقط برای فیلدهای دیتابیس)
sort_by = query_info.get('sort_by', 'created_at')
sort_desc = query_info.get('sort_desc', True) sort_desc = query_info.get('sort_desc', True)
if sort_by not in ['balance', 'status']:
# مرتب‌سازی در دیتابیس
if sort_by == 'code': if sort_by == 'code':
query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc()) query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc())
elif sort_by == 'alias_name': elif sort_by == 'alias_name':
@ -286,7 +308,6 @@ def get_persons_by_business(
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc()) query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
elif sort_by == 'last_name': elif sort_by == 'last_name':
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc()) query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
# person_type sorting removed - use person_types instead
elif sort_by == 'created_at': elif sort_by == 'created_at':
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc()) query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
elif sort_by == 'updated_at': elif sort_by == 'updated_at':
@ -294,18 +315,86 @@ def get_persons_by_business(
else: else:
query = query.order_by(Person.created_at.desc()) query = query.order_by(Person.created_at.desc())
# اعمال صفحه‌بندی
skip = query_info.get('skip', 0) skip = query_info.get('skip', 0)
take = query_info.get('take', 20) take = query_info.get('take', 20)
# اگر نیاز به محاسبه تراز قبل از pagination است
if needs_balance_before_pagination:
# دریافت همه persons
all_persons = query.all()
# تبدیل به دیکشنری و محاسبه تراز
all_items = []
person_ids = [p.id for p in all_persons]
balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id)
for person in all_persons:
item = _person_to_dict(person)
balance, status = balances.get(person.id, (0.0, "بدون تراکنش"))
item['balance'] = balance
item['status'] = status
all_items.append(item)
# اعمال فیلتر balance و status
if query_info.get('filters'):
for filter_item in query_info['filters']:
if isinstance(filter_item, dict):
field = filter_item.get('property')
operator = filter_item.get('operator')
value = filter_item.get('value')
else:
field = getattr(filter_item, 'property', None)
operator = getattr(filter_item, 'operator', None)
value = getattr(filter_item, 'value', None)
if field == 'balance':
if operator == '=':
all_items = [item for item in all_items if item['balance'] == value]
elif operator == '>':
all_items = [item for item in all_items if item['balance'] > value]
elif operator == '>=':
all_items = [item for item in all_items if item['balance'] >= value]
elif operator == '<':
all_items = [item for item in all_items if item['balance'] < value]
elif operator == '<=':
all_items = [item for item in all_items if item['balance'] <= value]
elif field == 'status':
if operator == '=' and isinstance(value, str):
all_items = [item for item in all_items if item['status'] == value]
elif operator == 'in' and isinstance(value, list):
all_items = [item for item in all_items if item['status'] in value]
# مرتب‌سازی
if sort_by == 'balance':
all_items.sort(key=lambda x: x['balance'], reverse=sort_desc)
elif sort_by == 'status':
all_items.sort(key=lambda x: x['status'], reverse=sort_desc)
# محاسبه total بعد از فیلتر
total = len(all_items)
# اعمال pagination
items = all_items[skip:skip + take]
else:
# روش معمولی: ابتدا pagination، سپس محاسبه تراز
persons = query.offset(skip).limit(take).all() persons = query.offset(skip).limit(take).all()
# تبدیل به دیکشنری # تبدیل به دیکشنری
items = [_person_to_dict(person) for person in persons] items = [_person_to_dict(person) for person in persons]
# محاسبه تراز برای persons فعلی
person_ids = [p.id for p in persons]
balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id)
for item in items:
person_id = item['id']
balance, status = balances.get(person_id, (0.0, "بدون تراکنش"))
item['balance'] = balance
item['status'] = status
# محاسبه اطلاعات صفحه‌بندی # محاسبه اطلاعات صفحه‌بندی
total_pages = (total + take - 1) // take total_pages = (total + take - 1) // take if take > 0 else 0
current_page = (skip // take) + 1 current_page = (skip // take) + 1 if take > 0 else 1
pagination = { pagination = {
'total': total, 'total': total,
@ -535,3 +624,129 @@ def count_persons(db: Session, business_id: int, search_query: Optional[str] = N
query = query.filter(search_filter) query = query.filter(search_filter)
return query.count() return query.count()
def calculate_person_balance(
db: Session,
person_id: int,
fiscal_year_id: Optional[int] = None
) -> tuple[float, str]:
"""
محاسبه تراز و وضعیت مالی یک شخص
Args:
db: نشست پایگاه داده
person_id: شناسه شخص
fiscal_year_id: شناسه سال مالی (اختیاری)
Returns:
tuple: (تراز, وضعیت)
- تراز: credit - debit
- وضعیت: "بستانکار" | "بدهکار" | "بالانس" | "بدون تراکنش"
"""
# Query برای محاسبه مجموع بستانکار و بدهکار
query = db.query(
func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit'),
func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit')
).join(
Document, DocumentLine.document_id == Document.id
).filter(
DocumentLine.person_id == person_id,
Document.is_proforma == False # فقط اسناد قطعی
)
# اعمال فیلتر سال مالی
if fiscal_year_id:
query = query.filter(Document.fiscal_year_id == fiscal_year_id)
result = query.first()
if result is None:
return 0.0, "بدون تراکنش"
total_credit = float(result.total_credit or 0)
total_debit = float(result.total_debit or 0)
# محاسبه تراز: بستانکار - بدهکار
balance = total_credit - total_debit
# تعیین وضعیت
if total_credit == 0 and total_debit == 0:
status = "بدون تراکنش"
elif balance > 0:
status = "بستانکار"
elif balance < 0:
status = "بدهکار"
else: # balance == 0
status = "بالانس"
return balance, status
def calculate_persons_balances_bulk(
db: Session,
person_ids: List[int],
fiscal_year_id: Optional[int] = None
) -> Dict[int, tuple[float, str]]:
"""
محاسبه تراز و وضعیت چندین شخص به صورت دستهجمعی
Args:
db: نشست پایگاه داده
person_ids: لیست شناسههای اشخاص
fiscal_year_id: شناسه سال مالی (اختیاری)
Returns:
dict: {person_id: (balance, status)}
"""
if not person_ids:
return {}
# Query برای محاسبه مجموع بستانکار و بدهکار برای هر شخص
query = db.query(
DocumentLine.person_id,
func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit'),
func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit')
).join(
Document, DocumentLine.document_id == Document.id
).filter(
DocumentLine.person_id.in_(person_ids),
Document.is_proforma == False # فقط اسناد قطعی
)
# اعمال فیلتر سال مالی
if fiscal_year_id:
query = query.filter(Document.fiscal_year_id == fiscal_year_id)
# Group by person_id
query = query.group_by(DocumentLine.person_id)
results = query.all()
# ساخت دیکشنری نتایج
balances: Dict[int, tuple[float, str]] = {}
# ابتدا همه را به "بدون تراکنش" تنظیم می‌کنیم
for person_id in person_ids:
balances[person_id] = (0.0, "بدون تراکنش")
# سپس نتایج واقعی را اعمال می‌کنیم
for result in results:
person_id = result.person_id
total_credit = float(result.total_credit or 0)
total_debit = float(result.total_debit or 0)
# محاسبه تراز
balance = total_credit - total_debit
# تعیین وضعیت
if balance > 0:
status = "بستانکار"
elif balance < 0:
status = "بدهکار"
else: # balance == 0
status = "بالانس"
balances[person_id] = (balance, status)
return balances

View file

@ -352,6 +352,30 @@ def list_transfers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict
except Exception: except Exception:
pass pass
# Apply advanced filters (e.g., DataTable date range filters)
filters = query.get("filters")
if filters and isinstance(filters, (list, tuple)):
for flt in filters:
try:
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 == 'document_date':
if isinstance(val, str) and val:
try:
dt = _parse_iso_date(val)
col = getattr(Document, prop)
if op == ">=":
q = q.filter(col >= dt)
elif op == "<=":
q = q.filter(col <= dt)
except Exception:
pass
except Exception:
pass
search = query.get("search") search = query.get("search")
if search: if search:
q = q.filter(Document.code.ilike(f"%{search}%")) q = q.filter(Document.code.ilike(f"%{search}%"))
@ -606,9 +630,13 @@ def transfer_document_to_dict(db: Session, document: Document) -> Dict[str, Any]
line_dict["side"] = line.extra_info["side"] line_dict["side"] = line.extra_info["side"]
if "source_type" in line.extra_info: if "source_type" in line.extra_info:
line_dict["source_type"] = line.extra_info["source_type"] line_dict["source_type"] = line.extra_info["source_type"]
# Only assign source_type from source lines
if line_dict.get("side") == "source":
source_type = source_type or line.extra_info["source_type"] source_type = source_type or line.extra_info["source_type"]
if "destination_type" in line.extra_info: if "destination_type" in line.extra_info:
line_dict["destination_type"] = line.extra_info["destination_type"] line_dict["destination_type"] = line.extra_info["destination_type"]
# Only assign destination_type from destination lines
if line_dict.get("side") == "destination":
destination_type = destination_type or line.extra_info["destination_type"] destination_type = destination_type or line.extra_info["destination_type"]
if "is_commission_line" in line.extra_info: if "is_commission_line" in line.extra_info:
line_dict["is_commission_line"] = line.extra_info["is_commission_line"] line_dict["is_commission_line"] = line.extra_info["is_commission_line"]

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.core.responses import ApiError
from adapters.db.models.warehouse import Warehouse
from adapters.db.repositories.warehouse_repository import WarehouseRepository
from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest
def _to_dict(obj: Warehouse) -> Dict[str, Any]:
return {
"id": obj.id,
"business_id": obj.business_id,
"code": obj.code,
"name": obj.name,
"description": obj.description,
"is_default": obj.is_default,
"created_at": obj.created_at,
"updated_at": obj.updated_at,
}
def create_warehouse(db: Session, business_id: int, payload: WarehouseCreateRequest) -> Dict[str, Any]:
code = payload.code.strip()
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
repo = WarehouseRepository(db)
obj = repo.create(
business_id=business_id,
code=code,
name=payload.name.strip(),
description=payload.description,
is_default=bool(payload.is_default),
)
if obj.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)}
def list_warehouses(db: Session, business_id: int) -> Dict[str, Any]:
repo = WarehouseRepository(db)
rows = repo.list(business_id)
return {"items": [_to_dict(w) for w in rows]}
def get_warehouse(db: Session, business_id: int, warehouse_id: int) -> Optional[Dict[str, Any]]:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
return _to_dict(obj)
def update_warehouse(db: Session, business_id: int, warehouse_id: int, payload: WarehouseUpdateRequest) -> Optional[Dict[str, Any]]:
repo = WarehouseRepository(db)
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return None
if payload.code and payload.code.strip() != obj.code:
dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first()
if dup:
raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400)
updated = repo.update(
warehouse_id,
code=payload.code.strip() if isinstance(payload.code, str) else None,
name=payload.name.strip() if isinstance(payload.name, str) else None,
description=payload.description,
is_default=payload.is_default if payload.is_default is not None else None,
)
if not updated:
return None
if updated.is_default:
db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False})
db.commit()
return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)}
def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool:
obj = db.get(Warehouse, warehouse_id)
if not obj or obj.business_id != business_id:
return False
repo = WarehouseRepository(db)
return repo.delete(warehouse_id)

View file

@ -6,6 +6,7 @@ adapters/api/v1/__init__.py
adapters/api/v1/accounts.py adapters/api/v1/accounts.py
adapters/api/v1/auth.py adapters/api/v1/auth.py
adapters/api/v1/bank_accounts.py adapters/api/v1/bank_accounts.py
adapters/api/v1/boms.py
adapters/api/v1/business_dashboard.py adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py adapters/api/v1/business_users.py
adapters/api/v1/businesses.py adapters/api/v1/businesses.py
@ -14,6 +15,8 @@ adapters/api/v1/categories.py
adapters/api/v1/checks.py adapters/api/v1/checks.py
adapters/api/v1/currencies.py adapters/api/v1/currencies.py
adapters/api/v1/customers.py adapters/api/v1/customers.py
adapters/api/v1/documents.py
adapters/api/v1/expense_income.py
adapters/api/v1/fiscal_years.py adapters/api/v1/fiscal_years.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/invoices.py adapters/api/v1/invoices.py
@ -26,13 +29,16 @@ adapters/api/v1/receipts_payments.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
adapters/api/v1/tax_types.py adapters/api/v1/tax_types.py
adapters/api/v1/tax_units.py adapters/api/v1/tax_units.py
adapters/api/v1/transfers.py
adapters/api/v1/users.py adapters/api/v1/users.py
adapters/api/v1/warehouses.py
adapters/api/v1/admin/email_config.py adapters/api/v1/admin/email_config.py
adapters/api/v1/admin/file_storage.py adapters/api/v1/admin/file_storage.py
adapters/api/v1/schema_models/__init__.py adapters/api/v1/schema_models/__init__.py
adapters/api/v1/schema_models/account.py adapters/api/v1/schema_models/account.py
adapters/api/v1/schema_models/bank_account.py adapters/api/v1/schema_models/bank_account.py
adapters/api/v1/schema_models/check.py adapters/api/v1/schema_models/check.py
adapters/api/v1/schema_models/document.py
adapters/api/v1/schema_models/document_line.py adapters/api/v1/schema_models/document_line.py
adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/email.py
adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/file_storage.py
@ -40,6 +46,8 @@ adapters/api/v1/schema_models/person.py
adapters/api/v1/schema_models/price_list.py adapters/api/v1/schema_models/price_list.py
adapters/api/v1/schema_models/product.py adapters/api/v1/schema_models/product.py
adapters/api/v1/schema_models/product_attribute.py adapters/api/v1/schema_models/product_attribute.py
adapters/api/v1/schema_models/product_bom.py
adapters/api/v1/schema_models/warehouse.py
adapters/api/v1/support/__init__.py adapters/api/v1/support/__init__.py
adapters/api/v1/support/categories.py adapters/api/v1/support/categories.py
adapters/api/v1/support/operator.py adapters/api/v1/support/operator.py
@ -72,9 +80,11 @@ adapters/db/models/price_list.py
adapters/db/models/product.py adapters/db/models/product.py
adapters/db/models/product_attribute.py adapters/db/models/product_attribute.py
adapters/db/models/product_attribute_link.py adapters/db/models/product_attribute_link.py
adapters/db/models/product_bom.py
adapters/db/models/tax_type.py adapters/db/models/tax_type.py
adapters/db/models/tax_unit.py adapters/db/models/tax_unit.py
adapters/db/models/user.py adapters/db/models/user.py
adapters/db/models/warehouse.py
adapters/db/models/support/__init__.py adapters/db/models/support/__init__.py
adapters/db/models/support/category.py adapters/db/models/support/category.py
adapters/db/models/support/message.py adapters/db/models/support/message.py
@ -87,6 +97,7 @@ adapters/db/repositories/business_permission_repo.py
adapters/db/repositories/business_repo.py adapters/db/repositories/business_repo.py
adapters/db/repositories/cash_register_repository.py adapters/db/repositories/cash_register_repository.py
adapters/db/repositories/category_repository.py adapters/db/repositories/category_repository.py
adapters/db/repositories/document_repository.py
adapters/db/repositories/email_config_repository.py adapters/db/repositories/email_config_repository.py
adapters/db/repositories/file_storage_repository.py adapters/db/repositories/file_storage_repository.py
adapters/db/repositories/fiscal_year_repo.py adapters/db/repositories/fiscal_year_repo.py
@ -94,8 +105,10 @@ adapters/db/repositories/password_reset_repo.py
adapters/db/repositories/petty_cash_repository.py adapters/db/repositories/petty_cash_repository.py
adapters/db/repositories/price_list_repository.py adapters/db/repositories/price_list_repository.py
adapters/db/repositories/product_attribute_repository.py adapters/db/repositories/product_attribute_repository.py
adapters/db/repositories/product_bom_repository.py
adapters/db/repositories/product_repository.py adapters/db/repositories/product_repository.py
adapters/db/repositories/user_repo.py adapters/db/repositories/user_repo.py
adapters/db/repositories/warehouse_repository.py
adapters/db/repositories/support/__init__.py adapters/db/repositories/support/__init__.py
adapters/db/repositories/support/category_repository.py adapters/db/repositories/support/category_repository.py
adapters/db/repositories/support/message_repository.py adapters/db/repositories/support/message_repository.py
@ -120,13 +133,16 @@ app/core/smart_normalizer.py
app/services/api_key_service.py app/services/api_key_service.py
app/services/auth_service.py app/services/auth_service.py
app/services/bank_account_service.py app/services/bank_account_service.py
app/services/bom_service.py
app/services/bulk_price_update_service.py app/services/bulk_price_update_service.py
app/services/business_dashboard_service.py app/services/business_dashboard_service.py
app/services/business_service.py app/services/business_service.py
app/services/captcha_service.py app/services/captcha_service.py
app/services/cash_register_service.py app/services/cash_register_service.py
app/services/check_service.py app/services/check_service.py
app/services/document_service.py
app/services/email_service.py app/services/email_service.py
app/services/expense_income_service.py
app/services/file_storage_service.py app/services/file_storage_service.py
app/services/person_service.py app/services/person_service.py
app/services/petty_cash_service.py app/services/petty_cash_service.py
@ -135,6 +151,8 @@ app/services/product_attribute_service.py
app/services/product_service.py app/services/product_service.py
app/services/query_service.py app/services/query_service.py
app/services/receipt_payment_service.py app/services/receipt_payment_service.py
app/services/transfer_service.py
app/services/warehouse_service.py
app/services/pdf/__init__.py app/services/pdf/__init__.py
app/services/pdf/base_pdf_service.py app/services/pdf/base_pdf_service.py
app/services/pdf/modules/__init__.py app/services/pdf/modules/__init__.py
@ -196,6 +214,7 @@ migrations/versions/20251014_000201_add_person_id_to_document_lines.py
migrations/versions/20251014_000301_add_product_id_to_document_lines.py migrations/versions/20251014_000301_add_product_id_to_document_lines.py
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
migrations/versions/20251014_000501_add_quantity_to_document_lines.py migrations/versions/20251014_000501_add_quantity_to_document_lines.py
migrations/versions/20251021_000601_add_bom_and_warehouses.py
migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py

View file

@ -0,0 +1,137 @@
"""add BOM and warehouses tables
Revision ID: 20251021_000601_add_bom_and_warehouses
Revises: 20251014_000501_add_quantity_to_document_lines
Create Date: 2025-10-21 00:06:01.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20251021_000601_add_bom_and_warehouses"
down_revision = "20251014_000501_add_quantity_to_document_lines"
branch_labels = None
depends_on = None
def upgrade() -> None:
# warehouses
op.create_table(
"warehouses",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"),
)
op.create_index("ix_warehouses_business_id", "warehouses", ["business_id"])
op.create_index("ix_warehouses_code", "warehouses", ["code"])
op.create_index("ix_warehouses_name", "warehouses", ["name"])
op.create_index("ix_warehouses_is_default", "warehouses", ["is_default"])
# product_boms
op.create_table(
"product_boms",
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("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False),
sa.Column("version", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("effective_from", sa.Date(), nullable=True),
sa.Column("effective_to", sa.Date(), nullable=True),
sa.Column("yield_percent", sa.Numeric(5, 2), nullable=True),
sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'draft'")),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"),
)
op.create_index("ix_product_boms_business_id", "product_boms", ["business_id"])
op.create_index("ix_product_boms_product_id", "product_boms", ["product_id"])
op.create_index("ix_product_boms_is_default", "product_boms", ["is_default"])
op.create_index("ix_product_boms_status", "product_boms", ["status"])
# product_bom_items
op.create_table(
"product_bom_items",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False),
sa.Column("line_no", sa.Integer(), nullable=False),
sa.Column("component_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False),
sa.Column("qty_per", sa.Numeric(18, 6), nullable=False),
sa.Column("uom", sa.String(length=32), nullable=True),
sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True),
sa.Column("is_optional", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("substitute_group", sa.String(length=64), nullable=True),
sa.Column("suggested_warehouse_id", sa.Integer(), sa.ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True),
sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"),
)
op.create_index("ix_product_bom_items_bom_id", "product_bom_items", ["bom_id"])
op.create_index("ix_product_bom_items_component_product_id", "product_bom_items", ["component_product_id"])
# product_bom_outputs
op.create_table(
"product_bom_outputs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False),
sa.Column("line_no", sa.Integer(), nullable=False),
sa.Column("output_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False),
sa.Column("ratio", sa.Numeric(18, 6), nullable=False),
sa.Column("uom", sa.String(length=32), nullable=True),
sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"),
)
op.create_index("ix_product_bom_outputs_bom_id", "product_bom_outputs", ["bom_id"])
op.create_index("ix_product_bom_outputs_output_product_id", "product_bom_outputs", ["output_product_id"])
# product_bom_operations
op.create_table(
"product_bom_operations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False),
sa.Column("line_no", sa.Integer(), nullable=False),
sa.Column("operation_name", sa.String(length=255), nullable=False),
sa.Column("cost_fixed", sa.Numeric(18, 2), nullable=True),
sa.Column("cost_per_unit", sa.Numeric(18, 6), nullable=True),
sa.Column("cost_uom", sa.String(length=32), nullable=True),
sa.Column("work_center", sa.String(length=128), nullable=True),
sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"),
)
op.create_index("ix_product_bom_operations_bom_id", "product_bom_operations", ["bom_id"])
def downgrade() -> None:
op.drop_index("ix_product_bom_operations_bom_id", table_name="product_bom_operations")
op.drop_table("product_bom_operations")
op.drop_index("ix_product_bom_outputs_output_product_id", table_name="product_bom_outputs")
op.drop_index("ix_product_bom_outputs_bom_id", table_name="product_bom_outputs")
op.drop_table("product_bom_outputs")
op.drop_index("ix_product_bom_items_component_product_id", table_name="product_bom_items")
op.drop_index("ix_product_bom_items_bom_id", table_name="product_bom_items")
op.drop_table("product_bom_items")
op.drop_index("ix_product_boms_status", table_name="product_boms")
op.drop_index("ix_product_boms_is_default", table_name="product_boms")
op.drop_index("ix_product_boms_product_id", table_name="product_boms")
op.drop_index("ix_product_boms_business_id", table_name="product_boms")
op.drop_table("product_boms")
op.drop_index("ix_warehouses_is_default", table_name="warehouses")
op.drop_index("ix_warehouses_name", table_name="warehouses")
op.drop_index("ix_warehouses_code", table_name="warehouses")
op.drop_index("ix_warehouses_business_id", table_name="warehouses")
op.drop_table("warehouses")

View file

@ -207,6 +207,21 @@ class ApiClient {
); );
return response.data ?? []; return response.data ?? [];
} }
// Download Excel API
Future<List<int>> downloadExcel(String path, {Map<String, dynamic>? params}) async {
final response = await post<List<int>>(
path,
data: params,
responseType: ResponseType.bytes,
options: Options(
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
),
);
return response.data ?? [];
}
} }
// Utilities // Utilities

View file

@ -67,10 +67,15 @@ class HesabixDateUtils {
return monthNames[month - 1]; return monthNames[month - 1];
} }
/// Format date for API (always Gregorian) /// Format date with both Jalali and Gregorian for display
static String formatForAPI(DateTime? date) { static String formatDualCalendar(DateTime? date) {
if (date == null) return ''; if (date == null) return '';
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final jalali = Jalali.fromDateTime(date);
final jalaliStr = '${jalali.year}/${jalali.month.toString().padLeft(2, '0')}/${jalali.day.toString().padLeft(2, '0')}';
final gregorianStr = '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
return '$jalaliStr (میلادی: $gregorianStr)';
} }
/// Parse date from API (always Gregorian) /// Parse date from API (always Gregorian)

View file

@ -41,6 +41,8 @@ import 'pages/business/check_form_page.dart';
import 'pages/business/receipts_payments_list_page.dart'; import 'pages/business/receipts_payments_list_page.dart';
import 'pages/business/expense_income_list_page.dart'; import 'pages/business/expense_income_list_page.dart';
import 'pages/business/transfers_page.dart'; import 'pages/business/transfers_page.dart';
import 'pages/business/documents_page.dart';
import 'pages/business/warehouses_page.dart';
import 'pages/error_404_page.dart'; import 'pages/error_404_page.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
import 'core/calendar_controller.dart'; import 'core/calendar_controller.dart';
@ -761,6 +763,33 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
GoRoute(
path: '/business/:business_id/warehouses',
name: 'business_warehouses',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: WarehousesPage(
businessId: businessId,
),
);
},
),
GoRoute(
path: '/business/:business_id/documents',
name: 'business_documents',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: DocumentsPage(
businessId: businessId,
calendarController: _calendarController!,
authStore: _authStore!,
apiClient: ApiClient(),
),
);
},
),
GoRoute( GoRoute(
path: '/business/:business_id/checks', path: '/business/:business_id/checks',
name: 'business_checks', name: 'business_checks',

View file

@ -0,0 +1,87 @@
class Account {
final int? id;
final int? businessId; // nullable - برای حسابهای عمومی
final String name;
final String code;
final String accountType;
final int? parentId;
final String? description;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;
const Account({
this.id,
this.businessId, // nullable شد
required this.name,
required this.code,
required this.accountType,
this.parentId,
this.description,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
factory Account.fromJson(Map<String, dynamic> json) {
return Account(
id: json['id'] as int?,
businessId: json['business_id'] as int?, // nullable
name: json['name'] as String,
code: json['code'] as String,
accountType: json['account_type'] as String,
parentId: json['parent_id'] as int?,
description: json['description'] as String?,
isActive: json['is_active'] as bool? ?? true,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'business_id': businessId,
'name': name,
'code': code,
'account_type': accountType,
'parent_id': parentId,
'description': description,
'is_active': isActive,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
Account copyWith({
int? id,
int? businessId,
String? name,
String? code,
String? accountType,
int? parentId,
String? description,
bool? isActive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Account(
id: id ?? this.id,
businessId: businessId ?? this.businessId,
name: name ?? this.name,
code: code ?? this.code,
accountType: accountType ?? this.accountType,
parentId: parentId ?? this.parentId,
description: description ?? this.description,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
/// آیا این حساب یک حساب عمومی است؟
bool get isGeneralAccount => businessId == null;
/// نمایش نام کامل با کد
String get displayName => '$code - $name';
}

View file

@ -1,3 +1,5 @@
import 'account_model.dart';
class AccountTreeNode { class AccountTreeNode {
final int id; final int id;
final String code; final String code;
@ -81,9 +83,26 @@ class AccountTreeNode {
}).toList(); }).toList();
} }
/// آیا این نود قابل انتخاب است؟ (فقط leaf nodes)
bool get isSelectable => !hasChildren;
/// نمایش نام کامل با کد
String get displayName => '$code - $name';
/// تبدیل به Account
Account toAccount() {
return Account(
id: id,
code: code,
name: name,
accountType: accountType ?? 'accounting_document',
parentId: parentId,
);
}
@override @override
String toString() { String toString() {
return '$code - $name'; return displayName;
} }
@override @override

View file

@ -0,0 +1,259 @@
class BomItem {
final int lineNo;
final int componentProductId;
final double qtyPer;
final String? uom;
final double? wastagePercent;
final bool isOptional;
final String? substituteGroup;
final int? suggestedWarehouseId;
const BomItem({
required this.lineNo,
required this.componentProductId,
required this.qtyPer,
this.uom,
this.wastagePercent,
this.isOptional = false,
this.substituteGroup,
this.suggestedWarehouseId,
});
factory BomItem.fromJson(Map<String, dynamic> json) {
return BomItem(
lineNo: (json['line_no'] ?? json['lineNo']) as int,
componentProductId: (json['component_product_id'] ?? json['componentProductId']) as int,
qtyPer: double.tryParse(json['qty_per']?.toString() ?? '0') ?? 0,
uom: json['uom'] as String?,
wastagePercent: json['wastage_percent'] != null ? double.tryParse(json['wastage_percent'].toString()) : null,
isOptional: (json['is_optional'] ?? false) as bool,
substituteGroup: json['substitute_group'] as String?,
suggestedWarehouseId: json['suggested_warehouse_id'] as int?,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'line_no': lineNo,
'component_product_id': componentProductId,
'qty_per': qtyPer,
'uom': uom,
'wastage_percent': wastagePercent,
'is_optional': isOptional,
'substitute_group': substituteGroup,
'suggested_warehouse_id': suggestedWarehouseId,
};
}
}
class BomOutput {
final int lineNo;
final int outputProductId;
final double ratio;
final String? uom;
const BomOutput({
required this.lineNo,
required this.outputProductId,
required this.ratio,
this.uom,
});
factory BomOutput.fromJson(Map<String, dynamic> json) {
return BomOutput(
lineNo: (json['line_no'] ?? json['lineNo']) as int,
outputProductId: (json['output_product_id'] ?? json['outputProductId']) as int,
ratio: double.tryParse(json['ratio']?.toString() ?? '0') ?? 0,
uom: json['uom'] as String?,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'line_no': lineNo,
'output_product_id': outputProductId,
'ratio': ratio,
'uom': uom,
};
}
}
class BomOperation {
final int lineNo;
final String operationName;
final double? costFixed;
final double? costPerUnit;
final String? costUom;
final String? workCenter;
const BomOperation({
required this.lineNo,
required this.operationName,
this.costFixed,
this.costPerUnit,
this.costUom,
this.workCenter,
});
factory BomOperation.fromJson(Map<String, dynamic> json) {
return BomOperation(
lineNo: (json['line_no'] ?? json['lineNo']) as int,
operationName: (json['operation_name'] ?? json['operationName'] ?? '') as String,
costFixed: json['cost_fixed'] != null ? double.tryParse(json['cost_fixed'].toString()) : null,
costPerUnit: json['cost_per_unit'] != null ? double.tryParse(json['cost_per_unit'].toString()) : null,
costUom: json['cost_uom'] as String?,
workCenter: json['work_center'] as String?,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'line_no': lineNo,
'operation_name': operationName,
'cost_fixed': costFixed,
'cost_per_unit': costPerUnit,
'cost_uom': costUom,
'work_center': workCenter,
};
}
}
class ProductBOM {
final int? id;
final int businessId;
final int productId;
final String version;
final String name;
final bool isDefault;
final String? effectiveFrom;
final String? effectiveTo;
final double? yieldPercent;
final double? wastagePercent;
final String status;
final String? notes;
final DateTime? createdAt;
final DateTime? updatedAt;
final List<BomItem> items;
final List<BomOutput> outputs;
final List<BomOperation> operations;
const ProductBOM({
this.id,
required this.businessId,
required this.productId,
required this.version,
required this.name,
this.isDefault = false,
this.effectiveFrom,
this.effectiveTo,
this.yieldPercent,
this.wastagePercent,
this.status = 'draft',
this.notes,
this.createdAt,
this.updatedAt,
this.items = const <BomItem>[],
this.outputs = const <BomOutput>[],
this.operations = const <BomOperation>[],
});
factory ProductBOM.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => BomItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final outputs = (json['outputs'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => BomOutput.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final operations = (json['operations'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => BomOperation.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
return ProductBOM(
id: json['id'] as int?,
businessId: (json['business_id'] ?? json['businessId']) as int,
productId: (json['product_id'] ?? json['productId']) as int,
version: (json['version'] ?? '') as String,
name: (json['name'] ?? '') as String,
isDefault: (json['is_default'] ?? json['isDefault'] ?? false) as bool,
effectiveFrom: json['effective_from'] as String?,
effectiveTo: json['effective_to'] as String?,
yieldPercent: json['yield_percent'] != null ? double.tryParse(json['yield_percent'].toString()) : null,
wastagePercent: json['wastage_percent'] != null ? double.tryParse(json['wastage_percent'].toString()) : null,
status: (json['status'] ?? 'draft') as String,
notes: json['notes'] as String?,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
items: items,
outputs: outputs,
operations: operations,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'business_id': businessId,
'product_id': productId,
'version': version,
'name': name,
'is_default': isDefault,
'effective_from': effectiveFrom,
'effective_to': effectiveTo,
'yield_percent': yieldPercent,
'wastage_percent': wastagePercent,
'status': status,
'notes': notes,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
'items': items.map((e) => e.toJson()).toList(),
'outputs': outputs.map((e) => e.toJson()).toList(),
'operations': operations.map((e) => e.toJson()).toList(),
};
}
}
class BomExplosionItem {
final int componentProductId;
final double requiredQty;
final String? uom;
final int? suggestedWarehouseId;
final bool isOptional;
final String? substituteGroup;
const BomExplosionItem({
required this.componentProductId,
required this.requiredQty,
this.uom,
this.suggestedWarehouseId,
this.isOptional = false,
this.substituteGroup,
});
factory BomExplosionItem.fromJson(Map<String, dynamic> json) {
return BomExplosionItem(
componentProductId: (json['component_product_id'] ?? json['componentProductId']) as int,
requiredQty: double.tryParse(json['required_qty']?.toString() ?? '0') ?? 0,
uom: json['uom'] as String?,
suggestedWarehouseId: json['suggested_warehouse_id'] as int?,
isOptional: (json['is_optional'] ?? false) as bool,
substituteGroup: json['substitute_group'] as String?,
);
}
}
class BomExplosionResult {
final List<BomExplosionItem> items;
final List<BomOutput> outputs;
const BomExplosionResult({this.items = const <BomExplosionItem>[], this.outputs = const <BomOutput>[]});
factory BomExplosionResult.fromJson(Map<String, dynamic> json) {
final items = (json['items'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => BomExplosionItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
final outputs = (json['outputs'] as List<dynamic>? ?? const <dynamic>[])
.map((e) => BomOutput.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
return BomExplosionResult(items: items, outputs: outputs);
}
}

View file

@ -0,0 +1,521 @@
/// مدل سند حسابداری (Document)
class DocumentModel {
final int id;
final String code;
final int businessId;
final int fiscalYearId;
final int currencyId;
final int createdByUserId;
final DateTime registeredAt;
final DateTime documentDate;
final String documentType;
final bool isProforma;
final String? description;
final DateTime createdAt;
final DateTime updatedAt;
// اطلاعات مرتبط
final String? businessTitle;
final String? fiscalYearTitle;
final String? currencyCode;
final String? currencySymbol;
final String? createdByName;
// محاسبات
final double totalDebit;
final double totalCredit;
final int linesCount;
// سطرهای سند (فقط برای جزئیات)
final List<DocumentLineModel>? lines;
// اطلاعات اضافی
final Map<String, dynamic>? extraInfo;
final Map<String, dynamic>? developerSettings;
// فیلدهای formatted از سرور (برای نمایش)
final String? documentDateRaw;
final String? registeredAtRaw;
final String? createdAtRaw;
final String? updatedAtRaw;
DocumentModel({
required this.id,
required this.code,
required this.businessId,
required this.fiscalYearId,
required this.currencyId,
required this.createdByUserId,
required this.registeredAt,
required this.documentDate,
required this.documentType,
required this.isProforma,
this.description,
required this.createdAt,
required this.updatedAt,
this.businessTitle,
this.fiscalYearTitle,
this.currencyCode,
this.currencySymbol,
this.createdByName,
required this.totalDebit,
required this.totalCredit,
required this.linesCount,
this.lines,
this.extraInfo,
this.developerSettings,
this.documentDateRaw,
this.registeredAtRaw,
this.createdAtRaw,
this.updatedAtRaw,
});
factory DocumentModel.fromJson(Map<String, dynamic> json) {
return DocumentModel(
id: json['id'] as int,
code: json['code'] as String,
businessId: json['business_id'] as int,
fiscalYearId: json['fiscal_year_id'] as int,
currencyId: json['currency_id'] as int,
createdByUserId: json['created_by_user_id'] as int,
registeredAt: _parseDateTime(json['registered_at']),
documentDate: _parseDateTime(json['document_date']),
documentType: json['document_type'] as String,
isProforma: json['is_proforma'] as bool? ?? false,
description: json['description'] as String?,
createdAt: _parseDateTime(json['created_at']),
updatedAt: _parseDateTime(json['updated_at']),
businessTitle: json['business_title'] as String?,
fiscalYearTitle: json['fiscal_year_title'] as String?,
currencyCode: json['currency_code'] as String?,
currencySymbol: json['currency_symbol'] as String?,
createdByName: json['created_by_name'] as String?,
totalDebit: (json['total_debit'] as num?)?.toDouble() ?? 0.0,
totalCredit: (json['total_credit'] as num?)?.toDouble() ?? 0.0,
linesCount: json['lines_count'] as int? ?? 0,
lines: json['lines'] != null
? (json['lines'] as List)
.map((line) => DocumentLineModel.fromJson(line as Map<String, dynamic>))
.toList()
: null,
extraInfo: json['extra_info'] as Map<String, dynamic>?,
developerSettings: json['developer_settings'] as Map<String, dynamic>?,
documentDateRaw: json['document_date_raw'] as String? ?? json['document_date'] as String?,
registeredAtRaw: json['registered_at_raw'] as String? ?? json['registered_at'] as String?,
createdAtRaw: json['created_at_raw'] as String? ?? json['created_at'] as String?,
updatedAtRaw: json['updated_at_raw'] as String? ?? json['updated_at'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'business_id': businessId,
'fiscal_year_id': fiscalYearId,
'currency_id': currencyId,
'created_by_user_id': createdByUserId,
'registered_at': registeredAt.toIso8601String(),
'document_date': documentDate.toIso8601String(),
'document_type': documentType,
'is_proforma': isProforma,
'description': description,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'business_title': businessTitle,
'fiscal_year_title': fiscalYearTitle,
'currency_code': currencyCode,
'currency_symbol': currencySymbol,
'created_by_name': createdByName,
'total_debit': totalDebit,
'total_credit': totalCredit,
'lines_count': linesCount,
'lines': lines?.map((line) => line.toJson()).toList(),
'extra_info': extraInfo,
'developer_settings': developerSettings,
};
}
/// دریافت نام فارسی نوع سند
String getDocumentTypeName() {
switch (documentType) {
case 'expense':
return 'هزینه';
case 'income':
return 'درآمد';
case 'receipt':
return 'دریافت';
case 'payment':
return 'پرداخت';
case 'transfer':
return 'انتقال';
case 'manual':
return 'سند دستی';
case 'invoice':
return 'فاکتور';
default:
return documentType;
}
}
/// آیا سند قابل ویرایش است؟
bool get isEditable => documentType == 'manual';
/// آیا سند قابل حذف است؟
bool get isDeletable => documentType == 'manual';
/// دریافت وضعیت سند
String get statusText => isProforma ? 'پیش‌فاکتور' : 'قطعی';
/// Parse DateTime from various formats
static DateTime _parseDateTime(dynamic value) {
if (value == null) return DateTime.now();
if (value is DateTime) return value;
String dateStr = value.toString();
// Try ISO format first
try {
return DateTime.parse(dateStr);
} catch (e) {
// If ISO parse fails, try other formats
// Format: "1404/07/23 14:02:20" or "1404/07/23"
try {
// Remove time if exists and just use current datetime
// Since we can't easily parse Jalali dates without conversion
// we'll just return a valid DateTime
return DateTime.now();
} catch (e) {
return DateTime.now();
}
}
}
}
/// مدل سطر سند حسابداری (Document Line)
class DocumentLineModel {
final int id;
final int documentId;
final int? accountId;
final int? personId;
final int? productId;
final int? bankAccountId;
final int? cashRegisterId;
final int? pettyCashId;
final int? checkId;
final double? quantity;
final double debit;
final double credit;
final String? description;
final Map<String, dynamic>? extraInfo;
final DateTime createdAt;
final DateTime updatedAt;
// اطلاعات مرتبط
final String? accountCode;
final String? accountName;
final String? personName;
final String? productName;
final String? bankAccountName;
final String? cashRegisterName;
final String? pettyCashName;
final String? checkNumber;
DocumentLineModel({
required this.id,
required this.documentId,
this.accountId,
this.personId,
this.productId,
this.bankAccountId,
this.cashRegisterId,
this.pettyCashId,
this.checkId,
this.quantity,
required this.debit,
required this.credit,
this.description,
this.extraInfo,
required this.createdAt,
required this.updatedAt,
this.accountCode,
this.accountName,
this.personName,
this.productName,
this.bankAccountName,
this.cashRegisterName,
this.pettyCashName,
this.checkNumber,
});
factory DocumentLineModel.fromJson(Map<String, dynamic> json) {
return DocumentLineModel(
id: json['id'] as int,
documentId: json['document_id'] as int,
accountId: json['account_id'] as int?,
personId: json['person_id'] as int?,
productId: json['product_id'] as int?,
bankAccountId: json['bank_account_id'] as int?,
cashRegisterId: json['cash_register_id'] as int?,
pettyCashId: json['petty_cash_id'] as int?,
checkId: json['check_id'] as int?,
quantity: (json['quantity'] as num?)?.toDouble(),
debit: (json['debit'] as num?)?.toDouble() ?? 0.0,
credit: (json['credit'] as num?)?.toDouble() ?? 0.0,
description: json['description'] as String?,
extraInfo: json['extra_info'] as Map<String, dynamic>?,
createdAt: DocumentModel._parseDateTime(json['created_at']),
updatedAt: DocumentModel._parseDateTime(json['updated_at']),
accountCode: json['account_code'] as String?,
accountName: json['account_name'] as String?,
personName: json['person_name'] as String?,
productName: json['product_name'] as String?,
bankAccountName: json['bank_account_name'] as String?,
cashRegisterName: json['cash_register_name'] as String?,
pettyCashName: json['petty_cash_name'] as String?,
checkNumber: json['check_number'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'document_id': documentId,
'account_id': accountId,
'person_id': personId,
'product_id': productId,
'bank_account_id': bankAccountId,
'cash_register_id': cashRegisterId,
'petty_cash_id': pettyCashId,
'check_id': checkId,
'quantity': quantity,
'debit': debit,
'credit': credit,
'description': description,
'extra_info': extraInfo,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'account_code': accountCode,
'account_name': accountName,
'person_name': personName,
'product_name': productName,
'bank_account_name': bankAccountName,
'cash_register_name': cashRegisterName,
'petty_cash_name': pettyCashName,
'check_number': checkNumber,
};
}
/// دریافت نام کامل حساب
String get fullAccountName {
if (accountCode != null && accountName != null) {
return '$accountCode - $accountName';
}
return accountName ?? accountCode ?? '-';
}
/// دریافت نام طرفحساب
String? get counterpartyName {
return personName ??
bankAccountName ??
cashRegisterName ??
pettyCashName ??
checkNumber;
}
}
/// مدل درخواست ایجاد سطر سند
class DocumentLineCreateRequest {
final int accountId;
final int? personId;
final int? productId;
final int? bankAccountId;
final int? cashRegisterId;
final int? pettyCashId;
final int? checkId;
final double? quantity;
final double debit;
final double credit;
final String? description;
final Map<String, dynamic>? extraInfo;
DocumentLineCreateRequest({
required this.accountId,
this.personId,
this.productId,
this.bankAccountId,
this.cashRegisterId,
this.pettyCashId,
this.checkId,
this.quantity,
this.debit = 0,
this.credit = 0,
this.description,
this.extraInfo,
});
Map<String, dynamic> toJson() {
return {
'account_id': accountId,
if (personId != null) 'person_id': personId,
if (productId != null) 'product_id': productId,
if (bankAccountId != null) 'bank_account_id': bankAccountId,
if (cashRegisterId != null) 'cash_register_id': cashRegisterId,
if (pettyCashId != null) 'petty_cash_id': pettyCashId,
if (checkId != null) 'check_id': checkId,
if (quantity != null) 'quantity': quantity,
'debit': debit,
'credit': credit,
if (description != null) 'description': description,
if (extraInfo != null) 'extra_info': extraInfo,
};
}
}
/// مدل درخواست ایجاد سند دستی
class CreateManualDocumentRequest {
final String? code;
final DateTime documentDate;
final int? fiscalYearId;
final int currencyId;
final bool isProforma;
final String? description;
final List<DocumentLineCreateRequest> lines;
final Map<String, dynamic>? extraInfo;
CreateManualDocumentRequest({
this.code,
required this.documentDate,
this.fiscalYearId,
required this.currencyId,
this.isProforma = false,
this.description,
required this.lines,
this.extraInfo,
});
Map<String, dynamic> toJson() {
return {
if (code != null) 'code': code,
'document_date': documentDate.toIso8601String().split('T')[0], // فقط تاریخ
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
'currency_id': currencyId,
'is_proforma': isProforma,
if (description != null) 'description': description,
'lines': lines.map((line) => line.toJson()).toList(),
if (extraInfo != null) 'extra_info': extraInfo,
};
}
/// اعتبارسنجی درخواست
String? validate() {
if (lines.length < 2) {
return 'سند باید حداقل 2 سطر داشته باشد';
}
double totalDebit = 0;
double totalCredit = 0;
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
// بررسی که هر سطر یا بدهکار یا بستانکار داشته باشد
if (line.debit == 0 && line.credit == 0) {
return 'سطر ${i + 1} باید مقدار بدهکار یا بستانکار داشته باشد';
}
// نمیتواند هم بدهکار هم بستانکار داشته باشد
if (line.debit > 0 && line.credit > 0) {
return 'سطر ${i + 1} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد';
}
totalDebit += line.debit;
totalCredit += line.credit;
}
// بررسی متوازن بودن سند
if ((totalDebit - totalCredit).abs() > 0.01) {
final diff = totalDebit - totalCredit;
return 'سند متوازن نیست. تفاوت: ${diff.toStringAsFixed(2)}';
}
return null; // اعتبارسنجی موفق
}
}
/// مدل درخواست ویرایش سند دستی
class UpdateManualDocumentRequest {
final String? code;
final DateTime? documentDate;
final int? currencyId;
final bool? isProforma;
final String? description;
final List<DocumentLineCreateRequest>? lines;
final Map<String, dynamic>? extraInfo;
UpdateManualDocumentRequest({
this.code,
this.documentDate,
this.currencyId,
this.isProforma,
this.description,
this.lines,
this.extraInfo,
});
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
if (code != null) map['code'] = code;
if (documentDate != null) {
map['document_date'] = documentDate!.toIso8601String().split('T')[0];
}
if (currencyId != null) map['currency_id'] = currencyId;
if (isProforma != null) map['is_proforma'] = isProforma;
if (description != null) map['description'] = description;
if (lines != null) {
map['lines'] = lines!.map((line) => line.toJson()).toList();
}
if (extraInfo != null) map['extra_info'] = extraInfo;
return map;
}
/// اعتبارسنجی درخواست
String? validate() {
if (lines != null) {
if (lines!.length < 2) {
return 'سند باید حداقل 2 سطر داشته باشد';
}
double totalDebit = 0;
double totalCredit = 0;
for (int i = 0; i < lines!.length; i++) {
final line = lines![i];
if (line.debit == 0 && line.credit == 0) {
return 'سطر ${i + 1} باید مقدار بدهکار یا بستانکار داشته باشد';
}
if (line.debit > 0 && line.credit > 0) {
return 'سطر ${i + 1} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد';
}
totalDebit += line.debit;
totalCredit += line.credit;
}
if ((totalDebit - totalCredit).abs() > 0.01) {
final diff = totalDebit - totalCredit;
return 'سند متوازن نیست. تفاوت: ${diff.toStringAsFixed(2)}';
}
}
return null;
}
}

View file

@ -0,0 +1,394 @@
import 'package:hesabix_ui/models/account_model.dart';
/// مدل سند هزینه/درآمد
class ExpenseIncomeDocument {
final int id;
final String code;
final String documentType; // "expense" یا "income"
final String documentTypeName; // "هزینه" یا "درآمد"
final DateTime documentDate;
final int currencyId;
final String? currencyCode;
final double totalAmount;
final String? description;
final List<ItemLine> itemLines;
final List<CounterpartyLine> counterpartyLines;
final int itemLinesCount;
final int counterpartyLinesCount;
final String? createdByName;
final DateTime registeredAt;
final Map<String, dynamic>? extraInfo;
const ExpenseIncomeDocument({
required this.id,
required this.code,
required this.documentType,
required this.documentTypeName,
required this.documentDate,
required this.currencyId,
this.currencyCode,
required this.totalAmount,
this.description,
required this.itemLines,
required this.counterpartyLines,
required this.itemLinesCount,
required this.counterpartyLinesCount,
this.createdByName,
required this.registeredAt,
this.extraInfo,
});
/// آیا این سند درآمد است؟
bool get isIncome => documentType == 'income';
/// آیا این سند هزینه است؟
bool get isExpense => documentType == 'expense';
/// نام حسابهای آیتمها
String? get itemAccountNames {
if (itemLines.isEmpty) return null;
return itemLines.map((line) => line.accountName).join(', ');
}
/// اطلاعات طرفحسابها
String? get counterpartyInfo {
if (counterpartyLines.isEmpty) return null;
return counterpartyLines.map((line) => line.displayName).join(', ');
}
factory ExpenseIncomeDocument.fromJson(Map<String, dynamic> json) {
return ExpenseIncomeDocument(
id: json['id'] as int,
code: json['code'] as String,
documentType: json['document_type'] as String,
documentTypeName: json['document_type_name'] as String? ??
(json['document_type'] == 'income' ? 'درآمد' : 'هزینه'),
documentDate: DateTime.parse(json['document_date'] as String),
currencyId: json['currency_id'] as int,
currencyCode: json['currency_code'] as String?,
totalAmount: (json['total_amount'] as num).toDouble(),
description: json['description'] as String?,
itemLines: (json['item_lines'] as List<dynamic>?)
?.map((line) => ItemLine.fromJson(line as Map<String, dynamic>))
.toList() ?? [],
counterpartyLines: (json['counterparty_lines'] as List<dynamic>?)
?.map((line) => CounterpartyLine.fromJson(line as Map<String, dynamic>))
.toList() ?? [],
itemLinesCount: json['item_lines_count'] as int? ?? 0,
counterpartyLinesCount: json['counterparty_lines_count'] as int? ?? 0,
createdByName: json['created_by_name'] as String?,
registeredAt: DateTime.parse(json['registered_at'] as String),
extraInfo: json['extra_info'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'code': code,
'document_type': documentType,
'document_type_name': documentTypeName,
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
'currency_code': currencyCode,
'total_amount': totalAmount,
'description': description,
'item_lines': itemLines.map((line) => line.toJson()).toList(),
'counterparty_lines': counterpartyLines.map((line) => line.toJson()).toList(),
'item_lines_count': itemLinesCount,
'counterparty_lines_count': counterpartyLinesCount,
'created_by_name': createdByName,
'registered_at': registeredAt.toIso8601String(),
'extra_info': extraInfo,
};
}
}
/// خط آیتم (حساب هزینه/درآمد)
class ItemLine {
final int id;
final int accountId;
final String accountCode;
final String accountName;
final double amount;
final String? description;
const ItemLine({
required this.id,
required this.accountId,
required this.accountCode,
required this.accountName,
required this.amount,
this.description,
});
factory ItemLine.fromJson(Map<String, dynamic> json) {
return ItemLine(
id: json['id'] as int,
accountId: json['account_id'] as int,
accountCode: json['account_code'] as String,
accountName: json['account_name'] as String,
amount: (json['amount'] as num).toDouble(),
description: json['description'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'account_id': accountId,
'account_code': accountCode,
'account_name': accountName,
'amount': amount,
'description': description,
};
}
}
/// خط طرفحساب
class CounterpartyLine {
final int id;
final String transactionType;
final String transactionTypeName;
final double amount;
final DateTime transactionDate;
final String? description;
final double? commission;
// فیلدهای اختیاری بر اساس نوع تراکنش
final int? bankAccountId;
final String? bankAccountName;
final int? cashRegisterId;
final String? cashRegisterName;
final int? pettyCashId;
final String? pettyCashName;
final int? checkId;
final String? checkNumber;
final int? personId;
final String? personName;
const CounterpartyLine({
required this.id,
required this.transactionType,
required this.transactionTypeName,
required this.amount,
required this.transactionDate,
this.description,
this.commission,
this.bankAccountId,
this.bankAccountName,
this.cashRegisterId,
this.cashRegisterName,
this.pettyCashId,
this.pettyCashName,
this.checkId,
this.checkNumber,
this.personId,
this.personName,
});
/// نام نمایشی طرفحساب
String get displayName {
switch (transactionType) {
case 'bank':
return bankAccountName ?? 'حساب بانکی';
case 'cash_register':
return cashRegisterName ?? 'صندوق';
case 'petty_cash':
return pettyCashName ?? 'تنخواهگردان';
case 'check':
return checkNumber != null ? 'چک $checkNumber' : 'چک';
case 'person':
return personName ?? 'شخص';
default:
return transactionTypeName;
}
}
factory CounterpartyLine.fromJson(Map<String, dynamic> json) {
return CounterpartyLine(
id: json['id'] as int,
transactionType: json['transaction_type'] as String,
transactionTypeName: json['transaction_type_name'] as String? ??
_getTransactionTypeName(json['transaction_type'] as String),
amount: (json['amount'] as num).toDouble(),
transactionDate: DateTime.parse(json['transaction_date'] as String),
description: json['description'] as String?,
commission: json['commission'] != null ? (json['commission'] as num).toDouble() : null,
bankAccountId: json['bank_account_id'] as int?,
bankAccountName: json['bank_account_name'] as String?,
cashRegisterId: json['cash_register_id'] as int?,
cashRegisterName: json['cash_register_name'] as String?,
pettyCashId: json['petty_cash_id'] as int?,
pettyCashName: json['petty_cash_name'] as String?,
checkId: json['check_id'] as int?,
checkNumber: json['check_number'] as String?,
personId: json['person_id'] as int?,
personName: json['person_name'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'transaction_type': transactionType,
'transaction_type_name': transactionTypeName,
'amount': amount,
'transaction_date': transactionDate.toIso8601String(),
'description': description,
'commission': commission,
'bank_account_id': bankAccountId,
'bank_account_name': bankAccountName,
'cash_register_id': cashRegisterId,
'cash_register_name': cashRegisterName,
'petty_cash_id': pettyCashId,
'petty_cash_name': pettyCashName,
'check_id': checkId,
'check_number': checkNumber,
'person_id': personId,
'person_name': personName,
};
}
static String _getTransactionTypeName(String type) {
switch (type) {
case 'bank':
return 'بانک';
case 'cash_register':
return 'صندوق';
case 'petty_cash':
return 'تنخواهگردان';
case 'check':
return 'چک';
case 'person':
return 'شخص';
default:
return type;
}
}
}
/// نوع تراکنش برای فرم
enum TransactionType {
bank('bank', 'بانک'),
cashRegister('cash_register', 'صندوق'),
pettyCash('petty_cash', 'تنخواهگردان'),
check('check', 'چک'),
person('person', 'شخص');
const TransactionType(this.value, this.displayName);
final String value;
final String displayName;
static TransactionType? fromValue(String value) {
for (final type in TransactionType.values) {
if (type.value == value) return type;
}
return null;
}
}
/// دادههای خط آیتم برای فرم
class ItemLineData {
final int? accountId;
final String? accountName;
final double amount;
final String? description;
const ItemLineData({
this.accountId,
this.accountName,
required this.amount,
this.description,
});
ItemLineData copyWith({
int? accountId,
String? accountName,
double? amount,
String? description,
}) {
return ItemLineData(
accountId: accountId ?? this.accountId,
accountName: accountName ?? this.accountName,
amount: amount ?? this.amount,
description: description ?? this.description,
);
}
}
/// دادههای خط طرفحساب برای فرم
class CounterpartyLineData {
final TransactionType transactionType;
final double amount;
final DateTime transactionDate;
final String? description;
final double? commission;
// فیلدهای اختیاری بر اساس نوع تراکنش
final int? bankAccountId;
final String? bankAccountName;
final int? cashRegisterId;
final String? cashRegisterName;
final int? pettyCashId;
final String? pettyCashName;
final int? checkId;
final String? checkNumber;
final int? personId;
final String? personName;
const CounterpartyLineData({
required this.transactionType,
required this.amount,
required this.transactionDate,
this.description,
this.commission,
this.bankAccountId,
this.bankAccountName,
this.cashRegisterId,
this.cashRegisterName,
this.pettyCashId,
this.pettyCashName,
this.checkId,
this.checkNumber,
this.personId,
this.personName,
});
CounterpartyLineData copyWith({
TransactionType? transactionType,
double? amount,
DateTime? transactionDate,
String? description,
double? commission,
int? bankAccountId,
String? bankAccountName,
int? cashRegisterId,
String? cashRegisterName,
int? pettyCashId,
String? pettyCashName,
int? checkId,
String? checkNumber,
int? personId,
String? personName,
}) {
return CounterpartyLineData(
transactionType: transactionType ?? this.transactionType,
amount: amount ?? this.amount,
transactionDate: transactionDate ?? this.transactionDate,
description: description ?? this.description,
commission: commission ?? this.commission,
bankAccountId: bankAccountId ?? this.bankAccountId,
bankAccountName: bankAccountName ?? this.bankAccountName,
cashRegisterId: cashRegisterId ?? this.cashRegisterId,
cashRegisterName: cashRegisterName ?? this.cashRegisterName,
pettyCashId: pettyCashId ?? this.pettyCashId,
pettyCashName: pettyCashName ?? this.pettyCashName,
checkId: checkId ?? this.checkId,
checkNumber: checkNumber ?? this.checkNumber,
personId: personId ?? this.personId,
personName: personName ?? this.personName,
);
}
}

View file

@ -0,0 +1,53 @@
/// مدل پاسخ صفحهبندی شده
class PaginatedResponse<T> {
final List<T> items;
final int totalCount;
final int page;
final int pageSize;
final bool hasNextPage;
final bool hasPreviousPage;
const PaginatedResponse({
required this.items,
required this.totalCount,
required this.page,
required this.pageSize,
required this.hasNextPage,
required this.hasPreviousPage,
});
factory PaginatedResponse.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromJsonT,
) {
final items = (json['items'] as List<dynamic>?)
?.map((item) => fromJsonT(item as Map<String, dynamic>))
.toList() ?? <T>[];
final totalCount = json['total_count'] as int? ?? 0;
final page = json['page'] as int? ?? 1;
final pageSize = json['page_size'] as int? ?? 20;
final hasNextPage = json['has_next_page'] as bool? ?? false;
final hasPreviousPage = json['has_previous_page'] as bool? ?? false;
return PaginatedResponse<T>(
items: items,
totalCount: totalCount,
page: page,
pageSize: pageSize,
hasNextPage: hasNextPage,
hasPreviousPage: hasPreviousPage,
);
}
Map<String, dynamic> toJson() {
return {
'items': items,
'total_count': totalCount,
'page': page,
'page_size': pageSize,
'has_next_page': hasNextPage,
'has_previous_page': hasPreviousPage,
};
}
}

View file

@ -132,6 +132,10 @@ class Person {
final bool commissionExcludeAdditionsDeductions; final bool commissionExcludeAdditionsDeductions;
final bool commissionPostInInvoiceDocument; final bool commissionPostInInvoiceDocument;
// تراز و وضعیت مالی
final double? balance;
final String? status;
Person({ Person({
this.id, this.id,
required this.businessId, required this.businessId,
@ -167,6 +171,8 @@ class Person {
this.commissionExcludeDiscounts = false, this.commissionExcludeDiscounts = false,
this.commissionExcludeAdditionsDeductions = false, this.commissionExcludeAdditionsDeductions = false,
this.commissionPostInInvoiceDocument = false, this.commissionPostInInvoiceDocument = false,
this.balance,
this.status,
}); });
factory Person.fromJson(Map<String, dynamic> json) { factory Person.fromJson(Map<String, dynamic> json) {
@ -211,6 +217,8 @@ class Person {
commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false, commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false,
commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false, commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false,
commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false, commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false,
balance: (json['balance'] as num?)?.toDouble(),
status: json['status'] as String?,
); );
} }
@ -250,6 +258,8 @@ class Person {
'commission_exclude_discounts': commissionExcludeDiscounts, 'commission_exclude_discounts': commissionExcludeDiscounts,
'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions, 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions,
'commission_post_in_invoice_document': commissionPostInInvoiceDocument, 'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
'balance': balance,
'status': status,
}; };
} }

View file

@ -0,0 +1,85 @@
/// مدل ساده برای کالا - برای استفاده در انتخابگرهای تفصیل
class Product {
final int? id;
final int businessId;
final String? code;
final String name;
final String itemType;
final String? description;
final int? categoryId;
final bool trackInventory;
final num? baseSalesPrice;
final num? basePurchasePrice;
final String? mainUnit;
final String? secondaryUnit;
final bool isActive;
final DateTime? createdAt;
final DateTime? updatedAt;
const Product({
this.id,
required this.businessId,
this.code,
required this.name,
this.itemType = 'کالا',
this.description,
this.categoryId,
this.trackInventory = false,
this.baseSalesPrice,
this.basePurchasePrice,
this.mainUnit,
this.secondaryUnit,
this.isActive = true,
this.createdAt,
this.updatedAt,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int?,
businessId: (json['business_id'] ?? json['businessId']) as int,
code: json['code'] as String?,
name: (json['name'] ?? '') as String,
itemType: (json['item_type'] ?? 'کالا') as String,
description: json['description'] as String?,
categoryId: json['category_id'] as int?,
trackInventory: (json['track_inventory'] ?? false) as bool,
baseSalesPrice: json['base_sales_price'] != null ? num.tryParse(json['base_sales_price'].toString()) : null,
basePurchasePrice: json['base_purchase_price'] != null ? num.tryParse(json['base_purchase_price'].toString()) : null,
mainUnit: json['main_unit'] as String?,
secondaryUnit: json['secondary_unit'] as String?,
isActive: (json['is_active'] ?? true) as bool,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'business_id': businessId,
'code': code,
'name': name,
'item_type': itemType,
'description': description,
'category_id': categoryId,
'track_inventory': trackInventory,
'base_sales_price': baseSalesPrice,
'base_purchase_price': basePurchasePrice,
'main_unit': mainUnit,
'secondary_unit': secondaryUnit,
'is_active': isActive,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
/// نمایش نام کامل برای UI
String get displayName {
if (code != null && code!.isNotEmpty) {
return '$code - $name';
}
return name;
}
}

View file

@ -0,0 +1,49 @@
class Warehouse {
final int? id;
final int businessId;
final String code;
final String name;
final String? description;
final bool isDefault;
final DateTime? createdAt;
final DateTime? updatedAt;
const Warehouse({
this.id,
required this.businessId,
required this.code,
required this.name,
this.description,
this.isDefault = false,
this.createdAt,
this.updatedAt,
});
factory Warehouse.fromJson(Map<String, dynamic> json) {
return Warehouse(
id: json['id'] as int?,
businessId: (json['business_id'] ?? json['businessId']) as int,
code: (json['code'] ?? '') as String,
name: (json['name'] ?? '') as String,
description: json['description'] as String?,
isDefault: (json['is_default'] ?? json['isDefault'] ?? false) as bool,
createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null,
updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'business_id': businessId,
'code': code,
'name': name,
'description': description,
'is_default': isDefault,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
}

View file

@ -15,6 +15,7 @@ import '../../services/business_dashboard_service.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'receipts_payments_list_page.dart' show BulkSettlementDialog; import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
import '../../widgets/document/document_form_dialog.dart';
class BusinessShell extends StatefulWidget { class BusinessShell extends StatefulWidget {
final int businessId; final int businessId;
@ -629,6 +630,26 @@ class _BusinessShellState extends State<BusinessShell> {
} }
} }
Future<void> showAddDocumentDialog() async {
final calendarController = widget.calendarController ?? await CalendarController.load();
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => DocumentFormDialog(
businessId: widget.businessId,
calendarController: calendarController,
authStore: widget.authStore,
apiClient: ApiClient(),
fiscalYearId: null, // TODO: از context یا state بگیریم
currencyId: 1, // TODO: از تنظیمات بگیریم
),
);
if (result == true) {
// Document was successfully added, refresh the current page
_refreshCurrentPage();
}
}
bool isExpanded(_MenuItem item) { bool isExpanded(_MenuItem item) {
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded; if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
@ -979,6 +1000,9 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (item.label == t.checks) { } else if (item.label == t.checks) {
// Navigate to add check // Navigate to add check
context.go('/business/${widget.businessId}/checks/new'); context.go('/business/${widget.businessId}/checks/new');
} else if (item.label == t.documents) {
// Show add document dialog
showAddDocumentDialog();
} }
// سایر مسیرهای افزودن در آینده متصل میشوند // سایر مسیرهای افزودن در آینده متصل میشوند
}, },
@ -1081,6 +1105,9 @@ class _BusinessShellState extends State<BusinessShell> {
} else if (item.label == t.checks) { } else if (item.label == t.checks) {
// Navigate to add check // Navigate to add check
context.go('/business/${widget.businessId}/checks/new'); context.go('/business/${widget.businessId}/checks/new');
} else if (item.label == t.documents) {
// Show add document dialog
showAddDocumentDialog();
} }
}, },
child: Container( child: Container(

View file

@ -0,0 +1,622 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/document_model.dart';
import 'package:hesabix_ui/services/document_service.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/widgets/document/document_details_dialog.dart';
import 'package:hesabix_ui/widgets/document/document_form_dialog.dart';
/// صفحه لیست اسناد حسابداری (عمومی و اتوماتیک)
class DocumentsPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
const DocumentsPage({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
});
@override
State<DocumentsPage> createState() => _DocumentsPageState();
}
class _DocumentsPageState extends State<DocumentsPage> {
late DocumentService _service;
String? _selectedDocumentType;
DateTime? _fromDate;
DateTime? _toDate;
final GlobalKey _tableKey = GlobalKey();
int _selectedCount = 0;
// انواع اسناد
final Map<String, String> _documentTypes = {
'all': 'همه',
'manual': 'سند دستی',
'expense': 'هزینه',
'income': 'درآمد',
'receipt': 'دریافت',
'payment': 'پرداخت',
'transfer': 'انتقال',
'invoice': 'فاکتور',
};
@override
void initState() {
super.initState();
_service = DocumentService(widget.apiClient);
}
/// تازهسازی دادههای جدول
void _refreshData() {
final state = _tableKey.currentState;
if (state != null) {
try {
(state as dynamic).refresh();
return;
} catch (_) {}
}
if (mounted) setState(() {});
}
@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: [
// فیلترها
_buildFilters(t),
// جدول دادهها
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DataTableWidget<DocumentModel>(
key: _tableKey,
config: _buildTableConfig(t),
fromJson: (json) => DocumentModel.fromJson(json),
calendarController: widget.calendarController,
),
),
),
],
),
),
);
}
/// ساخت فیلترها
Widget _buildFilters(AppLocalizations t) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
children: [
// فیلتر نوع سند
SizedBox(
width: 200,
child: DropdownButtonFormField<String>(
initialValue: _selectedDocumentType,
decoration: const InputDecoration(
labelText: 'نوع سند',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
isDense: true,
),
items: _documentTypes.entries.map((entry) {
return DropdownMenuItem<String>(
value: entry.key == 'all' ? null : entry.key,
child: Text(entry.value),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedDocumentType = value;
});
_refreshData();
},
),
),
const SizedBox(width: 8),
// فیلتر از تاریخ
Expanded(
child: DateInputField(
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _fromDate = date);
_refreshData();
},
labelText: 'از تاریخ',
hintText: 'انتخاب تاریخ شروع',
),
),
const SizedBox(width: 8),
// فیلتر تا تاریخ
Expanded(
child: DateInputField(
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _toDate = date);
_refreshData();
},
labelText: 'تا تاریخ',
hintText: 'انتخاب تاریخ پایان',
),
),
const SizedBox(width: 8),
// دکمه پاک کردن فیلترها
IconButton(
onPressed: () {
setState(() {
_selectedDocumentType = null;
_fromDate = null;
_toDate = null;
});
_refreshData();
},
icon: const Icon(Icons.clear),
tooltip: 'پاک کردن فیلتر',
),
const Spacer(),
// دکمه افزودن سند جدید
ElevatedButton.icon(
onPressed: _createNewDocument,
icon: const Icon(Icons.add),
label: const Text('سند جدید'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
),
),
],
),
);
}
/// ایجاد سند جدید
Future<void> _createNewDocument() async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => DocumentFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
fiscalYearId: null, // TODO: از context یا state بگیریم
currencyId: 1, // TODO: از تنظیمات بگیریم
),
);
if (result == true) {
_refreshData();
}
}
/// ساخت تنظیمات جدول
DataTableConfig<DocumentModel> _buildTableConfig(AppLocalizations t) {
return DataTableConfig<DocumentModel>(
endpoint: '/businesses/${widget.businessId}/documents',
title: 'اسناد حسابداری',
excelEndpoint: '/businesses/${widget.businessId}/documents/export/excel',
customHeaderActions: [
if (_selectedCount > 0)
Tooltip(
message: 'حذف انتخاب‌شده‌ها',
child: FilledButton.icon(
onPressed: _handleBulkDelete,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
),
icon: const Icon(Icons.delete_forever),
label: Text('حذف ($_selectedCount)'),
),
),
],
getExportParams: () => {
'business_id': widget.businessId,
'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
},
additionalParams: {
if (_selectedDocumentType != null)
'document_type': _selectedDocumentType!,
if (_fromDate != null)
'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null)
'to_date': _toDate!.toUtc().toIso8601String(),
},
columns: [
// شماره سند
TextColumn(
'code',
'شماره سند',
width: ColumnWidth.medium,
formatter: (item) => item.code,
),
// نوع سند
CustomColumn(
'document_type',
'نوع',
width: ColumnWidth.medium,
builder: (item, index) {
final doc = item as DocumentModel;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getDocumentTypeColor(doc.documentType).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getDocumentTypeColor(doc.documentType),
width: 1,
),
),
child: Text(
doc.getDocumentTypeName(),
style: TextStyle(
color: _getDocumentTypeColor(doc.documentType),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
},
),
// تاریخ سند
TextColumn(
'document_date',
'تاریخ',
width: ColumnWidth.medium,
formatter: (item) => item.documentDateRaw ?? '-',
),
// سال مالی
TextColumn(
'fiscal_year_title',
'سال مالی',
width: ColumnWidth.medium,
formatter: (item) => item.fiscalYearTitle ?? '-',
),
// بدهکار
NumberColumn(
'total_debit',
'بدهکار',
width: ColumnWidth.large,
formatter: (item) => formatWithThousands(item.totalDebit.toInt()),
suffix: ' ریال',
),
// بستانکار
NumberColumn(
'total_credit',
'بستانکار',
width: ColumnWidth.large,
formatter: (item) => formatWithThousands(item.totalCredit.toInt()),
suffix: ' ریال',
),
// وضعیت
CustomColumn(
'is_proforma',
'وضعیت',
width: ColumnWidth.small,
builder: (item, index) {
final doc = item as DocumentModel;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: doc.isProforma
? Colors.orange.withValues(alpha: 0.1)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
doc.statusText,
style: TextStyle(
color: doc.isProforma ? Colors.orange : Colors.green,
fontSize: 11,
),
),
);
},
),
// توضیحات
TextColumn(
'description',
'توضیحات',
width: ColumnWidth.large,
formatter: (item) => item.description ?? '-',
),
// عملیات
ActionColumn(
'actions',
'عملیات',
width: ColumnWidth.medium,
actions: [
// مشاهده - برای همه اسناد
DataTableAction(
icon: Icons.visibility,
label: 'مشاهده',
onTap: (item) => _showDocumentDetails(item as DocumentModel),
),
// ویرایش - فقط برای manual
DataTableAction(
icon: Icons.edit,
label: 'ویرایش',
onTap: (item) => _editDocument(item as DocumentModel),
enabled: true,
),
// حذف - فقط برای manual
DataTableAction(
icon: Icons.delete,
label: 'حذف',
onTap: (item) => _deleteDocument(item as DocumentModel),
isDestructive: true,
),
],
),
],
searchFields: ['code', 'description'],
filterFields: ['document_type'],
dateRangeField: 'document_date',
showSearch: true,
showFilters: true,
showPagination: true,
showColumnSearch: true,
showRefreshButton: true,
showClearFiltersButton: true,
enableRowSelection: true,
enableMultiRowSelection: true,
showExportButtons: true,
showExcelExport: true,
showPdfExport: false,
defaultPageSize: 50,
pageSizeOptions: [20, 50, 100, 200],
onRowSelectionChanged: (rows) {
setState(() {
_selectedCount = rows.length;
});
},
);
}
/// رنگ بر اساس نوع سند
Color _getDocumentTypeColor(String type) {
switch (type) {
case 'manual':
return Colors.blue;
case 'expense':
return Colors.red;
case 'income':
return Colors.green;
case 'receipt':
return Colors.teal;
case 'payment':
return Colors.orange;
case 'transfer':
return Colors.purple;
case 'invoice':
return Colors.indigo;
default:
return Colors.grey;
}
}
/// نمایش جزئیات سند
Future<void> _showDocumentDetails(DocumentModel doc) async {
await showDialog(
context: context,
builder: (context) => DocumentDetailsDialog(
documentId: doc.id,
calendarController: widget.calendarController,
),
);
}
/// ویرایش سند
Future<void> _editDocument(DocumentModel doc) async {
if (!doc.isEditable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('فقط اسناد دستی قابل ویرایش هستند'),
backgroundColor: Colors.orange,
),
);
return;
}
// بارگذاری جزئیات کامل سند (با سطرها)
try {
final fullDocument = await _service.getDocument(doc.id);
if (!mounted) return;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => DocumentFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
document: fullDocument, // حالت ویرایش
fiscalYearId: fullDocument.fiscalYearId,
currencyId: fullDocument.currencyId,
),
);
if (result == true) {
_refreshData();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در بارگذاری سند: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
/// حذف سند
Future<void> _deleteDocument(DocumentModel doc) async {
if (!doc.isDeletable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('فقط اسناد دستی قابل حذف هستند'),
backgroundColor: Colors.orange,
),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('تأیید حذف'),
content: Text('آیا از حذف سند ${doc.code} اطمینان دارید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('حذف'),
),
],
),
);
if (confirmed == true) {
try {
await _service.deleteDocument(doc.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('سند با موفقیت حذف شد')),
);
_refreshData();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در حذف سند: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
/// حذف گروهی اسناد
Future<void> _handleBulkDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('تأیید حذف گروهی'),
content: Text(
'آیا از حذف $_selectedCount سند انتخاب شده اطمینان دارید؟\n\nتوجه: فقط اسناد دستی حذف خواهند شد.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('حذف'),
),
],
),
);
if (confirmed == true) {
try {
// دریافت آیتمهای انتخاب شده از جدول
final state = _tableKey.currentState;
if (state != null) {
final selectedRows =
(state as dynamic).getSelectedRows() as List<DocumentModel>;
final documentIds = selectedRows.map((doc) => doc.id).toList();
if (documentIds.isNotEmpty) {
final result = await _service.bulkDeleteDocuments(documentIds);
if (mounted) {
final deletedCount = result['deleted_count'] as int;
final skipped = result['skipped_auto_documents'] as List;
String message = '$deletedCount سند با موفقیت حذف شد';
if (skipped.isNotEmpty) {
message += '\n${skipped.length} سند اتوماتیک نادیده گرفته شد';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
_refreshData();
}
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در حذف گروهی: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
}

View file

@ -1,170 +1,670 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/api_client.dart'; import 'package:dio/dio.dart';
import '../../core/auth_store.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/calendar_controller.dart'; import 'package:hesabix_ui/core/calendar_controller.dart';
import '../../core/date_utils.dart' show HesabixDateUtils; import 'package:hesabix_ui/core/auth_store.dart';
import '../../utils/number_formatters.dart' show formatWithThousands; import 'package:hesabix_ui/core/api_client.dart';
import '../../services/expense_income_service.dart'; import 'package:hesabix_ui/models/expense_income_document.dart';
import 'expense_income_dialog.dart'; import 'package:hesabix_ui/services/expense_income_list_service.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils;
import 'package:hesabix_ui/widgets/expense_income/expense_income_form_dialog.dart';
import 'package:hesabix_ui/widgets/expense_income/expense_income_details_dialog.dart';
/// صفحه لیست اسناد هزینه و درآمد با ویجت جدول
class ExpenseIncomeListPage extends StatefulWidget { class ExpenseIncomeListPage extends StatefulWidget {
final int businessId; final int businessId;
final CalendarController calendarController; final CalendarController calendarController;
final AuthStore authStore; final AuthStore authStore;
final ApiClient apiClient; final ApiClient apiClient;
const ExpenseIncomeListPage({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient});
const ExpenseIncomeListPage({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
});
@override @override
State<ExpenseIncomeListPage> createState() => _ExpenseIncomeListPageState(); State<ExpenseIncomeListPage> createState() => _ExpenseIncomeListPageState();
} }
class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> { class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> {
int _tabIndex = 0; // 0 expense, 1 income late ExpenseIncomeListService _service;
final List<Map<String, dynamic>> _items = <Map<String, dynamic>>[]; String? _selectedDocumentType;
int _skip = 0; DateTime? _fromDate;
int _take = 20; DateTime? _toDate;
int _total = 0; // کلید کنترل جدول برای دسترسی به selection و refresh
bool _loading = false; final GlobalKey _tableKey = GlobalKey();
int _selectedCount = 0; // تعداد سطرهای انتخابشده
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _service = ExpenseIncomeListService(widget.apiClient);
} }
Future<void> _load() async { /// تازهسازی دادههای جدول
setState(() => _loading = true); void _refreshData() {
final state = _tableKey.currentState;
if (state != null) {
try { try {
final svc = ExpenseIncomeService(widget.apiClient); // استفاده از متد عمومی refresh در ویجت جدول
final res = await svc.list( // نوت: دسترسی دینامیک چون State کلاس خصوصی است
businessId: widget.businessId, // ignore: avoid_dynamic_calls
documentType: _tabIndex == 0 ? 'expense' : 'income', (state as dynamic).refresh();
skip: _skip, return;
take: _take, } catch (_) {}
);
final data = (res['items'] as List<dynamic>? ?? const <dynamic>[]).cast<Map<String, dynamic>>();
setState(() {
_items
..clear()
..addAll(data);
_total = (res['pagination']?['total'] as int?) ?? data.length;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red));
} finally {
if (mounted) setState(() => _loading = false);
} }
if (mounted) setState(() {});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( // هدر صفحه
_buildHeader(t),
// فیلترها
_buildFilters(t),
// جدول دادهها
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: DataTableWidget<ExpenseIncomeDocument>(
key: _tableKey,
config: _buildTableConfig(t),
fromJson: (json) => ExpenseIncomeDocument.fromJson(json),
calendarController: widget.calendarController,
),
),
),
],
),
),
);
}
/// ساخت هدر صفحه
Widget _buildHeader(AppLocalizations t) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row( child: Row(
children: [ children: [
const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600))), Expanded(
SegmentedButton<int>( child: Column(
segments: const [ButtonSegment(value: 0, label: Text('هزینه')), ButtonSegment(value: 1, label: Text('درآمد'))], crossAxisAlignment: CrossAxisAlignment.start,
selected: {_tabIndex}, children: [
onSelectionChanged: (s) async { Text(
setState(() { _tabIndex = s.first; _skip = 0; }); 'هزینه و درآمد',
await _load(); style: Theme.of(context).textTheme.titleLarge,
},
), ),
const SizedBox(width: 12), const SizedBox(height: 4),
FilledButton.icon( Text(
onPressed: () async { 'مدیریت اسناد هزینه و درآمد',
final ok = await showDialog<bool>( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
context: context, color: Theme.of(context).colorScheme.onSurfaceVariant,
builder: (_) => ExpenseIncomeDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
), ),
);
if (ok == true) _load();
},
icon: const Icon(Icons.add),
label: const Text('افزودن'),
), ),
], ],
), ),
), ),
Expanded( FilledButton.icon(
child: Card( onPressed: _onAddNew,
margin: const EdgeInsets.all(8), icon: const Icon(Icons.add),
child: _loading label: Text(t.add),
? const Center(child: CircularProgressIndicator()) ),
: _items.isEmpty ],
? const Center(child: Text('داده‌ای یافت نشد'))
: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final it = _items[i];
final code = (it['code'] ?? '').toString();
final type = (it['document_type'] ?? '').toString();
final dateStr = (it['document_date'] ?? '').toString();
final date = dateStr.isNotEmpty ? DateTime.tryParse(dateStr) : null;
final sumItems = _sum(it['items'] as List<dynamic>?);
final sumCps = _sum(it['counterparties'] as List<dynamic>?);
return ListTile(
title: Text(code),
subtitle: Text('${type == 'income' ? 'درآمد' : 'هزینه'}${date != null ? HesabixDateUtils.formatForDisplay(date, true) : '-'}'),
trailing: Text('${formatWithThousands(sumItems)} | ${formatWithThousands(sumCps)}'),
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => ExpenseIncomeDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
authStore: widget.authStore,
apiClient: widget.apiClient,
initial: it,
), ),
); );
if (ok == true) _load(); }
},
); /// ساخت بخش فیلترها
}, Widget _buildFilters(AppLocalizations t) {
), return Container(
), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row( child: Row(
children: [ children: [
Text('$_skip - ${_skip + _items.length} از $_total'), // فیلتر نوع سند
const Spacer(), Expanded(
IconButton(onPressed: _skip <= 0 ? null : () { setState(() { _skip = (_skip - _take).clamp(0, _total); }); _load(); }, icon: const Icon(Icons.chevron_right)), flex: 2,
IconButton(onPressed: (_skip + _take) >= _total ? null : () { setState(() { _skip = _skip + _take; }); _load(); }, icon: const Icon(Icons.chevron_left)), child: SegmentedButton<String?>(
segments: [
ButtonSegment<String?>(
value: null,
label: Text('همه'),
icon: const Icon(Icons.all_inclusive),
),
ButtonSegment<String?>(
value: 'expense',
label: Text('هزینه‌ها'),
icon: const Icon(Icons.trending_down),
),
ButtonSegment<String?>(
value: 'income',
label: Text('درآمدها'),
icon: const Icon(Icons.trending_up),
),
],
selected: _selectedDocumentType != null ? {_selectedDocumentType} : <String?>{},
onSelectionChanged: (set) {
setState(() {
_selectedDocumentType = set.first;
});
// refresh data when filter changes
_refreshData();
},
),
),
const SizedBox(width: 16),
// فیلتر تاریخ
Expanded(
flex: 3,
child: Row(
children: [
Expanded(
child: DateInputField(
value: _fromDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _fromDate = date);
_refreshData();
},
labelText: 'از تاریخ',
hintText: 'انتخاب تاریخ شروع',
),
),
const SizedBox(width: 8),
Expanded(
child: DateInputField(
value: _toDate,
calendarController: widget.calendarController,
onChanged: (date) {
setState(() => _toDate = date);
_refreshData();
},
labelText: 'تا تاریخ',
hintText: 'انتخاب تاریخ پایان',
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () {
setState(() {
_fromDate = null;
_toDate = null;
});
_refreshData();
},
icon: const Icon(Icons.clear),
tooltip: 'پاک کردن فیلتر تاریخ',
),
], ],
), ),
)
],
), ),
],
), ),
); );
} }
double _sum(List<dynamic>? lines) { /// ساخت تنظیمات جدول
if (lines == null) return 0; DataTableConfig<ExpenseIncomeDocument> _buildTableConfig(AppLocalizations t) {
double s = 0; return DataTableConfig<ExpenseIncomeDocument>(
for (final l in lines) { endpoint: '/businesses/${widget.businessId}/expense-income',
final m = (l as Map<String, dynamic>); title: 'هزینه و درآمد',
s += ((m['debit'] ?? 0) as num).toDouble(); excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
s += ((m['credit'] ?? 0) as num).toDouble(); pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf',
// دکمه حذف گروهی در هدر جدول
customHeaderActions: [
Tooltip(
message: 'حذف انتخاب‌شده‌ها',
child: FilledButton.icon(
onPressed: _selectedCount > 0 ? _onBulkDelete : null,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer,
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
),
icon: const Icon(Icons.delete_forever),
label: Text('حذف (${_selectedCount})'),
),
),
],
getExportParams: () => {
'business_id': widget.businessId,
// همیشه document_type را ارسال کن، حتی اگر null باشد
'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
},
columns: [
// کد سند
TextColumn(
'code',
'کد سند',
width: ColumnWidth.medium,
formatter: (item) => item.code,
),
// نوع سند
TextColumn(
'document_type',
'نوع',
width: ColumnWidth.small,
formatter: (item) => item.documentTypeName,
),
// تاریخ سند
DateColumn(
'document_date',
'تاریخ سند',
width: ColumnWidth.medium,
formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali),
),
// مبلغ کل
NumberColumn(
'total_amount',
'مبلغ کل',
width: ColumnWidth.large,
formatter: (item) => formatWithThousands(item.totalAmount),
suffix: ' ریال',
),
// نام حسابها
TextColumn(
'item_accounts',
'حساب‌ها',
width: ColumnWidth.medium,
formatter: (item) => item.itemAccountNames ?? 'نامشخص',
),
// اطلاعات طرفحساب
TextColumn(
'counterparty_info',
'طرف‌حساب',
width: ColumnWidth.medium,
formatter: (item) => item.counterpartyInfo ?? 'نامشخص',
),
// توضیحات
TextColumn(
'description',
'توضیحات',
width: ColumnWidth.large,
formatter: (item) => item.description ?? '',
),
// تعداد خطوط
NumberColumn(
'lines_count',
'خطوط',
width: ColumnWidth.small,
formatter: (item) => (item.itemLinesCount + item.counterpartyLinesCount).toString(),
),
// ایجادکننده
TextColumn(
'created_by_name',
'ایجادکننده',
width: ColumnWidth.medium,
formatter: (item) => item.createdByName ?? 'نامشخص',
),
// تاریخ ثبت
DateColumn(
'registered_at',
'تاریخ ثبت',
width: ColumnWidth.medium,
formatter: (item) => HesabixDateUtils.formatForDisplay(item.registeredAt, widget.calendarController.isJalali),
),
// عملیات
ActionColumn(
'actions',
'عملیات',
width: ColumnWidth.medium,
actions: [
DataTableAction(
icon: Icons.visibility,
label: 'مشاهده',
onTap: (item) => _onView(item),
),
DataTableAction(
icon: Icons.edit,
label: 'ویرایش',
onTap: (item) => _onEdit(item),
),
DataTableAction(
icon: Icons.delete,
label: 'حذف',
onTap: (item) => _onDelete(item),
isDestructive: true,
),
],
),
],
searchFields: ['code', 'created_by_name'],
filterFields: ['document_type'],
dateRangeField: 'document_date',
showSearch: true,
showFilters: true,
showPagination: true,
showColumnSearch: true,
showRefreshButton: true,
showClearFiltersButton: true,
enableRowSelection: true,
enableMultiRowSelection: true,
showExportButtons: true,
showExcelExport: true,
showPdfExport: true,
defaultPageSize: 20,
pageSizeOptions: [10, 20, 50, 100],
onRowSelectionChanged: (rows) {
setState(() {
_selectedCount = rows.length;
});
},
additionalParams: {
// همیشه document_type را ارسال کن، حتی اگر null باشد
'document_type': _selectedDocumentType,
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
},
onRowTap: (item) => _onView(item),
onRowDoubleTap: (item) => _onEdit(item),
emptyStateMessage: 'هیچ سند هزینه یا درآمدی یافت نشد',
loadingMessage: 'در حال بارگذاری اسناد...',
errorMessage: 'خطا در بارگذاری اسناد',
);
}
/// افزودن سند جدید
void _onAddNew() async {
final result = await showDialog<bool>(
context: context,
builder: (_) => ExpenseIncomeFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isIncome: false, // پیشفرض هزینه
businessInfo: widget.authStore.currentBusiness,
apiClient: widget.apiClient,
),
);
// اگر سند با موفقیت ثبت شد، جدول را تازهسازی کن
if (result == true) {
_refreshData();
}
}
/// مشاهده جزئیات سند
void _onView(ExpenseIncomeDocument document) async {
try {
// دریافت جزئیات کامل سند
final fullDoc = await _service.getById(document.id);
if (fullDoc == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('سند یافت نشد')),
);
return;
}
// نمایش دیالوگ مشاهده جزئیات
await showDialog(
context: context,
builder: (_) => ExpenseIncomeDetailsDialog(
document: fullDoc,
calendarController: widget.calendarController,
businessId: widget.businessId,
apiClient: widget.apiClient,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در بارگذاری جزئیات: $e')),
);
}
}
/// ویرایش سند
void _onEdit(ExpenseIncomeDocument document) async {
try {
// دریافت جزئیات کامل سند
final fullDoc = await _service.getById(document.id);
if (fullDoc == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('سند یافت نشد')),
);
return;
}
final result = await showDialog<bool>(
context: context,
builder: (_) => ExpenseIncomeFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
isIncome: fullDoc.isIncome,
businessInfo: widget.authStore.currentBusiness,
apiClient: widget.apiClient,
initialDocument: fullDoc,
),
);
if (result == true) {
_refreshData();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در آماده‌سازی ویرایش: $e')),
);
}
}
/// حذف سند
void _onDelete(ExpenseIncomeDocument document) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('تأیید حذف'),
content: Text('حذف سند ${document.code} غیرقابل بازگشت است. آیا ادامه می‌دهید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('انصراف'),
),
FilledButton(
onPressed: () async {
Navigator.pop(context);
await _performDelete(document);
},
child: const Text('حذف'),
),
],
),
);
}
/// انجام عملیات حذف
Future<void> _performDelete(ExpenseIncomeDocument document) async {
try {
// نمایش لودینگ هنگام حذف
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final success = await _service.delete(document.id);
if (success) {
if (mounted) {
Navigator.pop(context); // بستن لودینگ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('سند ${document.code} با موفقیت حذف شد'),
backgroundColor: Colors.green,
),
);
setState(() {
_selectedCount = 0; // پاکسازی شمارنده انتخاب پس از حذف
});
_refreshData();
}
} else {
if (mounted) Navigator.pop(context);
throw Exception('خطا در حذف سند');
}
} catch (e) {
if (mounted) {
// بستن لودینگ در صورت بروز خطا
Navigator.pop(context);
String message = 'خطا در حذف سند';
int? statusCode;
if (e is DioException) {
statusCode = e.response?.statusCode;
final data = e.response?.data;
try {
final detail = (data is Map<String, dynamic>) ? data['detail'] : null;
if (detail is Map<String, dynamic>) {
final err = detail['error'];
if (err is Map<String, dynamic>) {
final m = err['message'];
if (m is String && m.trim().isNotEmpty) {
message = m;
}
}
}
} catch (_) {
// ignore parse errors
}
if (statusCode == 404) {
message = 'سند یافت نشد یا قبلاً حذف شده است';
_refreshData();
} else if (statusCode == 403) {
message = 'دسترسی لازم برای حذف این سند را ندارید';
} else if (statusCode == 409) {
// پیام از سرور استخراج شده است (مثلاً سند قفل/دارای وابستگی)
if (message == 'خطا در حذف سند') {
message = 'حذف این سند امکان‌پذیر نیست';
}
}
} else {
message = e.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
}
}
/// حذف گروهی اسناد انتخابشده
Future<void> _onBulkDelete() async {
// استخراج آیتمهای انتخابشده از جدول
final state = _tableKey.currentState;
if (state == null) return;
List<dynamic> selectedItems = const [];
try {
// ignore: avoid_dynamic_calls
selectedItems = (state as dynamic).getSelectedItems();
} catch (_) {}
if (selectedItems.isEmpty) return;
// نگاشت به مدل و شناسهها
final docs = selectedItems.cast<ExpenseIncomeDocument>();
final ids = docs.map((d) => d.id).toList();
final codes = docs.map((d) => d.code).toList();
// تایید کاربر
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('تأیید حذف گروهی'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('تعداد اسناد انتخاب‌شده: ${ids.length}'),
const SizedBox(height: 8),
Text('این عملیات غیرقابل بازگشت است. ادامه می‌دهید؟'),
if (codes.isNotEmpty) ...[
const SizedBox(height: 8),
Text('نمونه کدها: ${codes.take(5).join(', ')}${codes.length > 5 ? ' ...' : ''}'),
],
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('حذف')),
],
);
},
);
if (confirmed != true) return;
// نمایش لودینگ
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
await _service.deleteMultiple(ids);
if (!mounted) return;
Navigator.pop(context); // بستن لودینگ
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${ids.length} سند با موفقیت حذف شد'),
backgroundColor: Colors.green,
),
);
setState(() {
_selectedCount = 0; // پاکسازی شمارنده انتخاب پس از حذف گروهی
});
_refreshData();
} catch (e) {
if (!mounted) return;
Navigator.pop(context); // بستن لودینگ
String message = 'خطا در حذف اسناد';
if (e is DioException) {
message = e.message ?? message;
} else {
message = e.toString();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
} }
return s;
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
import '../../widgets/data_table/data_table_widget.dart'; import '../../widgets/data_table/data_table_widget.dart';
@ -228,6 +229,89 @@ class _PersonsPageState extends State<PersonsPage> {
width: ColumnWidth.large, width: ColumnWidth.large,
formatter: (person) => person.website ?? '-', formatter: (person) => person.website ?? '-',
), ),
CustomColumn(
'balance',
'تراز',
width: ColumnWidth.medium,
sortable: true,
formatter: (person) {
final balance = person.balance ?? 0.0;
final formatter = NumberFormat('#,##0', 'en_US');
return formatter.format(balance);
},
builder: (person, index) {
final balance = person.balance ?? 0.0;
final formatter = NumberFormat('#,##0', 'en_US');
final formattedBalance = formatter.format(balance);
Color balanceColor;
if (balance > 0) {
balanceColor = Colors.green;
} else if (balance < 0) {
balanceColor = Colors.red;
} else {
balanceColor = Colors.grey;
}
return Text(
formattedBalance,
style: TextStyle(
color: balanceColor,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
},
),
CustomColumn(
'status',
'وضعیت',
width: ColumnWidth.medium,
filterType: ColumnFilterType.multiSelect,
filterOptions: [
FilterOption(value: 'بستانکار', label: 'بستانکار'),
FilterOption(value: 'بدهکار', label: 'بدهکار'),
FilterOption(value: 'بالانس', label: 'بالانس'),
FilterOption(value: 'بدون تراکنش', label: 'بدون تراکنش'),
],
formatter: (person) => person.status ?? '-',
builder: (person, index) {
final status = person.status ?? '-';
Color statusColor;
switch (status) {
case 'بستانکار':
statusColor = Colors.green;
break;
case 'بدهکار':
statusColor = Colors.red;
break;
case 'بالانس':
statusColor = Colors.blue;
break;
case 'بدون تراکنش':
statusColor = Colors.grey;
break;
default:
statusColor = Colors.black;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: statusColor.withOpacity(0.3)),
),
child: Text(
status,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
);
},
),
ActionColumn( ActionColumn(
'actions', 'actions',
t.actions, t.actions,

View file

@ -190,10 +190,16 @@ class _TransfersPageState extends State<TransfersPage> {
formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it), formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it),
), ),
TextColumn( TextColumn(
'route', 'source',
'مبدا → مقصد', 'مبدا',
width: ColumnWidth.large, width: ColumnWidth.large,
formatter: (it) => _composeRoute(it), formatter: (it) => _composeSource(it),
),
TextColumn(
'destination',
'مقصد',
width: ColumnWidth.large,
formatter: (it) => _composeDestination(it),
), ),
DateColumn( DateColumn(
'document_date', 'document_date',
@ -231,7 +237,7 @@ class _TransfersPageState extends State<TransfersPage> {
], ],
), ),
], ],
searchFields: ['code', 'created_by_name'], searchFields: ['code', 'created_by_name', 'source', 'destination'],
dateRangeField: 'document_date', dateRangeField: 'document_date',
showSearch: true, showSearch: true,
showFilters: true, showFilters: true,
@ -272,17 +278,19 @@ class _TransfersPageState extends State<TransfersPage> {
} }
} }
String _composeRoute(TransferDocument it) { String _composeSource(TransferDocument it) {
final src = '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim(); return '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim();
final dst = '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim(); }
if (src.isEmpty && dst.isEmpty) return '';
return '$src$dst'; String _composeDestination(TransferDocument it) {
return '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim();
} }
String _composeDesc(TransferDocument it) { String _composeDesc(TransferDocument it) {
final r = _composeRoute(it); final src = _composeSource(it);
if (r.isEmpty) return ''; final dst = _composeDestination(it);
return 'انتقال $r'; if (src.isEmpty && dst.isEmpty) return '';
return 'انتقال $src$dst';
} }
void _onAddNew() async { void _onAddNew() async {
@ -305,7 +313,10 @@ class _TransfersPageState extends State<TransfersPage> {
if (!mounted) return; if (!mounted) return;
showDialog( showDialog(
context: context, context: context,
builder: (_) => TransferDetailsDialog(document: full), builder: (_) => TransferDetailsDialog(
document: full,
calendarController: widget.calendarController,
),
); );
} }

View file

@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import '../../services/warehouse_service.dart';
import '../../models/warehouse_model.dart';
class WarehousesPage extends StatefulWidget {
final int businessId;
const WarehousesPage({super.key, required this.businessId});
@override
State<WarehousesPage> createState() => _WarehousesPageState();
}
class _WarehousesPageState extends State<WarehousesPage> {
final WarehouseService _service = WarehouseService();
bool _loading = true;
String? _error;
List<Warehouse> _items = const <Warehouse>[];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
setState(() {
_loading = true;
_error = null;
});
final items = await _service.listWarehouses(businessId: widget.businessId);
if (!mounted) return;
setState(() {
_items = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('مدیریت انبارها')),
floatingActionButton: FloatingActionButton(
onPressed: _showCreateDialog,
child: const Icon(Icons.add),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700)))
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, idx) {
final w = _items[idx];
return ListTile(
leading: Icon(w.isDefault ? Icons.star : Icons.store, color: w.isDefault ? Colors.orange : null),
title: Text('${w.code} - ${w.name}'),
subtitle: Text(w.description ?? ''),
onTap: () => _showEditDialog(w),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _delete(w),
),
);
},
),
),
);
}
Future<void> _showCreateDialog() async {
final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController();
bool isDefault = false;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('افزودن انبار'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: codeCtrl, decoration: const InputDecoration(labelText: 'کد')),
TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'نام')),
StatefulBuilder(builder: (ctx, setSt) {
return CheckboxListTile(
value: isDefault,
onChanged: (v) => setSt(() => isDefault = v ?? false),
title: const Text('پیش‌فرض'),
controlAffinity: ListTileControlAffinity.leading,
);
}),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')),
],
),
);
if (ok != true) return;
try {
final created = await _service.createWarehouse(
businessId: widget.businessId,
payload: {
'code': codeCtrl.text.trim(),
'name': nameCtrl.text.trim(),
'is_default': isDefault,
},
);
if (!mounted) return;
setState(() {
_items = [created, ..._items];
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _showEditDialog(Warehouse w) async {
final codeCtrl = TextEditingController(text: w.code);
final nameCtrl = TextEditingController(text: w.name);
bool isDefault = w.isDefault;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('ویرایش انبار'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: codeCtrl, decoration: const InputDecoration(labelText: 'کد')),
TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'نام')),
StatefulBuilder(builder: (ctx, setSt) {
return CheckboxListTile(
value: isDefault,
onChanged: (v) => setSt(() => isDefault = v ?? false),
title: const Text('پیش‌فرض'),
controlAffinity: ListTileControlAffinity.leading,
);
}),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')),
],
),
);
if (ok != true) return;
try {
final updated = await _service.updateWarehouse(
businessId: widget.businessId,
warehouseId: w.id!,
payload: {
'code': codeCtrl.text.trim(),
'name': nameCtrl.text.trim(),
'is_default': isDefault,
},
);
if (!mounted) return;
setState(() {
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _delete(Warehouse w) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('حذف انبار'),
content: Text('آیا از حذف «${w.name}» مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('حذف')),
],
),
);
if (ok != true) return;
try {
final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!);
if (!mounted) return;
if (deleted) {
setState(() {
_items = _items.where((e) => e.id != w.id).toList();
});
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
}

View file

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/pages/business/expense_income_list_page.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/api_client.dart';
/// صفحه تست برای لیست هزینه و درآمد
class ExpenseIncomeTestPage extends StatelessWidget {
const ExpenseIncomeTestPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('تست لیست هزینه و درآمد'),
),
body: ExpenseIncomeListPage(
businessId: 1, // ID کسب و کار تست
calendarController: CalendarController(),
authStore: AuthStore(),
apiClient: ApiClient(),
),
);
}
}

View file

@ -1,4 +1,5 @@
import '../core/api_client.dart'; import '../core/api_client.dart';
import '../models/account_model.dart';
class AccountService { class AccountService {
final ApiClient _client; final ApiClient _client;
@ -19,4 +20,71 @@ class AccountService {
return <String, dynamic>{'items': <dynamic>[]}; return <String, dynamic>{'items': <dynamic>[]};
} }
} }
/// دریافت لیست حسابها برای یک کسب و کار
Future<Map<String, dynamic>> getAccounts({required int businessId}) async {
try {
final res = await _client.get<Map<String, dynamic>>(
'/api/v1/accounts/business/$businessId',
);
final responseData = res.data?['data'] as Map<String, dynamic>?;
return responseData ?? <String, dynamic>{'items': <dynamic>[]};
} catch (e) {
print('خطا در دریافت حساب‌ها: $e');
return <String, dynamic>{'items': <dynamic>[]};
}
}
/// دریافت یک حساب خاص با ID
Future<Map<String, dynamic>> getAccount({
required int businessId,
required int accountId,
}) async {
try {
final res = await _client.get<Map<String, dynamic>>(
'/api/v1/accounts/business/$businessId/account/$accountId',
);
final responseData = res.data?['data'] as Map<String, dynamic>?;
if (responseData == null) {
throw Exception('حساب یافت نشد');
}
return responseData;
} catch (e) {
print('خطا در دریافت حساب $accountId: $e');
rethrow;
}
}
/// جستجوی حسابها
Future<Map<String, dynamic>> searchAccounts({
required int businessId,
String? searchQuery,
int limit = 50,
}) async {
try {
final requestData = <String, dynamic>{
'take': limit,
'skip': 0,
'sort_by': 'name',
'sort_desc': false,
};
if (searchQuery != null && searchQuery.isNotEmpty) {
requestData['search'] = searchQuery;
}
final res = await _client.post<Map<String, dynamic>>(
'/api/v1/accounts/business/$businessId',
data: requestData,
);
final responseData = res.data?['data'] as Map<String, dynamic>?;
return responseData ?? <String, dynamic>{'items': <dynamic>[]};
} catch (e) {
print('خطا در جستجوی حساب‌ها: $e');
return <String, dynamic>{'items': <dynamic>[]};
}
}
} }

View file

@ -0,0 +1,53 @@
import '../core/api_client.dart';
import '../models/bom_models.dart';
class BomService {
final ApiClient _api;
BomService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
Future<List<ProductBOM>> list({required int businessId, int? productId}) async {
final res = await _api.get<Map<String, dynamic>>(
'/api/v1/boms/business/$businessId',
query: productId != null ? {'product_id': productId} : null,
);
final data = res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{};
final items = data['items'] as List<dynamic>? ?? const <dynamic>[];
return items.map((e) => ProductBOM.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
Future<ProductBOM> create({required int businessId, required Map<String, dynamic> payload}) async {
final res = await _api.post<Map<String, dynamic>>('/api/v1/boms/business/$businessId', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return ProductBOM.fromJson(data);
}
Future<ProductBOM> getById({required int businessId, required int bomId}) async {
final res = await _api.get<Map<String, dynamic>>('/api/v1/boms/business/$businessId/$bomId');
final data = (res.data?['data']?['item'] as Map<String, dynamic>? ?? <String, dynamic>{});
return ProductBOM.fromJson(data);
}
Future<ProductBOM> update({required int businessId, required int bomId, required Map<String, dynamic> payload}) async {
final res = await _api.put<Map<String, dynamic>>('/api/v1/boms/business/$businessId/$bomId', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return ProductBOM.fromJson(data);
}
Future<bool> delete({required int businessId, required int bomId}) async {
final res = await _api.delete<Map<String, dynamic>>('/api/v1/boms/business/$businessId/$bomId');
return res.statusCode == 200 && (res.data?['data']?['deleted'] == true);
}
Future<BomExplosionResult> explode({required int businessId, int? productId, int? bomId, required double quantity}) async {
final payload = <String, dynamic>{
if (productId != null) 'product_id': productId,
if (bomId != null) 'bom_id': bomId,
'quantity': quantity,
};
final res = await _api.post<Map<String, dynamic>>('/api/v1/boms/business/$businessId/explode', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return BomExplosionResult.fromJson(data);
}
}

View file

@ -0,0 +1,283 @@
import 'package:dio/dio.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/document_model.dart';
/// سرویس مدیریت اسناد حسابداری
class DocumentService {
final ApiClient _apiClient;
DocumentService(this._apiClient);
/// دریافت لیست اسناد با فیلتر و صفحهبندی
///
/// Parameters:
/// - [businessId]: شناسه کسبوکار
/// - [documentType]: نوع سند (expense, income, receipt, payment, transfer, manual)
/// - [fiscalYearId]: شناسه سال مالی
/// - [fromDate]: از تاریخ (ISO format)
/// - [toDate]: تا تاریخ (ISO format)
/// - [currencyId]: شناسه ارز
/// - [isProforma]: پیشفاکتور یا قطعی
/// - [search]: جستجو در کد سند و توضیحات
/// - [sortBy]: فیلد مرتبسازی
/// - [sortDesc]: ترتیب نزولی
/// - [page]: شماره صفحه
/// - [perPage]: تعداد رکورد در هر صفحه
Future<Map<String, dynamic>> listDocuments({
required int businessId,
String? documentType,
int? fiscalYearId,
String? fromDate,
String? toDate,
int? currencyId,
bool? isProforma,
String? search,
String sortBy = 'document_date',
bool sortDesc = true,
int page = 1,
int perPage = 50,
}) async {
try {
final skip = (page - 1) * perPage;
final body = {
'take': perPage,
'skip': skip,
'sort_by': sortBy,
'sort_desc': sortDesc,
if (documentType != null) 'document_type': documentType,
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
if (fromDate != null) 'from_date': fromDate,
if (toDate != null) 'to_date': toDate,
if (currencyId != null) 'currency_id': currencyId,
if (isProforma != null) 'is_proforma': isProforma,
if (search != null && search.isNotEmpty) 'search': search,
};
final response = await _apiClient.post(
'/businesses/$businessId/documents',
data: body,
);
if (response.data['success'] == true) {
final data = response.data['data'];
return {
'items': (data['items'] as List)
.map((json) => DocumentModel.fromJson(json as Map<String, dynamic>))
.toList(),
'pagination': data['pagination'],
};
}
throw Exception(response.data['message'] ?? 'خطا در دریافت لیست اسناد');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور');
}
rethrow;
}
}
/// دریافت جزئیات یک سند
Future<DocumentModel> getDocument(int documentId) async {
try {
final response = await _apiClient.get('/documents/$documentId');
if (response.data['success'] == true) {
return DocumentModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
throw Exception(response.data['message'] ?? 'خطا در دریافت جزئیات سند');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور');
}
rethrow;
}
}
/// حذف یک سند (فقط اسناد manual)
Future<bool> deleteDocument(int documentId) async {
try {
final response = await _apiClient.delete('/documents/$documentId');
if (response.data['success'] == true) {
return true;
}
throw Exception(response.data['message'] ?? 'خطا در حذف سند');
} catch (e) {
if (e is DioException) {
final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور';
throw Exception(errorMessage);
}
rethrow;
}
}
/// حذف گروهی اسناد
Future<Map<String, dynamic>> bulkDeleteDocuments(List<int> documentIds) async {
try {
final response = await _apiClient.post(
'/documents/bulk-delete',
data: {'document_ids': documentIds},
);
if (response.data['success'] == true) {
return response.data['data'] as Map<String, dynamic>;
}
throw Exception(response.data['message'] ?? 'خطا در حذف گروهی اسناد');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور');
}
rethrow;
}
}
/// دریافت خلاصه آماری انواع اسناد
Future<Map<String, int>> getDocumentTypesSummary(int businessId) async {
try {
final response = await _apiClient.get(
'/businesses/$businessId/documents/types-summary',
);
if (response.data['success'] == true) {
final summary = response.data['data']['summary'] as Map<String, dynamic>;
return summary.map((key, value) => MapEntry(key, value as int));
}
throw Exception(response.data['message'] ?? 'خطا در دریافت آمار');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور');
}
rethrow;
}
}
/// خروجی Excel لیست اسناد
Future<void> exportToExcel({
required int businessId,
String? documentType,
int? fiscalYearId,
String? fromDate,
String? toDate,
int? currencyId,
bool? isProforma,
}) async {
try {
final body = {
if (documentType != null) 'document_type': documentType,
if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId,
if (fromDate != null) 'from_date': fromDate,
if (toDate != null) 'to_date': toDate,
if (currencyId != null) 'currency_id': currencyId,
if (isProforma != null) 'is_proforma': isProforma,
};
final response = await _apiClient.post(
'/businesses/$businessId/documents/export/excel',
data: body,
options: Options(
responseType: ResponseType.bytes,
),
);
// ذخیره فایل
// TODO: پیادهسازی ذخیره فایل
// میتوان از file_picker یا path_provider استفاده کرد
throw UnimplementedError('Export to Excel is not implemented yet');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در دریافت فایل Excel');
}
rethrow;
}
}
/// دریافت PDF یک سند
Future<void> downloadPdf(int documentId) async {
try {
final response = await _apiClient.get(
'/documents/$documentId/pdf',
options: Options(
responseType: ResponseType.bytes,
),
);
// ذخیره فایل
// TODO: پیادهسازی ذخیره فایل
throw UnimplementedError('PDF download is not implemented yet');
} catch (e) {
if (e is DioException) {
throw Exception(e.response?.data['message'] ?? 'خطا در دریافت فایل PDF');
}
rethrow;
}
}
/// ایجاد سند حسابداری دستی جدید
Future<DocumentModel> createManualDocument({
required int businessId,
required CreateManualDocumentRequest request,
}) async {
try {
// اعتبارسنجی درخواست
final validationError = request.validate();
if (validationError != null) {
throw Exception(validationError);
}
final response = await _apiClient.post(
'/businesses/$businessId/documents/manual',
data: request.toJson(),
);
if (response.data['success'] == true) {
return DocumentModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
throw Exception(response.data['message'] ?? 'خطا در ایجاد سند');
} catch (e) {
if (e is DioException) {
final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور';
throw Exception(errorMessage);
}
rethrow;
}
}
/// ویرایش سند حسابداری دستی
Future<DocumentModel> updateManualDocument({
required int documentId,
required UpdateManualDocumentRequest request,
}) async {
try {
// اعتبارسنجی درخواست
final validationError = request.validate();
if (validationError != null) {
throw Exception(validationError);
}
final response = await _apiClient.put(
'/documents/$documentId',
data: request.toJson(),
);
if (response.data['success'] == true) {
return DocumentModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
throw Exception(response.data['message'] ?? 'خطا در ویرایش سند');
} catch (e) {
if (e is DioException) {
final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور';
throw Exception(errorMessage);
}
rethrow;
}
}
}

View file

@ -0,0 +1,189 @@
import 'package:dio/dio.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/expense_income_document.dart';
import 'package:hesabix_ui/models/paginated_response.dart';
/// سرویس لیست اسناد هزینه/درآمد
class ExpenseIncomeListService {
final ApiClient _apiClient;
ExpenseIncomeListService(this._apiClient);
/// دریافت لیست اسناد هزینه/درآمد
Future<PaginatedResponse<ExpenseIncomeDocument>> getList({
required int businessId,
int page = 1,
int pageSize = 20,
String? documentType,
DateTime? fromDate,
DateTime? toDate,
String? search,
String? sortBy = 'document_date',
bool sortDesc = true,
}) async {
try {
final queryInfo = {
'take': pageSize,
'skip': (page - 1) * pageSize,
'sort_by': sortBy,
'sort_desc': sortDesc,
'search': search,
'document_type': documentType,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final response = await _apiClient.post(
'/businesses/$businessId/expense-income',
data: queryInfo,
);
final data = response.data['data'];
final items = (data['items'] as List)
.map((item) => ExpenseIncomeDocument.fromJson(item))
.toList();
final pagination = data['pagination'] as Map<String, dynamic>;
return PaginatedResponse<ExpenseIncomeDocument>(
items: items,
total: pagination['total'] as int,
page: pagination['page'] as int,
perPage: pagination['per_page'] as int,
totalPages: pagination['total_pages'] as int,
hasNext: pagination['has_next'] as bool,
hasPrev: pagination['has_prev'] as bool,
);
} catch (e) {
throw _handleError(e);
}
}
/// دریافت جزئیات یک سند
Future<ExpenseIncomeDocument?> getById(int documentId) async {
try {
final response = await _apiClient.get('/expense-income/$documentId');
final data = response.data['data'];
return ExpenseIncomeDocument.fromJson(data);
} catch (e) {
if (e is DioException && e.response?.statusCode == 404) {
return null;
}
throw _handleError(e);
}
}
/// حذف یک سند
Future<bool> delete(int documentId) async {
try {
await _apiClient.delete('/expense-income/$documentId');
return true;
} catch (e) {
throw _handleError(e);
}
}
/// حذف چندین سند
Future<bool> deleteMultiple(List<int> documentIds) async {
try {
await _apiClient.post('/expense-income/bulk-delete', data: {
'document_ids': documentIds,
});
return true;
} catch (e) {
throw _handleError(e);
}
}
/// دریافت فایل Excel
Future<List<int>> exportExcel({
required int businessId,
String? documentType,
DateTime? fromDate,
DateTime? toDate,
}) async {
try {
final params = {
'business_id': businessId,
if (documentType != null) 'document_type': documentType,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
return await _apiClient.downloadExcel(
'/businesses/$businessId/expense-income/export/excel',
params: params,
);
} catch (e) {
throw _handleError(e);
}
}
/// دریافت فایل PDF
Future<List<int>> exportPdf({
required int businessId,
String? documentType,
DateTime? fromDate,
DateTime? toDate,
}) async {
try {
// برای PDF از query parameters استفاده میکنیم
final queryParams = <String, dynamic>{
'business_id': businessId,
if (documentType != null) 'document_type': documentType,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
};
final response = await _apiClient.get<List<int>>(
'/businesses/$businessId/expense-income/export/pdf',
query: queryParams,
responseType: ResponseType.bytes,
options: Options(
headers: {
'Accept': 'application/pdf',
},
),
);
return response.data ?? [];
} catch (e) {
throw _handleError(e);
}
}
Exception _handleError(dynamic error) {
if (error is DioException) {
final response = error.response;
if (response != null) {
final data = response.data;
if (data is Map<String, dynamic>) {
final message = data['message'] ?? data['detail'] ?? error.message;
return Exception(message);
}
}
return Exception(error.message ?? 'خطا در ارتباط با سرور');
}
return Exception(error.toString());
}
}
/// پاسخ صفحهبندی شده
class PaginatedResponse<T> {
final List<T> items;
final int total;
final int page;
final int perPage;
final int totalPages;
final bool hasNext;
final bool hasPrev;
const PaginatedResponse({
required this.items,
required this.total,
required this.page,
required this.perPage,
required this.totalPages,
required this.hasNext,
required this.hasPrev,
});
}

View file

@ -1,60 +1,214 @@
import 'package:dio/dio.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/expense_income_document.dart';
/// سرویس CRUD اسناد هزینه/درآمد
class ExpenseIncomeService { class ExpenseIncomeService {
final ApiClient api; final ApiClient _apiClient;
ExpenseIncomeService(this.api);
Future<Map<String, dynamic>> create({ ExpenseIncomeService(this._apiClient);
/// ایجاد سند هزینه/درآمد جدید
Future<ExpenseIncomeDocument> create({
required int businessId, required int businessId,
required String documentType, // 'expense' | 'income' required String documentType,
required DateTime documentDate, required DateTime documentDate,
required int currencyId, required int currencyId,
required List<ItemLineData> itemLines,
required List<CounterpartyLineData> counterpartyLines,
String? description, String? description,
List<Map<String, dynamic>> itemLines = const [], Map<String, dynamic>? extraInfo,
List<Map<String, dynamic>> counterpartyLines = const [],
}) async { }) async {
final body = <String, dynamic>{ try {
// تبدیل itemLines به فرمت API
final itemLinesData = itemLines.map((line) => {
'account_id': line.accountId,
'amount': line.amount,
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
}).toList();
// تبدیل counterpartyLines به فرمت API
final counterpartyLinesData = counterpartyLines.map((line) {
final data = {
'transaction_type': line.transactionType.value,
'amount': line.amount,
'transaction_date': line.transactionDate.toIso8601String(),
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
if (line.commission != null && line.commission! > 0)
'commission': line.commission,
};
// اضافه کردن فیلدهای خاص بر اساس نوع تراکنش
switch (line.transactionType) {
case TransactionType.bank:
if (line.bankAccountId != null) {
data['bank_account_id'] = line.bankAccountId;
data['bank_account_name'] = line.bankAccountName;
}
break;
case TransactionType.cashRegister:
if (line.cashRegisterId != null) {
data['cash_register_id'] = line.cashRegisterId;
data['cash_register_name'] = line.cashRegisterName;
}
break;
case TransactionType.pettyCash:
if (line.pettyCashId != null) {
data['petty_cash_id'] = line.pettyCashId;
data['petty_cash_name'] = line.pettyCashName;
}
break;
case TransactionType.check:
if (line.checkId != null) {
data['check_id'] = line.checkId;
data['check_number'] = line.checkNumber;
}
break;
case TransactionType.person:
if (line.personId != null) {
data['person_id'] = line.personId;
data['person_name'] = line.personName;
}
break;
}
return data;
}).toList();
final requestData = {
'document_type': documentType, 'document_type': documentType,
'document_date': documentDate.toIso8601String(), 'document_date': documentDate.toIso8601String(),
'currency_id': currencyId, 'currency_id': currencyId,
if (description != null && description.isNotEmpty) 'description': description, if (description != null && description.isNotEmpty) 'description': description,
'item_lines': itemLines, 'item_lines': itemLinesData,
'counterparty_lines': counterpartyLines, 'counterparty_lines': counterpartyLinesData,
if (extraInfo != null) 'extra_info': extraInfo,
}; };
final res = await api.post<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/expense-income/create', final response = await _apiClient.post(
data: body, '/businesses/$businessId/expense-income/create',
data: requestData,
); );
return res.data ?? <String, dynamic>{};
final data = response.data['data'];
return ExpenseIncomeDocument.fromJson(data);
} catch (e) {
throw _handleError(e);
}
} }
Future<Map<String, dynamic>> list({ /// ویرایش سند هزینه/درآمد
required int businessId, Future<ExpenseIncomeDocument> update({
String? documentType, // 'expense' | 'income' required int documentId,
DateTime? fromDate, required DateTime documentDate,
DateTime? toDate, required int currencyId,
int skip = 0, required List<ItemLineData> itemLines,
int take = 20, required List<CounterpartyLineData> counterpartyLines,
String? search, String? description,
String? sortBy, Map<String, dynamic>? extraInfo,
bool sortDesc = true,
}) async { }) async {
final body = <String, dynamic>{ try {
'skip': skip, // تبدیل itemLines به فرمت API
'take': take, final itemLinesData = itemLines.map((line) => {
'sort_desc': sortDesc, 'account_id': line.accountId,
if (sortBy != null) 'sort_by': sortBy, 'amount': line.amount,
if (search != null && search.isNotEmpty) 'search': search, if (line.description != null && line.description!.isNotEmpty)
if (documentType != null) 'document_type': documentType, 'description': line.description,
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), }).toList();
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
// تبدیل counterpartyLines به فرمت API
final counterpartyLinesData = counterpartyLines.map((line) {
final data = {
'transaction_type': line.transactionType.value,
'amount': line.amount,
'transaction_date': line.transactionDate.toIso8601String(),
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
if (line.commission != null && line.commission! > 0)
'commission': line.commission,
}; };
final res = await api.post<Map<String, dynamic>>(
'/api/v1/businesses/$businessId/expense-income', // اضافه کردن فیلدهای خاص بر اساس نوع تراکنش
data: body, switch (line.transactionType) {
case TransactionType.bank:
if (line.bankAccountId != null) {
data['bank_account_id'] = line.bankAccountId;
data['bank_account_name'] = line.bankAccountName;
}
break;
case TransactionType.cashRegister:
if (line.cashRegisterId != null) {
data['cash_register_id'] = line.cashRegisterId;
data['cash_register_name'] = line.cashRegisterName;
}
break;
case TransactionType.pettyCash:
if (line.pettyCashId != null) {
data['petty_cash_id'] = line.pettyCashId;
data['petty_cash_name'] = line.pettyCashName;
}
break;
case TransactionType.check:
if (line.checkId != null) {
data['check_id'] = line.checkId;
data['check_number'] = line.checkNumber;
}
break;
case TransactionType.person:
if (line.personId != null) {
data['person_id'] = line.personId;
data['person_name'] = line.personName;
}
break;
}
return data;
}).toList();
final requestData = {
'document_date': documentDate.toIso8601String(),
'currency_id': currencyId,
if (description != null && description.isNotEmpty) 'description': description,
'item_lines': itemLinesData,
'counterparty_lines': counterpartyLinesData,
if (extraInfo != null) 'extra_info': extraInfo,
};
final response = await _apiClient.put(
'/expense-income/$documentId',
data: requestData,
); );
return res.data ?? <String, dynamic>{};
final data = response.data['data'];
return ExpenseIncomeDocument.fromJson(data);
} catch (e) {
throw _handleError(e);
}
}
/// دریافت فایل PDF یک سند
Future<List<int>> generatePdf(int documentId) async {
try {
return await _apiClient.downloadPdf('/expense-income/$documentId/pdf');
} catch (e) {
throw _handleError(e);
}
}
Exception _handleError(dynamic error) {
if (error is DioException) {
final response = error.response;
if (response != null) {
final data = response.data;
if (data is Map<String, dynamic>) {
final message = data['message'] ?? data['detail'] ?? error.message;
return Exception(message);
}
}
return Exception(error.message ?? 'خطا در ارتباط با سرور');
}
return Exception(error.toString());
} }
} }

View file

@ -0,0 +1,39 @@
import '../core/api_client.dart';
import '../models/warehouse_model.dart';
class WarehouseService {
final ApiClient _api;
WarehouseService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
Future<List<Warehouse>> listWarehouses({required int businessId}) async {
final res = await _api.get<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId');
final data = res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{};
final items = data['items'] as List<dynamic>? ?? const <dynamic>[];
return items.map((e) => Warehouse.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
Future<Warehouse> createWarehouse({required int businessId, required Map<String, dynamic> payload}) async {
final res = await _api.post<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return Warehouse.fromJson(data);
}
Future<Warehouse> getWarehouse({required int businessId, required int warehouseId}) async {
final res = await _api.get<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId');
final data = (res.data?['data']?['item'] as Map<String, dynamic>? ?? <String, dynamic>{});
return Warehouse.fromJson(data);
}
Future<Warehouse> updateWarehouse({required int businessId, required int warehouseId, required Map<String, dynamic> payload}) async {
final res = await _api.put<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId', data: payload);
final data = (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
return Warehouse.fromJson(data);
}
Future<bool> deleteWarehouse({required int businessId, required int warehouseId}) async {
final res = await _api.delete<Map<String, dynamic>>('/api/v1/warehouses/business/$businessId/$warehouseId');
return res.statusCode == 200 && (res.data?['data']?['deleted'] == true);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/models/document_model.dart';
import 'package:hesabix_ui/services/document_service.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
/// دیالوگ نمایش جزئیات کامل سند حسابداری
class DocumentDetailsDialog extends StatefulWidget {
final int documentId;
final CalendarController calendarController;
const DocumentDetailsDialog({
super.key,
required this.documentId,
required this.calendarController,
});
@override
State<DocumentDetailsDialog> createState() => _DocumentDetailsDialogState();
}
class _DocumentDetailsDialogState extends State<DocumentDetailsDialog> {
late DocumentService _service;
DocumentModel? _document;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_service = DocumentService(ApiClient());
_loadDocument();
}
Future<void> _loadDocument() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final doc = await _service.getDocument(widget.documentId);
if (mounted) {
setState(() {
_document = doc;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
height: MediaQuery.of(context).size.height * 0.85,
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
children: [
// هدر
_buildHeader(theme),
// محتوای اصلی
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? _buildError()
: _buildContent(theme),
),
// فوتر
_buildFooter(),
],
),
),
);
}
/// ساخت هدر دیالوگ
Widget _buildHeader(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
Icon(
Icons.description,
size: 28,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'جزئیات سند ${_document?.code ?? ''}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer,
),
),
if (_document != null)
Text(
_document!.getDocumentTypeName(),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
color: theme.colorScheme.onPrimaryContainer,
),
],
),
);
}
/// ساخت پیام خطا
Widget _buildError() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
'خطا در بارگذاری سند',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'خطای نامشخص',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadDocument,
icon: const Icon(Icons.refresh),
label: const Text('تلاش مجدد'),
),
],
),
);
}
/// ساخت محتوای اصلی
Widget _buildContent(ThemeData theme) {
if (_document == null) return const SizedBox();
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// اطلاعات هدر سند
_buildDocumentHeader(theme),
const SizedBox(height: 24),
// جدول سطرهای سند
_buildLinesTable(theme),
const SizedBox(height: 16),
// جمع کل
_buildTotals(theme),
],
),
);
}
/// ساخت اطلاعات هدر سند
Widget _buildDocumentHeader(ThemeData theme) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'اطلاعات سند',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Divider(),
const SizedBox(height: 8),
_buildInfoRow('شماره سند:', _document!.code),
_buildInfoRow('نوع سند:', _document!.getDocumentTypeName()),
_buildInfoRow('تاریخ سند:', _document!.documentDateRaw ?? '-'),
_buildInfoRow('سال مالی:', _document!.fiscalYearTitle ?? '-'),
_buildInfoRow('ارز:', _document!.currencyCode ?? '-'),
_buildInfoRow('وضعیت:', _document!.statusText),
_buildInfoRow('ایجادکننده:', _document!.createdByName ?? '-'),
if (_document!.description != null && _document!.description!.isNotEmpty)
_buildInfoRow('توضیحات:', _document!.description!),
],
),
),
);
}
/// ساخت یک ردیف اطلاعات
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
);
}
/// ساخت جدول سطرهای سند
Widget _buildLinesTable(ThemeData theme) {
final lines = _document!.lines ?? [];
if (lines.isEmpty) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'هیچ سطری برای این سند ثبت نشده است',
style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey),
),
),
),
);
}
return Card(
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'سطرهای سند',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const Divider(height: 1),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.all(
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
),
columns: const [
DataColumn(label: Text('ردیف', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('حساب', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('طرف‌حساب', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('بدهکار', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('بستانکار', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('توضیحات', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: lines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return DataRow(
cells: [
DataCell(Text('${index + 1}')),
DataCell(
SizedBox(
width: 200,
child: Text(
line.fullAccountName,
overflow: TextOverflow.ellipsis,
),
),
),
DataCell(
SizedBox(
width: 150,
child: Text(
line.counterpartyName ?? '-',
overflow: TextOverflow.ellipsis,
),
),
),
DataCell(
Text(
line.debit > 0 ? formatWithThousands(line.debit.toInt()) : '-',
style: const TextStyle(
fontFamily: 'monospace',
color: Colors.red,
),
textDirection: TextDirection.ltr,
),
),
DataCell(
Text(
line.credit > 0 ? formatWithThousands(line.credit.toInt()) : '-',
style: const TextStyle(
fontFamily: 'monospace',
color: Colors.green,
),
textDirection: TextDirection.ltr,
),
),
DataCell(
SizedBox(
width: 200,
child: Text(
line.description ?? '-',
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
),
],
);
}).toList(),
),
),
],
),
);
}
/// ساخت بخش جمع کل
Widget _buildTotals(ThemeData theme) {
return Card(
elevation: 2,
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTotalItem(
'جمع بدهکار',
formatWithThousands(_document!.totalDebit.toInt()),
Colors.red,
),
Container(
width: 2,
height: 40,
color: theme.dividerColor,
),
_buildTotalItem(
'جمع بستانکار',
formatWithThousands(_document!.totalCredit.toInt()),
Colors.green,
),
Container(
width: 2,
height: 40,
color: theme.dividerColor,
),
_buildTotalItem(
'تعداد سطرها',
'${_document!.linesCount}',
theme.colorScheme.primary,
),
],
),
),
);
}
/// ساخت یک آیتم از جمع کل
Widget _buildTotalItem(String label, String value, Color color) {
return Column(
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: color,
fontFamily: 'monospace',
),
textDirection: TextDirection.ltr,
),
],
);
}
/// ساخت فوتر دیالوگ
Widget _buildFooter() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// دکمه چاپ PDF
OutlinedButton.icon(
onPressed: () {
// TODO: پیادهسازی چاپ PDF
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('چاپ PDF در حال پیاده‌سازی است')),
);
},
icon: const Icon(Icons.picture_as_pdf),
label: const Text('چاپ PDF'),
),
const SizedBox(width: 12),
// دکمه بستن
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('بستن'),
),
],
),
);
}
}

View file

@ -0,0 +1,531 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/core/auth_store.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/models/document_model.dart';
import 'package:hesabix_ui/models/account_model.dart';
import 'package:hesabix_ui/services/document_service.dart';
import 'package:hesabix_ui/services/account_service.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/widgets/document/document_line_editor.dart';
/// دیالوگ ایجاد یا ویرایش سند حسابداری دستی
class DocumentFormDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final AuthStore authStore;
final ApiClient apiClient;
final DocumentModel? document; // null = ایجاد جدید, not null = ویرایش
final int? fiscalYearId;
final int? currencyId;
const DocumentFormDialog({
super.key,
required this.businessId,
required this.calendarController,
required this.authStore,
required this.apiClient,
this.document,
this.fiscalYearId,
this.currencyId,
});
@override
State<DocumentFormDialog> createState() => _DocumentFormDialogState();
}
class _DocumentFormDialogState extends State<DocumentFormDialog> {
final _formKey = GlobalKey<FormState>();
late DocumentService _service;
// کنترلرها
final TextEditingController _codeController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
// مقادیر فرم
DateTime? _documentDate;
int? _currencyId;
bool _isProforma = false;
List<DocumentLineEdit> _lines = [];
// وضعیت
bool _isLoading = false;
bool _isSaving = false;
@override
void initState() {
super.initState();
_service = DocumentService(widget.apiClient);
_currencyId = widget.currencyId ?? 1; // پیشفرض ریال
_documentDate = DateTime.now();
// اگر حالت ویرایش است، مقادیر را بارگذاری کن
if (widget.document != null) {
_loadDocumentData();
} else {
// خط خالی برای شروع
_lines = [
DocumentLineEdit(),
DocumentLineEdit(),
];
}
}
/// بارگذاری اطلاعات سند برای ویرایش
Future<void> _loadDocumentData() async {
final doc = widget.document!;
_codeController.text = doc.code;
_descriptionController.text = doc.description ?? '';
_documentDate = doc.documentDate;
_currencyId = doc.currencyId;
_isProforma = doc.isProforma;
// تبدیل سطرهای سند به DocumentLineEdit
if (doc.lines != null && doc.lines!.isNotEmpty) {
// بارگذاری Account برای هر سطر
final accountService = AccountService(client: widget.apiClient);
final loadedLines = <DocumentLineEdit>[];
setState(() {
_isLoading = true;
});
try {
for (final line in doc.lines!) {
Account? account;
try {
// بارگذاری حساب از API
if (line.accountId != null) {
final accountData = await accountService.getAccount(
businessId: widget.businessId,
accountId: line.accountId!,
);
account = Account.fromJson(accountData);
}
} catch (e) {
print('خطا در بارگذاری حساب ${line.accountId}: $e');
// در صورت خطا، یک حساب خالی با ID میسازیم
account = Account(
id: line.accountId,
businessId: widget.businessId,
code: 'خطا',
name: 'خطا در بارگذاری',
accountType: 'asset',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
loadedLines.add(DocumentLineEdit(
account: account,
detail: {
if (line.personId != null) 'person_id': line.personId,
if (line.productId != null) 'product_id': line.productId,
if (line.bankAccountId != null) 'bank_account_id': line.bankAccountId,
if (line.cashRegisterId != null) 'cash_register_id': line.cashRegisterId,
if (line.pettyCashId != null) 'petty_cash_id': line.pettyCashId,
if (line.checkId != null) 'check_id': line.checkId,
},
debit: line.debit,
credit: line.credit,
description: line.description,
quantity: line.quantity,
));
}
if (mounted) {
setState(() {
_lines = loadedLines;
_isLoading = false;
});
}
} catch (e) {
print('خطا در بارگذاری سطرهای سند: $e');
if (mounted) {
setState(() {
_lines = [
DocumentLineEdit(),
DocumentLineEdit(),
];
_isLoading = false;
});
}
}
} else {
_lines = [
DocumentLineEdit(),
DocumentLineEdit(),
];
}
}
@override
void dispose() {
_codeController.dispose();
_descriptionController.dispose();
super.dispose();
}
/// ذخیره سند
Future<void> _saveDocument() async {
if (!_formKey.currentState!.validate()) {
return;
}
// بررسی تاریخ
if (_documentDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('تاریخ سند الزامی است')),
);
return;
}
// بررسی حداقل 2 سطر
if (_lines.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('سند باید حداقل 2 سطر داشته باشد')),
);
return;
}
// بررسی اینکه تمام سطرها حساب داشته باشند
for (int i = 0; i < _lines.length; i++) {
if (_lines[i].account == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('سطر ${i + 1} باید حساب داشته باشد')),
);
return;
}
}
setState(() => _isSaving = true);
try {
if (widget.document == null) {
// ایجاد سند جدید
await _createDocument();
} else {
// ویرایش سند
await _updateDocument();
}
if (mounted) {
Navigator.of(context).pop(true); // بازگشت با موفقیت
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.document == null
? 'سند با موفقیت ایجاد شد'
: 'سند با موفقیت ویرایش شد'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
}
}
/// ایجاد سند جدید
Future<void> _createDocument() async {
final request = CreateManualDocumentRequest(
code: _codeController.text.isEmpty ? null : _codeController.text,
documentDate: _documentDate!,
fiscalYearId: widget.fiscalYearId,
currencyId: _currencyId!,
isProforma: _isProforma,
description: _descriptionController.text.isEmpty
? null
: _descriptionController.text,
lines: _lines.map((line) => line.toRequest()).toList(),
);
await _service.createManualDocument(
businessId: widget.businessId,
request: request,
);
}
/// ویرایش سند
Future<void> _updateDocument() async {
final request = UpdateManualDocumentRequest(
code: _codeController.text.isEmpty ? null : _codeController.text,
documentDate: _documentDate,
currencyId: _currencyId,
isProforma: _isProforma,
description: _descriptionController.text.isEmpty
? null
: _descriptionController.text,
lines: _lines.map((line) => line.toRequest()).toList(),
);
await _service.updateManualDocument(
documentId: widget.document!.id,
request: request,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEditMode = widget.document != null;
return Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.95,
height: MediaQuery.of(context).size.height * 0.95,
constraints: const BoxConstraints(maxWidth: 1400),
child: Column(
children: [
// هدر
_buildHeader(theme, isEditMode),
// محتوای فرم
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// بخش اطلاعات هدر سند
_buildHeaderSection(theme),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
// بخش سطرهای سند
DocumentLinesEditor(
businessId: widget.businessId,
initialLines: _lines,
onChanged: (lines) {
setState(() {
_lines = lines;
});
},
),
],
),
),
),
),
// فوتر (دکمههای ذخیره و انصراف)
_buildFooter(theme),
],
),
),
);
}
/// ساخت هدر دیالوگ
Widget _buildHeader(ThemeData theme, bool isEditMode) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
Icon(
isEditMode ? Icons.edit_document : Icons.add_box,
size: 28,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isEditMode ? 'ویرایش سند حسابداری' : 'ایجاد سند حسابداری جدید',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
color: theme.colorScheme.onPrimaryContainer,
),
],
),
);
}
/// ساخت بخش اطلاعات هدر سند
Widget _buildHeaderSection(ThemeData theme) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'اطلاعات سند',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
// شماره سند
Expanded(
flex: 2,
child: TextFormField(
controller: _codeController,
decoration: const InputDecoration(
labelText: 'شماره سند',
hintText: 'خودکار',
border: OutlineInputBorder(),
helperText: 'اختیاری - اگر خالی باشد خودکار تولید می‌شود',
),
),
),
const SizedBox(width: 16),
// تاریخ سند
Expanded(
flex: 2,
child: DateInputField(
calendarController: widget.calendarController,
value: _documentDate,
onChanged: (date) {
setState(() {
_documentDate = date;
});
},
labelText: 'تاریخ سند',
hintText: 'انتخاب تاریخ',
),
),
const SizedBox(width: 16),
// ارز (ساده شده - در آینده از API بیاید)
Expanded(
flex: 2,
child: DropdownButtonFormField<int>(
value: _currencyId,
decoration: const InputDecoration(
labelText: 'ارز',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 1, child: Text('ریال')),
DropdownMenuItem(value: 2, child: Text('دلار')),
DropdownMenuItem(value: 3, child: Text('یورو')),
],
onChanged: (value) {
setState(() {
_currencyId = value;
});
},
),
),
const SizedBox(width: 16),
// چکباکس پیشفاکتور
Expanded(
flex: 1,
child: CheckboxListTile(
title: const Text('پیش‌فاکتور'),
value: _isProforma,
onChanged: (value) {
setState(() {
_isProforma = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
),
],
),
const SizedBox(height: 16),
// توضیحات سند
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'توضیحات سند',
hintText: 'توضیحات کلی در مورد این سند...',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
),
);
}
/// ساخت فوتر دیالوگ
Widget _buildFooter(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: theme.dividerColor,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// دکمه انصراف
OutlinedButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text('انصراف'),
),
),
const SizedBox(width: 12),
// دکمه ذخیره
ElevatedButton.icon(
onPressed: _isSaving ? null : _saveDocument,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save),
label: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(_isSaving ? 'در حال ذخیره...' : 'ذخیره سند'),
),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
),
],
),
);
}
}

View file

@ -0,0 +1,630 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hesabix_ui/models/account_model.dart';
import 'package:hesabix_ui/models/document_model.dart';
import 'package:hesabix_ui/widgets/document/detail_selector_widget.dart';
import 'package:hesabix_ui/widgets/invoice/account_tree_combobox_widget.dart';
import 'package:hesabix_ui/utils/number_formatters.dart';
/// مدل داخلی برای نگهداری اطلاعات یک سطر سند در حین ویرایش
class DocumentLineEdit {
Account? account;
Map<String, dynamic>? detail; // person_id, product_id, etc.
double debit;
double credit;
String? description;
double? quantity;
DocumentLineEdit({
this.account,
this.detail,
this.debit = 0,
this.credit = 0,
this.description,
this.quantity,
});
/// تبدیل به DocumentLineCreateRequest
DocumentLineCreateRequest toRequest() {
if (account == null) {
throw Exception('حساب نباید خالی باشد');
}
return DocumentLineCreateRequest(
accountId: account!.id!,
personId: detail?['person_id'],
productId: detail?['product_id'],
bankAccountId: detail?['bank_account_id'],
cashRegisterId: detail?['cash_register_id'],
pettyCashId: detail?['petty_cash_id'],
checkId: detail?['check_id'],
quantity: quantity,
debit: debit,
credit: credit,
description: description,
);
}
/// کپی
DocumentLineEdit copy() {
return DocumentLineEdit(
account: account,
detail: detail != null ? Map.from(detail!) : null,
debit: debit,
credit: credit,
description: description,
quantity: quantity,
);
}
}
/// ویجت ویرایشگر سطرهای سند
class DocumentLinesEditor extends StatefulWidget {
final int businessId;
final List<DocumentLineEdit> initialLines;
final ValueChanged<List<DocumentLineEdit>> onChanged;
const DocumentLinesEditor({
super.key,
required this.businessId,
required this.initialLines,
required this.onChanged,
});
@override
State<DocumentLinesEditor> createState() => _DocumentLinesEditorState();
}
class _DocumentLinesEditorState extends State<DocumentLinesEditor> {
late List<DocumentLineEdit> _lines;
@override
void initState() {
super.initState();
_lines = widget.initialLines.map((line) => line.copy()).toList();
if (_lines.isEmpty) {
_addNewLine();
_addNewLine();
}
}
void _addNewLine() {
setState(() {
_lines.add(DocumentLineEdit());
});
_notifyChanged();
}
void _removeLine(int index) {
if (_lines.length <= 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('سند باید حداقل 2 سطر داشته باشد')),
);
return;
}
setState(() {
_lines.removeAt(index);
});
_notifyChanged();
}
void _notifyChanged() {
widget.onChanged(_lines);
}
/// محاسبه جمع بدهکار
double get _totalDebit {
return _lines.fold(0.0, (sum, line) => sum + line.debit);
}
/// محاسبه جمع بستانکار
double get _totalCredit {
return _lines.fold(0.0, (sum, line) => sum + line.credit);
}
/// محاسبه مانده (تفاوت)
double get _balance {
return _totalDebit - _totalCredit;
}
/// آیا سند متوازن است؟
bool get _isBalanced {
return _balance.abs() < 0.01;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// هدر
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'سطرهای سند',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: _addNewLine,
icon: const Icon(Icons.add),
label: const Text('افزودن سطر'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
),
],
),
const SizedBox(height: 16),
// جدول سطرها
Container(
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
// هدر جدول
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(7),
),
),
child: Row(
children: [
SizedBox(
width: 40,
child: Text(
'#',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
Expanded(
flex: 3,
child: Text(
'حساب',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Text(
'تفضیل',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: Text(
'بدهکار',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: Text(
'بستانکار',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Text(
'توضیحات',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 48,
child: Text(
'عملیات',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
),
),
// سطرهای جدول
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _lines.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor,
),
itemBuilder: (context, index) {
return _buildLineRow(index);
},
),
],
),
),
const SizedBox(height: 16),
// خلاصه و جمع
_buildSummary(theme),
],
);
}
/// ساخت یک سطر از جدول
Widget _buildLineRow(int index) {
final line = _lines[index];
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// شماره ردیف
SizedBox(
width: 40,
child: Center(
child: CircleAvatar(
radius: 14,
backgroundColor: theme.colorScheme.primary.withOpacity(0.2),
child: Text(
'${index + 1}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
),
// حساب
Expanded(
flex: 3,
child: AccountTreeComboboxWidget(
businessId: widget.businessId,
selectedAccount: line.account,
onChanged: (account) {
setState(() {
line.account = account;
// ریست کردن تفضیل وقتی حساب عوض میشود
line.detail = null;
});
_notifyChanged();
},
label: '',
hintText: 'انتخاب حساب',
isRequired: true,
),
),
const SizedBox(width: 8),
// تفضیل
Expanded(
flex: 2,
child: DetailSelectorWidget(
businessId: widget.businessId,
selectedAccount: line.account,
detailType: _getAccountDetailType(line.account),
selectedDetailId: line.detail?['person_id'] ??
line.detail?['product_id'] ??
line.detail?['bank_account_id'],
onChanged: (detail) {
setState(() {
line.detail = detail;
});
_notifyChanged();
},
label: '',
),
),
const SizedBox(width: 8),
// بدهکار
SizedBox(
width: 120,
child: TextFormField(
initialValue: line.debit > 0 ? line.debit.toString() : '',
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
isDense: true,
),
keyboardType: TextInputType.number,
textAlign: TextAlign.left,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
onChanged: (value) {
line.debit = double.tryParse(value) ?? 0;
// اگر بدهکار وارد شد، بستانکار را صفر کن
if (line.debit > 0) {
line.credit = 0;
}
setState(() {});
_notifyChanged();
},
),
),
const SizedBox(width: 8),
// بستانکار
SizedBox(
width: 120,
child: TextFormField(
initialValue: line.credit > 0 ? line.credit.toString() : '',
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
isDense: true,
),
keyboardType: TextInputType.number,
textAlign: TextAlign.left,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
onChanged: (value) {
line.credit = double.tryParse(value) ?? 0;
// اگر بستانکار وارد شد، بدهکار را صفر کن
if (line.credit > 0) {
line.debit = 0;
}
setState(() {});
_notifyChanged();
},
),
),
const SizedBox(width: 8),
// توضیحات
Expanded(
flex: 2,
child: TextFormField(
initialValue: line.description,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'توضیحات',
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
isDense: true,
),
maxLines: 1,
onChanged: (value) {
line.description = value.isEmpty ? null : value;
_notifyChanged();
},
),
),
const SizedBox(width: 8),
// دکمه حذف
SizedBox(
width: 48,
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'حذف سطر',
onPressed: () => _removeLine(index),
),
),
],
),
);
}
/// ساخت بخش خلاصه
Widget _buildSummary(ThemeData theme) {
return Card(
elevation: 2,
color: _isBalanced
? theme.colorScheme.primaryContainer.withOpacity(0.3)
: Colors.orange.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// جمع بدهکار
Column(
children: [
const Text(
'جمع بدهکار',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
formatWithThousands(_totalDebit.toInt()),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Colors.red,
fontFamily: 'monospace',
),
textDirection: TextDirection.ltr,
),
],
),
Container(
width: 2,
height: 40,
color: theme.dividerColor,
),
// جمع بستانکار
Column(
children: [
const Text(
'جمع بستانکار',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
formatWithThousands(_totalCredit.toInt()),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Colors.green,
fontFamily: 'monospace',
),
textDirection: TextDirection.ltr,
),
],
),
Container(
width: 2,
height: 40,
color: theme.dividerColor,
),
// مانده
Column(
children: [
const Text(
'مانده',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
formatWithThousands(_balance.abs().toInt()),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: _isBalanced ? Colors.green : Colors.orange,
fontFamily: 'monospace',
),
textDirection: TextDirection.ltr,
),
const SizedBox(width: 8),
Icon(
_isBalanced ? Icons.check_circle : Icons.warning,
color: _isBalanced ? Colors.green : Colors.orange,
size: 20,
),
],
),
],
),
// وضعیت
if (!_isBalanced)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'⚠️ سند متوازن نیست',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'✓ سند متوازن است',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
/// تشخیص نوع تفضیل بر اساس حساب
/// TODO: باید از API یا از metadata حساب بیاید
String? _getAccountDetailType(Account? account) {
if (account == null) return null;
// این یک پیادهسازی ساده است
// در واقع باید از account.detailType یا API استفاده شود
final accountName = account.name.toLowerCase();
if (accountName.contains('دریافتنی') ||
accountName.contains('پرداختنی') ||
accountName.contains('مشتری') ||
accountName.contains('تامین')) {
return 'person';
}
if (accountName.contains('موجودی') ||
accountName.contains('کالا') ||
accountName.contains('انبار')) {
return 'product';
}
if (accountName.contains('بانک')) {
return 'bank_account';
}
if (accountName.contains('صندوق')) {
return 'cash_register';
}
if (accountName.contains('تنخواه')) {
return 'petty_cash';
}
if (accountName.contains('چک')) {
return 'check';
}
return null; // حساب نیاز به تفضیل ندارد
}
}

View file

@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/expense_income_document.dart';
import 'package:hesabix_ui/services/expense_income_service.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils;
import 'dart:html' as html;
/// دیالوگ مشاهده جزئیات سند هزینه/درآمد
class ExpenseIncomeDetailsDialog extends StatefulWidget {
final ExpenseIncomeDocument document;
final CalendarController calendarController;
final int businessId;
final ApiClient apiClient;
const ExpenseIncomeDetailsDialog({
super.key,
required this.document,
required this.calendarController,
required this.businessId,
required this.apiClient,
});
@override
State<ExpenseIncomeDetailsDialog> createState() => _ExpenseIncomeDetailsDialogState();
}
class _ExpenseIncomeDetailsDialogState extends State<ExpenseIncomeDetailsDialog> {
bool _isGeneratingPdf = false;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final doc = widget.document;
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000, maxHeight: 800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// هدر دیالوگ
_buildHeader(t, doc),
// محتوای اصلی
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// اطلاعات کلی سند
_buildDocumentInfo(t, doc),
const SizedBox(height: 24),
// خطوط حسابها
_buildItemLines(t, doc),
const SizedBox(height: 24),
// خطوط طرفحسابها
_buildCounterpartyLines(t, doc),
],
),
),
),
// دکمههای پایین
_buildFooter(t),
],
),
),
);
}
Widget _buildHeader(AppLocalizations t, ExpenseIncomeDocument doc) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'جزئیات سند ${doc.documentTypeName}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
'کد سند: ${doc.code}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
tooltip: 'بستن',
),
],
),
);
}
Widget _buildDocumentInfo(AppLocalizations t, ExpenseIncomeDocument doc) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'اطلاعات کلی سند',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_buildInfoRow('نوع سند', doc.documentTypeName),
_buildInfoRow('تاریخ سند', HesabixDateUtils.formatForDisplay(doc.documentDate, widget.calendarController.isJalali)),
_buildInfoRow('تاریخ ثبت', HesabixDateUtils.formatForDisplay(doc.registeredAt, widget.calendarController.isJalali)),
_buildInfoRow('ارز', doc.currencyCode ?? 'نامشخص'),
_buildInfoRow('ایجادکننده', doc.createdByName ?? 'نامشخص'),
_buildInfoRow('مبلغ کل', formatWithThousands(doc.totalAmount) + ' ریال'),
if (doc.description != null && doc.description!.isNotEmpty)
_buildInfoRow('توضیحات', doc.description!),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
Widget _buildItemLines(AppLocalizations t, ExpenseIncomeDocument doc) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'خطوط حساب‌ها (${doc.itemLinesCount})',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
if (doc.itemLines.isEmpty)
const Text('هیچ خط حسابی یافت نشد')
else
...doc.itemLines.map((line) => _buildItemLineItem(line)),
],
),
),
);
}
Widget _buildItemLineItem(ItemLine line) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.accountName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
'کد: ${line.accountCode}',
style: Theme.of(context).textTheme.bodySmall,
),
if (line.description != null && line.description!.isNotEmpty)
Text(
line.description!,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Text(
formatWithThousands(line.amount) + ' ریال',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildCounterpartyLines(AppLocalizations t, ExpenseIncomeDocument doc) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'خطوط طرف‌حساب‌ها (${doc.counterpartyLinesCount})',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
if (doc.counterpartyLines.isEmpty)
const Text('هیچ خط طرف‌حسابی یافت نشد')
else
...doc.counterpartyLines.map((line) => _buildCounterpartyLineItem(line)),
],
),
),
);
}
Widget _buildCounterpartyLineItem(CounterpartyLine line) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
'نوع: ${line.transactionTypeName}',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'تاریخ: ${HesabixDateUtils.formatForDisplay(line.transactionDate, widget.calendarController.isJalali)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatWithThousands(line.amount) + ' ریال',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (line.commission != null && line.commission! > 0)
Text(
'کارمزد: ${formatWithThousands(line.commission!)} ریال',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
),
],
),
if (line.description != null && line.description!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
line.description!,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
);
}
Widget _buildFooter(AppLocalizations t) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.close),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isGeneratingPdf ? null : _generatePdf,
icon: _isGeneratingPdf
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.picture_as_pdf),
label: Text(_isGeneratingPdf ? 'در حال تولید...' : 'خروجی PDF'),
),
],
),
);
}
Future<void> _generatePdf() async {
setState(() {
_isGeneratingPdf = true;
});
try {
// ایجاد PDF از سند
final service = ExpenseIncomeService(widget.apiClient);
final pdfBytes = await service.generatePdf(widget.document.id);
// ذخیره فایل
await _savePdfFile(pdfBytes, widget.document.code);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('فایل PDF با موفقیت تولید شد'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در تولید PDF: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isGeneratingPdf = false;
});
}
}
}
Future<void> _savePdfFile(List<int> bytes, String filename) async {
try {
// استفاده از dart:html برای دانلود فایل در وب
final blob = html.Blob([bytes]);
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', filename.endsWith('.pdf') ? filename : '$filename.pdf')
..click();
html.Url.revokeObjectUrl(url);
print('✅ PDF downloaded successfully: $filename');
} catch (e) {
print('❌ Error downloading PDF: $e');
rethrow;
}
}
}

View file

@ -0,0 +1,928 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/core/api_client.dart';
import 'package:hesabix_ui/models/expense_income_document.dart';
import 'package:hesabix_ui/models/account_model.dart';
import 'package:hesabix_ui/models/person_model.dart';
import 'package:hesabix_ui/models/business_dashboard_models.dart';
import 'package:hesabix_ui/services/expense_income_service.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart';
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/account_combobox_widget.dart';
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
/// دیالوگ ایجاد/ویرایش سند هزینه/درآمد
class ExpenseIncomeFormDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
final bool isIncome;
final BusinessWithPermission? businessInfo;
final ApiClient apiClient;
final ExpenseIncomeDocument? initialDocument;
const ExpenseIncomeFormDialog({
super.key,
required this.businessId,
required this.calendarController,
required this.isIncome,
this.businessInfo,
required this.apiClient,
this.initialDocument,
});
@override
State<ExpenseIncomeFormDialog> createState() => _ExpenseIncomeFormDialogState();
}
class _ExpenseIncomeFormDialogState extends State<ExpenseIncomeFormDialog> {
final _formKey = GlobalKey<FormState>();
late DateTime _docDate;
late bool _isIncome;
int? _selectedCurrencyId;
final TextEditingController _descriptionController = TextEditingController();
final List<_ItemLine> _itemLines = <_ItemLine>[];
final List<_CounterpartyLine> _counterpartyLines = <_CounterpartyLine>[];
@override
void initState() {
super.initState();
final initial = widget.initialDocument;
if (initial != null) {
// حالت ویرایش: پرکردن اولیه از سند
_isIncome = initial.isIncome;
_docDate = initial.documentDate;
_selectedCurrencyId = initial.currencyId;
_descriptionController.text = initial.description ?? '';
// تبدیل خطوط آیتمها
_itemLines.clear();
for (final line in initial.itemLines) {
_itemLines.add(
_ItemLine(
accountId: line.accountId.toString(),
accountName: line.accountName,
amount: line.amount,
description: line.description,
),
);
}
// تبدیل خطوط طرفحسابها
_counterpartyLines.clear();
for (final line in initial.counterpartyLines) {
_counterpartyLines.add(
_CounterpartyLine(
transactionType: TransactionType.fromValue(line.transactionType) ?? TransactionType.bank,
amount: line.amount,
transactionDate: line.transactionDate,
description: line.description,
commission: line.commission,
bankAccountId: line.bankAccountId?.toString(),
bankAccountName: line.bankAccountName,
cashRegisterId: line.cashRegisterId?.toString(),
cashRegisterName: line.cashRegisterName,
pettyCashId: line.pettyCashId?.toString(),
pettyCashName: line.pettyCashName,
checkId: line.checkId?.toString(),
checkNumber: line.checkNumber,
personId: line.personId?.toString(),
personName: line.personName,
),
);
}
} else {
// حالت ایجاد
_docDate = DateTime.now();
_isIncome = widget.isIncome;
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
}
}
@override
void dispose() {
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final sumItems = _itemLines.fold<double>(0, (p, e) => p + e.amount);
final sumCounterparties = _counterpartyLines.fold<double>(0, (p, e) => p + e.amount);
final diff = sumItems - sumCounterparties;
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200, maxHeight: 800),
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(
'هزینه و درآمد',
style: Theme.of(context).textTheme.titleLarge,
),
),
if (widget.initialDocument == null)
SegmentedButton<bool>(
segments: [
ButtonSegment<bool>(value: false, label: Text('هزینه')),
ButtonSegment<bool>(value: true, label: Text('درآمد')),
],
selected: {_isIncome},
onSelectionChanged: (s) => setState(() => _isIncome = 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: 'انتخاب ارز',
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'توضیحات کلی سند',
hintText: 'توضیحات اختیاری...',
border: OutlineInputBorder(),
),
maxLines: 2,
),
),
const Divider(height: 1),
Expanded(
child: Row(
children: [
Expanded(
child: _ItemLinesPanel(
businessId: widget.businessId,
lines: _itemLines,
onChanged: (ls) => setState(() {
_itemLines.clear();
_itemLines.addAll(ls);
}),
),
),
const VerticalDivider(width: 1),
Expanded(
child: _CounterpartyLinesPanel(
businessId: widget.businessId,
lines: _counterpartyLines,
onChanged: (ls) => setState(() {
_counterpartyLines.clear();
_counterpartyLines.addAll(ls);
}),
),
),
],
),
),
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: 'حساب‌ها', value: sumItems),
_TotalChip(label: 'طرف‌حساب‌ها', value: sumCounterparties),
_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 && _itemLines.isNotEmpty && _counterpartyLines.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 = ExpenseIncomeService(widget.apiClient);
// تبدیل itemLines به فرمت مورد نیاز API
final itemLinesData = _itemLines.map((line) => {
'account_id': int.parse(line.accountId!),
'amount': line.amount,
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
}).toList();
// تبدیل counterpartyLines به فرمت مورد نیاز API
final counterpartyLinesData = _counterpartyLines.map((line) => {
'transaction_type': line.transactionType.value,
'amount': line.amount,
'transaction_date': line.transactionDate.toIso8601String(),
if (line.commission != null && line.commission! > 0)
'commission': line.commission,
if (line.description != null && line.description!.isNotEmpty)
'description': line.description,
// اطلاعات اضافی بر اساس نوع تراکنش
if (line.transactionType == TransactionType.bank) ...{
if (line.bankAccountId != null) 'bank_account_id': int.parse(line.bankAccountId!),
if (line.bankAccountName != null) 'bank_account_name': line.bankAccountName,
},
if (line.transactionType == TransactionType.cashRegister) ...{
if (line.cashRegisterId != null) 'cash_register_id': int.parse(line.cashRegisterId!),
if (line.cashRegisterName != null) 'cash_register_name': line.cashRegisterName,
},
if (line.transactionType == TransactionType.pettyCash) ...{
if (line.pettyCashId != null) 'petty_cash_id': int.parse(line.pettyCashId!),
if (line.pettyCashName != null) 'petty_cash_name': line.pettyCashName,
},
if (line.transactionType == TransactionType.check) ...{
if (line.checkId != null) 'check_id': int.parse(line.checkId!),
if (line.checkNumber != null) 'check_number': line.checkNumber,
},
if (line.transactionType == TransactionType.person) ...{
if (line.personId != null) 'person_id': int.parse(line.personId!),
if (line.personName != null) 'person_name': line.personName,
},
}).toList();
// اگر initialDocument وجود دارد، حالت ویرایش
if (widget.initialDocument != null) {
await service.update(
documentId: widget.initialDocument!.id,
documentDate: _docDate,
currencyId: _selectedCurrencyId!,
description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null,
itemLines: itemLinesData.map((data) => ItemLineData(
accountId: data['account_id'] as int,
amount: (data['amount'] as num).toDouble(),
description: data['description'] as String?,
)).toList(),
counterpartyLines: counterpartyLinesData.map((data) => CounterpartyLineData(
transactionType: TransactionType.fromValue(data['transaction_type'] as String) ?? TransactionType.bank,
amount: (data['amount'] as num).toDouble(),
transactionDate: DateTime.parse(data['transaction_date'] as String),
description: data['description'] as String?,
commission: data['commission'] != null ? (data['commission'] as num).toDouble() : null,
bankAccountId: data['bank_account_id'] as int?,
bankAccountName: data['bank_account_name'] as String?,
cashRegisterId: data['cash_register_id'] as int?,
cashRegisterName: data['cash_register_name'] as String?,
pettyCashId: data['petty_cash_id'] as int?,
pettyCashName: data['petty_cash_name'] as String?,
checkId: data['check_id'] as int?,
checkNumber: data['check_number'] as String?,
personId: data['person_id'] as int?,
personName: data['person_name'] as String?,
)).toList(),
);
} else {
// ایجاد سند جدید
await service.create(
businessId: widget.businessId,
documentType: _isIncome ? 'income' : 'expense',
documentDate: _docDate,
currencyId: _selectedCurrencyId!,
description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null,
itemLines: itemLinesData.map((data) => ItemLineData(
accountId: data['account_id'] as int,
amount: (data['amount'] as num).toDouble(),
description: data['description'] as String?,
)).toList(),
counterpartyLines: counterpartyLinesData.map((data) => CounterpartyLineData(
transactionType: TransactionType.fromValue(data['transaction_type'] as String) ?? TransactionType.bank,
amount: (data['amount'] as num).toDouble(),
transactionDate: DateTime.parse(data['transaction_date'] as String),
description: data['description'] as String?,
commission: data['commission'] != null ? (data['commission'] as num).toDouble() : null,
bankAccountId: data['bank_account_id'] as int?,
bankAccountName: data['bank_account_name'] as String?,
cashRegisterId: data['cash_register_id'] as int?,
cashRegisterName: data['cash_register_name'] as String?,
pettyCashId: data['petty_cash_id'] as int?,
pettyCashName: data['petty_cash_name'] as String?,
checkId: data['check_id'] as int?,
checkNumber: data['check_number'] as String?,
personId: data['person_id'] as int?,
personName: data['person_name'] as String?,
)).toList(),
);
}
if (!mounted) return;
// بستن dialog loading
Navigator.pop(context);
// بستن dialog اصلی با موفقیت
Navigator.pop(context, true);
// نمایش پیام موفقیت
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.initialDocument != null
? 'سند با موفقیت ویرایش شد'
: (_isIncome ? 'سند درآمد با موفقیت ثبت شد' : 'سند هزینه با موفقیت ثبت شد'),
),
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 _ItemLinesPanel extends StatefulWidget {
final int businessId;
final List<_ItemLine> lines;
final ValueChanged<List<_ItemLine>> onChanged;
const _ItemLinesPanel({
required this.businessId,
required this.lines,
required this.onChanged,
});
@override
State<_ItemLinesPanel> createState() => _ItemLinesPanelState();
}
class _ItemLinesPanelState extends State<_ItemLinesPanel> {
@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('حساب‌های هزینه/درآمد', style: Theme.of(context).textTheme.titleMedium)),
IconButton(
onPressed: () {
final newLines = List<_ItemLine>.from(widget.lines);
newLines.add(_ItemLine.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 _ItemLineTile(
businessId: widget.businessId,
line: line,
onChanged: (l) {
final newLines = List<_ItemLine>.from(widget.lines);
newLines[i] = l;
widget.onChanged(newLines);
},
onDelete: () {
final newLines = List<_ItemLine>.from(widget.lines);
newLines.removeAt(i);
widget.onChanged(newLines);
},
);
},
),
),
],
),
);
}
}
class _ItemLineTile extends StatefulWidget {
final int businessId;
final _ItemLine line;
final ValueChanged<_ItemLine> onChanged;
final VoidCallback onDelete;
const _ItemLineTile({
required this.businessId,
required this.line,
required this.onChanged,
required this.onDelete,
});
@override
State<_ItemLineTile> createState() => _ItemLineTileState();
}
class _ItemLineTileState extends State<_ItemLineTile> {
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: AccountComboboxWidget(
businessId: widget.businessId,
selectedAccount: widget.line.accountId != null
? Account(
id: int.tryParse(widget.line.accountId!),
businessId: widget.businessId,
name: widget.line.accountName ?? '',
code: '',
accountType: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
)
: null,
onChanged: (opt) {
widget.onChanged(widget.line.copyWith(
accountId: opt?.id?.toString(),
accountName: opt?.name
));
},
label: 'حساب',
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 _CounterpartyLinesPanel extends StatefulWidget {
final int businessId;
final List<_CounterpartyLine> lines;
final ValueChanged<List<_CounterpartyLine>> onChanged;
const _CounterpartyLinesPanel({
required this.businessId,
required this.lines,
required this.onChanged,
});
@override
State<_CounterpartyLinesPanel> createState() => _CounterpartyLinesPanelState();
}
class _CounterpartyLinesPanelState extends State<_CounterpartyLinesPanel> {
@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('طرف‌حساب‌ها', style: Theme.of(context).textTheme.titleMedium)),
IconButton(
onPressed: () {
final newLines = List<_CounterpartyLine>.from(widget.lines);
newLines.add(_CounterpartyLine.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 _CounterpartyLineTile(
businessId: widget.businessId,
line: line,
onChanged: (l) {
final newLines = List<_CounterpartyLine>.from(widget.lines);
newLines[i] = l;
widget.onChanged(newLines);
},
onDelete: () {
final newLines = List<_CounterpartyLine>.from(widget.lines);
newLines.removeAt(i);
widget.onChanged(newLines);
},
);
},
),
),
],
),
);
}
}
class _CounterpartyLineTile extends StatefulWidget {
final int businessId;
final _CounterpartyLine line;
final ValueChanged<_CounterpartyLine> onChanged;
final VoidCallback onDelete;
const _CounterpartyLineTile({
required this.businessId,
required this.line,
required this.onChanged,
required this.onDelete,
});
@override
State<_CounterpartyLineTile> createState() => _CounterpartyLineTileState();
}
class _CounterpartyLineTileState extends State<_CounterpartyLineTile> {
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: DropdownButtonFormField<TransactionType>(
value: widget.line.transactionType,
decoration: const InputDecoration(
labelText: 'نوع تراکنش',
),
items: TransactionType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.displayName),
);
}).toList(),
onChanged: (type) {
if (type != null) {
widget.onChanged(widget.line.copyWith(transactionType: type));
}
},
),
),
const SizedBox(width: 8),
// مبلغ
SizedBox(
width: 150,
child: TextFormField(
controller: _amountController,
decoration: InputDecoration(
labelText: t.amount,
),
keyboardType: TextInputType.number,
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),
// فیلدهای اضافی بر اساس نوع تراکنش
_buildTransactionTypeFields(),
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()
)),
),
],
),
),
);
}
Widget _buildTransactionTypeFields() {
switch (widget.line.transactionType) {
case TransactionType.person:
return 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: (person) {
widget.onChanged(widget.line.copyWith(
personId: person?.id?.toString(),
personName: person?.aliasName,
));
},
label: 'شخص',
hintText: 'انتخاب شخص',
isRequired: true,
),
),
],
);
default:
return const SizedBox.shrink();
}
}
}
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 _ItemLine {
final String? accountId;
final String? accountName;
final double amount;
final String? description;
const _ItemLine({this.accountId, this.accountName, required this.amount, this.description});
factory _ItemLine.empty() => const _ItemLine(amount: 0);
_ItemLine copyWith({String? accountId, String? accountName, double? amount, String? description}) {
return _ItemLine(
accountId: accountId ?? this.accountId,
accountName: accountName ?? this.accountName,
amount: amount ?? this.amount,
description: description ?? this.description,
);
}
}
class _CounterpartyLine {
final TransactionType transactionType;
final double amount;
final DateTime transactionDate;
final String? description;
final double? commission;
// فیلدهای اختیاری بر اساس نوع تراکنش
final String? bankAccountId;
final String? bankAccountName;
final String? cashRegisterId;
final String? cashRegisterName;
final String? pettyCashId;
final String? pettyCashName;
final String? checkId;
final String? checkNumber;
final String? personId;
final String? personName;
const _CounterpartyLine({
required this.transactionType,
required this.amount,
required this.transactionDate,
this.description,
this.commission,
this.bankAccountId,
this.bankAccountName,
this.cashRegisterId,
this.cashRegisterName,
this.pettyCashId,
this.pettyCashName,
this.checkId,
this.checkNumber,
this.personId,
this.personName,
});
factory _CounterpartyLine.empty() => _CounterpartyLine(
transactionType: TransactionType.bank,
amount: 0,
transactionDate: DateTime.now(),
);
_CounterpartyLine copyWith({
TransactionType? transactionType,
double? amount,
DateTime? transactionDate,
String? description,
double? commission,
String? bankAccountId,
String? bankAccountName,
String? cashRegisterId,
String? cashRegisterName,
String? pettyCashId,
String? pettyCashName,
String? checkId,
String? checkNumber,
String? personId,
String? personName,
}) {
return _CounterpartyLine(
transactionType: transactionType ?? this.transactionType,
amount: amount ?? this.amount,
transactionDate: transactionDate ?? this.transactionDate,
description: description ?? this.description,
commission: commission ?? this.commission,
bankAccountId: bankAccountId ?? this.bankAccountId,
bankAccountName: bankAccountName ?? this.bankAccountName,
cashRegisterId: cashRegisterId ?? this.cashRegisterId,
cashRegisterName: cashRegisterName ?? this.cashRegisterName,
pettyCashId: pettyCashId ?? this.pettyCashId,
pettyCashName: pettyCashName ?? this.pettyCashName,
checkId: checkId ?? this.checkId,
checkNumber: checkNumber ?? this.checkNumber,
personId: personId ?? this.personId,
personName: personName ?? this.personName,
);
}
}

View file

@ -0,0 +1,324 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../models/account_model.dart';
import '../../services/account_service.dart';
class AccountComboboxWidget extends StatefulWidget {
final int businessId;
final Account? selectedAccount;
final ValueChanged<Account?> onChanged;
final String label;
final String hintText;
final bool isRequired;
const AccountComboboxWidget({
super.key,
required this.businessId,
this.selectedAccount,
required this.onChanged,
this.label = 'حساب',
this.hintText = 'انتخاب حساب',
this.isRequired = false,
});
@override
State<AccountComboboxWidget> createState() => _AccountComboboxWidgetState();
}
class _AccountComboboxWidgetState extends State<AccountComboboxWidget> {
final AccountService _accountService = AccountService();
final TextEditingController _searchController = TextEditingController();
Timer? _debounceTimer;
List<Account> _accounts = [];
bool _isLoading = false;
bool _isSearching = false;
@override
void initState() {
super.initState();
_searchController.text = widget.selectedAccount?.name ?? '';
_loadAccounts();
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _loadAccounts() async {
setState(() {
_isLoading = true;
});
try {
final response = await _accountService.getAccounts(businessId: widget.businessId);
final items = (response['items'] as List<dynamic>?)
?.map((item) => Account.fromJson(item as Map<String, dynamic>))
.toList() ?? [];
setState(() {
_accounts = items;
});
} catch (e) {
print('خطا در لود کردن حساب‌ها: $e');
setState(() {
_accounts = [];
});
} finally {
setState(() {
_isLoading = false;
});
}
}
void _onQueryChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_performSearch(query);
});
}
Future<void> _performSearch(String query) async {
if (_isSearching) return;
setState(() {
_isSearching = true;
});
try {
final response = await _accountService.searchAccounts(
businessId: widget.businessId,
searchQuery: query.isEmpty ? null : query,
limit: 50,
);
final items = (response['items'] as List<dynamic>?)
?.map((item) => Account.fromJson(item as Map<String, dynamic>))
.toList() ?? [];
if (mounted) {
setState(() {
_accounts = items;
});
}
} catch (e) {
print('خطا در جستجوی حساب‌ها: $e');
if (mounted) {
setState(() {
_accounts = [];
});
}
} finally {
if (mounted) {
setState(() {
_isSearching = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _searchController,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
suffixIcon: _isLoading || _isSearching
? const SizedBox(
width: 20,
height: 20,
child: Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
readOnly: true,
validator: widget.isRequired
? (value) {
if (value == null || value.isEmpty) {
return '${widget.label} الزامی است';
}
return null;
}
: null,
onTap: () => _showAccountSelectionDialog(),
);
}
void _showAccountSelectionDialog() {
showDialog(
context: context,
builder: (context) => _AccountSelectionDialog(
accounts: _accounts,
selectedAccount: widget.selectedAccount,
onAccountSelected: (account) {
widget.onChanged(account);
_searchController.text = account?.name ?? '';
Navigator.pop(context);
},
),
);
}
}
class _AccountSelectionDialog extends StatefulWidget {
final List<Account> accounts;
final Account? selectedAccount;
final ValueChanged<Account?> onAccountSelected;
const _AccountSelectionDialog({
required this.accounts,
this.selectedAccount,
required this.onAccountSelected,
});
@override
State<_AccountSelectionDialog> createState() => _AccountSelectionDialogState();
}
class _AccountSelectionDialogState extends State<_AccountSelectionDialog> {
String _searchQuery = '';
List<Account> _filteredAccounts = [];
@override
void initState() {
super.initState();
_filteredAccounts = widget.accounts;
}
void _filterAccounts(String query) {
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_filteredAccounts = widget.accounts;
} else {
_filteredAccounts = widget.accounts
.where((account) =>
account.name.toLowerCase().contains(query.toLowerCase()) ||
account.code.toLowerCase().contains(query.toLowerCase()))
.toList();
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600, maxHeight: 500),
child: Column(
children: [
// هدر دیالوگ
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Expanded(
child: Text(
'انتخاب حساب',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
tooltip: 'بستن',
),
],
),
),
// فیلد جستجو
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(
labelText: 'جستجو',
hintText: 'نام یا کد حساب...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: _filterAccounts,
),
),
// لیست حسابها
Expanded(
child: _filteredAccounts.isEmpty
? Center(
child: Text(
_searchQuery.isEmpty ? 'هیچ حسابی یافت نشد' : 'نتیجه‌ای یافت نشد',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: _filteredAccounts.length,
itemBuilder: (context, index) {
final account = _filteredAccounts[index];
final isSelected = widget.selectedAccount?.id == account.id;
return ListTile(
title: Text(account.name),
subtitle: Text('کد: ${account.code}'),
selected: isSelected,
onTap: () {
widget.onAccountSelected(account);
},
trailing: isSelected
? Icon(
Icons.check,
color: theme.colorScheme.primary,
)
: null,
);
},
),
),
// دکمههای پایین
Container(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('انصراف'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
widget.onAccountSelected(null);
Navigator.pop(context);
},
child: const Text('پاک کردن انتخاب'),
),
],
),
),
],
),
),
);
}
}

View file

@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/account_model.dart';
import '../../models/account_tree_node.dart'; import '../../models/account_tree_node.dart';
import '../../services/account_service.dart'; import '../../services/account_service.dart';
/// ویجت انتخاب حساب با ساختار درختی
/// فقط حسابهایی که فرزند ندارند (leaf nodes) قابل انتخاب هستند
class AccountTreeComboboxWidget extends StatefulWidget { class AccountTreeComboboxWidget extends StatefulWidget {
final int businessId; final int businessId;
final AccountTreeNode? selectedAccount; final Account? selectedAccount;
final ValueChanged<AccountTreeNode?> onChanged; final ValueChanged<Account?> onChanged;
final String label; final String label;
final String hintText; final String hintText;
final bool isRequired; final bool isRequired;
@ -26,16 +29,25 @@ class AccountTreeComboboxWidget extends StatefulWidget {
class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> { class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
final AccountService _accountService = AccountService(); final AccountService _accountService = AccountService();
List<AccountTreeNode> _accounts = []; final TextEditingController _searchController = TextEditingController();
List<AccountTreeNode> _accountTree = [];
bool _isLoading = false; bool _isLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadAccounts(); _searchController.text = widget.selectedAccount?.displayName ?? '';
_loadAccountsTree();
} }
Future<void> _loadAccounts() async { @override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadAccountsTree() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
}); });
@ -47,12 +59,12 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
.toList() ?? []; .toList() ?? [];
setState(() { setState(() {
_accounts = items; _accountTree = items;
}); });
} catch (e) { } catch (e) {
print('خطا در لود کردن حساب‌ها: $e'); print('خطا در لود کردن درخت حساب‌ها: $e');
setState(() { setState(() {
_accounts = []; _accountTree = [];
}); });
} finally { } finally {
setState(() { setState(() {
@ -61,92 +73,47 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); return TextFormField(
controller: _searchController,
return Column( decoration: InputDecoration(
crossAxisAlignment: CrossAxisAlignment.start, labelText: widget.label,
children: [ hintText: widget.hintText,
// لیبل suffixIcon: _isLoading
if (widget.label.isNotEmpty) ? const SizedBox(
Padding( width: 20,
padding: const EdgeInsets.only(bottom: 8), height: 20,
child: Row( child: Padding(
children: [ padding: EdgeInsets.all(12),
Text(
widget.label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (widget.isRequired)
Text(
' *',
style: TextStyle(
color: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
// فیلد انتخاب
InkWell(
onTap: _isLoading ? null : _showAccountDialog,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.account_balance_wallet,
color: theme.colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.selectedAccount?.toString() ?? widget.hintText,
style: theme.textTheme.bodyMedium?.copyWith(
color: widget.selectedAccount != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
),
),
),
if (_isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
),
) )
else : const Icon(Icons.account_tree),
Icon( border: const OutlineInputBorder(),
Icons.arrow_drop_down,
color: theme.colorScheme.onSurfaceVariant,
), ),
], readOnly: true,
), validator: widget.isRequired
), ? (value) {
), if (value == null || value.isEmpty) {
], return '${widget.label} الزامی است';
}
return null;
}
: null,
onTap: () => _showAccountTreeDialog(),
); );
} }
void _showAccountDialog() { void _showAccountTreeDialog() {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AccountSelectionDialog( builder: (context) => _AccountTreeDialog(
accounts: _accounts, accountTree: _accountTree,
selectedAccount: widget.selectedAccount, selectedAccount: widget.selectedAccount,
onAccountSelected: (account) { onAccountSelected: (account) {
widget.onChanged(account); widget.onChanged(account);
_searchController.text = account?.displayName ?? '';
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@ -154,55 +121,112 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
} }
} }
class AccountSelectionDialog extends StatefulWidget { /// دیالوگ انتخاب حساب با ساختار درختی
final List<AccountTreeNode> accounts; class _AccountTreeDialog extends StatefulWidget {
final AccountTreeNode? selectedAccount; final List<AccountTreeNode> accountTree;
final ValueChanged<AccountTreeNode?> onAccountSelected; final Account? selectedAccount;
final ValueChanged<Account?> onAccountSelected;
const AccountSelectionDialog({ const _AccountTreeDialog({
super.key, required this.accountTree,
required this.accounts,
this.selectedAccount, this.selectedAccount,
required this.onAccountSelected, required this.onAccountSelected,
}); });
@override @override
State<AccountSelectionDialog> createState() => _AccountSelectionDialogState(); State<_AccountTreeDialog> createState() => _AccountTreeDialogState();
} }
class _AccountSelectionDialogState extends State<AccountSelectionDialog> { class _AccountTreeDialogState extends State<_AccountTreeDialog> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
List<AccountTreeNode> _filteredAccounts = []; final Set<int> _expandedNodes = {};
final Set<int> _expandedNodes = <int>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filteredAccounts = widget.accounts; // باز کردن خودکار مسیر به حساب انتخاب شده
// همه گرههای سطح اول را به صورت پیشفرض باز کن if (widget.selectedAccount != null) {
_expandedNodes.addAll(widget.accounts.map((account) => account.id)); _expandToNode(widget.accountTree, widget.selectedAccount!.id!);
}
} }
void _filterAccounts(String query) { @override
void dispose() {
_searchController.dispose();
super.dispose();
}
/// باز کردن مسیر تا یک نود خاص
bool _expandToNode(List<AccountTreeNode> nodes, int targetId) {
for (final node in nodes) {
if (node.id == targetId) {
return true;
}
if (node.children.isNotEmpty) {
if (_expandToNode(node.children, targetId)) {
setState(() { setState(() {
_searchQuery = query; _expandedNodes.add(node.id);
if (query.isEmpty) { });
_filteredAccounts = widget.accounts; return true;
}
}
}
return false;
}
/// فیلتر کردن درخت بر اساس جستجو
List<AccountTreeNode> _filterTree(List<AccountTreeNode> nodes) {
if (_searchQuery.isEmpty) {
return nodes;
}
final List<AccountTreeNode> filtered = [];
final query = _searchQuery.toLowerCase();
for (final node in nodes) {
final matchesSearch = node.name.toLowerCase().contains(query) ||
node.code.toLowerCase().contains(query);
final filteredChildren = _filterTree(node.children);
if (matchesSearch || filteredChildren.isNotEmpty) {
filtered.add(AccountTreeNode(
id: node.id,
code: node.code,
name: node.name,
accountType: node.accountType,
parentId: node.parentId,
children: filteredChildren,
));
// باز کردن خودکار نودهای دارای نتیجه جستجو
if (filteredChildren.isNotEmpty) {
_expandedNodes.add(node.id);
}
}
}
return filtered;
}
void _toggleExpanded(int nodeId) {
setState(() {
if (_expandedNodes.contains(nodeId)) {
_expandedNodes.remove(nodeId);
} else { } else {
_filteredAccounts = widget.accounts _expandedNodes.add(nodeId);
.expand((account) => account.searchAccounts(query))
.where((account) => !account.hasChildren) // فقط حسابهای بدون فرزند
.toList();
} }
}); });
} }
void _expandAll() { void _expandAll(List<AccountTreeNode> nodes) {
setState(() { setState(() {
_expandedNodes.clear(); for (final node in nodes) {
// همه گرههایی که فرزند دارند را باز کن if (node.children.isNotEmpty) {
for (final account in widget.accounts) { _expandedNodes.add(node.id);
_addAllExpandableNodes(account); _expandAll(node.children);
}
} }
}); });
} }
@ -213,31 +237,21 @@ class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
}); });
} }
void _addAllExpandableNodes(AccountTreeNode account) {
if (account.hasChildren) {
_expandedNodes.add(account.id);
for (final child in account.children) {
_addAllExpandableNodes(child);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final filteredTree = _filterTree(widget.accountTree);
return Dialog( return Dialog(
child: Container( child: ConstrainedBox(
width: 600, constraints: const BoxConstraints(maxWidth: 700, maxHeight: 600),
height: 500,
margin: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// هدر // هدر دیالوگ
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary, color: theme.colorScheme.primaryContainer,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
topRight: Radius.circular(12), topRight: Radius.circular(12),
@ -246,98 +260,115 @@ class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.account_balance_wallet, Icons.account_tree,
color: theme.colorScheme.onPrimary, color: theme.colorScheme.onPrimaryContainer,
size: 24,
), ),
const SizedBox(width: 8), const SizedBox(width: 12),
Text( Expanded(
'انتخاب حساب', child: Text(
'انتخاب حساب (درختی)',
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onPrimary, color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold, ),
), ),
), ),
const Spacer(),
IconButton( IconButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
color: theme.colorScheme.onPrimary, tooltip: 'بستن',
), ),
], ],
), ),
), ),
// جستجو و دکمههای کنترل // فیلد جستجو و دکمههای باز/بسته کردن
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
TextField( TextField(
decoration: InputDecoration( controller: _searchController,
hintText: 'جستجو در حساب‌ها...', decoration: const InputDecoration(
prefixIcon: const Icon(Icons.search), labelText: 'جستجو',
border: const OutlineInputBorder(), hintText: 'نام یا کد حساب...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
), ),
onChanged: _filterAccounts, onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
), ),
if (_searchQuery.isEmpty) ...[ const SizedBox(height: 8),
const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
TextButton.icon( TextButton.icon(
onPressed: _expandAll, onPressed: () => _expandAll(filteredTree),
icon: const Icon(Icons.expand_more), icon: const Icon(Icons.unfold_more, size: 18),
label: const Text('همه را باز کن'), label: const Text('باز کردن همه'),
), ),
const SizedBox(width: 8),
TextButton.icon( TextButton.icon(
onPressed: _collapseAll, onPressed: _collapseAll,
icon: const Icon(Icons.expand_less), icon: const Icon(Icons.unfold_less, size: 18),
label: const Text('همه را ببند'), label: const Text('بستن همه'),
), ),
], ],
), ),
], ],
],
), ),
), ),
// لیست حسابها const Divider(height: 1),
// درخت حسابها
Expanded( Expanded(
child: Container( child: filteredTree.isEmpty
margin: const EdgeInsets.symmetric(horizontal: 8), ? Center(
child: _searchQuery.isEmpty child: Text(
? _buildTreeView() _searchQuery.isEmpty ? 'هیچ حسابی یافت نشد' : 'نتیجه‌ای یافت نشد',
: _buildSearchResults(), style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView(
padding: const EdgeInsets.all(8),
children: _buildTreeNodes(filteredTree, 0),
), ),
), ),
// دکمهها const Divider(height: 1),
// دکمههای پایین
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'فقط حساب‌های انتهایی قابل انتخاب هستند',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
Row(
children: [ children: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('انصراف'), child: const Text('انصراف'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (widget.selectedAccount != null) FilledButton(
TextButton(
onPressed: () { onPressed: () {
widget.onAccountSelected(null); widget.onAccountSelected(null);
Navigator.pop(context);
}, },
child: const Text('حذف انتخاب'), child: const Text('پاک کردن'),
),
],
), ),
], ],
), ),
@ -348,141 +379,160 @@ class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
); );
} }
Widget _buildTreeView() { List<Widget> _buildTreeNodes(List<AccountTreeNode> nodes, int level) {
return ListView.builder( final List<Widget> widgets = [];
itemCount: widget.accounts.length,
itemBuilder: (context, index) {
return _buildAccountNode(widget.accounts[index], 0);
},
);
}
Widget _buildSearchResults() { for (final node in nodes) {
return ListView.builder( final isExpanded = _expandedNodes.contains(node.id);
padding: const EdgeInsets.symmetric(vertical: 8), final hasChildren = node.children.isNotEmpty;
itemCount: _filteredAccounts.length, final isSelected = widget.selectedAccount?.id == node.id;
itemBuilder: (context, index) { final isSelectable = node.isSelectable;
final account = _filteredAccounts[index];
return Container( widgets.add(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), _TreeNodeWidget(
child: ListTile( node: node,
shape: RoundedRectangleBorder( level: level,
borderRadius: BorderRadius.circular(8), isExpanded: isExpanded,
), hasChildren: hasChildren,
tileColor: account.id == widget.selectedAccount?.id isSelected: isSelected,
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) isSelectable: isSelectable,
onTap: isSelectable
? () => widget.onAccountSelected(node.toAccount())
: null, : null,
leading: Icon( onToggleExpand: hasChildren ? () => _toggleExpanded(node.id) : null,
Icons.account_balance_wallet, ),
color: account.id == widget.selectedAccount?.id );
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant, if (isExpanded && hasChildren) {
widgets.addAll(_buildTreeNodes(node.children, level + 1));
}
}
return widgets;
}
}
/// ویجت نمایش یک نود در درخت
class _TreeNodeWidget extends StatelessWidget {
final AccountTreeNode node;
final int level;
final bool isExpanded;
final bool hasChildren;
final bool isSelected;
final bool isSelectable;
final VoidCallback? onTap;
final VoidCallback? onToggleExpand;
const _TreeNodeWidget({
required this.node,
required this.level,
required this.isExpanded,
required this.hasChildren,
required this.isSelected,
required this.isSelectable,
this.onTap,
this.onToggleExpand,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final indent = level * 24.0;
return InkWell(
onTap: isSelectable ? onTap : onToggleExpand,
child: Container(
padding: EdgeInsets.only(
right: indent + 8,
left: 8,
top: 8,
bottom: 8,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primaryContainer.withOpacity(0.5)
: null,
border: Border(
right: isSelected
? BorderSide(
color: theme.colorScheme.primary,
width: 3,
)
: BorderSide.none,
),
),
child: Row(
children: [
// آیکون باز/بسته کردن
SizedBox(
width: 24,
child: hasChildren
? IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
iconSize: 20,
onPressed: onToggleExpand,
icon: Icon(
isExpanded ? Icons.expand_more : Icons.chevron_left,
color: theme.colorScheme.onSurface,
), ),
title: Text(account.name),
subtitle: Text('کد: ${account.code}'),
trailing: account.id == widget.selectedAccount?.id
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
) )
: null, : null,
onTap: () => widget.onAccountSelected(account),
), ),
);
},
);
}
Widget _buildAccountNode(AccountTreeNode account, int level) { const SizedBox(width: 8),
final theme = Theme.of(context);
final isSelected = account.id == widget.selectedAccount?.id;
final canSelect = !account.hasChildren;
final isExpanded = _expandedNodes.contains(account.id);
return Column( // آیکون حساب
Icon(
hasChildren ? Icons.folder : Icons.receipt_long,
size: 20,
color: isSelectable
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
// اطلاعات حساب
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Text(
margin: EdgeInsets.symmetric( node.name,
horizontal: 8, style: theme.textTheme.bodyMedium?.copyWith(
vertical: 2, fontWeight: hasChildren ? FontWeight.bold : FontWeight.normal,
color: isSelectable
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
), ),
child: ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 16 + (level * 24),
vertical: 8,
), ),
shape: RoundedRectangleBorder( Text(
borderRadius: BorderRadius.circular(8), 'کد: ${node.code}',
), style: theme.textTheme.bodySmall?.copyWith(
tileColor: isSelected
? theme.colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
leading: account.hasChildren
? IconButton(
icon: Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
onPressed: () {
setState(() {
if (isExpanded) {
_expandedNodes.remove(account.id);
} else {
_expandedNodes.add(account.id);
}
});
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
)
: Icon(
Icons.account_balance_wallet,
color: canSelect
? (isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant)
: theme.colorScheme.outline,
),
title: Text(
account.name,
style: TextStyle(
color: canSelect
? theme.colorScheme.onSurface
: theme.colorScheme.outline,
fontWeight: account.hasChildren ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
'کد: ${account.code}',
style: TextStyle(
color: canSelect
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.outline,
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: theme.colorScheme.primary,
)
: null,
onTap: canSelect ? () => widget.onAccountSelected(account) : null,
),
),
// نمایش فرزندان فقط اگر گره باز باشد
if (account.hasChildren && isExpanded)
...account.children.map((child) => _buildAccountNode(child, level + 1)),
// خط جداکننده بین حسابهای مختلف (فقط برای سطح اول)
if (level == 0 && account != widget.accounts.last)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
), ),
], ],
),
),
// نشانگر انتخاب یا غیرفعال بودن
if (isSelected)
Icon(
Icons.check_circle,
color: theme.colorScheme.primary,
size: 20,
)
else if (!isSelectable)
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
size: 18,
),
],
),
),
); );
} }
} }

View file

@ -838,10 +838,22 @@ class _TransactionDialogState extends State<TransactionDialog> {
Widget _buildAccountFields() { Widget _buildAccountFields() {
return AccountTreeComboboxWidget( return AccountTreeComboboxWidget(
businessId: widget.businessId, businessId: widget.businessId,
selectedAccount: _selectedAccount, selectedAccount: _selectedAccount?.toAccount(),
onChanged: (account) { onChanged: (account) {
setState(() { setState(() {
_selectedAccount = account; // تبدیل Account به AccountTreeNode - فقط id را نگه میداریم
// برای استفاده کامل، باید از tree اصلی پیدا شود
if (account != null) {
_selectedAccount = AccountTreeNode(
id: account.id!,
code: account.code,
name: account.name,
accountType: account.accountType,
parentId: account.parentId,
);
} else {
_selectedAccount = null;
}
}); });
}, },
label: 'حساب *', label: 'حساب *',

View file

@ -5,6 +5,7 @@ import '../../controllers/product_form_controller.dart';
import 'sections/product_basic_info_section.dart'; import 'sections/product_basic_info_section.dart';
import 'sections/product_pricing_inventory_section.dart'; import 'sections/product_pricing_inventory_section.dart';
import 'sections/product_tax_section.dart'; import 'sections/product_tax_section.dart';
import 'sections/product_bom_section.dart';
class ProductFormDialog extends StatefulWidget { class ProductFormDialog extends StatefulWidget {
final int businessId; final int businessId;
@ -83,7 +84,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
Widget _buildFormContent() { Widget _buildFormContent() {
return DefaultTabController( return DefaultTabController(
length: 3, length: 4,
child: SizedBox( child: SizedBox(
height: MediaQuery.of(context).size.height > 800 ? 700 : 600, height: MediaQuery.of(context).size.height > 800 ? 700 : 600,
child: Column( child: Column(
@ -99,6 +100,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
_buildBasicInfoTab(), _buildBasicInfoTab(),
_buildPricingInventoryTab(), _buildPricingInventoryTab(),
_buildTaxTab(), _buildTaxTab(),
_buildBomTab(),
], ],
), ),
), ),
@ -118,6 +120,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
Tab(text: t.productGeneralInfo), Tab(text: t.productGeneralInfo),
Tab(text: t.pricingAndInventory), Tab(text: t.pricingAndInventory),
Tab(text: t.tax), Tab(text: t.tax),
const Tab(text: 'فرمول تولید'),
], ],
); );
} }
@ -168,6 +171,17 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
); );
} }
Widget _buildBomTab() {
final productId = widget.product != null ? widget.product!['id'] as int? : null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: ProductBomSection(
businessId: widget.businessId,
productId: productId,
),
);
}
Widget _buildErrorMessage() { Widget _buildErrorMessage() {
return Container( return Container(
margin: const EdgeInsets.only(top: 16), margin: const EdgeInsets.only(top: 16),

View file

@ -0,0 +1,305 @@
import 'package:flutter/material.dart';
import '../../../services/bom_service.dart';
import '../../../models/bom_models.dart';
class ProductBomSection extends StatefulWidget {
final int businessId;
final int? productId;
const ProductBomSection({super.key, required this.businessId, required this.productId});
@override
State<ProductBomSection> createState() => _ProductBomSectionState();
}
class _ProductBomSectionState extends State<ProductBomSection> {
final BomService _service = BomService();
bool _loading = true;
String? _error;
List<ProductBOM> _items = const <ProductBOM>[];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (widget.productId == null) {
setState(() {
_loading = false;
});
return;
}
try {
setState(() {
_loading = true;
_error = null;
});
final items = await _service.list(businessId: widget.businessId, productId: widget.productId);
if (!mounted) return;
setState(() {
_items = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (widget.productId == null) {
return _buildDisabledState();
}
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700)));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('فرمول‌های تولید', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
FilledButton.icon(
onPressed: _showCreateDialog,
icon: const Icon(Icons.add),
label: const Text('افزودن فرمول'),
),
],
),
const SizedBox(height: 12),
Expanded(
child: _items.isEmpty
? const Center(child: Text('هنوز فرمولی تعریف نشده است'))
: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, idx) {
final bom = _items[idx];
return ListTile(
leading: Icon(bom.isDefault ? Icons.star : Icons.blur_on, color: bom.isDefault ? Colors.orange : null),
title: Text('${bom.name} (v${bom.version})'),
subtitle: Text('وضعیت: ${bom.status} | بازده: ${bom.yieldPercent ?? 0}٪ | پرت: ${bom.wastagePercent ?? 0}٪'),
trailing: Wrap(spacing: 8, children: [
IconButton(
tooltip: 'انفجار فرمول',
icon: const Icon(Icons.auto_awesome),
onPressed: () => _explode(bom),
),
IconButton(
tooltip: 'ویرایش',
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(bom),
),
IconButton(
tooltip: 'حذف',
icon: const Icon(Icons.delete_outline),
onPressed: () => _delete(bom),
),
]),
);
},
),
),
],
);
}
Widget _buildDisabledState() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: const Text('برای مدیریت فرمول تولید، ابتدا کالا را ذخیره کنید.'),
);
}
Future<void> _explode(ProductBOM bom) async {
try {
final result = await _service.explode(
businessId: widget.businessId,
bomId: bom.id,
quantity: 1,
);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('خروجی انفجار فرمول (برای ۱ واحد)'),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('مواد موردنیاز:'),
const SizedBox(height: 8),
SizedBox(
height: 160,
child: ListView.builder(
itemCount: result.items.length,
itemBuilder: (ctx, i) {
final it = result.items[i];
return Text('- ${it.componentProductId} × ${it.requiredQty} ${it.uom ?? ''}');
},
),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('بستن')),
],
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _showCreateDialog() async {
final controller = TextEditingController();
final nameController = TextEditingController();
bool isDefault = false;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('افزودن فرمول تولید'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: 'عنوان')),
TextField(controller: controller, decoration: const InputDecoration(labelText: 'نسخه (مثلاً v1)')),
const SizedBox(height: 8),
StatefulBuilder(builder: (ctx, setSt) {
return CheckboxListTile(
value: isDefault,
onChanged: (v) => setSt(() => isDefault = v ?? false),
title: const Text('به عنوان پیش‌فرض تنظیم شود'),
controlAffinity: ListTileControlAffinity.leading,
);
})
],
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')),
],
),
);
if (ok != true) return;
try {
final created = await _service.create(
businessId: widget.businessId,
payload: {
'product_id': widget.productId,
'version': controller.text.trim().isEmpty ? 'v1' : controller.text.trim(),
'name': nameController.text.trim().isEmpty ? 'BOM' : nameController.text.trim(),
'is_default': isDefault,
'items': <Map<String, dynamic>>[],
'outputs': <Map<String, dynamic>>[],
'operations': <Map<String, dynamic>>[],
},
);
if (!mounted) return;
setState(() {
_items = [created, ..._items];
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _showEditDialog(ProductBOM bom) async {
final controller = TextEditingController(text: bom.version);
final nameController = TextEditingController(text: bom.name);
bool isDefault = bom.isDefault;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('ویرایش فرمول تولید'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: 'عنوان')),
TextField(controller: controller, decoration: const InputDecoration(labelText: 'نسخه')),
const SizedBox(height: 8),
StatefulBuilder(builder: (ctx, setSt) {
return CheckboxListTile(
value: isDefault,
onChanged: (v) => setSt(() => isDefault = v ?? false),
title: const Text('پیش‌فرض'),
controlAffinity: ListTileControlAffinity.leading,
);
})
],
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')),
],
),
);
if (ok != true) return;
try {
final updated = await _service.update(
businessId: widget.businessId,
bomId: bom.id!,
payload: {
'version': controller.text.trim(),
'name': nameController.text.trim(),
'is_default': isDefault,
},
);
if (!mounted) return;
setState(() {
_items = _items.map((e) => e.id == updated.id ? updated : e).toList();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
Future<void> _delete(ProductBOM bom) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('حذف فرمول'),
content: Text('آیا از حذف «${bom.name}» مطمئن هستید؟'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('حذف')),
],
),
);
if (ok != true) return;
try {
await _service.delete(businessId: widget.businessId, bomId: bom.id!);
if (!mounted) return;
setState(() {
_items = _items.where((e) => e.id != bom.id).toList();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
}
}
}

View file

@ -1,15 +1,53 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/date_utils.dart';
import '../../core/calendar_controller.dart';
class TransferDetailsDialog extends StatelessWidget { class TransferDetailsDialog extends StatelessWidget {
final Map<String, dynamic> document; final Map<String, dynamic> document;
const TransferDetailsDialog({super.key, required this.document}); final CalendarController calendarController;
const TransferDetailsDialog({
super.key,
required this.document,
required this.calendarController,
});
String _typeFa(String? t) {
switch (t) {
case 'bank':
return 'بانک';
case 'cash_register':
return 'صندوق';
case 'petty_cash':
return 'تنخواه';
default:
return t ?? '';
}
}
String _formatSourceDestination(String? type, String? name) {
final typeFa = _typeFa(type);
final nameStr = name ?? '';
if (typeFa.isEmpty && nameStr.isEmpty) return '';
return '$typeFa $nameStr'.trim();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final lines = List<Map<String, dynamic>>.from(document['account_lines'] as List? ?? const []); final lines = List<Map<String, dynamic>>.from(document['account_lines'] as List? ?? const []);
final code = document['code'] as String? ?? ''; final code = document['code'] as String? ?? '';
final date = document['document_date'] as String? ?? ''; final dateStr = document['document_date'] as String? ?? '';
final date = DateTime.tryParse(dateStr);
final total = (document['total_amount'] as num?)?.toStringAsFixed(0) ?? '0'; final total = (document['total_amount'] as num?)?.toStringAsFixed(0) ?? '0';
// Get source and destination info
final sourceType = document['source_type'] as String?;
final sourceName = document['source_name'] as String?;
final destinationType = document['destination_type'] as String?;
final destinationName = document['destination_name'] as String?;
final sourceText = _formatSourceDestination(sourceType, sourceName);
final destinationText = _formatSourceDestination(destinationType, destinationName);
return Dialog( return Dialog(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600), constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600),
@ -29,11 +67,54 @@ class TransferDetailsDialog extends StatelessWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Chip(label: Text('تاریخ: $date')), Row(
children: [
Chip(label: Text('تاریخ: ${HesabixDateUtils.formatForDisplay(date, calendarController.isJalali)}')),
const SizedBox(width: 8), const SizedBox(width: 8),
Chip(label: Text('مبلغ کل: $total')), Chip(label: Text('مبلغ کل: $total ریال')),
],
),
const SizedBox(height: 8),
if (sourceText.isNotEmpty || destinationText.isNotEmpty) ...[
Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('مبدا', style: TextStyle(fontWeight: FontWeight.bold)),
Text(sourceText.isNotEmpty ? sourceText : 'نامشخص'),
],
),
),
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_forward),
const SizedBox(width: 8),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('مقصد', style: TextStyle(fontWeight: FontWeight.bold)),
Text(destinationText.isNotEmpty ? destinationText : 'نامشخص'),
],
),
),
),
),
],
),
],
], ],
), ),
), ),
@ -49,11 +130,28 @@ class TransferDetailsDialog extends StatelessWidget {
final side = (l['side'] as String?) ?? ''; final side = (l['side'] as String?) ?? '';
final isCommission = (l['is_commission_line'] as bool?) ?? false; final isCommission = (l['is_commission_line'] as bool?) ?? false;
final amount = (l['amount'] as num?)?.toStringAsFixed(0) ?? ''; final amount = (l['amount'] as num?)?.toStringAsFixed(0) ?? '';
String sideText = '';
if (isCommission) {
sideText = 'کارمزد';
} else {
switch (side) {
case 'source':
sideText = 'مبدا';
break;
case 'destination':
sideText = 'مقصد';
break;
default:
sideText = side;
}
}
return ListTile( return ListTile(
leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet), leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet),
title: Text(name), title: Text(name),
subtitle: Text('کد: $code • سمت: ${isCommission ? 'کارمزد' : side}'), subtitle: Text('کد: $code • سمت: $sideText'),
trailing: Text(amount), trailing: Text('$amount ریال'),
); );
}, },
), ),