progress in documents
This commit is contained in:
parent
c88b1ccdd0
commit
bd1fd1a39a
156
EXPENSE_INCOME_IMPLEMENTATION.md
Normal file
156
EXPENSE_INCOME_IMPLEMENTATION.md
Normal 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. **دسترسی**: بررسی مجوزهای کاربر برای دسترسی به بخش
|
||||
|
||||
## پشتیبانی
|
||||
|
||||
برای گزارش مشکلات یا درخواست ویژگیهای جدید، لطفاً با تیم توسعه تماس بگیرید.
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from adapters.api.v1.schemas import SuccessResponse
|
||||
|
|
@ -15,6 +16,15 @@ from adapters.db.models.account import Account
|
|||
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]:
|
||||
by_id: dict[int, AccountTreeNode] = {}
|
||||
roots: list[AccountTreeNode] = []
|
||||
|
|
@ -55,3 +65,135 @@ def get_accounts_tree(
|
|||
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)
|
||||
|
||||
|
||||
|
|
|
|||
121
hesabixAPI/adapters/api/v1/boms.py
Normal file
121
hesabixAPI/adapters/api/v1/boms.py
Normal 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)
|
||||
448
hesabixAPI/adapters/api/v1/documents.py
Normal file
448
hesabixAPI/adapters/api/v1/documents.py
Normal 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"
|
||||
)
|
||||
|
||||
|
|
@ -14,6 +14,10 @@ from adapters.api.v1.schemas import QueryInfo
|
|||
from app.services.expense_income_service import (
|
||||
create_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")
|
||||
|
||||
|
||||
@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"}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form
|
||||
from fastapi import UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
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.business import Business
|
||||
from adapters.db.models.fiscal_year import FiscalYear
|
||||
|
||||
router = APIRouter(prefix="/persons", tags=["persons"])
|
||||
|
||||
|
|
@ -190,6 +192,26 @@ async def get_persons_endpoint(
|
|||
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 = {
|
||||
"take": query_info.take,
|
||||
"skip": query_info.skip,
|
||||
|
|
@ -199,7 +221,7 @@ async def get_persons_endpoint(
|
|||
"search_fields": query_info.search_fields,
|
||||
"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'] = [
|
||||
|
|
@ -232,6 +254,26 @@ async def export_persons_excel(
|
|||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
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
|
||||
query_dict = {
|
||||
"take": int(body.get("take", 20)),
|
||||
|
|
@ -243,7 +285,7 @@ async def export_persons_excel(
|
|||
"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', [])
|
||||
# Format date/time fields using existing helper
|
||||
|
|
@ -381,6 +423,26 @@ async def export_persons_pdf(
|
|||
from weasyprint import HTML, CSS
|
||||
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
|
||||
query_dict = {
|
||||
"take": int(body.get("take", 20)),
|
||||
|
|
@ -392,7 +454,7 @@ async def export_persons_pdf(
|
|||
"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 = [format_datetime_fields(item, request) for item in items]
|
||||
|
||||
|
|
|
|||
207
hesabixAPI/adapters/api/v1/schema_models/document.py
Normal file
207
hesabixAPI/adapters/api/v1/schema_models/document.py
Normal 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="اطلاعات اضافی")
|
||||
|
||||
|
|
@ -223,6 +223,10 @@ class PersonResponse(BaseModel):
|
|||
commission_exclude_additions_deductions: 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:
|
||||
from_attributes = True
|
||||
|
||||
|
|
|
|||
126
hesabixAPI/adapters/api/v1/schema_models/product_bom.py
Normal file
126
hesabixAPI/adapters/api/v1/schema_models/product_bom.py
Normal 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
|
||||
|
||||
|
||||
34
hesabixAPI/adapters/api/v1/schema_models/warehouse.py
Normal file
34
hesabixAPI/adapters/api/v1/schema_models/warehouse.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -49,9 +49,13 @@ async def list_transfers_endpoint(
|
|||
try:
|
||||
body_json = await request.json()
|
||||
if isinstance(body_json, dict):
|
||||
# Forward simple date range params
|
||||
for key in ["from_date", "to_date"]:
|
||||
if key in body_json:
|
||||
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:
|
||||
pass
|
||||
|
||||
|
|
|
|||
105
hesabixAPI/adapters/api/v1/warehouses.py
Normal file
105
hesabixAPI/adapters/api/v1/warehouses.py
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -41,3 +41,5 @@ from .bank_account import BankAccount # noqa: F401
|
|||
from .cash_register import CashRegister # noqa: F401
|
||||
from .petty_cash import PettyCash # noqa: F401
|
||||
from .check import Check # noqa: F401
|
||||
from .warehouse import Warehouse # noqa: F401
|
||||
from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401
|
||||
|
|
|
|||
125
hesabixAPI/adapters/db/models/product_bom.py
Normal file
125
hesabixAPI/adapters/db/models/product_bom.py
Normal 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)
|
||||
|
||||
|
||||
33
hesabixAPI/adapters/db/models/warehouse.py
Normal file
33
hesabixAPI/adapters/db/models/warehouse.py
Normal 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)
|
||||
|
||||
|
||||
514
hesabixAPI/adapters/db/repositories/document_repository.py
Normal file
514
hesabixAPI/adapters/db/repositories/document_repository.py
Normal 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, ""
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
47
hesabixAPI/adapters/db/repositories/warehouse_repository.py
Normal file
47
hesabixAPI/adapters/db/repositories/warehouse_repository.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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.transfers import router as transfers_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.error_handlers import register_error_handlers
|
||||
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(product_attributes_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(invoices_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(receipts_payments_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(documents_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
# Support endpoints
|
||||
|
|
|
|||
221
hesabixAPI/app/services/bom_service.py
Normal file
221
hesabixAPI/app/services/bom_service.py
Normal 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}
|
||||
|
||||
|
||||
536
hesabixAPI/app/services/document_service.py
Normal file
536
hesabixAPI/app/services/document_service.py
Normal 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
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from app.core.responses import ApiError
|
|||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
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 (
|
||||
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(
|
||||
db: Session,
|
||||
business_id: int,
|
||||
query_info: Dict[str, Any]
|
||||
query_info: Dict[str, Any],
|
||||
fiscal_year_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""دریافت لیست اشخاص با جستجو و فیلتر"""
|
||||
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'):
|
||||
search_term = f"%{query_info['search']}%"
|
||||
|
|
@ -274,10 +295,11 @@ def get_persons_by_business(
|
|||
# شمارش کل رکوردها
|
||||
total = query.count()
|
||||
|
||||
# اعمال مرتبسازی
|
||||
sort_by = query_info.get('sort_by', 'created_at')
|
||||
# اعمال مرتبسازی (فقط برای فیلدهای دیتابیس)
|
||||
sort_desc = query_info.get('sort_desc', True)
|
||||
|
||||
if sort_by not in ['balance', 'status']:
|
||||
# مرتبسازی در دیتابیس
|
||||
if sort_by == 'code':
|
||||
query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc())
|
||||
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())
|
||||
elif sort_by == 'last_name':
|
||||
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':
|
||||
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
||||
elif sort_by == 'updated_at':
|
||||
|
|
@ -294,18 +315,86 @@ def get_persons_by_business(
|
|||
else:
|
||||
query = query.order_by(Person.created_at.desc())
|
||||
|
||||
# اعمال صفحهبندی
|
||||
skip = query_info.get('skip', 0)
|
||||
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()
|
||||
|
||||
# تبدیل به دیکشنری
|
||||
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
|
||||
current_page = (skip // take) + 1
|
||||
total_pages = (total + take - 1) // take if take > 0 else 0
|
||||
current_page = (skip // take) + 1 if take > 0 else 1
|
||||
|
||||
pagination = {
|
||||
'total': total,
|
||||
|
|
@ -535,3 +624,129 @@ def count_persons(db: Session, business_id: int, search_query: Optional[str] = N
|
|||
query = query.filter(search_filter)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -352,6 +352,30 @@ def list_transfers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict
|
|||
except Exception:
|
||||
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")
|
||||
if 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"]
|
||||
if "source_type" in line.extra_info:
|
||||
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"]
|
||||
if "destination_type" in line.extra_info:
|
||||
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"]
|
||||
if "is_commission_line" in line.extra_info:
|
||||
line_dict["is_commission_line"] = line.extra_info["is_commission_line"]
|
||||
|
|
|
|||
90
hesabixAPI/app/services/warehouse_service.py
Normal file
90
hesabixAPI/app/services/warehouse_service.py
Normal 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)
|
||||
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ adapters/api/v1/__init__.py
|
|||
adapters/api/v1/accounts.py
|
||||
adapters/api/v1/auth.py
|
||||
adapters/api/v1/bank_accounts.py
|
||||
adapters/api/v1/boms.py
|
||||
adapters/api/v1/business_dashboard.py
|
||||
adapters/api/v1/business_users.py
|
||||
adapters/api/v1/businesses.py
|
||||
|
|
@ -14,6 +15,8 @@ adapters/api/v1/categories.py
|
|||
adapters/api/v1/checks.py
|
||||
adapters/api/v1/currencies.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/health.py
|
||||
adapters/api/v1/invoices.py
|
||||
|
|
@ -26,13 +29,16 @@ adapters/api/v1/receipts_payments.py
|
|||
adapters/api/v1/schemas.py
|
||||
adapters/api/v1/tax_types.py
|
||||
adapters/api/v1/tax_units.py
|
||||
adapters/api/v1/transfers.py
|
||||
adapters/api/v1/users.py
|
||||
adapters/api/v1/warehouses.py
|
||||
adapters/api/v1/admin/email_config.py
|
||||
adapters/api/v1/admin/file_storage.py
|
||||
adapters/api/v1/schema_models/__init__.py
|
||||
adapters/api/v1/schema_models/account.py
|
||||
adapters/api/v1/schema_models/bank_account.py
|
||||
adapters/api/v1/schema_models/check.py
|
||||
adapters/api/v1/schema_models/document.py
|
||||
adapters/api/v1/schema_models/document_line.py
|
||||
adapters/api/v1/schema_models/email.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/product.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/categories.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_attribute.py
|
||||
adapters/db/models/product_attribute_link.py
|
||||
adapters/db/models/product_bom.py
|
||||
adapters/db/models/tax_type.py
|
||||
adapters/db/models/tax_unit.py
|
||||
adapters/db/models/user.py
|
||||
adapters/db/models/warehouse.py
|
||||
adapters/db/models/support/__init__.py
|
||||
adapters/db/models/support/category.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/cash_register_repository.py
|
||||
adapters/db/repositories/category_repository.py
|
||||
adapters/db/repositories/document_repository.py
|
||||
adapters/db/repositories/email_config_repository.py
|
||||
adapters/db/repositories/file_storage_repository.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/price_list_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/user_repo.py
|
||||
adapters/db/repositories/warehouse_repository.py
|
||||
adapters/db/repositories/support/__init__.py
|
||||
adapters/db/repositories/support/category_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/auth_service.py
|
||||
app/services/bank_account_service.py
|
||||
app/services/bom_service.py
|
||||
app/services/bulk_price_update_service.py
|
||||
app/services/business_dashboard_service.py
|
||||
app/services/business_service.py
|
||||
app/services/captcha_service.py
|
||||
app/services/cash_register_service.py
|
||||
app/services/check_service.py
|
||||
app/services/document_service.py
|
||||
app/services/email_service.py
|
||||
app/services/expense_income_service.py
|
||||
app/services/file_storage_service.py
|
||||
app/services/person_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/query_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/base_pdf_service.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_000401_add_payment_refs_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/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
@ -207,6 +207,21 @@ class ApiClient {
|
|||
);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -67,10 +67,15 @@ class HesabixDateUtils {
|
|||
return monthNames[month - 1];
|
||||
}
|
||||
|
||||
/// Format date for API (always Gregorian)
|
||||
static String formatForAPI(DateTime? date) {
|
||||
/// Format date with both Jalali and Gregorian for display
|
||||
static String formatDualCalendar(DateTime? date) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ import 'pages/business/check_form_page.dart';
|
|||
import 'pages/business/receipts_payments_list_page.dart';
|
||||
import 'pages/business/expense_income_list_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 'core/locale_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(
|
||||
path: '/business/:business_id/checks',
|
||||
name: 'business_checks',
|
||||
|
|
|
|||
87
hesabixUI/hesabix_ui/lib/models/account_model.dart
Normal file
87
hesabixUI/hesabix_ui/lib/models/account_model.dart
Normal 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';
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import 'account_model.dart';
|
||||
|
||||
class AccountTreeNode {
|
||||
final int id;
|
||||
final String code;
|
||||
|
|
@ -81,9 +83,26 @@ class AccountTreeNode {
|
|||
}).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
|
||||
String toString() {
|
||||
return '$code - $name';
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
259
hesabixUI/hesabix_ui/lib/models/bom_models.dart
Normal file
259
hesabixUI/hesabix_ui/lib/models/bom_models.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
521
hesabixUI/hesabix_ui/lib/models/document_model.dart
Normal file
521
hesabixUI/hesabix_ui/lib/models/document_model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
394
hesabixUI/hesabix_ui/lib/models/expense_income_document.dart
Normal file
394
hesabixUI/hesabix_ui/lib/models/expense_income_document.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
53
hesabixUI/hesabix_ui/lib/models/paginated_response.dart
Normal file
53
hesabixUI/hesabix_ui/lib/models/paginated_response.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +132,10 @@ class Person {
|
|||
final bool commissionExcludeAdditionsDeductions;
|
||||
final bool commissionPostInInvoiceDocument;
|
||||
|
||||
// تراز و وضعیت مالی
|
||||
final double? balance;
|
||||
final String? status;
|
||||
|
||||
Person({
|
||||
this.id,
|
||||
required this.businessId,
|
||||
|
|
@ -167,6 +171,8 @@ class Person {
|
|||
this.commissionExcludeDiscounts = false,
|
||||
this.commissionExcludeAdditionsDeductions = false,
|
||||
this.commissionPostInInvoiceDocument = false,
|
||||
this.balance,
|
||||
this.status,
|
||||
});
|
||||
|
||||
factory Person.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -211,6 +217,8 @@ class Person {
|
|||
commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false,
|
||||
commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? 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_additions_deductions': commissionExcludeAdditionsDeductions,
|
||||
'commission_post_in_invoice_document': commissionPostInInvoiceDocument,
|
||||
'balance': balance,
|
||||
'status': status,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
85
hesabixUI/hesabix_ui/lib/models/product_model.dart
Normal file
85
hesabixUI/hesabix_ui/lib/models/product_model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
49
hesabixUI/hesabix_ui/lib/models/warehouse_model.dart
Normal file
49
hesabixUI/hesabix_ui/lib/models/warehouse_model.dart
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ import '../../services/business_dashboard_service.dart';
|
|||
import '../../core/api_client.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
|
||||
import '../../widgets/document/document_form_dialog.dart';
|
||||
|
||||
class BusinessShell extends StatefulWidget {
|
||||
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) {
|
||||
if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded;
|
||||
|
|
@ -979,6 +1000,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (item.label == t.checks) {
|
||||
// Navigate to add check
|
||||
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) {
|
||||
// Navigate to add check
|
||||
context.go('/business/${widget.businessId}/checks/new');
|
||||
} else if (item.label == t.documents) {
|
||||
// Show add document dialog
|
||||
showAddDocumentDialog();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
|
|
|
|||
622
hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart
Normal file
622
hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +1,670 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
import '../../core/date_utils.dart' show HesabixDateUtils;
|
||||
import '../../utils/number_formatters.dart' show formatWithThousands;
|
||||
import '../../services/expense_income_service.dart';
|
||||
import 'expense_income_dialog.dart';
|
||||
import 'package:dio/dio.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/expense_income_document.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 {
|
||||
final int businessId;
|
||||
final CalendarController calendarController;
|
||||
final AuthStore authStore;
|
||||
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
|
||||
State<ExpenseIncomeListPage> createState() => _ExpenseIncomeListPageState();
|
||||
}
|
||||
|
||||
class _ExpenseIncomeListPageState extends State<ExpenseIncomeListPage> {
|
||||
int _tabIndex = 0; // 0 expense, 1 income
|
||||
final List<Map<String, dynamic>> _items = <Map<String, dynamic>>[];
|
||||
int _skip = 0;
|
||||
int _take = 20;
|
||||
int _total = 0;
|
||||
bool _loading = false;
|
||||
late ExpenseIncomeListService _service;
|
||||
String? _selectedDocumentType;
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
// کلید کنترل جدول برای دسترسی به selection و refresh
|
||||
final GlobalKey _tableKey = GlobalKey();
|
||||
int _selectedCount = 0; // تعداد سطرهای انتخابشده
|
||||
|
||||
@override
|
||||
void 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 {
|
||||
final svc = ExpenseIncomeService(widget.apiClient);
|
||||
final res = await svc.list(
|
||||
businessId: widget.businessId,
|
||||
documentType: _tabIndex == 0 ? 'expense' : 'income',
|
||||
skip: _skip,
|
||||
take: _take,
|
||||
);
|
||||
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);
|
||||
// استفاده از متد عمومی refresh در ویجت جدول
|
||||
// نوت: دسترسی دینامیک چون State کلاس خصوصی است
|
||||
// ignore: avoid_dynamic_calls
|
||||
(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: [
|
||||
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),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600))),
|
||||
SegmentedButton<int>(
|
||||
segments: const [ButtonSegment(value: 0, label: Text('هزینه')), ButtonSegment(value: 1, label: Text('درآمد'))],
|
||||
selected: {_tabIndex},
|
||||
onSelectionChanged: (s) async {
|
||||
setState(() { _tabIndex = s.first; _skip = 0; });
|
||||
await _load();
|
||||
},
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'هزینه و درآمد',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ExpenseIncomeDialog(
|
||||
businessId: widget.businessId,
|
||||
calendarController: widget.calendarController,
|
||||
authStore: widget.authStore,
|
||||
apiClient: widget.apiClient,
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'مدیریت اسناد هزینه و درآمد',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
if (ok == true) _load();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('افزودن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: _loading
|
||||
? 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,
|
||||
FilledButton.icon(
|
||||
onPressed: _onAddNew,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(t.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) _load();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
}
|
||||
|
||||
/// ساخت بخش فیلترها
|
||||
Widget _buildFilters(AppLocalizations t) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('$_skip - ${_skip + _items.length} از $_total'),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: _skip <= 0 ? null : () { setState(() { _skip = (_skip - _take).clamp(0, _total); }); _load(); }, icon: const Icon(Icons.chevron_right)),
|
||||
IconButton(onPressed: (_skip + _take) >= _total ? null : () { setState(() { _skip = _skip + _take; }); _load(); }, icon: const Icon(Icons.chevron_left)),
|
||||
// فیلتر نوع سند
|
||||
Expanded(
|
||||
flex: 2,
|
||||
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;
|
||||
double s = 0;
|
||||
for (final l in lines) {
|
||||
final m = (l as Map<String, dynamic>);
|
||||
s += ((m['debit'] ?? 0) as num).toDouble();
|
||||
s += ((m['credit'] ?? 0) as num).toDouble();
|
||||
/// ساخت تنظیمات جدول
|
||||
DataTableConfig<ExpenseIncomeDocument> _buildTableConfig(AppLocalizations t) {
|
||||
return DataTableConfig<ExpenseIncomeDocument>(
|
||||
endpoint: '/businesses/${widget.businessId}/expense-income',
|
||||
title: 'هزینه و درآمد',
|
||||
excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
import '../../widgets/data_table/data_table_widget.dart';
|
||||
|
|
@ -228,6 +229,89 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
width: ColumnWidth.large,
|
||||
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(
|
||||
'actions',
|
||||
t.actions,
|
||||
|
|
|
|||
|
|
@ -190,10 +190,16 @@ class _TransfersPageState extends State<TransfersPage> {
|
|||
formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it),
|
||||
),
|
||||
TextColumn(
|
||||
'route',
|
||||
'مبدا → مقصد',
|
||||
'source',
|
||||
'مبدا',
|
||||
width: ColumnWidth.large,
|
||||
formatter: (it) => _composeRoute(it),
|
||||
formatter: (it) => _composeSource(it),
|
||||
),
|
||||
TextColumn(
|
||||
'destination',
|
||||
'مقصد',
|
||||
width: ColumnWidth.large,
|
||||
formatter: (it) => _composeDestination(it),
|
||||
),
|
||||
DateColumn(
|
||||
'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',
|
||||
showSearch: true,
|
||||
showFilters: true,
|
||||
|
|
@ -272,17 +278,19 @@ class _TransfersPageState extends State<TransfersPage> {
|
|||
}
|
||||
}
|
||||
|
||||
String _composeRoute(TransferDocument it) {
|
||||
final src = '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim();
|
||||
final dst = '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim();
|
||||
if (src.isEmpty && dst.isEmpty) return '';
|
||||
return '$src → $dst';
|
||||
String _composeSource(TransferDocument it) {
|
||||
return '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim();
|
||||
}
|
||||
|
||||
String _composeDestination(TransferDocument it) {
|
||||
return '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim();
|
||||
}
|
||||
|
||||
String _composeDesc(TransferDocument it) {
|
||||
final r = _composeRoute(it);
|
||||
if (r.isEmpty) return '';
|
||||
return 'انتقال $r';
|
||||
final src = _composeSource(it);
|
||||
final dst = _composeDestination(it);
|
||||
if (src.isEmpty && dst.isEmpty) return '';
|
||||
return 'انتقال $src → $dst';
|
||||
}
|
||||
|
||||
void _onAddNew() async {
|
||||
|
|
@ -305,7 +313,10 @@ class _TransfersPageState extends State<TransfersPage> {
|
|||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => TransferDetailsDialog(document: full),
|
||||
builder: (_) => TransferDetailsDialog(
|
||||
document: full,
|
||||
calendarController: widget.calendarController,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
210
hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart
Normal file
210
hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart
Normal 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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import '../core/api_client.dart';
|
||||
import '../models/account_model.dart';
|
||||
|
||||
class AccountService {
|
||||
final ApiClient _client;
|
||||
|
|
@ -19,4 +20,71 @@ class AccountService {
|
|||
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>[]};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
hesabixUI/hesabix_ui/lib/services/bom_service.dart
Normal file
53
hesabixUI/hesabix_ui/lib/services/bom_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
283
hesabixUI/hesabix_ui/lib/services/document_service.dart
Normal file
283
hesabixUI/hesabix_ui/lib/services/document_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,60 +1,214 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:hesabix_ui/core/api_client.dart';
|
||||
import 'package:hesabix_ui/models/expense_income_document.dart';
|
||||
|
||||
/// سرویس CRUD اسناد هزینه/درآمد
|
||||
class ExpenseIncomeService {
|
||||
final ApiClient api;
|
||||
ExpenseIncomeService(this.api);
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<Map<String, dynamic>> create({
|
||||
ExpenseIncomeService(this._apiClient);
|
||||
|
||||
/// ایجاد سند هزینه/درآمد جدید
|
||||
Future<ExpenseIncomeDocument> create({
|
||||
required int businessId,
|
||||
required String documentType, // 'expense' | 'income'
|
||||
required String documentType,
|
||||
required DateTime documentDate,
|
||||
required int currencyId,
|
||||
required List<ItemLineData> itemLines,
|
||||
required List<CounterpartyLineData> counterpartyLines,
|
||||
String? description,
|
||||
List<Map<String, dynamic>> itemLines = const [],
|
||||
List<Map<String, dynamic>> counterpartyLines = const [],
|
||||
Map<String, dynamic>? extraInfo,
|
||||
}) 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_date': documentDate.toIso8601String(),
|
||||
'currency_id': currencyId,
|
||||
if (description != null && description.isNotEmpty) 'description': description,
|
||||
'item_lines': itemLines,
|
||||
'counterparty_lines': counterpartyLines,
|
||||
'item_lines': itemLinesData,
|
||||
'counterparty_lines': counterpartyLinesData,
|
||||
if (extraInfo != null) 'extra_info': extraInfo,
|
||||
};
|
||||
final res = await api.post<Map<String, dynamic>>(
|
||||
'/api/v1/businesses/$businessId/expense-income/create',
|
||||
data: body,
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/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,
|
||||
String? documentType, // 'expense' | 'income'
|
||||
DateTime? fromDate,
|
||||
DateTime? toDate,
|
||||
int skip = 0,
|
||||
int take = 20,
|
||||
String? search,
|
||||
String? sortBy,
|
||||
bool sortDesc = true,
|
||||
/// ویرایش سند هزینه/درآمد
|
||||
Future<ExpenseIncomeDocument> update({
|
||||
required int documentId,
|
||||
required DateTime documentDate,
|
||||
required int currencyId,
|
||||
required List<ItemLineData> itemLines,
|
||||
required List<CounterpartyLineData> counterpartyLines,
|
||||
String? description,
|
||||
Map<String, dynamic>? extraInfo,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'skip': skip,
|
||||
'take': take,
|
||||
'sort_desc': sortDesc,
|
||||
if (sortBy != null) 'sort_by': sortBy,
|
||||
if (search != null && search.isNotEmpty) 'search': search,
|
||||
if (documentType != null) 'document_type': documentType,
|
||||
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
|
||||
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
|
||||
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,
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
39
hesabixUI/hesabix_ui/lib/services/warehouse_service.dart
Normal file
39
hesabixUI/hesabix_ui/lib/services/warehouse_service.dart
Normal 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
|
|
@ -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('بستن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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; // حساب نیاز به تفضیل ندارد
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('پاک کردن انتخاب'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../models/account_model.dart';
|
||||
import '../../models/account_tree_node.dart';
|
||||
import '../../services/account_service.dart';
|
||||
|
||||
/// ویجت انتخاب حساب با ساختار درختی
|
||||
/// فقط حسابهایی که فرزند ندارند (leaf nodes) قابل انتخاب هستند
|
||||
class AccountTreeComboboxWidget extends StatefulWidget {
|
||||
final int businessId;
|
||||
final AccountTreeNode? selectedAccount;
|
||||
final ValueChanged<AccountTreeNode?> onChanged;
|
||||
final Account? selectedAccount;
|
||||
final ValueChanged<Account?> onChanged;
|
||||
final String label;
|
||||
final String hintText;
|
||||
final bool isRequired;
|
||||
|
|
@ -26,16 +29,25 @@ class AccountTreeComboboxWidget extends StatefulWidget {
|
|||
|
||||
class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
|
||||
final AccountService _accountService = AccountService();
|
||||
List<AccountTreeNode> _accounts = [];
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<AccountTreeNode> _accountTree = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void 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(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
|
@ -47,12 +59,12 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
|
|||
.toList() ?? [];
|
||||
|
||||
setState(() {
|
||||
_accounts = items;
|
||||
_accountTree = items;
|
||||
});
|
||||
} catch (e) {
|
||||
print('خطا در لود کردن حسابها: $e');
|
||||
print('خطا در لود کردن درخت حسابها: $e');
|
||||
setState(() {
|
||||
_accounts = [];
|
||||
_accountTree = [];
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
|
|
@ -61,92 +73,47 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// لیبل
|
||||
if (widget.label.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.isRequired)
|
||||
Text(
|
||||
' *',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// فیلد انتخاب
|
||||
InkWell(
|
||||
onTap: _isLoading ? null : _showAccountDialog,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.selectedAccount?.toString() ?? widget.hintText,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: widget.selectedAccount != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
return TextFormField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hintText,
|
||||
suffixIcon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
: const Icon(Icons.account_tree),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
readOnly: true,
|
||||
validator: widget.isRequired
|
||||
? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '${widget.label} الزامی است';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
: null,
|
||||
onTap: () => _showAccountTreeDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAccountDialog() {
|
||||
void _showAccountTreeDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AccountSelectionDialog(
|
||||
accounts: _accounts,
|
||||
builder: (context) => _AccountTreeDialog(
|
||||
accountTree: _accountTree,
|
||||
selectedAccount: widget.selectedAccount,
|
||||
onAccountSelected: (account) {
|
||||
widget.onChanged(account);
|
||||
_searchController.text = account?.displayName ?? '';
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
|
|
@ -154,55 +121,112 @@ class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
|
|||
}
|
||||
}
|
||||
|
||||
class AccountSelectionDialog extends StatefulWidget {
|
||||
final List<AccountTreeNode> accounts;
|
||||
final AccountTreeNode? selectedAccount;
|
||||
final ValueChanged<AccountTreeNode?> onAccountSelected;
|
||||
/// دیالوگ انتخاب حساب با ساختار درختی
|
||||
class _AccountTreeDialog extends StatefulWidget {
|
||||
final List<AccountTreeNode> accountTree;
|
||||
final Account? selectedAccount;
|
||||
final ValueChanged<Account?> onAccountSelected;
|
||||
|
||||
const AccountSelectionDialog({
|
||||
super.key,
|
||||
required this.accounts,
|
||||
const _AccountTreeDialog({
|
||||
required this.accountTree,
|
||||
this.selectedAccount,
|
||||
required this.onAccountSelected,
|
||||
});
|
||||
|
||||
@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 = '';
|
||||
List<AccountTreeNode> _filteredAccounts = [];
|
||||
final Set<int> _expandedNodes = <int>{};
|
||||
final Set<int> _expandedNodes = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredAccounts = widget.accounts;
|
||||
// همه گرههای سطح اول را به صورت پیشفرض باز کن
|
||||
_expandedNodes.addAll(widget.accounts.map((account) => account.id));
|
||||
// باز کردن خودکار مسیر به حساب انتخاب شده
|
||||
if (widget.selectedAccount != null) {
|
||||
_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(() {
|
||||
_searchQuery = query;
|
||||
if (query.isEmpty) {
|
||||
_filteredAccounts = widget.accounts;
|
||||
_expandedNodes.add(node.id);
|
||||
});
|
||||
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 {
|
||||
_filteredAccounts = widget.accounts
|
||||
.expand((account) => account.searchAccounts(query))
|
||||
.where((account) => !account.hasChildren) // فقط حسابهای بدون فرزند
|
||||
.toList();
|
||||
_expandedNodes.add(nodeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _expandAll() {
|
||||
void _expandAll(List<AccountTreeNode> nodes) {
|
||||
setState(() {
|
||||
_expandedNodes.clear();
|
||||
// همه گرههایی که فرزند دارند را باز کن
|
||||
for (final account in widget.accounts) {
|
||||
_addAllExpandableNodes(account);
|
||||
for (final node in nodes) {
|
||||
if (node.children.isNotEmpty) {
|
||||
_expandedNodes.add(node.id);
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final filteredTree = _filterTree(widget.accountTree);
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 600,
|
||||
height: 500,
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700, maxHeight: 600),
|
||||
child: Column(
|
||||
children: [
|
||||
// هدر
|
||||
// هدر دیالوگ
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
|
|
@ -246,98 +260,115 @@ class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
|
|||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
size: 24,
|
||||
Icons.account_tree,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'انتخاب حساب',
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'انتخاب حساب (درختی)',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
color: theme.colorScheme.onPrimary,
|
||||
tooltip: 'بستن',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// جستجو و دکمههای کنترل
|
||||
// فیلد جستجو و دکمههای باز/بسته کردن
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'جستجو در حسابها...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'جستجو',
|
||||
hintText: 'نام یا کد حساب...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _filterAccounts,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_searchQuery.isEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _expandAll,
|
||||
icon: const Icon(Icons.expand_more),
|
||||
label: const Text('همه را باز کن'),
|
||||
onPressed: () => _expandAll(filteredTree),
|
||||
icon: const Icon(Icons.unfold_more, size: 18),
|
||||
label: const Text('باز کردن همه'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _collapseAll,
|
||||
icon: const Icon(Icons.expand_less),
|
||||
label: const Text('همه را ببند'),
|
||||
icon: const Icon(Icons.unfold_less, size: 18),
|
||||
label: const Text('بستن همه'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// لیست حسابها
|
||||
const Divider(height: 1),
|
||||
|
||||
// درخت حسابها
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _searchQuery.isEmpty
|
||||
? _buildTreeView()
|
||||
: _buildSearchResults(),
|
||||
child: filteredTree.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty ? 'هیچ حسابی یافت نشد' : 'نتیجهای یافت نشد',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: _buildTreeNodes(filteredTree, 0),
|
||||
),
|
||||
),
|
||||
|
||||
// دکمهها
|
||||
const Divider(height: 1),
|
||||
|
||||
// دکمههای پایین
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'فقط حسابهای انتهایی قابل انتخاب هستند',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('انصراف'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.selectedAccount != null)
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
widget.onAccountSelected(null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('حذف انتخاب'),
|
||||
child: const Text('پاک کردن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -348,141 +379,160 @@ class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTreeView() {
|
||||
return ListView.builder(
|
||||
itemCount: widget.accounts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAccountNode(widget.accounts[index], 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
List<Widget> _buildTreeNodes(List<AccountTreeNode> nodes, int level) {
|
||||
final List<Widget> widgets = [];
|
||||
|
||||
Widget _buildSearchResults() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _filteredAccounts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final account = _filteredAccounts[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
tileColor: account.id == widget.selectedAccount?.id
|
||||
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
for (final node in nodes) {
|
||||
final isExpanded = _expandedNodes.contains(node.id);
|
||||
final hasChildren = node.children.isNotEmpty;
|
||||
final isSelected = widget.selectedAccount?.id == node.id;
|
||||
final isSelectable = node.isSelectable;
|
||||
|
||||
widgets.add(
|
||||
_TreeNodeWidget(
|
||||
node: node,
|
||||
level: level,
|
||||
isExpanded: isExpanded,
|
||||
hasChildren: hasChildren,
|
||||
isSelected: isSelected,
|
||||
isSelectable: isSelectable,
|
||||
onTap: isSelectable
|
||||
? () => widget.onAccountSelected(node.toAccount())
|
||||
: null,
|
||||
leading: Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: account.id == widget.selectedAccount?.id
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
onToggleExpand: hasChildren ? () => _toggleExpanded(node.id) : null,
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
onTap: () => widget.onAccountSelected(account),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAccountNode(AccountTreeNode account, int level) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = account.id == widget.selectedAccount?.id;
|
||||
final canSelect = !account.hasChildren;
|
||||
final isExpanded = _expandedNodes.contains(account.id);
|
||||
const SizedBox(width: 8),
|
||||
|
||||
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: [
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
Text(
|
||||
node.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
tileColor: isSelected
|
||||
? theme.colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: null,
|
||||
leading: account.hasChildren
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
Text(
|
||||
'کد: ${node.code}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -838,10 +838,22 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
Widget _buildAccountFields() {
|
||||
return AccountTreeComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedAccount: _selectedAccount,
|
||||
selectedAccount: _selectedAccount?.toAccount(),
|
||||
onChanged: (account) {
|
||||
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: 'حساب *',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../../controllers/product_form_controller.dart';
|
|||
import 'sections/product_basic_info_section.dart';
|
||||
import 'sections/product_pricing_inventory_section.dart';
|
||||
import 'sections/product_tax_section.dart';
|
||||
import 'sections/product_bom_section.dart';
|
||||
|
||||
class ProductFormDialog extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -83,7 +84,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
|
|||
|
||||
Widget _buildFormContent() {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
length: 4,
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height > 800 ? 700 : 600,
|
||||
child: Column(
|
||||
|
|
@ -99,6 +100,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
|
|||
_buildBasicInfoTab(),
|
||||
_buildPricingInventoryTab(),
|
||||
_buildTaxTab(),
|
||||
_buildBomTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -118,6 +120,7 @@ class _ProductFormDialogState extends State<ProductFormDialog> {
|
|||
Tab(text: t.productGeneralInfo),
|
||||
Tab(text: t.pricingAndInventory),
|
||||
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() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,15 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../core/date_utils.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
|
||||
class TransferDetailsDialog extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final lines = List<Map<String, dynamic>>.from(document['account_lines'] as List? ?? const []);
|
||||
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';
|
||||
|
||||
// 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(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600),
|
||||
|
|
@ -29,11 +67,54 @@ class TransferDetailsDialog extends StatelessWidget {
|
|||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Chip(label: Text('تاریخ: $date')),
|
||||
Row(
|
||||
children: [
|
||||
Chip(label: Text('تاریخ: ${HesabixDateUtils.formatForDisplay(date, calendarController.isJalali)}')),
|
||||
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 isCommission = (l['is_commission_line'] as bool?) ?? false;
|
||||
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(
|
||||
leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet),
|
||||
title: Text(name),
|
||||
subtitle: Text('کد: $code • سمت: ${isCommission ? 'کارمزد' : side}'),
|
||||
trailing: Text(amount),
|
||||
subtitle: Text('کد: $code • سمت: $sideText'),
|
||||
trailing: Text('$amount ریال'),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue