progress in some parts
This commit is contained in:
parent
8f4248e83f
commit
28ccc57f70
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
205
hesabixAPI/adapters/api/v1/inventory_transfers.py
Normal file
205
hesabixAPI/adapters/api/v1/inventory_transfers.py
Normal 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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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"<td>{cell(it.get('document_date'))}</td>"
|
||||
f"<td>{cell(it.get('document_code'))}</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 style='text-align:right'>{cell(it.get('debit'))}</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>
|
||||
|
|
|
|||
|
|
@ -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="فرزندان")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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="دریافت اطلاعات کلی سرویس و نسخه",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
169
hesabixAPI/app/services/inventory_transfer_service.py
Normal file
169
hesabixAPI/app/services/inventory_transfer_service.py
Normal 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(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<MyApp> {
|
|||
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<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(
|
||||
child: KardexPage(
|
||||
businessId: businessId,
|
||||
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(
|
||||
path: '/business/:business_id/documents',
|
||||
name: 'business_documents',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class AccountNode {
|
|||
final String code;
|
||||
final String name;
|
||||
final String? accountType;
|
||||
final int? businessId;
|
||||
final List<AccountNode> 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<AccountsPage> {
|
|||
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<AccountsPage> {
|
|||
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 codeCtrl = TextEditingController();
|
||||
final nameCtrl = TextEditingController();
|
||||
final typeCtrl = TextEditingController();
|
||||
String? selectedParentId;
|
||||
String? selectedType;
|
||||
String? selectedParentId = parent?.id;
|
||||
final parents = _flattenNodes();
|
||||
final result = await showDialog<bool>(
|
||||
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<String>(
|
||||
value: selectedParentId,
|
||||
items: [
|
||||
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
|
||||
...parents.map((p) => DropdownMenuItem<String>(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<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, prefixIcon: const Icon(Icons.category)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedParentId,
|
||||
items: [
|
||||
...(() {
|
||||
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: parent != null ? null : (v) {
|
||||
selectedParentId = v;
|
||||
if ((codeCtrl.text).trim().isEmpty) {
|
||||
final s = _suggestNextCode(parentId: selectedParentId);
|
||||
if (s != null) codeCtrl.text = s;
|
||||
}
|
||||
},
|
||||
decoration: const InputDecoration(labelText: 'حساب والد', prefixIcon: Icon(Icons.account_tree)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -145,8 +221,8 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
onPressed: () async {
|
||||
final name = nameCtrl.text.trim();
|
||||
final code = codeCtrl.text.trim();
|
||||
final atype = typeCtrl.text.trim();
|
||||
if (name.isEmpty || code.isEmpty || atype.isEmpty) {
|
||||
final atype = (selectedType ?? '').trim();
|
||||
if (name.isEmpty || code.isEmpty || atype.isEmpty || selectedParentId == null || selectedParentId!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final Map<String, dynamic> payload = {
|
||||
|
|
@ -158,24 +234,24 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
final pid = int.tryParse(selectedParentId!);
|
||||
if (pid != null) payload["parent_id"] = pid;
|
||||
}
|
||||
try {
|
||||
final api = ApiClient();
|
||||
await api.post(
|
||||
'/api/v1/accounts/business/${widget.businessId}/create',
|
||||
data: payload,
|
||||
try {
|
||||
final api = ApiClient();
|
||||
await api.post(
|
||||
'/api/v1/accounts/business/${widget.businessId}/create',
|
||||
data: payload,
|
||||
);
|
||||
if (context.mounted) Navigator.of(ctx).pop(true);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در ایجاد حساب: $e')),
|
||||
);
|
||||
if (context.mounted) Navigator.of(ctx).pop(true);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('خطا در ایجاد حساب: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
child: Text(t.add),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -216,7 +292,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
final t = AppLocalizations.of(context);
|
||||
final codeCtrl = TextEditingController(text: node.code);
|
||||
final nameCtrl = TextEditingController(text: node.name);
|
||||
final typeCtrl = TextEditingController(text: node.accountType ?? '');
|
||||
String? selectedType = node.accountType;
|
||||
final parents = _flattenNodes();
|
||||
String? selectedParentId;
|
||||
final result = await showDialog<bool>(
|
||||
|
|
@ -230,12 +306,29 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
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<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>(
|
||||
value: selectedParentId,
|
||||
items: [
|
||||
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; },
|
||||
decoration: const InputDecoration(labelText: 'حساب والد'),
|
||||
|
|
@ -249,7 +342,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
onPressed: () async {
|
||||
final name = nameCtrl.text.trim();
|
||||
final code = codeCtrl.text.trim();
|
||||
final atype = typeCtrl.text.trim();
|
||||
final atype = (selectedType ?? '').trim();
|
||||
if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
|
||||
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
|
||||
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
|
||||
|
|
@ -412,26 +505,44 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
||||
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
||||
onPressed: () => _toggleExpand(node),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
||||
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: 5, child: Text(node.name)),
|
||||
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: PopupMenuButton<String>(
|
||||
padding: EdgeInsets.zero,
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') _openEditDialog(node);
|
||||
if (v == 'delete') _confirmDelete(node);
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')),
|
||||
const PopupMenuItem<String>(value: 'delete', child: Text('حذف')),
|
||||
],
|
||||
onSelected: (v) {
|
||||
if (v == 'add_child') _openCreateDialog(parent: node);
|
||||
if (v == 'edit') _openEditDialog(node);
|
||||
if (v == 'delete') _confirmDelete(node);
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
final bool isOwned = node.businessId != null && node.businessId == widget.businessId;
|
||||
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;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -380,9 +380,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
label: t.shipments,
|
||||
icon: Icons.local_shipping,
|
||||
selectedIcon: Icons.local_shipping,
|
||||
path: '/business/${widget.businessId}/shipments',
|
||||
path: '/business/${widget.businessId}/inventory-transfers',
|
||||
type: _MenuItemType.simple,
|
||||
hasAddButton: true,
|
||||
hasAddButton: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -42,6 +42,8 @@ class NewInvoicePage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
|
||||
// تنظیمات انبار
|
||||
bool _postInventory = true; // ثبت اسناد انبار
|
||||
late TabController _tabController;
|
||||
|
||||
InvoiceType? _selectedInvoiceType;
|
||||
|
|
@ -360,13 +362,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
_selectedSeller = seller;
|
||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||
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;
|
||||
_commissionPercentage = seller.commissionSalePercent;
|
||||
_commissionPercentage = percent;
|
||||
_commissionAmount = null;
|
||||
} else if (seller.commissionSalesAmount != null) {
|
||||
} else if (amount != null) {
|
||||
_commissionType = CommissionType.amount;
|
||||
_commissionAmount = seller.commissionSalesAmount;
|
||||
_commissionAmount = amount;
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -678,13 +684,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
_selectedSeller = seller;
|
||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||
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;
|
||||
_commissionPercentage = seller.commissionSalePercent;
|
||||
_commissionPercentage = percent;
|
||||
_commissionAmount = null;
|
||||
} else if (seller.commissionSalesAmount != null) {
|
||||
} else if (amount != null) {
|
||||
_commissionType = CommissionType.amount;
|
||||
_commissionAmount = seller.commissionSalesAmount;
|
||||
_commissionAmount = amount;
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -837,6 +847,18 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
if (r.taxRate < 0 || r.taxRate > 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;
|
||||
|
|
@ -877,6 +899,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
'net': _sumTotal,
|
||||
},
|
||||
};
|
||||
// سوییچ ثبت اسناد انبار
|
||||
extraInfo['post_inventory'] = _postInventory;
|
||||
|
||||
// افزودن person_id بر اساس نوع فاکتور
|
||||
if (isSalesOrReturn && _selectedCustomer != null) {
|
||||
|
|
@ -947,6 +971,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
'tax_amount': taxAmount,
|
||||
'line_total': lineTotal,
|
||||
if (movement != null) 'movement': movement,
|
||||
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
|
||||
// اطلاعات اضافی برای ردیابی
|
||||
'unit': e.selectedUnit ?? e.mainUnit,
|
||||
'unit_price_source': e.unitPriceSource,
|
||||
|
|
@ -979,6 +1004,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
businessId: widget.businessId,
|
||||
selectedCurrencyId: _selectedCurrencyId,
|
||||
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
||||
postInventory: _postInventory,
|
||||
onChanged: (rows) {
|
||||
setState(() {
|
||||
_lineItems = rows;
|
||||
|
|
@ -1058,6 +1084,30 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
|||
),
|
||||
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(
|
||||
child: Padding(
|
||||
|
|
|
|||
|
|
@ -77,7 +77,12 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
return InkWell(
|
||||
onTap: () {
|
||||
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(
|
||||
|
|
@ -337,11 +342,21 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
label: 'کاردکس',
|
||||
onTap: (person) {
|
||||
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>) {
|
||||
final id = person['id'];
|
||||
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]
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>{});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -26,6 +26,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
|||
late List<String> _columnOrder;
|
||||
late Map<String, double> _columnWidths;
|
||||
late List<DataTableColumn> _columns; // Local copy of columns
|
||||
late Set<String> _pinnedLeft;
|
||||
late Set<String> _pinnedRight;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -34,6 +36,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
|||
_columnOrder = List.from(widget.currentSettings.columnOrder);
|
||||
_columnWidths = Map.from(widget.currentSettings.columnWidths);
|
||||
_columns = List.from(widget.columns); // Create local copy
|
||||
_pinnedLeft = Set<String>.from(widget.currentSettings.pinnedLeft);
|
||||
_pinnedRight = Set<String>.from(widget.currentSettings.pinnedRight);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -172,6 +176,13 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'پین',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.order,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
|
|
@ -269,6 +280,52 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
|||
),
|
||||
),
|
||||
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(
|
||||
Icons.drag_handle,
|
||||
size: 16,
|
||||
|
|
@ -312,6 +369,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
|||
visibleColumns: _visibleColumns,
|
||||
columnOrder: _columnOrder,
|
||||
columnWidths: _columnWidths,
|
||||
pinnedLeft: _pinnedLeft.toList(),
|
||||
pinnedRight: _pinnedRight.toList(),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop(newSettings);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' show FontFeature;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:file_saver/file_saver.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:dio/dio.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
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -83,6 +94,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_limit = widget.config.defaultPageSize;
|
||||
_setupSearchListener();
|
||||
_loadColumnSettings();
|
||||
_loadDensityPreference();
|
||||
_fetchData();
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +124,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_searchCtrl.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
_horizontalScrollController.dispose();
|
||||
_tableFocusNode.dispose();
|
||||
for (var controller in _columnSearchControllers.values) {
|
||||
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 {
|
||||
if (!widget.config.enableColumnSettings) {
|
||||
_visibleColumns = List.from(widget.config.columns);
|
||||
|
|
@ -227,6 +257,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_total = response.total;
|
||||
_totalPages = response.totalPages;
|
||||
_selectedRows.clear(); // Clear selection when data changes
|
||||
_activeRowIndex = _items.isNotEmpty ? 0 : -1;
|
||||
_lastSelectedRowIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -411,15 +443,27 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
if (!widget.config.enableRowSelection) return;
|
||||
|
||||
setState(() {
|
||||
if (widget.config.enableMultiRowSelection) {
|
||||
if (_selectedRows.contains(rowIndex)) {
|
||||
_selectedRows.remove(rowIndex);
|
||||
} else {
|
||||
_selectedRows.add(rowIndex);
|
||||
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 {
|
||||
_selectedRows.clear();
|
||||
_selectedRows.add(rowIndex);
|
||||
if (widget.config.enableMultiRowSelection) {
|
||||
if (_selectedRows.contains(rowIndex)) {
|
||||
_selectedRows.remove(rowIndex);
|
||||
} else {
|
||||
_selectedRows.add(rowIndex);
|
||||
}
|
||||
} else {
|
||||
_selectedRows.clear();
|
||||
_selectedRows.add(rowIndex);
|
||||
}
|
||||
_lastSelectedRowIndex = rowIndex;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -762,7 +806,51 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
shape: RoundedRectangleBorder(
|
||||
borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
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(
|
||||
padding: widget.config.padding ?? const EdgeInsets.all(16),
|
||||
margin: widget.config.margin,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -818,6 +906,53 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
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
|
||||
Expanded(
|
||||
child: _buildDataTable(t, theme),
|
||||
|
|
@ -829,6 +964,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
_buildFooter(t, theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -922,6 +1060,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
case 'columnSettings':
|
||||
_openColumnSettingsDialog();
|
||||
break;
|
||||
case 'toggleDensity':
|
||||
setState(() {
|
||||
_dense = !_dense;
|
||||
});
|
||||
_saveDensityPreference();
|
||||
break;
|
||||
}
|
||||
},
|
||||
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) {
|
||||
if (_loadingList) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.config.loadingWidget != null)
|
||||
widget.config.loadingWidget!
|
||||
else
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.config.loadingMessage ?? t.loading,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 8,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
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),
|
||||
),
|
||||
),
|
||||
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
|
||||
? _visibleColumns
|
||||
: 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) {
|
||||
final headerTextStyle = theme.textTheme.titleSmall?.copyWith(
|
||||
|
|
@ -1403,9 +1602,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
color: theme.colorScheme.onSurface,
|
||||
) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
|
||||
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 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(
|
||||
label: _ColumnHeaderWithSearch(
|
||||
|
|
@ -1419,6 +1621,74 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
: () { },
|
||||
hasActiveFilter: _columnSearchValues.containsKey(column.key),
|
||||
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),
|
||||
fixedWidth: computedWidth,
|
||||
|
|
@ -1444,7 +1714,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
horizontalMargin: 8,
|
||||
minWidth: widget.config.minTableWidth ?? 600,
|
||||
horizontalScrollController: _horizontalScrollController,
|
||||
headingRowHeight: 44,
|
||||
headingRowHeight: _dense ? 40 : 44,
|
||||
dataRowHeight: _dense ? 38 : 48,
|
||||
columns: columns,
|
||||
rows: _items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
|
|
@ -1510,6 +1781,21 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
}
|
||||
|
||||
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,
|
||||
onTap: widget.config.onRowTap != null
|
||||
? () => 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)
|
||||
if (column is TextColumn && column.formatter != null) {
|
||||
final text = column.formatter!(item) ?? '';
|
||||
return Text(
|
||||
final overflow = _getOverflow(column);
|
||||
final textWidget = Text(
|
||||
text,
|
||||
textAlign: _getTextAlign(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) {
|
||||
final text = column.formatter!(item) ?? '';
|
||||
return Text(
|
||||
final overflow = _getOverflow(column);
|
||||
final textWidget = Text(
|
||||
text,
|
||||
textAlign: _getTextAlign(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) {
|
||||
final text = column.formatter!(item) ?? '';
|
||||
return Text(
|
||||
final overflow = _getOverflow(column);
|
||||
final textWidget = Text(
|
||||
text,
|
||||
textAlign: _getTextAlign(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
|
||||
final value = DataTableUtils.getCellValue(item, column.key);
|
||||
final formattedValue = DataTableUtils.formatCellValue(value, column);
|
||||
return Text(
|
||||
final overflow = _getOverflow(column);
|
||||
final textWidget = Text(
|
||||
formattedValue,
|
||||
textAlign: _getTextAlign(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) {
|
||||
|
|
@ -1642,6 +1988,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
|||
final VoidCallback onSearch;
|
||||
final bool hasActiveFilter;
|
||||
final bool enabled;
|
||||
final void Function(double dx)? onResizeDrag;
|
||||
final VoidCallback? onPinLeft;
|
||||
final VoidCallback? onPinRight;
|
||||
final VoidCallback? onUnpin;
|
||||
final VoidCallback? onHide;
|
||||
|
||||
const _ColumnHeaderWithSearch({
|
||||
required this.text,
|
||||
|
|
@ -1652,6 +2003,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
|||
required this.onSearch,
|
||||
required this.hasActiveFilter,
|
||||
this.enabled = true,
|
||||
this.onResizeDrag,
|
||||
this.onPinLeft,
|
||||
this.onPinRight,
|
||||
this.onUnpin,
|
||||
this.onHide,
|
||||
});
|
||||
|
||||
@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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ class ColumnSettings {
|
|||
final List<String> visibleColumns;
|
||||
final List<String> columnOrder;
|
||||
final Map<String, double> columnWidths;
|
||||
final List<String> pinnedLeft;
|
||||
final List<String> pinnedRight;
|
||||
|
||||
const ColumnSettings({
|
||||
required this.visibleColumns,
|
||||
required this.columnOrder,
|
||||
this.columnWidths = const {},
|
||||
this.pinnedLeft = const [],
|
||||
this.pinnedRight = const [],
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -19,6 +23,8 @@ class ColumnSettings {
|
|||
'visibleColumns': visibleColumns,
|
||||
'columnOrder': columnOrder,
|
||||
'columnWidths': columnWidths,
|
||||
'pinnedLeft': pinnedLeft,
|
||||
'pinnedRight': pinnedRight,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +33,8 @@ class ColumnSettings {
|
|||
visibleColumns: List<String>.from(json['visibleColumns'] ?? []),
|
||||
columnOrder: List<String>.from(json['columnOrder'] ?? []),
|
||||
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>? columnOrder,
|
||||
Map<String, double>? columnWidths,
|
||||
List<String>? pinnedLeft,
|
||||
List<String>? pinnedRight,
|
||||
}) {
|
||||
return ColumnSettings(
|
||||
visibleColumns: visibleColumns ?? this.visibleColumns,
|
||||
columnOrder: columnOrder ?? this.columnOrder,
|
||||
columnWidths: columnWidths ?? this.columnWidths,
|
||||
pinnedLeft: pinnedLeft ?? this.pinnedLeft,
|
||||
pinnedRight: pinnedRight ?? this.pinnedRight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +104,8 @@ class ColumnSettingsService {
|
|||
return ColumnSettings(
|
||||
visibleColumns: List.from(columnKeys),
|
||||
columnOrder: List.from(columnKeys),
|
||||
pinnedLeft: const [],
|
||||
pinnedRight: const [],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,11 +156,22 @@ class ColumnSettingsService {
|
|||
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(
|
||||
visibleColumns: visibleColumns,
|
||||
columnOrder: columnOrder,
|
||||
columnWidths: validColumnWidths,
|
||||
pinnedLeft: leftPins,
|
||||
pinnedRight: rightPins,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import './product_combobox_widget.dart';
|
|||
// import './price_list_combobox_widget.dart';
|
||||
import '../../services/price_list_service.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import './warehouse_combobox_widget.dart';
|
||||
|
||||
class InvoiceLineItemsTable extends StatefulWidget {
|
||||
final int businessId;
|
||||
final int? selectedCurrencyId; // از تب ارز فاکتور
|
||||
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
||||
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
||||
final bool postInventory;
|
||||
|
||||
const InvoiceLineItemsTable({
|
||||
super.key,
|
||||
|
|
@ -18,6 +20,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
|||
this.selectedCurrencyId,
|
||||
this.onChanged,
|
||||
this.invoiceType = 'sales',
|
||||
this.postInventory = true,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -272,6 +275,15 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.postInventory)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Tooltip(
|
||||
message: 'انبار',
|
||||
child: Text('انبار', style: style),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Tooltip(
|
||||
|
|
@ -392,6 +404,24 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
|||
}),
|
||||
),
|
||||
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(
|
||||
flex: 3,
|
||||
child: SizedBox(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -33,8 +33,13 @@ case "$CMD" in
|
|||
serve)
|
||||
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
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
Loading…
Reference in a new issue