progress in some parts

This commit is contained in:
Hesabix 2025-11-04 01:51:23 +00:00
parent 8f4248e83f
commit 28ccc57f70
30 changed files with 2924 additions and 249 deletions

View file

@ -31,7 +31,16 @@ def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
roots: list[AccountTreeNode] = [] roots: list[AccountTreeNode] = []
for n in nodes: for n in nodes:
node = AccountTreeNode( node = AccountTreeNode(
id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id') id=n['id'],
code=n['code'],
name=n['name'],
account_type=n.get('account_type'),
parent_id=n.get('parent_id'),
business_id=n.get('business_id'),
is_public=n.get('is_public'),
has_children=n.get('has_children'),
can_edit=n.get('can_edit'),
can_delete=n.get('can_delete'),
) )
by_id[node.id] = node by_id[node.id] = node
for node in list(by_id.values()): for node in list(by_id.values()):
@ -58,10 +67,29 @@ def get_accounts_tree(
rows = db.query(Account).filter( rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all() ).order_by(Account.code.asc()).all()
flat = [ # محاسبه has_children با شمارش فرزندان در مجموعه
{"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id} children_map: dict[int, int] = {}
for r in rows for r in rows:
] if r.parent_id:
children_map[r.parent_id] = children_map.get(r.parent_id, 0) + 1
flat: list[Dict[str, Any]] = []
for r in rows:
is_public = r.business_id is None
has_children = children_map.get(r.id, 0) > 0
can_edit = (r.business_id == business_id) and True # شرط دسترسی نوشتن پایین‌تر بررسی می‌شود در UI/Endpoint
can_delete = can_edit and (not has_children)
flat.append({
"id": r.id,
"code": r.code,
"name": r.name,
"account_type": r.account_type,
"parent_id": r.parent_id,
"business_id": r.business_id,
"is_public": is_public,
"has_children": has_children,
"can_edit": can_edit,
"can_delete": can_delete,
})
tree = _build_tree(flat) tree = _build_tree(flat)
return success_response({"items": [n.model_dump() for n in tree]}, request) return success_response({"items": [n.model_dump() for n in tree]}, request)
@ -214,6 +242,17 @@ def create_business_account(
# اجازه نوشتن در بخش حسابداری لازم است # اجازه نوشتن در بخش حسابداری لازم است
if not ctx.can_write_section("accounting"): if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
# والد اجباری است
if body.parent_id is None:
raise ApiError("PARENT_REQUIRED", "Parent account is required", http_status=400)
# اگر والد عمومی است باید قبلا دارای زیرمجموعه باشد (اجازه ایجاد زیر شاخه برای برگ عمومی را نمی‌دهیم)
parent = db.get(Account, int(body.parent_id)) if body.parent_id is not None else None
if parent is None:
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
if parent.business_id is None:
# lazy-load children count
if not parent.children or len(parent.children) == 0:
raise ApiError("INVALID_PUBLIC_PARENT", "Cannot add child under a public leaf account", http_status=400)
try: try:
created = create_account( created = create_account(
db, db,
@ -238,7 +277,7 @@ def create_business_account(
@router.put( @router.put(
"/account/{account_id}", "/account/{account_id}",
summary="ویرایش حساب", summary="ویرایش حساب",
description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", description="ویرایش حساب اختصاصی بیزنس (دارای دسترسی write). حساب‌های عمومی غیرقابل‌ویرایش هستند.",
) )
def update_account_endpoint( def update_account_endpoint(
request: Request, request: Request,
@ -251,9 +290,9 @@ def update_account_endpoint(
if not data: if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
acc_business_id = data.get("business_id") acc_business_id = data.get("business_id")
# اگر عمومی است، فقط سوپرادمین # حساب‌های عمومی غیرقابل‌ویرایش هستند
if acc_business_id is None and not ctx.is_superadmin(): if acc_business_id is None:
raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403) raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
# اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد # اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد
if acc_business_id is not None: if acc_business_id is not None:
if not ctx.can_access_business(int(acc_business_id)): if not ctx.can_access_business(int(acc_business_id)):
@ -280,13 +319,15 @@ def update_account_endpoint(
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400) raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
if code == "INVALID_PARENT_BUSINESS": if code == "INVALID_PARENT_BUSINESS":
raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400) raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400)
if code == "PUBLIC_IMMUTABLE":
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
raise raise
@router.delete( @router.delete(
"/account/{account_id}", "/account/{account_id}",
summary="حذف حساب", summary="حذف حساب",
description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).", description="حذف حساب اختصاصی بیزنس (دارای دسترسی write). حساب‌های عمومی غیرقابل‌حذف هستند.",
) )
def delete_account_endpoint( def delete_account_endpoint(
request: Request, request: Request,
@ -298,16 +339,25 @@ def delete_account_endpoint(
if not data: if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
acc_business_id = data.get("business_id") acc_business_id = data.get("business_id")
if acc_business_id is None and not ctx.is_superadmin(): # حساب‌های عمومی غیرقابل‌حذف هستند
raise ApiError("FORBIDDEN", "Only superadmin can delete public accounts", http_status=403) if acc_business_id is None:
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
if acc_business_id is not None: if acc_business_id is not None:
if not ctx.can_access_business(int(acc_business_id)): if not ctx.can_access_business(int(acc_business_id)):
raise ApiError("FORBIDDEN", "No access to business", http_status=403) raise ApiError("FORBIDDEN", "No access to business", http_status=403)
if not ctx.can_write_section("accounting"): if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403) raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
try:
ok = delete_account(db, account_id) ok = delete_account(db, account_id)
if not ok: if not ok:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404) raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
return success_response(None, request, message="ACCOUNT_DELETED") return success_response(None, request, message="ACCOUNT_DELETED")
except ValueError as e:
code = str(e)
if code == "ACCOUNT_HAS_CHILDREN":
raise ApiError("ACCOUNT_HAS_CHILDREN", "Cannot delete account with children", http_status=400)
if code == "ACCOUNT_IN_USE":
raise ApiError("ACCOUNT_IN_USE", "Cannot delete account that is referenced by documents", http_status=400)
raise

View file

@ -0,0 +1,205 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.responses import success_response, ApiError, format_datetime_fields
from adapters.api.v1.schemas import QueryInfo
from adapters.db.models.document import Document
from sqlalchemy import and_
from app.services.inventory_transfer_service import create_inventory_transfer, DOCUMENT_TYPE_INVENTORY_TRANSFER
router = APIRouter(prefix="/inventory-transfers", tags=["inventory_transfers"])
@router.post("/business/{business_id}")
@require_business_access("business_id")
def create_inventory_transfer_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
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_inventory_transfer(db, business_id, ctx.user_id, payload)
return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message"))
@router.post("/business/{business_id}/query")
@require_business_access("business_id")
def query_inventory_transfers_endpoint(
request: Request,
business_id: int,
payload: QueryInfo,
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)
take = max(1, payload.take)
skip = max(0, payload.skip)
q = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER,
)
)
total = q.count()
rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all()
items = [{
"id": d.id,
"code": d.code,
"document_date": d.document_date,
"currency_id": d.currency_id,
"description": d.description,
} for d in rows]
return success_response(data={
"items": format_datetime_fields(items, request),
"pagination": {
"total": total,
"page": (skip // take) + 1,
"per_page": take,
"total_pages": (total + take - 1) // take,
"has_next": skip + take < total,
"has_prev": skip > 0,
},
"query_info": payload.model_dump(),
}, request=request)
@router.post("/business/{business_id}/export/excel",
summary="خروجی Excel لیست انتقال موجودی",
description="خروجی اکسل از لیست اسناد انتقال موجودی بین انبارها",
)
@require_business_access("business_id")
def export_inventory_transfers_excel(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
from fastapi.responses import Response
from openpyxl import Workbook
from io import BytesIO
import datetime
take = min(int(body.get("take", 1000)), 10000)
skip = int(body.get("skip", 0))
q = db.query(Document).filter(and_(Document.business_id == business_id, Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER))
rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all()
wb = Workbook()
ws = wb.active
ws.title = "InventoryTransfers"
ws.append(["code", "document_date", "description"])
for d in rows:
ws.append([d.code, d.document_date.isoformat(), d.description])
buf = BytesIO()
wb.save(buf)
content = buf.getvalue()
filename = f"inventory_transfers_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(content)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.post("/business/{business_id}/export/pdf",
summary="خروجی PDF لیست انتقال موجودی",
description="خروجی PDF از لیست اسناد انتقال موجودی بین انبارها",
)
@require_business_access("business_id")
def export_inventory_transfers_pdf(
request: Request,
business_id: int,
body: Dict[str, Any] = Body(default={}),
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not ctx.can_read_section("inventory"):
raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403)
from fastapi.responses import Response
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from html import escape
import datetime
take = min(int(body.get("take", 1000)), 10000)
skip = int(body.get("skip", 0))
q = db.query(Document).filter(and_(Document.business_id == business_id, Document.document_type == DOCUMENT_TYPE_INVENTORY_TRANSFER))
rows = q.order_by(Document.document_date.desc(), Document.id.desc()).offset(skip).limit(take).all()
def cell(v: Any) -> str:
return escape(v.isoformat()) if hasattr(v, 'isoformat') else escape(str(v) if v is not None else "")
rows_html = "".join([
f"<tr>"
f"<td>{cell(d.code)}</td>"
f"<td>{cell(d.document_date)}</td>"
f"<td>{cell(d.description)}</td>"
f"</tr>" for d in rows
])
html = f"""
<html>
<head>
<meta charset='utf-8'/>
<style>
body {{ font-family: sans-serif; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ border: 1px solid #ddd; padding: 6px; font-size: 12px; }}
th {{ background: #f5f5f5; text-align: right; }}
</style>
</head>
<body>
<h3>لیست انتقال موجودی بین انبارها</h3>
<table>
<thead>
<tr>
<th>کد سند</th>
<th>تاریخ سند</th>
<th>شرح</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</body>
</html>
"""
font_config = FontConfiguration()
pdf_bytes = HTML(string=html).write_pdf(stylesheets=[CSS(string="@page { size: A4 portrait; margin: 12mm; }")], font_config=font_config)
filename = f"inventory_transfers_{business_id}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
"Access-Control-Expose-Headers": "Content-Disposition",
},
)

View file

@ -53,6 +53,7 @@ async def list_kardex_lines_endpoint(
"petty_cash_ids", "petty_cash_ids",
"account_ids", "account_ids",
"check_ids", "check_ids",
"warehouse_ids",
"match_mode", "match_mode",
"result_scope", "result_scope",
): ):
@ -113,6 +114,7 @@ async def export_kardex_excel_endpoint(
"petty_cash_ids": body.get("petty_cash_ids"), "petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"), "account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"), "check_ids": body.get("check_ids"),
"warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any", "match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching", "result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)), "include_running_balance": bool(body.get("include_running_balance", False)),
@ -130,7 +132,7 @@ async def export_kardex_excel_endpoint(
ws = wb.active ws = wb.active
ws.title = "Kardex" ws.title = "Kardex"
headers = [ headers = [
"document_date", "document_code", "document_type", "description", "document_date", "document_code", "document_type", "warehouse", "movement", "description",
"debit", "credit", "quantity", "running_amount", "running_quantity", "debit", "credit", "quantity", "running_amount", "running_quantity",
] ]
ws.append(headers) ws.append(headers)
@ -139,6 +141,8 @@ async def export_kardex_excel_endpoint(
it.get("document_date"), it.get("document_date"),
it.get("document_code"), it.get("document_code"),
it.get("document_type"), it.get("document_type"),
it.get("warehouse_name") or it.get("warehouse_id"),
it.get("movement"),
it.get("description"), it.get("description"),
it.get("debit"), it.get("debit"),
it.get("credit"), it.get("credit"),
@ -205,6 +209,7 @@ async def export_kardex_pdf_endpoint(
"petty_cash_ids": body.get("petty_cash_ids"), "petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"), "account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"), "check_ids": body.get("check_ids"),
"warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any", "match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching", "result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)), "include_running_balance": bool(body.get("include_running_balance", False)),
@ -223,6 +228,8 @@ async def export_kardex_pdf_endpoint(
f"<td>{cell(it.get('document_date'))}</td>" f"<td>{cell(it.get('document_date'))}</td>"
f"<td>{cell(it.get('document_code'))}</td>" f"<td>{cell(it.get('document_code'))}</td>"
f"<td>{cell(it.get('document_type'))}</td>" f"<td>{cell(it.get('document_type'))}</td>"
f"<td>{cell(it.get('warehouse_name') or it.get('warehouse_id'))}</td>"
f"<td>{cell(it.get('movement'))}</td>"
f"<td>{cell(it.get('description'))}</td>" f"<td>{cell(it.get('description'))}</td>"
f"<td style='text-align:right'>{cell(it.get('debit'))}</td>" f"<td style='text-align:right'>{cell(it.get('debit'))}</td>"
f"<td style='text-align:right'>{cell(it.get('credit'))}</td>" f"<td style='text-align:right'>{cell(it.get('credit'))}</td>"
@ -252,6 +259,8 @@ async def export_kardex_pdf_endpoint(
<th>تاریخ سند</th> <th>تاریخ سند</th>
<th>کد سند</th> <th>کد سند</th>
<th>نوع سند</th> <th>نوع سند</th>
<th>انبار</th>
<th>جهت حرکت</th>
<th>شرح</th> <th>شرح</th>
<th>بدهکار</th> <th>بدهکار</th>
<th>بستانکار</th> <th>بستانکار</th>

View file

@ -10,6 +10,11 @@ class AccountTreeNode(BaseModel):
name: str = Field(..., description="نام حساب") name: str = Field(..., description="نام حساب")
account_type: Optional[str] = Field(default=None, description="نوع حساب") account_type: Optional[str] = Field(default=None, description="نوع حساب")
parent_id: Optional[int] = Field(default=None, description="شناسه والد") parent_id: Optional[int] = Field(default=None, description="شناسه والد")
business_id: Optional[int] = Field(default=None, description="شناسه کسب‌وکار؛ اگر تهی باشد حساب عمومی است")
is_public: Optional[bool] = Field(default=None, description="True اگر حساب عمومی باشد")
has_children: Optional[bool] = Field(default=None, description="دارای فرزند")
can_edit: Optional[bool] = Field(default=None, description="آیا کاربر فعلی می‌تواند ویرایش کند")
can_delete: Optional[bool] = Field(default=None, description="آیا کاربر فعلی می‌تواند حذف کند")
level: Optional[int] = Field(default=None, description="سطح حساب در درخت") level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان") children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")

View file

@ -11,7 +11,15 @@ class Base(DeclarativeBase):
settings = get_settings() settings = get_settings()
engine = create_engine(settings.mysql_dsn, echo=settings.sqlalchemy_echo, pool_pre_ping=True, pool_recycle=3600) engine = create_engine(
settings.mysql_dsn,
echo=settings.sqlalchemy_echo,
pool_pre_ping=True,
pool_recycle=3600,
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)

View file

@ -19,6 +19,10 @@ class Settings(BaseSettings):
db_port: int = 3306 db_port: int = 3306
db_name: str = "hesabix" db_name: str = "hesabix"
sqlalchemy_echo: bool = False sqlalchemy_echo: bool = False
# DB Pooling
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 10
# Logging # Logging
log_level: str = "INFO" log_level: str = "INFO"

View file

@ -36,6 +36,7 @@ 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.expense_income import router as expense_income_router
from adapters.api.v1.documents import router as documents_router from adapters.api.v1.documents import router as documents_router
from adapters.api.v1.kardex import router as kardex_router from adapters.api.v1.kardex import router as kardex_router
from adapters.api.v1.inventory_transfers import router as inventory_transfers_router
from app.core.i18n import negotiate_locale, Translator from app.core.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
@ -321,6 +322,7 @@ def create_app() -> FastAPI:
application.include_router(documents_router, prefix=settings.api_v1_prefix) application.include_router(documents_router, prefix=settings.api_v1_prefix)
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
application.include_router(kardex_router, prefix=settings.api_v1_prefix) application.include_router(kardex_router, prefix=settings.api_v1_prefix)
application.include_router(inventory_transfers_router, prefix=settings.api_v1_prefix)
# Support endpoints # Support endpoints
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
@ -335,6 +337,25 @@ def create_app() -> FastAPI:
register_error_handlers(application) register_error_handlers(application)
@application.middleware("http")
async def log_slow_requests(request: Request, call_next):
import time
import structlog
start = time.perf_counter()
try:
response = await call_next(request)
return response
finally:
duration_ms = int((time.perf_counter() - start) * 1000)
if duration_ms > 2000:
logger = structlog.get_logger()
logger.warning(
"slow_request",
path=str(request.url.path),
method=request.method,
duration_ms=duration_ms,
)
@application.get("/", @application.get("/",
summary="اطلاعات سرویس", summary="اطلاعات سرویس",
description="دریافت اطلاعات کلی سرویس و نسخه", description="دریافت اطلاعات کلی سرویس و نسخه",

View file

@ -72,6 +72,9 @@ def update_account(
obj = db.get(Account, account_id) obj = db.get(Account, account_id)
if not obj: if not obj:
return None return None
# جلوگیری از تغییر حساب‌های عمومی در لایه سرویس
if obj.business_id is None:
raise ValueError("PUBLIC_IMMUTABLE")
if parent_id is not None: if parent_id is not None:
parent_id = _validate_parent(db, parent_id, obj.business_id) parent_id = _validate_parent(db, parent_id, obj.business_id)
if name is not None: if name is not None:
@ -94,6 +97,12 @@ def delete_account(db: Session, account_id: int) -> bool:
obj = db.get(Account, account_id) obj = db.get(Account, account_id)
if not obj: if not obj:
return False return False
# جلوگیری از حذف اگر فرزند دارد
if obj.children and len(obj.children) > 0:
raise ValueError("ACCOUNT_HAS_CHILDREN")
# جلوگیری از حذف اگر در اسناد استفاده شده است
if obj.document_lines and len(obj.document_lines) > 0:
raise ValueError("ACCOUNT_IN_USE")
db.delete(obj) db.delete(obj)
db.commit() db.commit()
return True return True

View file

@ -178,7 +178,9 @@ def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, An
"check_id": obj.id, "check_id": obj.id,
}) })
# ایجاد سند # ایجاد سند (اگر چک واگذار شخص ندارد، از ثبت سند صرف‌نظر می شود)
skip_autopost = (ctype == "transferred" and not person_id)
if not skip_autopost:
document = Document( document = Document(
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}", code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
business_id=business_id, business_id=business_id,
@ -335,7 +337,8 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
lines: List[Dict[str, Any]] = [] lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED: if obj.type == CheckType.RECEIVED:
# Dr 10203 (bank), Cr 10403 # Dr 10203 (bank), Cr 10403 یا 10404 بسته به وضعیت
credit_code = "10404" if obj.status == CheckStatus.DEPOSITED else "10403"
lines.append({ lines.append({
"account_id": _ensure_account(db, "10203"), "account_id": _ensure_account(db, "10203"),
"bank_account_id": int(data.get("bank_account_id")), "bank_account_id": int(data.get("bank_account_id")),
@ -345,7 +348,7 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
"check_id": obj.id, "check_id": obj.id,
}) })
lines.append({ lines.append({
"account_id": _ensure_account(db, "10403"), "account_id": _ensure_account(db, credit_code),
"debit": Decimal(0), "debit": Decimal(0),
"credit": amount_dec, "credit": amount_dec,
"description": description or "وصول چک", "description": description or "وصول چک",
@ -483,8 +486,30 @@ def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
lines: List[Dict[str, Any]] = [] lines: List[Dict[str, Any]] = []
if obj.type == CheckType.RECEIVED: if obj.type == CheckType.RECEIVED:
# Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203 # فقط از وضعیت های DEPOSITED یا CLEARED اجازه برگشت
if obj.status not in (CheckStatus.DEPOSITED, CheckStatus.CLEARED):
raise ApiError("INVALID_STATE", f"Cannot bounce from status {obj.status}", http_status=400)
bank_account_id = data.get("bank_account_id") bank_account_id = data.get("bank_account_id")
if obj.status == CheckStatus.DEPOSITED:
# Dr 10403, Cr 10404
lines.append({
"account_id": _ensure_account(db, "10403"),
"debit": amount_dec,
"credit": Decimal(0),
"description": description or "برگشت چک",
"check_id": obj.id,
})
lines.append({
"account_id": _ensure_account(db, "10404"),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "برگشت چک",
"check_id": obj.id,
})
else:
# CLEARED: Dr 10403, Cr 10203 (نیازمند bank_account_id)
if not bank_account_id:
raise ApiError("BANK_ACCOUNT_REQUIRED", "bank_account_id is required to bounce a cleared check", http_status=400)
lines.append({ lines.append({
"account_id": _ensure_account(db, "10403"), "account_id": _ensure_account(db, "10403"),
"debit": amount_dec, "debit": amount_dec,
@ -494,7 +519,7 @@ def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
}) })
lines.append({ lines.append({
"account_id": _ensure_account(db, "10203"), "account_id": _ensure_account(db, "10203"),
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}), "bank_account_id": int(bank_account_id),
"debit": Decimal(0), "debit": Decimal(0),
"credit": amount_dec, "credit": amount_dec,
"description": description or "برگشت چک", "description": description or "برگشت چک",

View file

@ -0,0 +1,169 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.currency import Currency
from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.product import Product
from app.core.responses import ApiError
# از توابع موجود برای تاریخ و کنترل موجودی استفاده می‌کنیم
from app.services.invoice_service import _parse_iso_date, _get_current_fiscal_year, _ensure_stock_sufficient
DOCUMENT_TYPE_INVENTORY_TRANSFER = "inventory_transfer"
def _build_doc_code(prefix_base: str) -> str:
today = datetime.now().date()
prefix = f"{prefix_base}-{today.strftime('%Y%m%d')}"
return prefix
def _build_transfer_code(db: Session, business_id: int) -> str:
prefix = _build_doc_code("ITR")
last_doc = db.query(Document).filter(
and_(
Document.business_id == business_id,
Document.code.like(f"{prefix}-%"),
)
).order_by(Document.code.desc()).first()
if last_doc:
try:
last_num = int(last_doc.code.split("-")[-1])
next_num = last_num + 1
except Exception:
next_num = 1
else:
next_num = 1
return f"{prefix}-{next_num:04d}"
def create_inventory_transfer(
db: Session,
business_id: int,
user_id: int,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""ایجاد سند انتقال موجودی بین انبارها (بدون ثبت حسابداری)."""
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
currency_id = data.get("currency_id")
if not currency_id:
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
if not currency:
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
fiscal_year = _get_current_fiscal_year(db, business_id)
raw_lines: List[Dict[str, Any]] = list(data.get("lines") or [])
if not raw_lines:
raise ApiError("LINES_REQUIRED", "At least one transfer line is required", http_status=400)
# اعتبارسنجی خطوط و آماده‌سازی برای کنترل کسری
outgoing_lines: List[Dict[str, Any]] = []
for i, ln in enumerate(raw_lines, start=1):
pid = ln.get("product_id")
qty = Decimal(str(ln.get("quantity", 0) or 0))
src_wh = ln.get("source_warehouse_id")
dst_wh = ln.get("destination_warehouse_id")
if not pid or qty <= 0:
raise ApiError("INVALID_LINE", f"line {i}: product_id and positive quantity are required", http_status=400)
if src_wh is None or dst_wh is None:
raise ApiError("WAREHOUSE_REQUIRED", f"line {i}: source_warehouse_id and destination_warehouse_id are required", http_status=400)
if int(src_wh) == int(dst_wh):
raise ApiError("INVALID_WAREHOUSES", f"line {i}: source and destination warehouse cannot be the same", http_status=400)
# فقط برای محصولات کنترل موجودی، کنترل کسری لازم است
tracked = db.query(Product.track_inventory).filter(
and_(Product.business_id == business_id, Product.id == int(pid))
).scalar()
if bool(tracked):
outgoing_lines.append({
"product_id": int(pid),
"quantity": float(qty),
"extra_info": {
"warehouse_id": int(src_wh),
"movement": "out",
"inventory_tracked": True,
},
})
# کنترل کسری موجودی بر مبنای انبار مبدا
if outgoing_lines:
_ensure_stock_sufficient(db, business_id, document_date, outgoing_lines)
# ایجاد سند بدون ثبت حسابداری
doc_code = _build_transfer_code(db, business_id)
document = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=DOCUMENT_TYPE_INVENTORY_TRANSFER,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=data.get("description"),
extra_info={"source": "inventory_transfer"},
)
db.add(document)
db.flush()
# ایجاد خطوط کالایی: یک خروج از انبار مبدا و یک ورود به انبار مقصد
for ln in raw_lines:
pid = int(ln.get("product_id"))
qty = Decimal(str(ln.get("quantity", 0) or 0))
src_wh = int(ln.get("source_warehouse_id"))
dst_wh = int(ln.get("destination_warehouse_id"))
desc = ln.get("description")
db.add(DocumentLine(
document_id=document.id,
product_id=pid,
quantity=qty,
debit=Decimal(0),
credit=Decimal(0),
description=desc,
extra_info={
"movement": "out",
"warehouse_id": src_wh,
"inventory_tracked": True,
},
))
db.add(DocumentLine(
document_id=document.id,
product_id=pid,
quantity=qty,
debit=Decimal(0),
credit=Decimal(0),
description=desc,
extra_info={
"movement": "in",
"warehouse_id": dst_wh,
"inventory_tracked": True,
},
))
db.commit()
db.refresh(document)
return {
"message": "INVENTORY_TRANSFER_CREATED",
"data": {
"id": document.id,
"code": document.code,
"document_date": document.document_date.isoformat(),
},
}

View file

@ -54,6 +54,23 @@ def _get_costing_method(data: Dict[str, Any]) -> str:
return "average" return "average"
def _is_inventory_posting_enabled(data: Dict[str, Any]) -> bool:
"""خواندن فلگ ثبت اسناد انبار از extra_info. پیش‌فرض: فعال (True)."""
try:
extra = data.get("extra_info") or {}
val = extra.get("post_inventory")
if val is None:
return True
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return bool(val)
s = str(val).strip().lower()
return s not in ("false", "0", "no", "off")
except Exception:
return True
def _iter_product_movements( def _iter_product_movements(
db: Session, db: Session,
business_id: int, business_id: int,
@ -94,6 +111,13 @@ def _iter_product_movements(
movements = [] movements = []
for line, doc in rows: for line, doc in rows:
info = line.extra_info or {} info = line.extra_info or {}
# اگر خط صراحتاً به عنوان عدم ثبت انبار علامت‌گذاری شده، از حرکت صرف‌نظر کن
try:
posted = info.get("inventory_posted")
if posted is False:
continue
except Exception:
pass
movement = (info.get("movement") or None) movement = (info.get("movement") or None)
wh_id = info.get("warehouse_id") wh_id = info.get("warehouse_id")
if movement is None: if movement is None:
@ -319,8 +343,18 @@ def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
return account return account
def _get_person_control_account(db: Session) -> Account: def _get_person_control_account(db: Session, invoice_type: str | None = None) -> Account:
# عمومی اشخاص (پرداختنی/دریافتنی) پیش‌فرض: 20201 # انتخاب حساب طرف‌شخص بر اساس نوع فاکتور
# فروش/برگشت از فروش → دریافتنی ها 10401
# خرید/برگشت از خرید → پرداختنی ها 20201 (پیش‌فرض)
try:
inv_type = (invoice_type or "").strip()
if inv_type in {INVOICE_SALES, INVOICE_SALES_RETURN}:
return _get_fixed_account_by_code(db, "10401")
# سایر موارد (شامل خرید/برگشت از خرید)
return _get_fixed_account_by_code(db, "20201")
except Exception:
# fallback امن
return _get_fixed_account_by_code(db, "20201") return _get_fixed_account_by_code(db, "20201")
@ -368,6 +402,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
# فقط برای کالاهای دارای کنترل موجودی # فقط برای کالاهای دارای کنترل موجودی
if not bool(info.get("inventory_tracked")): if not bool(info.get("inventory_tracked")):
continue continue
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
if info.get("inventory_posted") is False:
continue
qty = Decimal(str(line.get("quantity", 0) or 0)) qty = Decimal(str(line.get("quantity", 0) or 0))
if info.get("cogs_amount") is not None: if info.get("cogs_amount") is not None:
total += Decimal(str(info.get("cogs_amount"))) total += Decimal(str(info.get("cogs_amount")))
@ -386,25 +423,113 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]: def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]:
# امکان override از extra_info.account_codes # امکان override از extra_info.account_codes
overrides = ((data.get("extra_info") or {}).get("account_codes") or {}) overrides = ((data.get("extra_info") or {}).get("account_codes") or {})
invoice_type = str(data.get("invoice_type", "")).strip()
def code(name: str, default_code: str) -> str: def code(name: str, default_code: str) -> str:
return str(overrides.get(name) or default_code) return str(overrides.get(name) or default_code)
return { return {
"revenue": _get_fixed_account_by_code(db, code("revenue", "70101")), # درآمد و برگشت فروش مطابق چارت سید:
"sales_return": _get_fixed_account_by_code(db, code("sales_return", "70102")), "revenue": _get_fixed_account_by_code(db, code("revenue", "50001")),
"inventory": _get_fixed_account_by_code(db, code("inventory", "10301")), "sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")),
"inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10302")), # موجودی و ساخته‌شده (در نبود حساب مجزا) هر دو 10102
"cogs": _get_fixed_account_by_code(db, code("cogs", "60101")), "inventory": _get_fixed_account_by_code(db, code("inventory", "10102")),
"vat_out": _get_fixed_account_by_code(db, code("vat_out", "20801")), "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")),
"vat_in": _get_fixed_account_by_code(db, code("vat_in", "10801")), # بهای تمام شده و VAT ها مطابق سید
"direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "60201")), "cogs": _get_fixed_account_by_code(db, code("cogs", "40001")),
"wip": _get_fixed_account_by_code(db, code("wip", "60301")), "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20101")),
"waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "60401")), "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10104")),
"person": _get_person_control_account(db), # مصرف مستقیم و ضایعات
"direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "70406")),
"wip": _get_fixed_account_by_code(db, code("wip", "10106")),
"waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "70407")),
# طرف‌شخص بر اساس نوع فاکتور
"person": _get_person_control_account(db, invoice_type),
} }
def _calculate_seller_commission(
db: Session,
invoice_type: str,
header_extra: Dict[str, Any],
totals: Dict[str, Any],
) -> Tuple[int | None, Decimal]:
"""محاسبه پورسانت فروشنده/بازاریاب بر اساس تنظیمات شخص یا override در فاکتور.
Returns: (seller_id, commission_amount)
"""
try:
ei = header_extra or {}
seller_id_raw = ei.get("seller_id")
seller_id: int | None = int(seller_id_raw) if seller_id_raw is not None else None
except Exception:
seller_id = None
if not seller_id:
return (None, Decimal(0))
# مبنای محاسبه
gross = Decimal(str((totals or {}).get("gross", 0)))
discount = Decimal(str((totals or {}).get("discount", 0)))
net = gross - discount
# اگر در فاکتور override شده باشد، همان اعمال شود
commission_cfg = ei.get("commission") if isinstance(ei.get("commission"), dict) else None
if commission_cfg:
value = Decimal(str(commission_cfg.get("value", 0))) if commission_cfg.get("value") is not None else Decimal(0)
ctype = (commission_cfg.get("type") or "").strip().lower()
if value <= 0:
return (seller_id, Decimal(0))
if ctype == "percentage":
amount = (net * value) / Decimal(100)
return (seller_id, amount)
if ctype == "amount":
return (seller_id, value)
return (seller_id, Decimal(0))
# در غیر اینصورت، از تنظیمات شخص استفاده می‌کنیم
person = db.query(Person).filter(Person.id == seller_id).first()
if not person:
return (seller_id, Decimal(0))
# اگر شخص اجازه‌ی ثبت پورسانت در سند فاکتور را نداده است، صفر برگردان
try:
if not bool(getattr(person, "commission_post_in_invoice_document", False)):
return (seller_id, Decimal(0))
except Exception:
pass
exclude_discounts = bool(getattr(person, "commission_exclude_discounts", False))
base_amount = gross if exclude_discounts else net
amount = Decimal(0)
if invoice_type == INVOICE_SALES:
percent = getattr(person, "commission_sale_percent", None)
fixed = getattr(person, "commission_sales_amount", None)
elif invoice_type == INVOICE_SALES_RETURN:
percent = getattr(person, "commission_sales_return_percent", None)
fixed = getattr(person, "commission_sales_return_amount", None)
else:
percent = None
fixed = None
if percent is not None:
try:
p = Decimal(str(percent))
if p > 0:
amount = (base_amount * p) / Decimal(100)
except Exception:
pass
elif fixed is not None:
try:
f = Decimal(str(fixed))
if f > 0:
amount = f
except Exception:
pass
return (seller_id, amount)
def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]: def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]:
try: try:
ei = data.get("extra_info") or {} ei = data.get("extra_info") or {}
@ -492,6 +617,7 @@ def create_invoice(
totals = _extract_totals_from_lines(lines_input) totals = _extract_totals_from_lines(lines_input)
# Inventory validation and costing pre-calculation # Inventory validation and costing pre-calculation
post_inventory: bool = _is_inventory_posting_enabled(data)
# Determine outgoing lines for stock checks # Determine outgoing lines for stock checks
movement_hint, _ = _movement_from_type(invoice_type) movement_hint, _ = _movement_from_type(invoice_type)
outgoing_lines: List[Dict[str, Any]] = [] outgoing_lines: List[Dict[str, Any]] = []
@ -518,6 +644,17 @@ def create_invoice(
info = dict(ln.get("extra_info") or {}) info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False)) info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info ln["extra_info"] = info
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
if post_inventory:
for ln in lines_input:
info = ln.get("extra_info") or {}
inv_tracked = bool(info.get("inventory_tracked"))
mv = info.get("movement") or movement_hint
if inv_tracked and mv in ("in", "out"):
wh = info.get("warehouse_id")
if wh is None:
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
# Filter outgoing lines to only inventory-tracked products for stock checks # Filter outgoing lines to only inventory-tracked products for stock checks
tracked_outgoing_lines: List[Dict[str, Any]] = [] tracked_outgoing_lines: List[Dict[str, Any]] = []
@ -527,12 +664,12 @@ def create_invoice(
tracked_outgoing_lines.append(ln) tracked_outgoing_lines.append(ln)
# Ensure stock sufficiency for outgoing (only for tracked products) # Ensure stock sufficiency for outgoing (only for tracked products)
if tracked_outgoing_lines: if post_inventory and tracked_outgoing_lines:
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines) _ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
# Costing method (only for tracked products) # Costing method (only for tracked products)
costing_method = _get_costing_method(data) costing_method = _get_costing_method(data)
if costing_method == "fifo" and tracked_outgoing_lines: if post_inventory and costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines) fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines # annotate lines with cogs_amount in the same order as tracked_outgoing_lines
i = 0 i = 0
@ -580,7 +717,9 @@ def create_invoice(
qty = Decimal(str(line.get("quantity", 0) or 0)) qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0: if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = line.get("extra_info") or {} extra_info = dict(line.get("extra_info") or {})
# علامت‌گذاری اینکه این خط در انبار پست شده/نشده است
extra_info["inventory_posted"] = bool(post_inventory)
db.add(DocumentLine( db.add(DocumentLine(
document_id=document.id, document_id=document.id,
product_id=int(product_id), product_id=int(product_id),
@ -599,7 +738,7 @@ def create_invoice(
tax = Decimal(str(totals["tax"])) tax = Decimal(str(totals["tax"]))
total_with_tax = net + tax total_with_tax = net + tax
# COGS when applicable # COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمی‌شوند)
cogs_total = _extract_cogs_total(lines_input) cogs_total = _extract_cogs_total(lines_input)
# Sales # Sales
@ -646,6 +785,51 @@ def create_invoice(
description="خروج از موجودی بابت فروش", description="خروج از موجودی بابت فروش",
)) ))
# --- پورسانت فروشنده/بازاریاب (در صورت وجود) ---
# محاسبه و ثبت پورسانت برای فروش و برگشت از فروش
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
if seller_id and commission_amount > 0:
# هزینه پورسانت: 70702، بستانکار: پرداختنی به فروشنده 20201
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if invoice_type == INVOICE_SALES:
# بدهکار هزینه، بستانکار فروشنده
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
# برگشت از فروش: معکوس
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
# Sales Return # Sales Return
elif invoice_type == INVOICE_SALES_RETURN: elif invoice_type == INVOICE_SALES_RETURN:
if person_id: if person_id:
@ -957,6 +1141,16 @@ def update_invoice(
info = dict(ln.get("extra_info") or {}) info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False)) info["inventory_tracked"] = bool(track_map.get(int(pid), False))
ln["extra_info"] = info ln["extra_info"] = info
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
if post_inventory_update:
for ln in lines_input:
info = ln.get("extra_info") or {}
inv_tracked = bool(info.get("inventory_tracked"))
mv = info.get("movement") or movement_hint
if inv_tracked and mv in ("in", "out"):
wh = info.get("warehouse_id")
if wh is None:
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیف‌های دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
tracked_outgoing_lines: List[Dict[str, Any]] = [] tracked_outgoing_lines: List[Dict[str, Any]] = []
for ln in outgoing_lines: for ln in outgoing_lines:
@ -964,12 +1158,13 @@ def update_invoice(
if pid and track_map.get(int(pid)): if pid and track_map.get(int(pid)):
tracked_outgoing_lines.append(ln) tracked_outgoing_lines.append(ln)
if tracked_outgoing_lines: header_for_costing = data if data else {"extra_info": document.extra_info}
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
if post_inventory_update and tracked_outgoing_lines:
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) _ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
header_for_costing = data if data else {"extra_info": document.extra_info}
costing_method = _get_costing_method(header_for_costing) costing_method = _get_costing_method(header_for_costing)
if costing_method == "fifo" and tracked_outgoing_lines: if post_inventory_update and costing_method == "fifo" and tracked_outgoing_lines:
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id) fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
i = 0 i = 0
for ln in lines_input: for ln in lines_input:
@ -987,7 +1182,8 @@ def update_invoice(
qty = Decimal(str(line.get("quantity", 0) or 0)) qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0: if not product_id or qty <= 0:
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400) raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
extra_info = line.get("extra_info") or {} extra_info = dict(line.get("extra_info") or {})
extra_info["inventory_posted"] = bool(post_inventory_update)
db.add(DocumentLine( db.add(DocumentLine(
document_id=document.id, document_id=document.id,
product_id=int(product_id), product_id=int(product_id),
@ -1000,7 +1196,8 @@ def update_invoice(
# Accounting lines if finalized # Accounting lines if finalized
if not document.is_proforma: if not document.is_proforma:
accounts = _resolve_accounts_for_invoice(db, data if data else {"extra_info": document.extra_info}) header_for_accounts: Dict[str, Any] = {"invoice_type": inv_type, **(data or {"extra_info": document.extra_info})}
accounts = _resolve_accounts_for_invoice(db, header_for_accounts)
header_extra = data.get("extra_info") or document.extra_info or {} header_extra = data.get("extra_info") or document.extra_info or {}
totals = (header_extra.get("totals") or {}) totals = (header_extra.get("totals") or {})
if not totals: if not totals:
@ -1059,6 +1256,47 @@ def update_invoice(
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساخته‌شده")) db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساخته‌شده"))
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان")) db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان"))
# --- پورسانت فروشنده/بازاریاب (به‌صورت تکمیلی) ---
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
seller_id, commission_amount = _calculate_seller_commission(db, inv_type, header_extra, totals)
if seller_id and commission_amount > 0:
commission_expense = _get_fixed_account_by_code(db, "70702")
seller_payable = _get_fixed_account_by_code(db, "20201")
if inv_type == INVOICE_SALES:
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=commission_amount,
credit=Decimal(0),
description="هزینه پورسانت فروش",
))
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=Decimal(0),
credit=commission_amount,
description="بابت پورسانت فروشنده/بازاریاب",
extra_info={"seller_id": int(seller_id)},
))
else:
db.add(DocumentLine(
document_id=document.id,
account_id=seller_payable.id,
person_id=int(seller_id),
debit=commission_amount,
credit=Decimal(0),
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
extra_info={"seller_id": int(seller_id)},
))
db.add(DocumentLine(
document_id=document.id,
account_id=commission_expense.id,
debit=Decimal(0),
credit=commission_amount,
description="تعدیل هزینه پورسانت",
))
db.commit() db.commit()
db.refresh(document) db.refresh(document)
return invoice_document_to_dict(db, document) return invoice_document_to_dict(db, document)

View file

@ -4,11 +4,13 @@ from typing import Any, Dict, List, Optional, Tuple
from datetime import date from datetime import date
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, exists, select import logging
from sqlalchemy import and_, or_, exists, select, Integer, cast
from adapters.db.models.document import Document from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine from adapters.db.models.document_line import DocumentLine
from adapters.db.models.fiscal_year import FiscalYear from adapters.db.models.fiscal_year import FiscalYear
from adapters.db.models.warehouse import Warehouse
# Helpers (reuse existing helpers from other services when possible) # Helpers (reuse existing helpers from other services when possible)
@ -42,6 +44,14 @@ def _collect_ids(query: Dict[str, Any], key: str) -> List[int]:
def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
logger = logging.getLogger(__name__)
try:
logger.debug("KARDEX list_kardex_lines called | business_id=%s | keys=%s", business_id, list(query.keys()))
logger.debug("KARDEX filters | person_ids=%s product_ids=%s account_ids=%s match_mode=%s result_scope=%s from=%s to=%s fy=%s",
query.get('person_ids'), query.get('product_ids'), query.get('account_ids'),
query.get('match_mode'), query.get('result_scope'), query.get('from_date'), query.get('to_date'), query.get('fiscal_year_id'))
except Exception:
pass
"""لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالت‌های تطابق. """لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالت‌های تطابق.
پارامترهای ورودی مورد انتظار در query: پارامترهای ورودی مورد انتظار در query:
@ -97,6 +107,7 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
petty_cash_ids = _collect_ids(query, "petty_cash_ids") petty_cash_ids = _collect_ids(query, "petty_cash_ids")
account_ids = _collect_ids(query, "account_ids") account_ids = _collect_ids(query, "account_ids")
check_ids = _collect_ids(query, "check_ids") check_ids = _collect_ids(query, "check_ids")
warehouse_ids = _collect_ids(query, "warehouse_ids")
# Match mode # Match mode
match_mode = str(query.get("match_mode") or "any").lower() match_mode = str(query.get("match_mode") or "any").lower()
@ -176,6 +187,17 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
# any: OR across groups on the same line # any: OR across groups on the same line
q = q.filter(or_(*group_filters)) q = q.filter(or_(*group_filters))
# Warehouse filter (JSON attribute inside extra_info)
if warehouse_ids:
try:
q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].as_string(), Integer).in_(warehouse_ids))
except Exception:
try:
q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].astext, Integer).in_(warehouse_ids))
except Exception:
# در صورت عدم پشتیبانی از عملگر JSON، از فیلتر نرم‌افزاری بعد از واکشی استفاده خواهد شد
pass
# Sorting # Sorting
sort_by = (query.get("sort_by") or "document_date") sort_by = (query.get("sort_by") or "document_date")
sort_desc = bool(query.get("sort_desc", True)) sort_desc = bool(query.get("sort_desc", True))
@ -206,6 +228,10 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
take = 20 take = 20
total = q.count() total = q.count()
try:
logger.debug("KARDEX query total=%s (after filters)", total)
except Exception:
pass
rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all() rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all()
# Running balance (optional) # Running balance (optional)
@ -213,6 +239,38 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
running_amount: float = 0.0 running_amount: float = 0.0
running_quantity: float = 0.0 running_quantity: float = 0.0
# گردآوری شناسه‌های انبار جهت نام‌گذاری
wh_ids_in_page: set[int] = set()
for line, _ in rows:
try:
info = line.extra_info or {}
wid = info.get("warehouse_id")
if wid is not None:
wh_ids_in_page.add(int(wid))
except Exception:
pass
wh_map: Dict[int, str] = {}
if wh_ids_in_page:
for w in db.query(Warehouse).filter(Warehouse.business_id == business_id, Warehouse.id.in_(list(wh_ids_in_page))).all():
try:
name = (w.name or "").strip()
code = (w.code or "").strip()
wh_map[int(w.id)] = f"{code} - {name}" if code else name
except Exception:
continue
def _movement_from_type(inv_type: str | None) -> str | None:
t = (inv_type or "").strip()
if t in ("invoice_sales",):
return "out"
if t in ("invoice_sales_return", "invoice_purchase"):
return "in"
if t in ("invoice_purchase_return", "invoice_direct_consumption", "invoice_waste"):
return "out"
# production: both in/out ممکن است
return None
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
for line, doc in rows: for line, doc in rows:
item: Dict[str, Any] = { item: Dict[str, Any] = {
@ -234,6 +292,20 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
"check_id": line.check_id, "check_id": line.check_id,
} }
# movement & warehouse
try:
info = line.extra_info or {}
mv = info.get("movement")
if mv is None:
mv = _movement_from_type(getattr(doc, "document_type", None))
wid = info.get("warehouse_id")
item["movement"] = mv
item["warehouse_id"] = int(wid) if wid is not None else None
if wid is not None:
item["warehouse_name"] = wh_map.get(int(wid))
except Exception:
pass
if include_running: if include_running:
try: try:
running_amount += float(line.debit or 0) - float(line.credit or 0) running_amount += float(line.debit or 0) - float(line.credit or 0)

View file

@ -11,6 +11,9 @@ DB_HOST=localhost
DB_PORT=3306 DB_PORT=3306
DB_NAME=hesabixpy DB_NAME=hesabixpy
SQLALCHEMY_ECHO=false SQLALCHEMY_ECHO=false
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=10
# Logging # Logging
LOG_LEVEL=INFO LOG_LEVEL=INFO

View file

@ -19,6 +19,7 @@ adapters/api/v1/documents.py
adapters/api/v1/expense_income.py adapters/api/v1/expense_income.py
adapters/api/v1/fiscal_years.py adapters/api/v1/fiscal_years.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/inventory_transfers.py
adapters/api/v1/invoices.py adapters/api/v1/invoices.py
adapters/api/v1/kardex.py adapters/api/v1/kardex.py
adapters/api/v1/persons.py adapters/api/v1/persons.py
@ -146,6 +147,7 @@ app/services/document_service.py
app/services/email_service.py app/services/email_service.py
app/services/expense_income_service.py app/services/expense_income_service.py
app/services/file_storage_service.py app/services/file_storage_service.py
app/services/inventory_transfer_service.py
app/services/invoice_service.py app/services/invoice_service.py
app/services/kardex_service.py app/services/kardex_service.py
app/services/person_service.py app/services/person_service.py

View file

@ -45,6 +45,7 @@ import 'pages/business/expense_income_list_page.dart';
import 'pages/business/transfers_page.dart'; import 'pages/business/transfers_page.dart';
import 'pages/business/documents_page.dart'; import 'pages/business/documents_page.dart';
import 'pages/business/warehouses_page.dart'; import 'pages/business/warehouses_page.dart';
import 'pages/business/inventory_transfers_page.dart';
import 'pages/error_404_page.dart'; import 'pages/error_404_page.dart';
import 'core/locale_controller.dart'; import 'core/locale_controller.dart';
import 'core/calendar_controller.dart'; import 'core/calendar_controller.dart';
@ -639,10 +640,37 @@ class _MyAppState extends State<MyApp> {
name: 'business_reports_kardex', name: 'business_reports_kardex',
pageBuilder: (context, state) { pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!); final businessId = int.parse(state.pathParameters['business_id']!);
// Parse person_id(s) from query
final qp = state.uri.queryParameters;
final qpAll = state.uri.queryParametersAll;
final Set<int> initialPersonIds = <int>{};
final single = int.tryParse(qp['person_id'] ?? '');
if (single != null) initialPersonIds.add(single);
final multi = (qpAll['person_id'] ?? const <String>[])
.map((e) => int.tryParse(e))
.whereType<int>();
initialPersonIds.addAll(multi);
// Also parse from extra
try {
if (state.extra is Map) {
final extra = state.extra as Map;
final list = extra['person_ids'];
if (list is List) {
for (final v in list) {
if (v is int) initialPersonIds.add(v);
else {
final p = int.tryParse('$v');
if (p != null) initialPersonIds.add(p);
}
}
}
}
} catch (_) {}
return NoTransitionPage( return NoTransitionPage(
child: KardexPage( child: KardexPage(
businessId: businessId, businessId: businessId,
calendarController: _calendarController!, calendarController: _calendarController!,
initialPersonIds: initialPersonIds.toList(),
), ),
); );
}, },
@ -808,6 +836,19 @@ class _MyAppState extends State<MyApp> {
); );
}, },
), ),
GoRoute(
path: '/business/:business_id/inventory-transfers',
name: 'business_inventory_transfers',
pageBuilder: (context, state) {
final businessId = int.parse(state.pathParameters['business_id']!);
return NoTransitionPage(
child: InventoryTransfersPage(
businessId: businessId,
calendarController: _calendarController!,
),
);
},
),
GoRoute( GoRoute(
path: '/business/:business_id/documents', path: '/business/:business_id/documents',
name: 'business_documents', name: 'business_documents',

View file

@ -29,6 +29,7 @@ class InvoiceLineItem {
// inventory/constraints // inventory/constraints
final int? minOrderQty; final int? minOrderQty;
final bool trackInventory; final bool trackInventory;
final int? warehouseId; // انبار انتخابی برای ردیف
// presentation // presentation
String? description; String? description;
@ -52,6 +53,7 @@ class InvoiceLineItem {
this.basePurchasePriceMainUnit, this.basePurchasePriceMainUnit,
this.minOrderQty, this.minOrderQty,
this.trackInventory = false, this.trackInventory = false,
this.warehouseId,
}); });
InvoiceLineItem copyWith({ InvoiceLineItem copyWith({
@ -73,6 +75,7 @@ class InvoiceLineItem {
num? basePurchasePriceMainUnit, num? basePurchasePriceMainUnit,
int? minOrderQty, int? minOrderQty,
bool? trackInventory, bool? trackInventory,
int? warehouseId,
}) { }) {
return InvoiceLineItem( return InvoiceLineItem(
productId: productId ?? this.productId, productId: productId ?? this.productId,
@ -93,6 +96,7 @@ class InvoiceLineItem {
basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit, basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit,
minOrderQty: minOrderQty ?? this.minOrderQty, minOrderQty: minOrderQty ?? this.minOrderQty,
trackInventory: trackInventory ?? this.trackInventory, trackInventory: trackInventory ?? this.trackInventory,
warehouseId: warehouseId ?? this.warehouseId,
); );
} }

View file

@ -8,6 +8,7 @@ class AccountNode {
final String code; final String code;
final String name; final String name;
final String? accountType; final String? accountType;
final int? businessId;
final List<AccountNode> children; final List<AccountNode> children;
final bool hasChildren; final bool hasChildren;
@ -16,6 +17,7 @@ class AccountNode {
required this.code, required this.code,
required this.name, required this.name,
this.accountType, this.accountType,
this.businessId,
this.children = const [], this.children = const [],
this.hasChildren = false, this.hasChildren = false,
}); });
@ -30,6 +32,9 @@ class AccountNode {
code: json['code']?.toString() ?? '', code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '', name: json['name']?.toString() ?? '',
accountType: json['account_type']?.toString(), accountType: json['account_type']?.toString(),
businessId: json['business_id'] is int
? (json['business_id'] as int)
: (json['business_id'] != null ? int.tryParse(json['business_id'].toString()) : null),
children: parsedChildren, children: parsedChildren,
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty, hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
); );
@ -86,6 +91,8 @@ class _AccountsPageState extends State<AccountsPage> {
items.add({ items.add({
"id": n.id, "id": n.id,
"title": ("\u200f" * level) + n.code + " - " + n.name, "title": ("\u200f" * level) + n.code + " - " + n.name,
"business_id": n.businessId?.toString() ?? "",
"has_children": n.hasChildren ? "1" : "0",
}); });
for (final c in n.children) { for (final c in n.children) {
dfs(c, level + 1); dfs(c, level + 1);
@ -97,56 +104,125 @@ class _AccountsPageState extends State<AccountsPage> {
return items; return items;
} }
Future<void> _openCreateDialog() async { String? _suggestNextCode({String? parentId}) {
List<String> codes = <String>[];
if (parentId == null || parentId.isEmpty) {
codes = _roots.map((e) => e.code).toList();
} else {
AccountNode? find(AccountNode n) {
if (n.id == parentId) return n;
for (final c in n.children) {
final x = find(c);
if (x != null) return x;
}
return null;
}
AccountNode? parent;
for (final r in _roots) {
parent = find(r);
if (parent != null) break;
}
if (parent != null) codes = parent.children.map((e) => e.code).toList();
}
final numeric = codes.map((c) => int.tryParse(c)).whereType<int>().toList();
if (numeric.isEmpty) return null;
final next = (numeric..sort()).last + 1;
return next.toString();
}
Future<void> _openCreateDialog({AccountNode? parent}) async {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
final codeCtrl = TextEditingController(); final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController(); final nameCtrl = TextEditingController();
final typeCtrl = TextEditingController(); String? selectedType;
String? selectedParentId; String? selectedParentId = parent?.id;
final parents = _flattenNodes(); final parents = _flattenNodes();
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
return AlertDialog( return AlertDialog(
title: Text(t.addAccount), title: Text(t.addAccount),
content: SingleChildScrollView( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( Row(children: [
Expanded(child: TextField(
controller: codeCtrl, controller: codeCtrl,
decoration: InputDecoration(labelText: t.code), decoration: InputDecoration(labelText: t.code, prefixIcon: const Icon(Icons.numbers)),
)),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
final s = _suggestNextCode(parentId: selectedParentId);
if (s != null) codeCtrl.text = s;
},
icon: const Icon(Icons.auto_fix_high, size: 18),
label: const Text('پیشنهاد کد'),
), ),
]),
const SizedBox(height: 10),
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
decoration: InputDecoration(labelText: t.title), decoration: InputDecoration(labelText: t.title, prefixIcon: const Icon(Icons.title)),
), ),
TextField( const SizedBox(height: 10),
controller: typeCtrl, DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: t.type), value: selectedType,
items: const [
DropdownMenuItem(value: 'bank', child: Text('بانک')),
DropdownMenuItem(value: 'cash_register', child: Text('صندوق')),
DropdownMenuItem(value: 'petty_cash', child: Text('تنخواه')),
DropdownMenuItem(value: 'check', child: Text('چک')),
DropdownMenuItem(value: 'person', child: Text('شخص')),
DropdownMenuItem(value: 'product', child: Text('کالا')),
DropdownMenuItem(value: 'service', child: Text('خدمت')),
DropdownMenuItem(value: 'accounting_document', child: Text('سند حسابداری')),
],
onChanged: (v) { selectedType = v; },
decoration: InputDecoration(labelText: t.type, prefixIcon: const Icon(Icons.category)),
), ),
const SizedBox(height: 10),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: selectedParentId, value: selectedParentId,
items: [ items: [
DropdownMenuItem<String>(value: null, child: Text('بدون والد')), ...(() {
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(), List<Map<String, String>> src = parents;
if (parent != null) {
return src.where((p) => p['id'] == parent.id).map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList();
}
return src.where((p) {
final bid = p['business_id'];
final hc = p['has_children'];
final isPublic = (bid == null || bid.isEmpty);
final isSameBusiness = bid == widget.businessId.toString();
return (isPublic && hc == '1') || isSameBusiness;
}).map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList();
})(),
], ],
onChanged: (v) { onChanged: parent != null ? null : (v) {
selectedParentId = v; selectedParentId = v;
if ((codeCtrl.text).trim().isEmpty) {
final s = _suggestNextCode(parentId: selectedParentId);
if (s != null) codeCtrl.text = s;
}
}, },
decoration: const InputDecoration(labelText: 'حساب والد'), decoration: const InputDecoration(labelText: 'حساب والد', prefixIcon: Icon(Icons.account_tree)),
), ),
], ],
), ),
), ),
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)), TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton( FilledButton(
onPressed: () async { onPressed: () async {
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
final code = codeCtrl.text.trim(); final code = codeCtrl.text.trim();
final atype = typeCtrl.text.trim(); final atype = (selectedType ?? '').trim();
if (name.isEmpty || code.isEmpty || atype.isEmpty) { if (name.isEmpty || code.isEmpty || atype.isEmpty || selectedParentId == null || selectedParentId!.isEmpty) {
return; return;
} }
final Map<String, dynamic> payload = { final Map<String, dynamic> payload = {
@ -216,7 +292,7 @@ class _AccountsPageState extends State<AccountsPage> {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
final codeCtrl = TextEditingController(text: node.code); final codeCtrl = TextEditingController(text: node.code);
final nameCtrl = TextEditingController(text: node.name); final nameCtrl = TextEditingController(text: node.name);
final typeCtrl = TextEditingController(text: node.accountType ?? ''); String? selectedType = node.accountType;
final parents = _flattenNodes(); final parents = _flattenNodes();
String? selectedParentId; String? selectedParentId;
final result = await showDialog<bool>( final result = await showDialog<bool>(
@ -230,12 +306,29 @@ class _AccountsPageState extends State<AccountsPage> {
children: [ children: [
TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)), TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)),
TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)), TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)),
TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)), DropdownButtonFormField<String>(
value: selectedType,
items: const [
DropdownMenuItem(value: 'bank', child: Text('بانک')),
DropdownMenuItem(value: 'cash_register', child: Text('صندوق')),
DropdownMenuItem(value: 'petty_cash', child: Text('تنخواه')),
DropdownMenuItem(value: 'check', child: Text('چک')),
DropdownMenuItem(value: 'person', child: Text('شخص')),
DropdownMenuItem(value: 'product', child: Text('کالا')),
DropdownMenuItem(value: 'service', child: Text('خدمت')),
DropdownMenuItem(value: 'accounting_document', child: Text('سند حسابداری')),
],
onChanged: (v) { selectedType = v; },
decoration: InputDecoration(labelText: t.type),
),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: selectedParentId, value: selectedParentId,
items: [ items: [
DropdownMenuItem<String>(value: null, child: Text('بدون والد')), DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(), ...parents.where((p) {
final bid = p['business_id'];
return (bid == null || bid.isEmpty) || bid == widget.businessId.toString();
}).map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
], ],
onChanged: (v) { selectedParentId = v; }, onChanged: (v) { selectedParentId = v; },
decoration: const InputDecoration(labelText: 'حساب والد'), decoration: const InputDecoration(labelText: 'حساب والد'),
@ -249,7 +342,7 @@ class _AccountsPageState extends State<AccountsPage> {
onPressed: () async { onPressed: () async {
final name = nameCtrl.text.trim(); final name = nameCtrl.text.trim();
final code = codeCtrl.text.trim(); final code = codeCtrl.text.trim();
final atype = typeCtrl.text.trim(); final atype = (selectedType ?? '').trim();
if (name.isEmpty || code.isEmpty || atype.isEmpty) return; if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype}; final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
if (selectedParentId != null && selectedParentId!.isNotEmpty) { if (selectedParentId != null && selectedParentId!.isNotEmpty) {
@ -417,6 +510,7 @@ class _AccountsPageState extends State<AccountsPage> {
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
if (node.businessId == null) const SizedBox(width: 20, child: Icon(Icons.lock_outline, size: 16)),
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
Expanded(flex: 5, child: Text(node.name)), Expanded(flex: 5, child: Text(node.name)),
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
@ -425,13 +519,30 @@ class _AccountsPageState extends State<AccountsPage> {
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onSelected: (v) { onSelected: (v) {
if (v == 'add_child') _openCreateDialog(parent: node);
if (v == 'edit') _openEditDialog(node); if (v == 'edit') _openEditDialog(node);
if (v == 'delete') _confirmDelete(node); if (v == 'delete') _confirmDelete(node);
}, },
itemBuilder: (context) => [ itemBuilder: (context) {
const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')), final bool isOwned = node.businessId != null && node.businessId == widget.businessId;
const PopupMenuItem<String>(value: 'delete', child: Text('حذف')), final bool canEdit = isOwned;
], final bool canDelete = isOwned && !node.hasChildren;
final bool canAddChild = widget.authStore.canWriteSection('accounting') && ((node.businessId == null && node.hasChildren) || isOwned);
final List<PopupMenuEntry<String>> items = <PopupMenuEntry<String>>[];
if (canAddChild) {
items.add(const PopupMenuItem<String>(value: 'add_child', child: Text('افزودن ریز حساب')));
}
if (canEdit) {
items.add(const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')));
}
if (canDelete) {
items.add(const PopupMenuItem<String>(value: 'delete', child: Text('حذف')));
}
if (items.isEmpty) {
return [const PopupMenuItem<String>(value: 'noop', enabled: false, child: Text('غیرقابل ویرایش'))];
}
return items;
},
), ),
), ),
], ],

View file

@ -380,9 +380,9 @@ class _BusinessShellState extends State<BusinessShell> {
label: t.shipments, label: t.shipments,
icon: Icons.local_shipping, icon: Icons.local_shipping,
selectedIcon: Icons.local_shipping, selectedIcon: Icons.local_shipping,
path: '/business/${widget.businessId}/shipments', path: '/business/${widget.businessId}/inventory-transfers',
type: _MenuItemType.simple, type: _MenuItemType.simple,
hasAddButton: true, hasAddButton: false,
), ),
], ],
), ),

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.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/core/calendar_controller.dart';
import 'package:hesabix_ui/widgets/transfer/inventory_transfer_form_dialog.dart';
class InventoryTransfersPage extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
const InventoryTransfersPage({super.key, required this.businessId, required this.calendarController});
@override
State<InventoryTransfersPage> createState() => _InventoryTransfersPageState();
}
class _InventoryTransfersPageState extends State<InventoryTransfersPage> {
final GlobalKey _tableKey = GlobalKey();
void _refreshTable() {
final state = _tableKey.currentState;
if (state != null) {
try {
(state as dynamic).refresh();
return;
} catch (_) {}
}
if (mounted) setState(() {});
}
Future<void> _onAddNew() async {
final res = await showDialog<bool>(
context: context,
builder: (_) => InventoryTransferFormDialog(
businessId: widget.businessId,
calendarController: widget.calendarController,
),
);
if (res == true) _refreshTable();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('انتقال موجودی بین انبارها', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
FilledButton.icon(onPressed: _onAddNew, icon: const Icon(Icons.add), label: const Text('افزودن انتقال')),
],
),
const SizedBox(height: 8),
Expanded(
child: DataTableWidget<Map<String, dynamic>>(
key: _tableKey,
config: DataTableConfig<Map<String, dynamic>>(
endpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/query',
excelEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/excel',
pdfEndpoint: '/api/v1/inventory-transfers/business/${widget.businessId}/export/pdf',
title: 'انتقال موجودی بین انبارها',
showBackButton: true,
showSearch: false,
showPagination: true,
showRowNumbers: true,
enableSorting: true,
showExportButtons: true,
columns: [
TextColumn('code', 'کد سند', formatter: (it) => (it as Map<String, dynamic>)['code']?.toString()),
DateColumn('document_date', 'تاریخ سند', formatter: (it) => (it as Map<String, dynamic>)['document_date']?.toString()),
TextColumn('description', 'شرح', formatter: (it) => (it as Map<String, dynamic>)['description']?.toString()),
],
searchFields: const [],
defaultPageSize: 20,
),
fromJson: (json) => Map<String, dynamic>.from(json as Map),
calendarController: widget.calendarController,
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,8 @@ class NewInvoicePage extends StatefulWidget {
} }
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin { class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
// تنظیمات انبار
bool _postInventory = true; // ثبت اسناد انبار
late TabController _tabController; late TabController _tabController;
InvoiceType? _selectedInvoiceType; InvoiceType? _selectedInvoiceType;
@ -360,13 +362,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
_selectedSeller = seller; _selectedSeller = seller;
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده // تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
if (seller != null) { if (seller != null) {
if (seller.commissionSalePercent != null) { final isSales = _selectedInvoiceType == InvoiceType.sales;
final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn;
final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null);
final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null);
if (percent != null) {
_commissionType = CommissionType.percentage; _commissionType = CommissionType.percentage;
_commissionPercentage = seller.commissionSalePercent; _commissionPercentage = percent;
_commissionAmount = null; _commissionAmount = null;
} else if (seller.commissionSalesAmount != null) { } else if (amount != null) {
_commissionType = CommissionType.amount; _commissionType = CommissionType.amount;
_commissionAmount = seller.commissionSalesAmount; _commissionAmount = amount;
_commissionPercentage = null; _commissionPercentage = null;
} }
} else { } else {
@ -678,13 +684,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
_selectedSeller = seller; _selectedSeller = seller;
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده // تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
if (seller != null) { if (seller != null) {
if (seller.commissionSalePercent != null) { final isSales = _selectedInvoiceType == InvoiceType.sales;
final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn;
final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null);
final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null);
if (percent != null) {
_commissionType = CommissionType.percentage; _commissionType = CommissionType.percentage;
_commissionPercentage = seller.commissionSalePercent; _commissionPercentage = percent;
_commissionAmount = null; _commissionAmount = null;
} else if (seller.commissionSalesAmount != null) { } else if (amount != null) {
_commissionType = CommissionType.amount; _commissionType = CommissionType.amount;
_commissionAmount = seller.commissionSalesAmount; _commissionAmount = amount;
_commissionPercentage = null; _commissionPercentage = null;
} }
} else { } else {
@ -837,6 +847,18 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
if (r.taxRate < 0 || r.taxRate > 100) { if (r.taxRate < 0 || r.taxRate > 100) {
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد'; return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
} }
// الزام انبار در حالت ثبت اسناد انبار و کالاهای تحت کنترل موجودی
if (_postInventory && r.trackInventory) {
final isOut = _selectedInvoiceType == InvoiceType.sales ||
_selectedInvoiceType == InvoiceType.purchaseReturn ||
_selectedInvoiceType == InvoiceType.directConsumption ||
_selectedInvoiceType == InvoiceType.waste;
final isIn = _selectedInvoiceType == InvoiceType.purchase ||
_selectedInvoiceType == InvoiceType.salesReturn;
if ((isOut || isIn) && r.warehouseId == null) {
return 'انبار ردیف ${i + 1} الزامی است';
}
}
} }
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn; final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
@ -877,6 +899,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
'net': _sumTotal, 'net': _sumTotal,
}, },
}; };
// سوییچ ثبت اسناد انبار
extraInfo['post_inventory'] = _postInventory;
// افزودن person_id بر اساس نوع فاکتور // افزودن person_id بر اساس نوع فاکتور
if (isSalesOrReturn && _selectedCustomer != null) { if (isSalesOrReturn && _selectedCustomer != null) {
@ -947,6 +971,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
'tax_amount': taxAmount, 'tax_amount': taxAmount,
'line_total': lineTotal, 'line_total': lineTotal,
if (movement != null) 'movement': movement, if (movement != null) 'movement': movement,
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
// اطلاعات اضافی برای ردیابی // اطلاعات اضافی برای ردیابی
'unit': e.selectedUnit ?? e.mainUnit, 'unit': e.selectedUnit ?? e.mainUnit,
'unit_price_source': e.unitPriceSource, 'unit_price_source': e.unitPriceSource,
@ -979,6 +1004,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
businessId: widget.businessId, businessId: widget.businessId,
selectedCurrencyId: _selectedCurrencyId, selectedCurrencyId: _selectedCurrencyId,
invoiceType: (_selectedInvoiceType?.value ?? 'sales'), invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
postInventory: _postInventory,
onChanged: (rows) { onChanged: (rows) {
setState(() { setState(() {
_lineItems = rows; _lineItems = rows;
@ -1058,6 +1084,30 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// تنظیمات انبار
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('ثبت اسناد انبار'),
subtitle: const Text('در صورت غیرفعال‌سازی، حرکات موجودی ثبت نمی‌شوند و کنترل کسری انجام نمی‌گردد'),
value: _postInventory,
onChanged: (value) {
setState(() {
_postInventory = value;
});
},
),
],
),
),
),
const SizedBox(height: 24),
// چاپ فاکتور بعد از صدور // چاپ فاکتور بعد از صدور
Card( Card(
child: Padding( child: Padding(

View file

@ -77,7 +77,12 @@ class _PersonsPageState extends State<PersonsPage> {
return InkWell( return InkWell(
onTap: () { onTap: () {
if (person.id != null) { if (person.id != null) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}'); context.go(
'/business/${widget.businessId}/reports/kardex',
extra: {
'person_ids': [person.id]
},
);
} }
}, },
child: Text( child: Text(
@ -337,11 +342,21 @@ class _PersonsPageState extends State<PersonsPage> {
label: 'کاردکس', label: 'کاردکس',
onTap: (person) { onTap: (person) {
if (person is Person && person.id != null) { if (person is Person && person.id != null) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}'); context.go(
'/business/${widget.businessId}/reports/kardex',
extra: {
'person_ids': [person.id]
},
);
} else if (person is Map<String, dynamic>) { } else if (person is Map<String, dynamic>) {
final id = person['id']; final id = person['id'];
if (id is int) { if (id is int) {
context.go('/business/${widget.businessId}/reports/kardex?person_id=$id'); context.go(
'/business/${widget.businessId}/reports/kardex',
extra: {
'person_ids': [id]
},
);
} }
} }
}, },

View file

@ -0,0 +1,13 @@
import '../core/api_client.dart';
class InventoryTransferService {
final ApiClient _api;
InventoryTransferService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient();
Future<Map<String, dynamic>> create({required int businessId, required Map<String, dynamic> payload}) async {
final res = await _api.post<Map<String, dynamic>>('/api/v1/inventory-transfers/business/$businessId', data: payload);
return Map<String, dynamic>.from(res.data?['data'] as Map? ?? <String, dynamic>{});
}
}

View file

@ -26,6 +26,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
late List<String> _columnOrder; late List<String> _columnOrder;
late Map<String, double> _columnWidths; late Map<String, double> _columnWidths;
late List<DataTableColumn> _columns; // Local copy of columns late List<DataTableColumn> _columns; // Local copy of columns
late Set<String> _pinnedLeft;
late Set<String> _pinnedRight;
@override @override
void initState() { void initState() {
@ -34,6 +36,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
_columnOrder = List.from(widget.currentSettings.columnOrder); _columnOrder = List.from(widget.currentSettings.columnOrder);
_columnWidths = Map.from(widget.currentSettings.columnWidths); _columnWidths = Map.from(widget.currentSettings.columnWidths);
_columns = List.from(widget.columns); // Create local copy _columns = List.from(widget.columns); // Create local copy
_pinnedLeft = Set<String>.from(widget.currentSettings.pinnedLeft);
_pinnedRight = Set<String>.from(widget.currentSettings.pinnedRight);
} }
@override @override
@ -172,6 +176,13 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(
'پین',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
Text( Text(
t.order, t.order,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
@ -269,6 +280,52 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: 'پین چپ',
child: IconButton(
icon: Icon(Icons.push_pin,
size: 16,
color: _pinnedLeft.contains(column.key)
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant),
onPressed: () {
setState(() {
_pinnedRight.remove(column.key);
if (_pinnedLeft.contains(column.key)) {
_pinnedLeft.remove(column.key);
} else {
_pinnedLeft.add(column.key);
}
});
},
),
),
Tooltip(
message: 'پین راست',
child: IconButton(
icon: Icon(Icons.push_pin_outlined,
size: 16,
color: _pinnedRight.contains(column.key)
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant),
onPressed: () {
setState(() {
_pinnedLeft.remove(column.key);
if (_pinnedRight.contains(column.key)) {
_pinnedRight.remove(column.key);
} else {
_pinnedRight.add(column.key);
}
});
},
),
),
],
),
const SizedBox(width: 8),
Icon( Icon(
Icons.drag_handle, Icons.drag_handle,
size: 16, size: 16,
@ -312,6 +369,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
visibleColumns: _visibleColumns, visibleColumns: _visibleColumns,
columnOrder: _columnOrder, columnOrder: _columnOrder,
columnWidths: _columnWidths, columnWidths: _columnWidths,
pinnedLeft: _pinnedLeft.toList(),
pinnedRight: _pinnedRight.toList(),
); );
Navigator.of(context).pop(newSettings); Navigator.of(context).pop(newSettings);

View file

@ -1,9 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' show FontFeature;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:data_table_2/data_table_2.dart'; import 'package:data_table_2/data_table_2.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
@ -76,6 +79,14 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
// Scroll controller for horizontal scrolling // Scroll controller for horizontal scrolling
late ScrollController _horizontalScrollController; late ScrollController _horizontalScrollController;
// Density (row height)
bool _dense = false;
// Keyboard focus and navigation
final FocusNode _tableFocusNode = FocusNode(debugLabel: 'DataTableFocus');
int _activeRowIndex = -1;
int? _lastSelectedRowIndex;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -83,6 +94,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_limit = widget.config.defaultPageSize; _limit = widget.config.defaultPageSize;
_setupSearchListener(); _setupSearchListener();
_loadColumnSettings(); _loadColumnSettings();
_loadDensityPreference();
_fetchData(); _fetchData();
} }
@ -112,6 +124,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_searchCtrl.dispose(); _searchCtrl.dispose();
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_horizontalScrollController.dispose(); _horizontalScrollController.dispose();
_tableFocusNode.dispose();
for (var controller in _columnSearchControllers.values) { for (var controller in _columnSearchControllers.values) {
controller.dispose(); controller.dispose();
} }
@ -128,6 +141,23 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
}); });
} }
Future<void> _loadDensityPreference() async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'data_table_density_${widget.config.effectiveTableId}';
final dense = prefs.getBool(key) ?? false;
if (mounted) setState(() => _dense = dense);
} catch (_) {}
}
Future<void> _saveDensityPreference() async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'data_table_density_${widget.config.effectiveTableId}';
await prefs.setBool(key, _dense);
} catch (_) {}
}
Future<void> _loadColumnSettings() async { Future<void> _loadColumnSettings() async {
if (!widget.config.enableColumnSettings) { if (!widget.config.enableColumnSettings) {
_visibleColumns = List.from(widget.config.columns); _visibleColumns = List.from(widget.config.columns);
@ -227,6 +257,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_total = response.total; _total = response.total;
_totalPages = response.totalPages; _totalPages = response.totalPages;
_selectedRows.clear(); // Clear selection when data changes _selectedRows.clear(); // Clear selection when data changes
_activeRowIndex = _items.isNotEmpty ? 0 : -1;
_lastSelectedRowIndex = null;
}); });
} }
@ -411,6 +443,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
if (!widget.config.enableRowSelection) return; if (!widget.config.enableRowSelection) return;
setState(() { setState(() {
final bool isShift = HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) ||
HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight);
if (widget.config.enableMultiRowSelection && isShift && _lastSelectedRowIndex != null) {
final int start = math.min(_lastSelectedRowIndex!, rowIndex);
final int end = math.max(_lastSelectedRowIndex!, rowIndex);
for (int i = start; i <= end; i++) {
_selectedRows.add(i);
}
} else {
if (widget.config.enableMultiRowSelection) { if (widget.config.enableMultiRowSelection) {
if (_selectedRows.contains(rowIndex)) { if (_selectedRows.contains(rowIndex)) {
_selectedRows.remove(rowIndex); _selectedRows.remove(rowIndex);
@ -421,6 +463,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_selectedRows.clear(); _selectedRows.clear();
_selectedRows.add(rowIndex); _selectedRows.add(rowIndex);
} }
_lastSelectedRowIndex = rowIndex;
}
}); });
if (widget.config.onRowSelectionChanged != null) { if (widget.config.onRowSelectionChanged != null) {
@ -762,6 +806,50 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
), ),
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.keyJ): const MoveRowIntent(1),
LogicalKeySet(LogicalKeyboardKey.keyK): const MoveRowIntent(-1),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const MoveRowIntent(1),
LogicalKeySet(LogicalKeyboardKey.arrowUp): const MoveRowIntent(-1),
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateRowIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const ToggleSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.escape): const ClearSelectionIntent(),
LogicalKeySet(LogicalKeyboardKey.keyA, LogicalKeyboardKey.control): const SelectAllIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
MoveRowIntent: CallbackAction<MoveRowIntent>(onInvoke: (intent) {
if (_items.isEmpty) return null;
setState(() {
final next = (_activeRowIndex == -1 ? 0 : _activeRowIndex) + intent.delta;
_activeRowIndex = next.clamp(0, _items.length - 1);
});
return null;
}),
ActivateRowIntent: CallbackAction<ActivateRowIntent>(onInvoke: (intent) {
if (_activeRowIndex >= 0 && _activeRowIndex < _items.length && widget.config.onRowTap != null) {
widget.config.onRowTap!(_items[_activeRowIndex]);
}
return null;
}),
ToggleSelectionIntent: CallbackAction<ToggleSelectionIntent>(onInvoke: (intent) {
if (widget.config.enableRowSelection && _activeRowIndex >= 0 && _activeRowIndex < _items.length) {
_toggleRowSelection(_activeRowIndex);
}
return null;
}),
ClearSelectionIntent: CallbackAction<ClearSelectionIntent>(onInvoke: (intent) {
_clearRowSelection();
return null;
}),
SelectAllIntent: CallbackAction<SelectAllIntent>(onInvoke: (intent) {
_selectAllRows();
return null;
}),
},
child: Focus(
focusNode: _tableFocusNode,
child: Container( child: Container(
padding: widget.config.padding ?? const EdgeInsets.all(16), padding: widget.config.padding ?? const EdgeInsets.all(16),
margin: widget.config.margin, margin: widget.config.margin,
@ -818,6 +906,53 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
const SizedBox(height: 10), const SizedBox(height: 10),
], ],
// Selection toolbar
if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.2)),
),
child: Row(
children: [
Icon(Icons.check_box, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text('${_selectedRows.length} مورد انتخاب شده',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onPrimaryContainer,
),
),
const Spacer(),
TextButton.icon(
onPressed: _clearRowSelection,
icon: const Icon(Icons.clear),
label: const Text('لغو انتخاب'),
),
if (widget.config.excelEndpoint != null) ...[
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () => _exportData('excel', true),
icon: const Icon(Icons.table_chart),
label: const Text('خروجی اکسل انتخاب‌ها'),
),
],
if (widget.config.pdfEndpoint != null) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _exportData('pdf', true),
icon: const Icon(Icons.picture_as_pdf),
label: const Text('PDF انتخاب‌ها'),
),
],
],
),
),
const SizedBox(height: 10),
],
// Data Table // Data Table
Expanded( Expanded(
child: _buildDataTable(t, theme), child: _buildDataTable(t, theme),
@ -831,6 +966,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
], ],
), ),
), ),
),
),
),
); );
} }
@ -922,6 +1060,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
case 'columnSettings': case 'columnSettings':
_openColumnSettingsDialog(); _openColumnSettingsDialog();
break; break;
case 'toggleDensity':
setState(() {
_dense = !_dense;
});
_saveDensityPreference();
break;
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@ -954,6 +1098,17 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
], ],
), ),
), ),
const PopupMenuDivider(),
PopupMenuItem(
value: 'toggleDensity',
child: Row(
children: [
Icon(_dense ? Icons.check_box : Icons.check_box_outline_blank, size: 20),
const SizedBox(width: 8),
const Text('حالت فشرده'),
],
),
),
], ],
), ),
], ],
@ -1253,21 +1408,36 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
Widget _buildDataTable(AppLocalizations t, ThemeData theme) { Widget _buildDataTable(AppLocalizations t, ThemeData theme) {
if (_loadingList) { if (_loadingList) {
return Center( return Column(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (widget.config.loadingWidget != null) Expanded(
widget.config.loadingWidget! child: ListView.builder(
else itemCount: 8,
const CircularProgressIndicator(), itemBuilder: (context, index) {
const SizedBox(height: 16), return Padding(
Text( padding: const EdgeInsets.symmetric(vertical: 6.0),
widget.config.loadingMessage ?? t.loading, child: Row(
style: theme.textTheme.bodyMedium, children: List.generate(5, (i) {
return Expanded(
child: Container(
height: _dense ? 28 : 36,
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(6),
), ),
),
);
}),
),
);
},
),
),
const SizedBox(height: 12),
Text(widget.config.loadingMessage ?? t.loading, style: theme.textTheme.bodyMedium),
const SizedBox(height: 8),
], ],
),
); );
} }
@ -1322,6 +1492,23 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
), ),
), ),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
FilledButton.icon(
onPressed: _fetchData,
icon: const Icon(Icons.refresh),
label: Text(t.refresh),
),
if (_hasActiveFilters())
OutlinedButton.icon(
onPressed: _clearAllFilters,
icon: const Icon(Icons.filter_alt_off),
label: Text(t.clear),
),
],
),
], ],
), ),
); );
@ -1395,7 +1582,19 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
? _visibleColumns ? _visibleColumns
: widget.config.columns; : widget.config.columns;
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); List<DataTableColumn> dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
// Reorder by pinning if settings available
if (widget.config.enableColumnSettings && _columnSettings != null) {
final visibleKeys = _columnSettings!.visibleColumns.toSet();
final order = _columnSettings!.columnOrder;
List<String> middleKeys = order.where((k) => visibleKeys.contains(k)).toList();
final leftKeys = _columnSettings!.pinnedLeft.where((k) => middleKeys.contains(k)).toList();
final rightKeys = _columnSettings!.pinnedRight.where((k) => middleKeys.contains(k)).toList();
middleKeys.removeWhere((k) => leftKeys.contains(k) || rightKeys.contains(k));
List<String> finalOrder = [...leftKeys, ...middleKeys, ...rightKeys];
final mapByKey = {for (final c in dataColumnsToShow) c.key: c};
dataColumnsToShow = finalOrder.map((k) => mapByKey[k]).whereType<DataTableColumn>().toList();
}
columns.addAll(dataColumnsToShow.map((column) { columns.addAll(dataColumnsToShow.map((column) {
final headerTextStyle = theme.textTheme.titleSmall?.copyWith( final headerTextStyle = theme.textTheme.titleSmall?.copyWith(
@ -1403,9 +1602,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600); ) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
final double baseWidth = DataTableUtils.getColumnWidth(column.width); final double baseWidth = DataTableUtils.getColumnWidth(column.width);
final double affordancePadding = 48.0; final double affordancePadding = 64.0; // space for icons + resize handle
final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding; final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding;
final double computedWidth = math.max(baseWidth, headerTextWidth); final double minWidth = 96.0;
final double defaultWidth = math.max(baseWidth, headerTextWidth);
final double savedWidth = _columnSettings?.columnWidths[column.key] ?? defaultWidth;
final double computedWidth = math.max(savedWidth, minWidth);
return DataColumn2( return DataColumn2(
label: _ColumnHeaderWithSearch( label: _ColumnHeaderWithSearch(
@ -1419,6 +1621,74 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
: () { }, : () { },
hasActiveFilter: _columnSearchValues.containsKey(column.key), hasActiveFilter: _columnSearchValues.containsKey(column.key),
enabled: widget.config.enableSorting && column.sortable, enabled: widget.config.enableSorting && column.sortable,
onResizeDrag: widget.config.enableColumnSettings ? (dx) {
if (_columnSettings == null) return;
final current = _columnSettings!.columnWidths[column.key] ?? savedWidth;
final next = math.max(minWidth, current + dx);
final updated = _columnSettings!.copyWith(
columnWidths: {
..._columnSettings!.columnWidths,
column.key: next,
},
);
setState(() {
_columnSettings = updated;
});
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
} : null,
onPinLeft: widget.config.enableColumnSettings ? () {
if (_columnSettings == null) return;
final updated = _columnSettings!.copyWith(
pinnedLeft: {
..._columnSettings!.pinnedLeft,
column.key,
}.toList(),
pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(),
);
setState(() {
_columnSettings = updated;
_visibleColumns = _getVisibleColumnsFromSettings(updated);
});
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
} : null,
onPinRight: widget.config.enableColumnSettings ? () {
if (_columnSettings == null) return;
final updated = _columnSettings!.copyWith(
pinnedRight: {
..._columnSettings!.pinnedRight,
column.key,
}.toList(),
pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(),
);
setState(() {
_columnSettings = updated;
_visibleColumns = _getVisibleColumnsFromSettings(updated);
});
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
} : null,
onUnpin: widget.config.enableColumnSettings ? () {
if (_columnSettings == null) return;
final updated = _columnSettings!.copyWith(
pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(),
pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(),
);
setState(() {
_columnSettings = updated;
_visibleColumns = _getVisibleColumnsFromSettings(updated);
});
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
} : null,
onHide: widget.config.enableColumnSettings ? () {
if (_columnSettings == null) return;
final updated = _columnSettings!.copyWith(
visibleColumns: _columnSettings!.visibleColumns.where((k) => k != column.key).toList(),
);
setState(() {
_columnSettings = updated;
_visibleColumns = _getVisibleColumnsFromSettings(updated);
});
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
} : null,
), ),
size: DataTableUtils.getColumnSize(column.width), size: DataTableUtils.getColumnSize(column.width),
fixedWidth: computedWidth, fixedWidth: computedWidth,
@ -1444,7 +1714,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
horizontalMargin: 8, horizontalMargin: 8,
minWidth: widget.config.minTableWidth ?? 600, minWidth: widget.config.minTableWidth ?? 600,
horizontalScrollController: _horizontalScrollController, horizontalScrollController: _horizontalScrollController,
headingRowHeight: 44, headingRowHeight: _dense ? 40 : 44,
dataRowHeight: _dense ? 38 : 48,
columns: columns, columns: columns,
rows: _items.asMap().entries.map((entry) { rows: _items.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
@ -1510,6 +1781,21 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
} }
return DataRow2( return DataRow2(
color: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withValues(alpha: 0.08);
}
if (states.contains(WidgetState.hovered)) {
return theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
}
if (index == _activeRowIndex && _tableFocusNode.hasFocus) {
return theme.colorScheme.primary.withValues(alpha: 0.06);
}
final Color? base = widget.config.rowBackgroundColor;
final Color? alt = widget.config.alternateRowBackgroundColor ??
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15);
return (index % 2 == 1) ? alt : base;
}),
selected: isSelected, selected: isSelected,
onTap: widget.config.onRowTap != null onTap: widget.config.onRowTap != null
? () => widget.config.onRowTap!(item) ? () => widget.config.onRowTap!(item)
@ -1540,41 +1826,101 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
// This allows working with strongly-typed objects (not just Map) // This allows working with strongly-typed objects (not just Map)
if (column is TextColumn && column.formatter != null) { if (column is TextColumn && column.formatter != null) {
final text = column.formatter!(item) ?? ''; final text = column.formatter!(item) ?? '';
return Text( final overflow = _getOverflow(column);
final textWidget = Text(
text, text,
textAlign: _getTextAlign(column), textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column), maxLines: _getMaxLines(column),
overflow: _getOverflow(column), overflow: overflow,
); );
final wrapped = GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('متن کپی شد')),
);
},
child: textWidget,
);
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
? Tooltip(message: text, child: wrapped)
: wrapped;
} }
if (column is NumberColumn && column.formatter != null) { if (column is NumberColumn && column.formatter != null) {
final text = column.formatter!(item) ?? ''; final text = column.formatter!(item) ?? '';
return Text( final overflow = _getOverflow(column);
final textWidget = Text(
text, text,
textAlign: _getTextAlign(column), textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column), maxLines: _getMaxLines(column),
overflow: _getOverflow(column), overflow: overflow,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
); );
final wrapped = GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('عدد کپی شد')),
);
},
child: textWidget,
);
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
? Tooltip(message: text, child: wrapped)
: wrapped;
} }
if (column is DateColumn && column.formatter != null) { if (column is DateColumn && column.formatter != null) {
final text = column.formatter!(item) ?? ''; final text = column.formatter!(item) ?? '';
return Text( final overflow = _getOverflow(column);
final textWidget = Text(
text, text,
textAlign: _getTextAlign(column), textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column), maxLines: _getMaxLines(column),
overflow: _getOverflow(column), overflow: overflow,
); );
final wrapped = GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('تاریخ کپی شد')),
);
},
child: textWidget,
);
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
? Tooltip(message: text, child: wrapped)
: wrapped;
} }
// 4) Fallback: get property value from Map items by key // 4) Fallback: get property value from Map items by key
final value = DataTableUtils.getCellValue(item, column.key); final value = DataTableUtils.getCellValue(item, column.key);
final formattedValue = DataTableUtils.formatCellValue(value, column); final formattedValue = DataTableUtils.formatCellValue(value, column);
return Text( final overflow = _getOverflow(column);
final textWidget = Text(
formattedValue, formattedValue,
textAlign: _getTextAlign(column), textAlign: _getTextAlign(column),
maxLines: _getMaxLines(column), maxLines: _getMaxLines(column),
overflow: _getOverflow(column), overflow: overflow,
style: column is NumberColumn
? Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
)
: null,
); );
final wrapped = GestureDetector(
onLongPress: () {
Clipboard.setData(ClipboardData(text: formattedValue));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('مقدار کپی شد')),
);
},
child: textWidget,
);
return (overflow == TextOverflow.ellipsis && formattedValue.isNotEmpty)
? Tooltip(message: formattedValue, child: wrapped)
: wrapped;
} }
Widget _buildActionButtons(dynamic item, ActionColumn column) { Widget _buildActionButtons(dynamic item, ActionColumn column) {
@ -1642,6 +1988,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
final VoidCallback onSearch; final VoidCallback onSearch;
final bool hasActiveFilter; final bool hasActiveFilter;
final bool enabled; final bool enabled;
final void Function(double dx)? onResizeDrag;
final VoidCallback? onPinLeft;
final VoidCallback? onPinRight;
final VoidCallback? onUnpin;
final VoidCallback? onHide;
const _ColumnHeaderWithSearch({ const _ColumnHeaderWithSearch({
required this.text, required this.text,
@ -1652,6 +2003,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
required this.onSearch, required this.onSearch,
required this.hasActiveFilter, required this.hasActiveFilter,
this.enabled = true, this.enabled = true,
this.onResizeDrag,
this.onPinLeft,
this.onPinRight,
this.onUnpin,
this.onHide,
}); });
@override @override
@ -1726,6 +2082,55 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
), ),
), ),
), ),
if (onResizeDrag != null) ...[
const SizedBox(width: 6),
MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) => onResizeDrag!(details.delta.dx),
child: Container(
width: 8,
height: 28,
),
),
),
],
if (onPinLeft != null || onPinRight != null || onUnpin != null || onHide != null) ...[
const SizedBox(width: 4),
PopupMenuButton<String>(
padding: EdgeInsets.zero,
tooltip: 'تنظیمات ستون',
icon: Icon(Icons.more_vert, size: 16, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7)),
onSelected: (value) {
switch (value) {
case 'pinLeft':
onPinLeft?.call();
break;
case 'pinRight':
onPinRight?.call();
break;
case 'unpin':
onUnpin?.call();
break;
case 'hide':
onHide?.call();
break;
}
},
itemBuilder: (context) => [
if (onPinLeft != null)
const PopupMenuItem(value: 'pinLeft', child: Text('پین چپ')),
if (onPinRight != null)
const PopupMenuItem(value: 'pinRight', child: Text('پین راست')),
if (onUnpin != null)
const PopupMenuItem(value: 'unpin', child: Text('برداشتن پین')),
const PopupMenuDivider(),
if (onHide != null)
const PopupMenuItem(value: 'hide', child: Text('مخفی کردن ستون')),
],
),
],
], ],
), ),
), ),
@ -1733,3 +2138,25 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
); );
} }
} }
// Keyboard intents
class MoveRowIntent extends Intent {
final int delta;
const MoveRowIntent(this.delta);
}
class ActivateRowIntent extends Intent {
const ActivateRowIntent();
}
class ToggleSelectionIntent extends Intent {
const ToggleSelectionIntent();
}
class ClearSelectionIntent extends Intent {
const ClearSelectionIntent();
}
class SelectAllIntent extends Intent {
const SelectAllIntent();
}

View file

@ -7,11 +7,15 @@ class ColumnSettings {
final List<String> visibleColumns; final List<String> visibleColumns;
final List<String> columnOrder; final List<String> columnOrder;
final Map<String, double> columnWidths; final Map<String, double> columnWidths;
final List<String> pinnedLeft;
final List<String> pinnedRight;
const ColumnSettings({ const ColumnSettings({
required this.visibleColumns, required this.visibleColumns,
required this.columnOrder, required this.columnOrder,
this.columnWidths = const {}, this.columnWidths = const {},
this.pinnedLeft = const [],
this.pinnedRight = const [],
}); });
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -19,6 +23,8 @@ class ColumnSettings {
'visibleColumns': visibleColumns, 'visibleColumns': visibleColumns,
'columnOrder': columnOrder, 'columnOrder': columnOrder,
'columnWidths': columnWidths, 'columnWidths': columnWidths,
'pinnedLeft': pinnedLeft,
'pinnedRight': pinnedRight,
}; };
} }
@ -27,6 +33,8 @@ class ColumnSettings {
visibleColumns: List<String>.from(json['visibleColumns'] ?? []), visibleColumns: List<String>.from(json['visibleColumns'] ?? []),
columnOrder: List<String>.from(json['columnOrder'] ?? []), columnOrder: List<String>.from(json['columnOrder'] ?? []),
columnWidths: Map<String, double>.from(json['columnWidths'] ?? {}), columnWidths: Map<String, double>.from(json['columnWidths'] ?? {}),
pinnedLeft: List<String>.from(json['pinnedLeft'] ?? []),
pinnedRight: List<String>.from(json['pinnedRight'] ?? []),
); );
} }
@ -34,11 +42,15 @@ class ColumnSettings {
List<String>? visibleColumns, List<String>? visibleColumns,
List<String>? columnOrder, List<String>? columnOrder,
Map<String, double>? columnWidths, Map<String, double>? columnWidths,
List<String>? pinnedLeft,
List<String>? pinnedRight,
}) { }) {
return ColumnSettings( return ColumnSettings(
visibleColumns: visibleColumns ?? this.visibleColumns, visibleColumns: visibleColumns ?? this.visibleColumns,
columnOrder: columnOrder ?? this.columnOrder, columnOrder: columnOrder ?? this.columnOrder,
columnWidths: columnWidths ?? this.columnWidths, columnWidths: columnWidths ?? this.columnWidths,
pinnedLeft: pinnedLeft ?? this.pinnedLeft,
pinnedRight: pinnedRight ?? this.pinnedRight,
); );
} }
} }
@ -92,6 +104,8 @@ class ColumnSettingsService {
return ColumnSettings( return ColumnSettings(
visibleColumns: List.from(columnKeys), visibleColumns: List.from(columnKeys),
columnOrder: List.from(columnKeys), columnOrder: List.from(columnKeys),
pinnedLeft: const [],
pinnedRight: const [],
); );
} }
@ -142,11 +156,22 @@ class ColumnSettingsService {
validColumnWidths[entry.key] = entry.value; validColumnWidths[entry.key] = entry.value;
} }
} }
// Sanitize pins to only include visible columns
final leftPins = <String>[];
for (final key in userSettings.pinnedLeft) {
if (visibleColumns.contains(key)) leftPins.add(key);
}
final rightPins = <String>[];
for (final key in userSettings.pinnedRight) {
if (visibleColumns.contains(key)) rightPins.add(key);
}
return userSettings.copyWith( return userSettings.copyWith(
visibleColumns: visibleColumns, visibleColumns: visibleColumns,
columnOrder: columnOrder, columnOrder: columnOrder,
columnWidths: validColumnWidths, columnWidths: validColumnWidths,
pinnedLeft: leftPins,
pinnedRight: rightPins,
); );
} }
} }

View file

@ -5,12 +5,14 @@ import './product_combobox_widget.dart';
// import './price_list_combobox_widget.dart'; // import './price_list_combobox_widget.dart';
import '../../services/price_list_service.dart'; import '../../services/price_list_service.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import './warehouse_combobox_widget.dart';
class InvoiceLineItemsTable extends StatefulWidget { class InvoiceLineItemsTable extends StatefulWidget {
final int businessId; final int businessId;
final int? selectedCurrencyId; // از تب ارز فاکتور final int? selectedCurrencyId; // از تب ارز فاکتور
final ValueChanged<List<InvoiceLineItem>>? onChanged; final ValueChanged<List<InvoiceLineItem>>? onChanged;
final String invoiceType; // sales | purchase | sales_return | purchase_return | ... final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
final bool postInventory;
const InvoiceLineItemsTable({ const InvoiceLineItemsTable({
super.key, super.key,
@ -18,6 +20,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
this.selectedCurrencyId, this.selectedCurrencyId,
this.onChanged, this.onChanged,
this.invoiceType = 'sales', this.invoiceType = 'sales',
this.postInventory = true,
}); });
@override @override
@ -272,6 +275,15 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (widget.postInventory)
Expanded(
flex: 2,
child: Tooltip(
message: 'انبار',
child: Text('انبار', style: style),
),
),
const SizedBox(width: 8),
Expanded( Expanded(
flex: 3, flex: 3,
child: Tooltip( child: Tooltip(
@ -392,6 +404,24 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
}), }),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (widget.postInventory)
Flexible(
flex: 2,
child: SizedBox(
height: 36,
child: WarehouseComboboxWidget(
businessId: widget.businessId,
selectedWarehouseId: item.warehouseId,
onChanged: (wid) {
_updateRow(index, item.copyWith(warehouseId: wid));
},
label: 'انبار',
hintText: 'انتخاب انبار',
isRequired: item.trackInventory,
),
),
),
if (widget.postInventory) const SizedBox(width: 8),
Flexible( Flexible(
flex: 3, flex: 3,
child: SizedBox( child: SizedBox(

View file

@ -0,0 +1,82 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../services/warehouse_service.dart';
import '../../models/warehouse_model.dart';
class WarehouseComboboxWidget extends StatefulWidget {
final int businessId;
final int? selectedWarehouseId;
final ValueChanged<int?> onChanged;
final String label;
final String hintText;
final bool isRequired;
const WarehouseComboboxWidget({
super.key,
required this.businessId,
required this.onChanged,
this.selectedWarehouseId,
this.label = 'انبار',
this.hintText = 'انتخاب انبار',
this.isRequired = false,
});
@override
State<WarehouseComboboxWidget> createState() => _WarehouseComboboxWidgetState();
}
class _WarehouseComboboxWidgetState extends State<WarehouseComboboxWidget> {
final WarehouseService _service = WarehouseService();
List<Warehouse> _items = const <Warehouse>[];
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final items = await _service.listWarehouses(businessId: widget.businessId);
if (!mounted) return;
setState(() => _items = items);
} catch (_) {
if (!mounted) return;
setState(() => _items = const <Warehouse>[]);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_loading) {
return SizedBox(
height: 36,
child: Center(
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary)),
),
);
}
return DropdownButtonFormField<int>(
value: widget.selectedWarehouseId,
isDense: true,
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
items: _items.map((w) {
final title = (w.code.isNotEmpty) ? '${w.code} - ${w.name}' : w.name;
return DropdownMenuItem<int>(value: w.id!, child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis));
}).toList(),
onChanged: widget.onChanged,
hint: Text(widget.hintText),
);
}
}

View file

@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:hesabix_ui/core/calendar_controller.dart';
import 'package:hesabix_ui/widgets/date_input_field.dart';
import 'package:hesabix_ui/widgets/invoice/product_combobox_widget.dart';
import 'package:hesabix_ui/widgets/invoice/warehouse_combobox_widget.dart';
import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart';
import 'package:hesabix_ui/services/inventory_transfer_service.dart';
class InventoryTransferFormDialog extends StatefulWidget {
final int businessId;
final CalendarController calendarController;
const InventoryTransferFormDialog({
super.key,
required this.businessId,
required this.calendarController,
});
@override
State<InventoryTransferFormDialog> createState() => _InventoryTransferFormDialogState();
}
class _InventoryTransferFormDialogState extends State<InventoryTransferFormDialog> {
final _formKey = GlobalKey<FormState>();
final InventoryTransferService _service = InventoryTransferService();
DateTime _documentDate = DateTime.now();
int? _currencyId;
String? _description;
final List<_TransferRow> _rows = <_TransferRow>[];
void _addRow() {
setState(() => _rows.add(_TransferRow()));
}
void _removeRow(int index) {
setState(() => _rows.removeAt(index));
}
Future<void> _submit() async {
if (_currencyId == null) {
_showError('انتخاب ارز الزامی است');
return;
}
if (_rows.isEmpty) {
_showError('حداقل یک ردیف انتقال اضافه کنید');
return;
}
for (int i = 0; i < _rows.length; i++) {
final r = _rows[i];
if (r.productId == null) { _showError('محصول ردیف ${i + 1} انتخاب نشده است'); return; }
if ((r.quantity ?? 0) <= 0) { _showError('تعداد ردیف ${i + 1} باید > 0 باشد'); return; }
if (r.sourceWarehouseId == null || r.destinationWarehouseId == null) { _showError('انبار مبدا و مقصد در ردیف ${i + 1} الزامی است'); return; }
if (r.sourceWarehouseId == r.destinationWarehouseId) { _showError('انبار مبدا و مقصد در ردیف ${i + 1} نمی‌تواند یکسان باشد'); return; }
}
final payload = <String, dynamic>{
'document_date': _documentDate.toIso8601String().substring(0, 10),
'currency_id': _currencyId,
if ((_description ?? '').isNotEmpty) 'description': _description,
'lines': _rows.map((r) => {
'product_id': r.productId,
'quantity': r.quantity,
'source_warehouse_id': r.sourceWarehouseId,
'destination_warehouse_id': r.destinationWarehouseId,
if ((r.description ?? '').isNotEmpty) 'description': r.description,
}).toList(),
};
try {
await _service.create(businessId: widget.businessId, payload: payload);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (e) {
_showError('خطا در ثبت انتقال: $e');
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message), backgroundColor: Colors.red));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('انتقال موجودی بین انبارها'),
content: SizedBox(
width: 900,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: DateInputField(
value: _documentDate,
labelText: 'تاریخ سند *',
calendarController: widget.calendarController,
onChanged: (d) => setState(() => _documentDate = d ?? DateTime.now()),
),
),
const SizedBox(width: 12),
Expanded(
child: CurrencyPickerWidget(
businessId: widget.businessId,
selectedCurrencyId: _currencyId,
onChanged: (cid) => setState(() => _currencyId = cid),
label: 'ارز *',
hintText: 'انتخاب ارز',
),
),
],
),
const SizedBox(height: 12),
TextFormField(
decoration: const InputDecoration(
labelText: 'شرح',
border: OutlineInputBorder(),
),
onChanged: (v) => _description = v.trim(),
),
const SizedBox(height: 12),
Row(
children: [
ElevatedButton.icon(onPressed: _addRow, icon: const Icon(Icons.add), label: const Text('افزودن ردیف')),
],
),
const SizedBox(height: 8),
_rows.isEmpty
? const Padding(
padding: EdgeInsets.all(8.0),
child: Text('ردیفی افزوده نشده است'),
)
: Column(
children: _rows.asMap().entries.map((e) => _buildRow(context, e.key, e.value)).toList(),
),
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')),
FilledButton.icon(onPressed: _submit, icon: const Icon(Icons.save), label: const Text('ثبت انتقال')),
],
);
}
Widget _buildRow(BuildContext context, int index, _TransferRow row) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 260,
height: 36,
child: ProductComboboxWidget(
businessId: widget.businessId,
selectedProduct: row.productId != null ? {'id': row.productId, 'code': row.productCode, 'name': row.productName} : null,
onChanged: (p) => setState(() {
if (p == null) {
row.productId = null;
row.productCode = null;
row.productName = null;
} else {
row.productId = int.tryParse('${p['id']}');
row.productCode = p['code']?.toString();
row.productName = p['name']?.toString();
}
}),
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
height: 36,
child: TextFormField(
initialValue: (row.quantity ?? 0).toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder(), labelText: 'تعداد'),
onChanged: (v) => row.quantity = num.tryParse(v.replaceAll(',', '')) ?? 0,
),
),
const SizedBox(width: 8),
SizedBox(
width: 220,
height: 36,
child: WarehouseComboboxWidget(
businessId: widget.businessId,
selectedWarehouseId: row.sourceWarehouseId,
onChanged: (wid) => setState(() => row.sourceWarehouseId = wid),
label: 'انبار مبدا',
hintText: 'انتخاب انبار مبدا',
isRequired: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 220,
height: 36,
child: WarehouseComboboxWidget(
businessId: widget.businessId,
selectedWarehouseId: row.destinationWarehouseId,
onChanged: (wid) => setState(() => row.destinationWarehouseId = wid),
label: 'انبار مقصد',
hintText: 'انتخاب انبار مقصد',
isRequired: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 220,
height: 36,
child: TextFormField(
initialValue: row.description ?? '',
onChanged: (v) => row.description = v.trim(),
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
hintText: 'شرح ردیف',
),
),
),
const SizedBox(width: 8),
IconButton(onPressed: () => _removeRow(index), icon: const Icon(Icons.delete, color: Colors.red)),
],
),
);
}
}
class _TransferRow {
int? productId;
String? productCode;
String? productName;
num? quantity = 1;
int? sourceWarehouseId;
int? destinationWarehouseId;
String? description;
}

View file

@ -33,8 +33,13 @@ case "$CMD" in
serve) serve)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
;; ;;
serve-workers)
# اجرای uvicorn با چند worker (بدون reload)
WORKERS=${WORKERS:-4}
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers "$WORKERS"
;;
*) *)
echo "Usage: $0 [serve|migrate|test]" echo "Usage: $0 [serve|serve-workers|migrate|test]"
exit 1 exit 1
;; ;;
esac esac