diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py
index 6d64d71..641791d 100644
--- a/hesabixAPI/adapters/api/v1/accounts.py
+++ b/hesabixAPI/adapters/api/v1/accounts.py
@@ -31,7 +31,16 @@ def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]:
roots: list[AccountTreeNode] = []
for n in nodes:
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
for node in list(by_id.values()):
@@ -58,10 +67,29 @@ def get_accounts_tree(
rows = db.query(Account).filter(
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
).order_by(Account.code.asc()).all()
- flat = [
- {"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
- for r in rows
- ]
+ # محاسبه has_children با شمارش فرزندان در مجموعه
+ children_map: dict[int, int] = {}
+ 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)
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"):
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:
created = create_account(
db,
@@ -238,7 +277,7 @@ def create_business_account(
@router.put(
"/account/{account_id}",
summary="ویرایش حساب",
- description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
+ description="ویرایش حساب اختصاصی بیزنس (دارای دسترسی write). حسابهای عمومی غیرقابلویرایش هستند.",
)
def update_account_endpoint(
request: Request,
@@ -251,9 +290,9 @@ def update_account_endpoint(
if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
acc_business_id = data.get("business_id")
- # اگر عمومی است، فقط سوپرادمین
- if acc_business_id is None and not ctx.is_superadmin():
- raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403)
+ # حسابهای عمومی غیرقابلویرایش هستند
+ if acc_business_id is None:
+ raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
# اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد
if acc_business_id is not None:
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)
if code == "INVALID_PARENT_BUSINESS":
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
@router.delete(
"/account/{account_id}",
summary="حذف حساب",
- description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
+ description="حذف حساب اختصاصی بیزنس (دارای دسترسی write). حسابهای عمومی غیرقابلحذف هستند.",
)
def delete_account_endpoint(
request: Request,
@@ -298,16 +339,25 @@ def delete_account_endpoint(
if not data:
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
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 not ctx.can_access_business(int(acc_business_id)):
raise ApiError("FORBIDDEN", "No access to business", http_status=403)
if not ctx.can_write_section("accounting"):
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
- ok = delete_account(db, account_id)
- if not ok:
- raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
- return success_response(None, request, message="ACCOUNT_DELETED")
+ try:
+ ok = delete_account(db, account_id)
+ if not ok:
+ raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
+ 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
diff --git a/hesabixAPI/adapters/api/v1/inventory_transfers.py b/hesabixAPI/adapters/api/v1/inventory_transfers.py
new file mode 100644
index 0000000..a531e29
--- /dev/null
+++ b/hesabixAPI/adapters/api/v1/inventory_transfers.py
@@ -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"
"
+ f"| {cell(d.code)} | "
+ f"{cell(d.document_date)} | "
+ f"{cell(d.description)} | "
+ f"
" for d in rows
+ ])
+
+ html = f"""
+
+
+
+
+
+
+ لیست انتقال موجودی بین انبارها
+
+
+
+ | کد سند |
+ تاریخ سند |
+ شرح |
+
+
+
+ {rows_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",
+ },
+ )
+
+
diff --git a/hesabixAPI/adapters/api/v1/kardex.py b/hesabixAPI/adapters/api/v1/kardex.py
index dffdbdd..66573f5 100644
--- a/hesabixAPI/adapters/api/v1/kardex.py
+++ b/hesabixAPI/adapters/api/v1/kardex.py
@@ -53,6 +53,7 @@ async def list_kardex_lines_endpoint(
"petty_cash_ids",
"account_ids",
"check_ids",
+ "warehouse_ids",
"match_mode",
"result_scope",
):
@@ -113,6 +114,7 @@ async def export_kardex_excel_endpoint(
"petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"),
+ "warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)),
@@ -130,7 +132,7 @@ async def export_kardex_excel_endpoint(
ws = wb.active
ws.title = "Kardex"
headers = [
- "document_date", "document_code", "document_type", "description",
+ "document_date", "document_code", "document_type", "warehouse", "movement", "description",
"debit", "credit", "quantity", "running_amount", "running_quantity",
]
ws.append(headers)
@@ -139,6 +141,8 @@ async def export_kardex_excel_endpoint(
it.get("document_date"),
it.get("document_code"),
it.get("document_type"),
+ it.get("warehouse_name") or it.get("warehouse_id"),
+ it.get("movement"),
it.get("description"),
it.get("debit"),
it.get("credit"),
@@ -205,6 +209,7 @@ async def export_kardex_pdf_endpoint(
"petty_cash_ids": body.get("petty_cash_ids"),
"account_ids": body.get("account_ids"),
"check_ids": body.get("check_ids"),
+ "warehouse_ids": body.get("warehouse_ids"),
"match_mode": body.get("match_mode") or "any",
"result_scope": body.get("result_scope") or "lines_matching",
"include_running_balance": bool(body.get("include_running_balance", False)),
@@ -223,6 +228,8 @@ async def export_kardex_pdf_endpoint(
f"{cell(it.get('document_date'))} | "
f"{cell(it.get('document_code'))} | "
f"{cell(it.get('document_type'))} | "
+ f"{cell(it.get('warehouse_name') or it.get('warehouse_id'))} | "
+ f"{cell(it.get('movement'))} | "
f"{cell(it.get('description'))} | "
f"{cell(it.get('debit'))} | "
f"{cell(it.get('credit'))} | "
@@ -252,6 +259,8 @@ async def export_kardex_pdf_endpoint(
تاریخ سند |
کد سند |
نوع سند |
+ انبار |
+ جهت حرکت |
شرح |
بدهکار |
بستانکار |
diff --git a/hesabixAPI/adapters/api/v1/schema_models/account.py b/hesabixAPI/adapters/api/v1/schema_models/account.py
index 2405f44..ff44a59 100644
--- a/hesabixAPI/adapters/api/v1/schema_models/account.py
+++ b/hesabixAPI/adapters/api/v1/schema_models/account.py
@@ -10,6 +10,11 @@ class AccountTreeNode(BaseModel):
name: str = Field(..., description="نام حساب")
account_type: Optional[str] = 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="سطح حساب در درخت")
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
diff --git a/hesabixAPI/adapters/db/session.py b/hesabixAPI/adapters/db/session.py
index 066d8d3..7ca9cb9 100644
--- a/hesabixAPI/adapters/db/session.py
+++ b/hesabixAPI/adapters/db/session.py
@@ -11,7 +11,15 @@ class Base(DeclarativeBase):
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)
diff --git a/hesabixAPI/app/core/settings.py b/hesabixAPI/app/core/settings.py
index 54215a2..2784a3f 100644
--- a/hesabixAPI/app/core/settings.py
+++ b/hesabixAPI/app/core/settings.py
@@ -19,6 +19,10 @@ class Settings(BaseSettings):
db_port: int = 3306
db_name: str = "hesabix"
sqlalchemy_echo: bool = False
+ # DB Pooling
+ db_pool_size: int = 10
+ db_max_overflow: int = 20
+ db_pool_timeout: int = 10
# Logging
log_level: str = "INFO"
diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py
index e029d3d..1d64b53 100644
--- a/hesabixAPI/app/main.py
+++ b/hesabixAPI/app/main.py
@@ -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.documents import router as documents_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.error_handlers import register_error_handlers
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(fiscal_years_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
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)
+ @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("/",
summary="اطلاعات سرویس",
description="دریافت اطلاعات کلی سرویس و نسخه",
diff --git a/hesabixAPI/app/services/account_service.py b/hesabixAPI/app/services/account_service.py
index fb4e142..761011b 100644
--- a/hesabixAPI/app/services/account_service.py
+++ b/hesabixAPI/app/services/account_service.py
@@ -72,6 +72,9 @@ def update_account(
obj = db.get(Account, account_id)
if not obj:
return None
+ # جلوگیری از تغییر حسابهای عمومی در لایه سرویس
+ if obj.business_id is None:
+ raise ValueError("PUBLIC_IMMUTABLE")
if parent_id is not None:
parent_id = _validate_parent(db, parent_id, obj.business_id)
if name is not None:
@@ -94,6 +97,12 @@ def delete_account(db: Session, account_id: int) -> bool:
obj = db.get(Account, account_id)
if not obj:
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.commit()
return True
diff --git a/hesabixAPI/app/services/check_service.py b/hesabixAPI/app/services/check_service.py
index 8b79753..65e592e 100644
--- a/hesabixAPI/app/services/check_service.py
+++ b/hesabixAPI/app/services/check_service.py
@@ -178,32 +178,34 @@ def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, An
"check_id": obj.id,
})
- # ایجاد سند
- document = Document(
- code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
- business_id=business_id,
- fiscal_year_id=fiscal_year.id,
- currency_id=int(data.get("currency_id")),
- created_by_user_id=int(user_id),
- document_date=document_date,
- document_type="check",
- is_proforma=False,
- description=description,
- extra_info={
- "source": "check_create",
- "check_id": obj.id,
- "check_type": ctype,
- },
- )
- db.add(document)
- db.flush()
+ # ایجاد سند (اگر چک واگذار شخص ندارد، از ثبت سند صرفنظر میشود)
+ skip_autopost = (ctype == "transferred" and not person_id)
+ if not skip_autopost:
+ document = Document(
+ code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
+ business_id=business_id,
+ fiscal_year_id=fiscal_year.id,
+ currency_id=int(data.get("currency_id")),
+ created_by_user_id=int(user_id),
+ document_date=document_date,
+ document_type="check",
+ is_proforma=False,
+ description=description,
+ extra_info={
+ "source": "check_create",
+ "check_id": obj.id,
+ "check_type": ctype,
+ },
+ )
+ db.add(document)
+ db.flush()
- for line in lines:
- db.add(DocumentLine(document_id=document.id, **line))
+ for line in lines:
+ db.add(DocumentLine(document_id=document.id, **line))
- db.commit()
- db.refresh(document)
- created_document_id = document.id
+ db.commit()
+ db.refresh(document)
+ created_document_id = document.id
except Exception:
# در صورت شکست ایجاد سند، تغییری در ایجاد چک نمیدهیم و خطا نمیریزیم
# (میتوان رفتار را سختگیرانه کرد و رولبک نمود؛ فعلاً نرم)
@@ -335,7 +337,8 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
lines: List[Dict[str, Any]] = []
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({
"account_id": _ensure_account(db, "10203"),
"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,
})
lines.append({
- "account_id": _ensure_account(db, "10403"),
+ "account_id": _ensure_account(db, credit_code),
"debit": Decimal(0),
"credit": amount_dec,
"description": description or "وصول چک",
@@ -483,23 +486,45 @@ def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
lines: List[Dict[str, Any]] = []
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")
- 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, "10203"),
- **({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
- "debit": Decimal(0),
- "credit": amount_dec,
- "description": description or "برگشت چک",
- "check_id": obj.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({
+ "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, "10203"),
+ "bank_account_id": int(bank_account_id),
+ "debit": Decimal(0),
+ "credit": amount_dec,
+ "description": description or "برگشت چک",
+ "check_id": obj.id,
+ })
else:
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
if not obj.person_id:
diff --git a/hesabixAPI/app/services/inventory_transfer_service.py b/hesabixAPI/app/services/inventory_transfer_service.py
new file mode 100644
index 0000000..5710563
--- /dev/null
+++ b/hesabixAPI/app/services/inventory_transfer_service.py
@@ -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(),
+ },
+ }
+
+
diff --git a/hesabixAPI/app/services/invoice_service.py b/hesabixAPI/app/services/invoice_service.py
index bcd51af..efa2886 100644
--- a/hesabixAPI/app/services/invoice_service.py
+++ b/hesabixAPI/app/services/invoice_service.py
@@ -54,6 +54,23 @@ def _get_costing_method(data: Dict[str, Any]) -> str:
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(
db: Session,
business_id: int,
@@ -94,6 +111,13 @@ def _iter_product_movements(
movements = []
for line, doc in rows:
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)
wh_id = info.get("warehouse_id")
if movement is None:
@@ -319,9 +343,19 @@ def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
return account
-def _get_person_control_account(db: Session) -> Account:
- # عمومی اشخاص (پرداختنی/دریافتنی) پیشفرض: 20201
- return _get_fixed_account_by_code(db, "20201")
+def _get_person_control_account(db: Session, invoice_type: str | None = None) -> Account:
+ # انتخاب حساب طرفشخص بر اساس نوع فاکتور
+ # فروش/برگشت از فروش → دریافتنی ها 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")
def _build_doc_code(prefix_base: str) -> str:
@@ -368,6 +402,9 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
# فقط برای کالاهای دارای کنترل موجودی
if not bool(info.get("inventory_tracked")):
continue
+ # اگر خط برای انبار پست نشده، در COGS لحاظ نشود
+ if info.get("inventory_posted") is False:
+ continue
qty = Decimal(str(line.get("quantity", 0) or 0))
if info.get("cogs_amount") is not None:
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]:
# امکان override از extra_info.account_codes
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:
return str(overrides.get(name) or default_code)
return {
- "revenue": _get_fixed_account_by_code(db, code("revenue", "70101")),
- "sales_return": _get_fixed_account_by_code(db, code("sales_return", "70102")),
- "inventory": _get_fixed_account_by_code(db, code("inventory", "10301")),
- "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10302")),
- "cogs": _get_fixed_account_by_code(db, code("cogs", "60101")),
- "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20801")),
- "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10801")),
- "direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "60201")),
- "wip": _get_fixed_account_by_code(db, code("wip", "60301")),
- "waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "60401")),
- "person": _get_person_control_account(db),
+ # درآمد و برگشت فروش مطابق چارت سید:
+ "revenue": _get_fixed_account_by_code(db, code("revenue", "50001")),
+ "sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")),
+ # موجودی و ساختهشده (در نبود حساب مجزا) هر دو 10102
+ "inventory": _get_fixed_account_by_code(db, code("inventory", "10102")),
+ "inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")),
+ # بهای تمام شده و VAT ها مطابق سید
+ "cogs": _get_fixed_account_by_code(db, code("cogs", "40001")),
+ "vat_out": _get_fixed_account_by_code(db, code("vat_out", "20101")),
+ "vat_in": _get_fixed_account_by_code(db, code("vat_in", "10104")),
+ # مصرف مستقیم و ضایعات
+ "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]:
try:
ei = data.get("extra_info") or {}
@@ -492,6 +617,7 @@ def create_invoice(
totals = _extract_totals_from_lines(lines_input)
# Inventory validation and costing pre-calculation
+ post_inventory: bool = _is_inventory_posting_enabled(data)
# Determine outgoing lines for stock checks
movement_hint, _ = _movement_from_type(invoice_type)
outgoing_lines: List[Dict[str, Any]] = []
@@ -518,6 +644,17 @@ def create_invoice(
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
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
tracked_outgoing_lines: List[Dict[str, Any]] = []
@@ -527,12 +664,12 @@ def create_invoice(
tracked_outgoing_lines.append(ln)
# 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)
# Costing method (only for tracked products)
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)
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
i = 0
@@ -580,7 +717,9 @@ def create_invoice(
qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0:
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(
document_id=document.id,
product_id=int(product_id),
@@ -599,7 +738,7 @@ def create_invoice(
tax = Decimal(str(totals["tax"]))
total_with_tax = net + tax
- # COGS when applicable
+ # COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمیشوند)
cogs_total = _extract_cogs_total(lines_input)
# Sales
@@ -646,6 +785,51 @@ def create_invoice(
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
elif invoice_type == INVOICE_SALES_RETURN:
if person_id:
@@ -957,6 +1141,16 @@ def update_invoice(
info = dict(ln.get("extra_info") or {})
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
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]] = []
for ln in outgoing_lines:
@@ -964,12 +1158,13 @@ def update_invoice(
if pid and track_map.get(int(pid)):
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)
- header_for_costing = data if data else {"extra_info": document.extra_info}
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)
i = 0
for ln in lines_input:
@@ -987,7 +1182,8 @@ def update_invoice(
qty = Decimal(str(line.get("quantity", 0) or 0))
if not product_id or qty <= 0:
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(
document_id=document.id,
product_id=int(product_id),
@@ -1000,7 +1196,8 @@ def update_invoice(
# Accounting lines if finalized
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 {}
totals = (header_extra.get("totals") or {})
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["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.refresh(document)
return invoice_document_to_dict(db, document)
diff --git a/hesabixAPI/app/services/kardex_service.py b/hesabixAPI/app/services/kardex_service.py
index 2b67bce..a80de76 100644
--- a/hesabixAPI/app/services/kardex_service.py
+++ b/hesabixAPI/app/services/kardex_service.py
@@ -4,11 +4,13 @@ from typing import Any, Dict, List, Optional, Tuple
from datetime import date
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_line import DocumentLine
from adapters.db.models.fiscal_year import FiscalYear
+from adapters.db.models.warehouse import Warehouse
# 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]:
+ 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:
@@ -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")
account_ids = _collect_ids(query, "account_ids")
check_ids = _collect_ids(query, "check_ids")
+ warehouse_ids = _collect_ids(query, "warehouse_ids")
# Match mode
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
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
sort_by = (query.get("sort_by") or "document_date")
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
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()
# 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_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]] = []
for line, doc in rows:
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,
}
+ # 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:
try:
running_amount += float(line.debit or 0) - float(line.credit or 0)
diff --git a/hesabixAPI/env.example b/hesabixAPI/env.example
index df7db48..6f569f8 100644
--- a/hesabixAPI/env.example
+++ b/hesabixAPI/env.example
@@ -11,6 +11,9 @@ DB_HOST=localhost
DB_PORT=3306
DB_NAME=hesabixpy
SQLALCHEMY_ECHO=false
+DB_POOL_SIZE=10
+DB_MAX_OVERFLOW=20
+DB_POOL_TIMEOUT=10
# Logging
LOG_LEVEL=INFO
diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
index 9273166..9977015 100644
--- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
+++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt
@@ -19,6 +19,7 @@ adapters/api/v1/documents.py
adapters/api/v1/expense_income.py
adapters/api/v1/fiscal_years.py
adapters/api/v1/health.py
+adapters/api/v1/inventory_transfers.py
adapters/api/v1/invoices.py
adapters/api/v1/kardex.py
adapters/api/v1/persons.py
@@ -146,6 +147,7 @@ app/services/document_service.py
app/services/email_service.py
app/services/expense_income_service.py
app/services/file_storage_service.py
+app/services/inventory_transfer_service.py
app/services/invoice_service.py
app/services/kardex_service.py
app/services/person_service.py
diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart
index 3e3b2d8..580b9b2 100644
--- a/hesabixUI/hesabix_ui/lib/main.dart
+++ b/hesabixUI/hesabix_ui/lib/main.dart
@@ -45,6 +45,7 @@ import 'pages/business/expense_income_list_page.dart';
import 'pages/business/transfers_page.dart';
import 'pages/business/documents_page.dart';
import 'pages/business/warehouses_page.dart';
+import 'pages/business/inventory_transfers_page.dart';
import 'pages/error_404_page.dart';
import 'core/locale_controller.dart';
import 'core/calendar_controller.dart';
@@ -639,10 +640,37 @@ class _MyAppState extends State {
name: 'business_reports_kardex',
pageBuilder: (context, state) {
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 initialPersonIds = {};
+ final single = int.tryParse(qp['person_id'] ?? '');
+ if (single != null) initialPersonIds.add(single);
+ final multi = (qpAll['person_id'] ?? const [])
+ .map((e) => int.tryParse(e))
+ .whereType();
+ 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(
child: KardexPage(
businessId: businessId,
calendarController: _calendarController!,
+ initialPersonIds: initialPersonIds.toList(),
),
);
},
@@ -808,6 +836,19 @@ class _MyAppState extends State {
);
},
),
+ 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(
path: '/business/:business_id/documents',
name: 'business_documents',
diff --git a/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
index fd4c49d..5c8d852 100644
--- a/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
+++ b/hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
@@ -29,6 +29,7 @@ class InvoiceLineItem {
// inventory/constraints
final int? minOrderQty;
final bool trackInventory;
+ final int? warehouseId; // انبار انتخابی برای ردیف
// presentation
String? description;
@@ -52,6 +53,7 @@ class InvoiceLineItem {
this.basePurchasePriceMainUnit,
this.minOrderQty,
this.trackInventory = false,
+ this.warehouseId,
});
InvoiceLineItem copyWith({
@@ -73,6 +75,7 @@ class InvoiceLineItem {
num? basePurchasePriceMainUnit,
int? minOrderQty,
bool? trackInventory,
+ int? warehouseId,
}) {
return InvoiceLineItem(
productId: productId ?? this.productId,
@@ -93,6 +96,7 @@ class InvoiceLineItem {
basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit,
minOrderQty: minOrderQty ?? this.minOrderQty,
trackInventory: trackInventory ?? this.trackInventory,
+ warehouseId: warehouseId ?? this.warehouseId,
);
}
diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
index 291dfdb..cc04668 100644
--- a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
+++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart
@@ -8,6 +8,7 @@ class AccountNode {
final String code;
final String name;
final String? accountType;
+ final int? businessId;
final List children;
final bool hasChildren;
@@ -16,6 +17,7 @@ class AccountNode {
required this.code,
required this.name,
this.accountType,
+ this.businessId,
this.children = const [],
this.hasChildren = false,
});
@@ -30,6 +32,9 @@ class AccountNode {
code: json['code']?.toString() ?? '',
name: json['name']?.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,
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
);
@@ -86,6 +91,8 @@ class _AccountsPageState extends State {
items.add({
"id": n.id,
"title": ("\u200f" * level) + n.code + " - " + n.name,
+ "business_id": n.businessId?.toString() ?? "",
+ "has_children": n.hasChildren ? "1" : "0",
});
for (final c in n.children) {
dfs(c, level + 1);
@@ -97,46 +104,115 @@ class _AccountsPageState extends State {
return items;
}
- Future _openCreateDialog() async {
+ String? _suggestNextCode({String? parentId}) {
+ List codes = [];
+ 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().toList();
+ if (numeric.isEmpty) return null;
+ final next = (numeric..sort()).last + 1;
+ return next.toString();
+ }
+
+ Future _openCreateDialog({AccountNode? parent}) async {
final t = AppLocalizations.of(context);
final codeCtrl = TextEditingController();
final nameCtrl = TextEditingController();
- final typeCtrl = TextEditingController();
- String? selectedParentId;
+ String? selectedType;
+ String? selectedParentId = parent?.id;
final parents = _flattenNodes();
final result = await showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(t.addAccount),
- content: SingleChildScrollView(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: codeCtrl,
- decoration: InputDecoration(labelText: t.code),
- ),
- TextField(
- controller: nameCtrl,
- decoration: InputDecoration(labelText: t.title),
- ),
- TextField(
- controller: typeCtrl,
- decoration: InputDecoration(labelText: t.type),
- ),
- DropdownButtonFormField(
- value: selectedParentId,
- items: [
- DropdownMenuItem(value: null, child: Text('بدون والد')),
- ...parents.map((p) => DropdownMenuItem(value: p["id"], child: Text(p["title"]!))).toList(),
- ],
- onChanged: (v) {
- selectedParentId = v;
- },
- decoration: const InputDecoration(labelText: 'حساب والد'),
- ),
- ],
+ content: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 460),
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(children: [
+ Expanded(child: TextField(
+ controller: codeCtrl,
+ 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(
+ controller: nameCtrl,
+ decoration: InputDecoration(labelText: t.title, prefixIcon: const Icon(Icons.title)),
+ ),
+ const SizedBox(height: 10),
+ DropdownButtonFormField(
+ 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(
+ value: selectedParentId,
+ items: [
+ ...(() {
+ List