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] = []
|
roots: list[AccountTreeNode] = []
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
node = AccountTreeNode(
|
node = AccountTreeNode(
|
||||||
id=n['id'], code=n['code'], name=n['name'], account_type=n.get('account_type'), parent_id=n.get('parent_id')
|
id=n['id'],
|
||||||
|
code=n['code'],
|
||||||
|
name=n['name'],
|
||||||
|
account_type=n.get('account_type'),
|
||||||
|
parent_id=n.get('parent_id'),
|
||||||
|
business_id=n.get('business_id'),
|
||||||
|
is_public=n.get('is_public'),
|
||||||
|
has_children=n.get('has_children'),
|
||||||
|
can_edit=n.get('can_edit'),
|
||||||
|
can_delete=n.get('can_delete'),
|
||||||
)
|
)
|
||||||
by_id[node.id] = node
|
by_id[node.id] = node
|
||||||
for node in list(by_id.values()):
|
for node in list(by_id.values()):
|
||||||
|
|
@ -58,10 +67,29 @@ def get_accounts_tree(
|
||||||
rows = db.query(Account).filter(
|
rows = db.query(Account).filter(
|
||||||
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
|
(Account.business_id == None) | (Account.business_id == business_id) # noqa: E711
|
||||||
).order_by(Account.code.asc()).all()
|
).order_by(Account.code.asc()).all()
|
||||||
flat = [
|
# محاسبه has_children با شمارش فرزندان در مجموعه
|
||||||
{"id": r.id, "code": r.code, "name": r.name, "account_type": r.account_type, "parent_id": r.parent_id}
|
children_map: dict[int, int] = {}
|
||||||
for r in rows
|
for r in rows:
|
||||||
]
|
if r.parent_id:
|
||||||
|
children_map[r.parent_id] = children_map.get(r.parent_id, 0) + 1
|
||||||
|
flat: list[Dict[str, Any]] = []
|
||||||
|
for r in rows:
|
||||||
|
is_public = r.business_id is None
|
||||||
|
has_children = children_map.get(r.id, 0) > 0
|
||||||
|
can_edit = (r.business_id == business_id) and True # شرط دسترسی نوشتن پایینتر بررسی میشود در UI/Endpoint
|
||||||
|
can_delete = can_edit and (not has_children)
|
||||||
|
flat.append({
|
||||||
|
"id": r.id,
|
||||||
|
"code": r.code,
|
||||||
|
"name": r.name,
|
||||||
|
"account_type": r.account_type,
|
||||||
|
"parent_id": r.parent_id,
|
||||||
|
"business_id": r.business_id,
|
||||||
|
"is_public": is_public,
|
||||||
|
"has_children": has_children,
|
||||||
|
"can_edit": can_edit,
|
||||||
|
"can_delete": can_delete,
|
||||||
|
})
|
||||||
tree = _build_tree(flat)
|
tree = _build_tree(flat)
|
||||||
return success_response({"items": [n.model_dump() for n in tree]}, request)
|
return success_response({"items": [n.model_dump() for n in tree]}, request)
|
||||||
|
|
||||||
|
|
@ -214,6 +242,17 @@ def create_business_account(
|
||||||
# اجازه نوشتن در بخش حسابداری لازم است
|
# اجازه نوشتن در بخش حسابداری لازم است
|
||||||
if not ctx.can_write_section("accounting"):
|
if not ctx.can_write_section("accounting"):
|
||||||
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
|
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
|
||||||
|
# والد اجباری است
|
||||||
|
if body.parent_id is None:
|
||||||
|
raise ApiError("PARENT_REQUIRED", "Parent account is required", http_status=400)
|
||||||
|
# اگر والد عمومی است باید قبلا دارای زیرمجموعه باشد (اجازه ایجاد زیر شاخه برای برگ عمومی را نمیدهیم)
|
||||||
|
parent = db.get(Account, int(body.parent_id)) if body.parent_id is not None else None
|
||||||
|
if parent is None:
|
||||||
|
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
|
||||||
|
if parent.business_id is None:
|
||||||
|
# lazy-load children count
|
||||||
|
if not parent.children or len(parent.children) == 0:
|
||||||
|
raise ApiError("INVALID_PUBLIC_PARENT", "Cannot add child under a public leaf account", http_status=400)
|
||||||
try:
|
try:
|
||||||
created = create_account(
|
created = create_account(
|
||||||
db,
|
db,
|
||||||
|
|
@ -238,7 +277,7 @@ def create_business_account(
|
||||||
@router.put(
|
@router.put(
|
||||||
"/account/{account_id}",
|
"/account/{account_id}",
|
||||||
summary="ویرایش حساب",
|
summary="ویرایش حساب",
|
||||||
description="ویرایش حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
|
description="ویرایش حساب اختصاصی بیزنس (دارای دسترسی write). حسابهای عمومی غیرقابلویرایش هستند.",
|
||||||
)
|
)
|
||||||
def update_account_endpoint(
|
def update_account_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -251,9 +290,9 @@ def update_account_endpoint(
|
||||||
if not data:
|
if not data:
|
||||||
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
|
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
|
||||||
acc_business_id = data.get("business_id")
|
acc_business_id = data.get("business_id")
|
||||||
# اگر عمومی است، فقط سوپرادمین
|
# حسابهای عمومی غیرقابلویرایش هستند
|
||||||
if acc_business_id is None and not ctx.is_superadmin():
|
if acc_business_id is None:
|
||||||
raise ApiError("FORBIDDEN", "Only superadmin can edit public accounts", http_status=403)
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
|
||||||
# اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد
|
# اگر متعلق به بیزنس است باید دسترسی داشته باشد و write accounting داشته باشد
|
||||||
if acc_business_id is not None:
|
if acc_business_id is not None:
|
||||||
if not ctx.can_access_business(int(acc_business_id)):
|
if not ctx.can_access_business(int(acc_business_id)):
|
||||||
|
|
@ -280,13 +319,15 @@ def update_account_endpoint(
|
||||||
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
|
raise ApiError("PARENT_NOT_FOUND", "Parent account not found", http_status=400)
|
||||||
if code == "INVALID_PARENT_BUSINESS":
|
if code == "INVALID_PARENT_BUSINESS":
|
||||||
raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400)
|
raise ApiError("INVALID_PARENT_BUSINESS", "Parent must be public or within the same business", http_status=400)
|
||||||
|
if code == "PUBLIC_IMMUTABLE":
|
||||||
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/account/{account_id}",
|
"/account/{account_id}",
|
||||||
summary="حذف حساب",
|
summary="حذف حساب",
|
||||||
description="حذف حساب عمومی (فقط سوپرادمین) یا حساب اختصاصی بیزنس (دارای دسترسی write).",
|
description="حذف حساب اختصاصی بیزنس (دارای دسترسی write). حسابهای عمومی غیرقابلحذف هستند.",
|
||||||
)
|
)
|
||||||
def delete_account_endpoint(
|
def delete_account_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -298,16 +339,25 @@ def delete_account_endpoint(
|
||||||
if not data:
|
if not data:
|
||||||
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
|
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
|
||||||
acc_business_id = data.get("business_id")
|
acc_business_id = data.get("business_id")
|
||||||
if acc_business_id is None and not ctx.is_superadmin():
|
# حسابهای عمومی غیرقابلحذف هستند
|
||||||
raise ApiError("FORBIDDEN", "Only superadmin can delete public accounts", http_status=403)
|
if acc_business_id is None:
|
||||||
|
raise ApiError("FORBIDDEN", "Public accounts are immutable", http_status=403)
|
||||||
if acc_business_id is not None:
|
if acc_business_id is not None:
|
||||||
if not ctx.can_access_business(int(acc_business_id)):
|
if not ctx.can_access_business(int(acc_business_id)):
|
||||||
raise ApiError("FORBIDDEN", "No access to business", http_status=403)
|
raise ApiError("FORBIDDEN", "No access to business", http_status=403)
|
||||||
if not ctx.can_write_section("accounting"):
|
if not ctx.can_write_section("accounting"):
|
||||||
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
|
raise ApiError("FORBIDDEN", "Missing write permission for accounting", http_status=403)
|
||||||
ok = delete_account(db, account_id)
|
try:
|
||||||
if not ok:
|
ok = delete_account(db, account_id)
|
||||||
raise ApiError("ACCOUNT_NOT_FOUND", "Account not found", http_status=404)
|
if not ok:
|
||||||
return success_response(None, request, message="ACCOUNT_DELETED")
|
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",
|
"petty_cash_ids",
|
||||||
"account_ids",
|
"account_ids",
|
||||||
"check_ids",
|
"check_ids",
|
||||||
|
"warehouse_ids",
|
||||||
"match_mode",
|
"match_mode",
|
||||||
"result_scope",
|
"result_scope",
|
||||||
):
|
):
|
||||||
|
|
@ -113,6 +114,7 @@ async def export_kardex_excel_endpoint(
|
||||||
"petty_cash_ids": body.get("petty_cash_ids"),
|
"petty_cash_ids": body.get("petty_cash_ids"),
|
||||||
"account_ids": body.get("account_ids"),
|
"account_ids": body.get("account_ids"),
|
||||||
"check_ids": body.get("check_ids"),
|
"check_ids": body.get("check_ids"),
|
||||||
|
"warehouse_ids": body.get("warehouse_ids"),
|
||||||
"match_mode": body.get("match_mode") or "any",
|
"match_mode": body.get("match_mode") or "any",
|
||||||
"result_scope": body.get("result_scope") or "lines_matching",
|
"result_scope": body.get("result_scope") or "lines_matching",
|
||||||
"include_running_balance": bool(body.get("include_running_balance", False)),
|
"include_running_balance": bool(body.get("include_running_balance", False)),
|
||||||
|
|
@ -130,7 +132,7 @@ async def export_kardex_excel_endpoint(
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Kardex"
|
ws.title = "Kardex"
|
||||||
headers = [
|
headers = [
|
||||||
"document_date", "document_code", "document_type", "description",
|
"document_date", "document_code", "document_type", "warehouse", "movement", "description",
|
||||||
"debit", "credit", "quantity", "running_amount", "running_quantity",
|
"debit", "credit", "quantity", "running_amount", "running_quantity",
|
||||||
]
|
]
|
||||||
ws.append(headers)
|
ws.append(headers)
|
||||||
|
|
@ -139,6 +141,8 @@ async def export_kardex_excel_endpoint(
|
||||||
it.get("document_date"),
|
it.get("document_date"),
|
||||||
it.get("document_code"),
|
it.get("document_code"),
|
||||||
it.get("document_type"),
|
it.get("document_type"),
|
||||||
|
it.get("warehouse_name") or it.get("warehouse_id"),
|
||||||
|
it.get("movement"),
|
||||||
it.get("description"),
|
it.get("description"),
|
||||||
it.get("debit"),
|
it.get("debit"),
|
||||||
it.get("credit"),
|
it.get("credit"),
|
||||||
|
|
@ -205,6 +209,7 @@ async def export_kardex_pdf_endpoint(
|
||||||
"petty_cash_ids": body.get("petty_cash_ids"),
|
"petty_cash_ids": body.get("petty_cash_ids"),
|
||||||
"account_ids": body.get("account_ids"),
|
"account_ids": body.get("account_ids"),
|
||||||
"check_ids": body.get("check_ids"),
|
"check_ids": body.get("check_ids"),
|
||||||
|
"warehouse_ids": body.get("warehouse_ids"),
|
||||||
"match_mode": body.get("match_mode") or "any",
|
"match_mode": body.get("match_mode") or "any",
|
||||||
"result_scope": body.get("result_scope") or "lines_matching",
|
"result_scope": body.get("result_scope") or "lines_matching",
|
||||||
"include_running_balance": bool(body.get("include_running_balance", False)),
|
"include_running_balance": bool(body.get("include_running_balance", False)),
|
||||||
|
|
@ -223,6 +228,8 @@ async def export_kardex_pdf_endpoint(
|
||||||
f"<td>{cell(it.get('document_date'))}</td>"
|
f"<td>{cell(it.get('document_date'))}</td>"
|
||||||
f"<td>{cell(it.get('document_code'))}</td>"
|
f"<td>{cell(it.get('document_code'))}</td>"
|
||||||
f"<td>{cell(it.get('document_type'))}</td>"
|
f"<td>{cell(it.get('document_type'))}</td>"
|
||||||
|
f"<td>{cell(it.get('warehouse_name') or it.get('warehouse_id'))}</td>"
|
||||||
|
f"<td>{cell(it.get('movement'))}</td>"
|
||||||
f"<td>{cell(it.get('description'))}</td>"
|
f"<td>{cell(it.get('description'))}</td>"
|
||||||
f"<td style='text-align:right'>{cell(it.get('debit'))}</td>"
|
f"<td style='text-align:right'>{cell(it.get('debit'))}</td>"
|
||||||
f"<td style='text-align:right'>{cell(it.get('credit'))}</td>"
|
f"<td style='text-align:right'>{cell(it.get('credit'))}</td>"
|
||||||
|
|
@ -252,6 +259,8 @@ async def export_kardex_pdf_endpoint(
|
||||||
<th>تاریخ سند</th>
|
<th>تاریخ سند</th>
|
||||||
<th>کد سند</th>
|
<th>کد سند</th>
|
||||||
<th>نوع سند</th>
|
<th>نوع سند</th>
|
||||||
|
<th>انبار</th>
|
||||||
|
<th>جهت حرکت</th>
|
||||||
<th>شرح</th>
|
<th>شرح</th>
|
||||||
<th>بدهکار</th>
|
<th>بدهکار</th>
|
||||||
<th>بستانکار</th>
|
<th>بستانکار</th>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class AccountTreeNode(BaseModel):
|
||||||
name: str = Field(..., description="نام حساب")
|
name: str = Field(..., description="نام حساب")
|
||||||
account_type: Optional[str] = Field(default=None, description="نوع حساب")
|
account_type: Optional[str] = Field(default=None, description="نوع حساب")
|
||||||
parent_id: Optional[int] = Field(default=None, description="شناسه والد")
|
parent_id: Optional[int] = Field(default=None, description="شناسه والد")
|
||||||
|
business_id: Optional[int] = Field(default=None, description="شناسه کسبوکار؛ اگر تهی باشد حساب عمومی است")
|
||||||
|
is_public: Optional[bool] = Field(default=None, description="True اگر حساب عمومی باشد")
|
||||||
|
has_children: Optional[bool] = Field(default=None, description="دارای فرزند")
|
||||||
|
can_edit: Optional[bool] = Field(default=None, description="آیا کاربر فعلی میتواند ویرایش کند")
|
||||||
|
can_delete: Optional[bool] = Field(default=None, description="آیا کاربر فعلی میتواند حذف کند")
|
||||||
level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
|
level: Optional[int] = Field(default=None, description="سطح حساب در درخت")
|
||||||
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
|
children: List["AccountTreeNode"] = Field(default_factory=list, description="فرزندان")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ class Base(DeclarativeBase):
|
||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
engine = create_engine(settings.mysql_dsn, echo=settings.sqlalchemy_echo, pool_pre_ping=True, pool_recycle=3600)
|
engine = create_engine(
|
||||||
|
settings.mysql_dsn,
|
||||||
|
echo=settings.sqlalchemy_echo,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
pool_timeout=settings.db_pool_timeout,
|
||||||
|
)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ class Settings(BaseSettings):
|
||||||
db_port: int = 3306
|
db_port: int = 3306
|
||||||
db_name: str = "hesabix"
|
db_name: str = "hesabix"
|
||||||
sqlalchemy_echo: bool = False
|
sqlalchemy_echo: bool = False
|
||||||
|
# DB Pooling
|
||||||
|
db_pool_size: int = 10
|
||||||
|
db_max_overflow: int = 20
|
||||||
|
db_pool_timeout: int = 10
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||||
from adapters.api.v1.expense_income import router as expense_income_router
|
from adapters.api.v1.expense_income import router as expense_income_router
|
||||||
from adapters.api.v1.documents import router as documents_router
|
from adapters.api.v1.documents import router as documents_router
|
||||||
from adapters.api.v1.kardex import router as kardex_router
|
from adapters.api.v1.kardex import router as kardex_router
|
||||||
|
from adapters.api.v1.inventory_transfers import router as inventory_transfers_router
|
||||||
from app.core.i18n import negotiate_locale, Translator
|
from app.core.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||||
|
|
@ -321,6 +322,7 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(documents_router, prefix=settings.api_v1_prefix)
|
application.include_router(documents_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
|
application.include_router(kardex_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(inventory_transfers_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
# Support endpoints
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
@ -335,6 +337,25 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
register_error_handlers(application)
|
register_error_handlers(application)
|
||||||
|
|
||||||
|
@application.middleware("http")
|
||||||
|
async def log_slow_requests(request: Request, call_next):
|
||||||
|
import time
|
||||||
|
import structlog
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||||
|
if duration_ms > 2000:
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
logger.warning(
|
||||||
|
"slow_request",
|
||||||
|
path=str(request.url.path),
|
||||||
|
method=request.method,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
@application.get("/",
|
@application.get("/",
|
||||||
summary="اطلاعات سرویس",
|
summary="اطلاعات سرویس",
|
||||||
description="دریافت اطلاعات کلی سرویس و نسخه",
|
description="دریافت اطلاعات کلی سرویس و نسخه",
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ def update_account(
|
||||||
obj = db.get(Account, account_id)
|
obj = db.get(Account, account_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None
|
return None
|
||||||
|
# جلوگیری از تغییر حسابهای عمومی در لایه سرویس
|
||||||
|
if obj.business_id is None:
|
||||||
|
raise ValueError("PUBLIC_IMMUTABLE")
|
||||||
if parent_id is not None:
|
if parent_id is not None:
|
||||||
parent_id = _validate_parent(db, parent_id, obj.business_id)
|
parent_id = _validate_parent(db, parent_id, obj.business_id)
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
|
@ -94,6 +97,12 @@ def delete_account(db: Session, account_id: int) -> bool:
|
||||||
obj = db.get(Account, account_id)
|
obj = db.get(Account, account_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
return False
|
return False
|
||||||
|
# جلوگیری از حذف اگر فرزند دارد
|
||||||
|
if obj.children and len(obj.children) > 0:
|
||||||
|
raise ValueError("ACCOUNT_HAS_CHILDREN")
|
||||||
|
# جلوگیری از حذف اگر در اسناد استفاده شده است
|
||||||
|
if obj.document_lines and len(obj.document_lines) > 0:
|
||||||
|
raise ValueError("ACCOUNT_IN_USE")
|
||||||
db.delete(obj)
|
db.delete(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -178,32 +178,34 @@ def create_check(db: Session, business_id: int, user_id: int, data: Dict[str, An
|
||||||
"check_id": obj.id,
|
"check_id": obj.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ایجاد سند
|
# ایجاد سند (اگر چک واگذار شخص ندارد، از ثبت سند صرفنظر میشود)
|
||||||
document = Document(
|
skip_autopost = (ctype == "transferred" and not person_id)
|
||||||
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
|
if not skip_autopost:
|
||||||
business_id=business_id,
|
document = Document(
|
||||||
fiscal_year_id=fiscal_year.id,
|
code=f"CHK-{document_date.strftime('%Y%m%d')}-{int(datetime.utcnow().timestamp())%100000}",
|
||||||
currency_id=int(data.get("currency_id")),
|
business_id=business_id,
|
||||||
created_by_user_id=int(user_id),
|
fiscal_year_id=fiscal_year.id,
|
||||||
document_date=document_date,
|
currency_id=int(data.get("currency_id")),
|
||||||
document_type="check",
|
created_by_user_id=int(user_id),
|
||||||
is_proforma=False,
|
document_date=document_date,
|
||||||
description=description,
|
document_type="check",
|
||||||
extra_info={
|
is_proforma=False,
|
||||||
"source": "check_create",
|
description=description,
|
||||||
"check_id": obj.id,
|
extra_info={
|
||||||
"check_type": ctype,
|
"source": "check_create",
|
||||||
},
|
"check_id": obj.id,
|
||||||
)
|
"check_type": ctype,
|
||||||
db.add(document)
|
},
|
||||||
db.flush()
|
)
|
||||||
|
db.add(document)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
db.add(DocumentLine(document_id=document.id, **line))
|
db.add(DocumentLine(document_id=document.id, **line))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(document)
|
db.refresh(document)
|
||||||
created_document_id = document.id
|
created_document_id = document.id
|
||||||
except Exception:
|
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]] = []
|
lines: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
if obj.type == CheckType.RECEIVED:
|
if obj.type == CheckType.RECEIVED:
|
||||||
# Dr 10203 (bank), Cr 10403
|
# Dr 10203 (bank), Cr 10403 یا 10404 بسته به وضعیت
|
||||||
|
credit_code = "10404" if obj.status == CheckStatus.DEPOSITED else "10403"
|
||||||
lines.append({
|
lines.append({
|
||||||
"account_id": _ensure_account(db, "10203"),
|
"account_id": _ensure_account(db, "10203"),
|
||||||
"bank_account_id": int(data.get("bank_account_id")),
|
"bank_account_id": int(data.get("bank_account_id")),
|
||||||
|
|
@ -345,7 +348,7 @@ def clear_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
|
||||||
"check_id": obj.id,
|
"check_id": obj.id,
|
||||||
})
|
})
|
||||||
lines.append({
|
lines.append({
|
||||||
"account_id": _ensure_account(db, "10403"),
|
"account_id": _ensure_account(db, credit_code),
|
||||||
"debit": Decimal(0),
|
"debit": Decimal(0),
|
||||||
"credit": amount_dec,
|
"credit": amount_dec,
|
||||||
"description": description or "وصول چک",
|
"description": description or "وصول چک",
|
||||||
|
|
@ -483,23 +486,45 @@ def bounce_check(db: Session, check_id: int, user_id: int, data: Dict[str, Any])
|
||||||
lines: List[Dict[str, Any]] = []
|
lines: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
if obj.type == CheckType.RECEIVED:
|
if obj.type == CheckType.RECEIVED:
|
||||||
# Reverse cash if previously cleared; simplified: Dr 10403, Cr 10203
|
# فقط از وضعیتهای DEPOSITED یا CLEARED اجازه برگشت
|
||||||
|
if obj.status not in (CheckStatus.DEPOSITED, CheckStatus.CLEARED):
|
||||||
|
raise ApiError("INVALID_STATE", f"Cannot bounce from status {obj.status}", http_status=400)
|
||||||
bank_account_id = data.get("bank_account_id")
|
bank_account_id = data.get("bank_account_id")
|
||||||
lines.append({
|
if obj.status == CheckStatus.DEPOSITED:
|
||||||
"account_id": _ensure_account(db, "10403"),
|
# Dr 10403, Cr 10404
|
||||||
"debit": amount_dec,
|
lines.append({
|
||||||
"credit": Decimal(0),
|
"account_id": _ensure_account(db, "10403"),
|
||||||
"description": description or "برگشت چک",
|
"debit": amount_dec,
|
||||||
"check_id": obj.id,
|
"credit": Decimal(0),
|
||||||
})
|
"description": description or "برگشت چک",
|
||||||
lines.append({
|
"check_id": obj.id,
|
||||||
"account_id": _ensure_account(db, "10203"),
|
})
|
||||||
**({"bank_account_id": int(bank_account_id)} if bank_account_id else {}),
|
lines.append({
|
||||||
"debit": Decimal(0),
|
"account_id": _ensure_account(db, "10404"),
|
||||||
"credit": amount_dec,
|
"debit": Decimal(0),
|
||||||
"description": description or "برگشت چک",
|
"credit": amount_dec,
|
||||||
"check_id": obj.id,
|
"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:
|
else:
|
||||||
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
|
# transferred: Dr 20202, Cr 20201(person) (increase AP again)
|
||||||
if not obj.person_id:
|
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"
|
return "average"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_inventory_posting_enabled(data: Dict[str, Any]) -> bool:
|
||||||
|
"""خواندن فلگ ثبت اسناد انبار از extra_info. پیشفرض: فعال (True)."""
|
||||||
|
try:
|
||||||
|
extra = data.get("extra_info") or {}
|
||||||
|
val = extra.get("post_inventory")
|
||||||
|
if val is None:
|
||||||
|
return True
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return val
|
||||||
|
if isinstance(val, (int, float)):
|
||||||
|
return bool(val)
|
||||||
|
s = str(val).strip().lower()
|
||||||
|
return s not in ("false", "0", "no", "off")
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _iter_product_movements(
|
def _iter_product_movements(
|
||||||
db: Session,
|
db: Session,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
|
|
@ -94,6 +111,13 @@ def _iter_product_movements(
|
||||||
movements = []
|
movements = []
|
||||||
for line, doc in rows:
|
for line, doc in rows:
|
||||||
info = line.extra_info or {}
|
info = line.extra_info or {}
|
||||||
|
# اگر خط صراحتاً به عنوان عدم ثبت انبار علامتگذاری شده، از حرکت صرفنظر کن
|
||||||
|
try:
|
||||||
|
posted = info.get("inventory_posted")
|
||||||
|
if posted is False:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
movement = (info.get("movement") or None)
|
movement = (info.get("movement") or None)
|
||||||
wh_id = info.get("warehouse_id")
|
wh_id = info.get("warehouse_id")
|
||||||
if movement is None:
|
if movement is None:
|
||||||
|
|
@ -319,9 +343,19 @@ def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
def _get_person_control_account(db: Session) -> Account:
|
def _get_person_control_account(db: Session, invoice_type: str | None = None) -> Account:
|
||||||
# عمومی اشخاص (پرداختنی/دریافتنی) پیشفرض: 20201
|
# انتخاب حساب طرفشخص بر اساس نوع فاکتور
|
||||||
return _get_fixed_account_by_code(db, "20201")
|
# فروش/برگشت از فروش → دریافتنی ها 10401
|
||||||
|
# خرید/برگشت از خرید → پرداختنی ها 20201 (پیشفرض)
|
||||||
|
try:
|
||||||
|
inv_type = (invoice_type or "").strip()
|
||||||
|
if inv_type in {INVOICE_SALES, INVOICE_SALES_RETURN}:
|
||||||
|
return _get_fixed_account_by_code(db, "10401")
|
||||||
|
# سایر موارد (شامل خرید/برگشت از خرید)
|
||||||
|
return _get_fixed_account_by_code(db, "20201")
|
||||||
|
except Exception:
|
||||||
|
# fallback امن
|
||||||
|
return _get_fixed_account_by_code(db, "20201")
|
||||||
|
|
||||||
|
|
||||||
def _build_doc_code(prefix_base: str) -> str:
|
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")):
|
if not bool(info.get("inventory_tracked")):
|
||||||
continue
|
continue
|
||||||
|
# اگر خط برای انبار پست نشده، در COGS لحاظ نشود
|
||||||
|
if info.get("inventory_posted") is False:
|
||||||
|
continue
|
||||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||||
if info.get("cogs_amount") is not None:
|
if info.get("cogs_amount") is not None:
|
||||||
total += Decimal(str(info.get("cogs_amount")))
|
total += Decimal(str(info.get("cogs_amount")))
|
||||||
|
|
@ -386,25 +423,113 @@ def _extract_cogs_total(lines: List[Dict[str, Any]]) -> Decimal:
|
||||||
def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]:
|
def _resolve_accounts_for_invoice(db: Session, data: Dict[str, Any]) -> Dict[str, Account]:
|
||||||
# امکان override از extra_info.account_codes
|
# امکان override از extra_info.account_codes
|
||||||
overrides = ((data.get("extra_info") or {}).get("account_codes") or {})
|
overrides = ((data.get("extra_info") or {}).get("account_codes") or {})
|
||||||
|
invoice_type = str(data.get("invoice_type", "")).strip()
|
||||||
|
|
||||||
def code(name: str, default_code: str) -> str:
|
def code(name: str, default_code: str) -> str:
|
||||||
return str(overrides.get(name) or default_code)
|
return str(overrides.get(name) or default_code)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"revenue": _get_fixed_account_by_code(db, code("revenue", "70101")),
|
# درآمد و برگشت فروش مطابق چارت سید:
|
||||||
"sales_return": _get_fixed_account_by_code(db, code("sales_return", "70102")),
|
"revenue": _get_fixed_account_by_code(db, code("revenue", "50001")),
|
||||||
"inventory": _get_fixed_account_by_code(db, code("inventory", "10301")),
|
"sales_return": _get_fixed_account_by_code(db, code("sales_return", "50002")),
|
||||||
"inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10302")),
|
# موجودی و ساختهشده (در نبود حساب مجزا) هر دو 10102
|
||||||
"cogs": _get_fixed_account_by_code(db, code("cogs", "60101")),
|
"inventory": _get_fixed_account_by_code(db, code("inventory", "10102")),
|
||||||
"vat_out": _get_fixed_account_by_code(db, code("vat_out", "20801")),
|
"inventory_finished": _get_fixed_account_by_code(db, code("inventory_finished", "10102")),
|
||||||
"vat_in": _get_fixed_account_by_code(db, code("vat_in", "10801")),
|
# بهای تمام شده و VAT ها مطابق سید
|
||||||
"direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "60201")),
|
"cogs": _get_fixed_account_by_code(db, code("cogs", "40001")),
|
||||||
"wip": _get_fixed_account_by_code(db, code("wip", "60301")),
|
"vat_out": _get_fixed_account_by_code(db, code("vat_out", "20101")),
|
||||||
"waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "60401")),
|
"vat_in": _get_fixed_account_by_code(db, code("vat_in", "10104")),
|
||||||
"person": _get_person_control_account(db),
|
# مصرف مستقیم و ضایعات
|
||||||
|
"direct_consumption": _get_fixed_account_by_code(db, code("direct_consumption", "70406")),
|
||||||
|
"wip": _get_fixed_account_by_code(db, code("wip", "10106")),
|
||||||
|
"waste_expense": _get_fixed_account_by_code(db, code("waste_expense", "70407")),
|
||||||
|
# طرفشخص بر اساس نوع فاکتور
|
||||||
|
"person": _get_person_control_account(db, invoice_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_seller_commission(
|
||||||
|
db: Session,
|
||||||
|
invoice_type: str,
|
||||||
|
header_extra: Dict[str, Any],
|
||||||
|
totals: Dict[str, Any],
|
||||||
|
) -> Tuple[int | None, Decimal]:
|
||||||
|
"""محاسبه پورسانت فروشنده/بازاریاب بر اساس تنظیمات شخص یا override در فاکتور.
|
||||||
|
|
||||||
|
Returns: (seller_id, commission_amount)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ei = header_extra or {}
|
||||||
|
seller_id_raw = ei.get("seller_id")
|
||||||
|
seller_id: int | None = int(seller_id_raw) if seller_id_raw is not None else None
|
||||||
|
except Exception:
|
||||||
|
seller_id = None
|
||||||
|
if not seller_id:
|
||||||
|
return (None, Decimal(0))
|
||||||
|
|
||||||
|
# مبنای محاسبه
|
||||||
|
gross = Decimal(str((totals or {}).get("gross", 0)))
|
||||||
|
discount = Decimal(str((totals or {}).get("discount", 0)))
|
||||||
|
net = gross - discount
|
||||||
|
|
||||||
|
# اگر در فاکتور override شده باشد، همان اعمال شود
|
||||||
|
commission_cfg = ei.get("commission") if isinstance(ei.get("commission"), dict) else None
|
||||||
|
if commission_cfg:
|
||||||
|
value = Decimal(str(commission_cfg.get("value", 0))) if commission_cfg.get("value") is not None else Decimal(0)
|
||||||
|
ctype = (commission_cfg.get("type") or "").strip().lower()
|
||||||
|
if value <= 0:
|
||||||
|
return (seller_id, Decimal(0))
|
||||||
|
if ctype == "percentage":
|
||||||
|
amount = (net * value) / Decimal(100)
|
||||||
|
return (seller_id, amount)
|
||||||
|
if ctype == "amount":
|
||||||
|
return (seller_id, value)
|
||||||
|
return (seller_id, Decimal(0))
|
||||||
|
|
||||||
|
# در غیر اینصورت، از تنظیمات شخص استفاده میکنیم
|
||||||
|
person = db.query(Person).filter(Person.id == seller_id).first()
|
||||||
|
if not person:
|
||||||
|
return (seller_id, Decimal(0))
|
||||||
|
|
||||||
|
# اگر شخص اجازهی ثبت پورسانت در سند فاکتور را نداده است، صفر برگردان
|
||||||
|
try:
|
||||||
|
if not bool(getattr(person, "commission_post_in_invoice_document", False)):
|
||||||
|
return (seller_id, Decimal(0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
exclude_discounts = bool(getattr(person, "commission_exclude_discounts", False))
|
||||||
|
base_amount = gross if exclude_discounts else net
|
||||||
|
|
||||||
|
amount = Decimal(0)
|
||||||
|
if invoice_type == INVOICE_SALES:
|
||||||
|
percent = getattr(person, "commission_sale_percent", None)
|
||||||
|
fixed = getattr(person, "commission_sales_amount", None)
|
||||||
|
elif invoice_type == INVOICE_SALES_RETURN:
|
||||||
|
percent = getattr(person, "commission_sales_return_percent", None)
|
||||||
|
fixed = getattr(person, "commission_sales_return_amount", None)
|
||||||
|
else:
|
||||||
|
percent = None
|
||||||
|
fixed = None
|
||||||
|
|
||||||
|
if percent is not None:
|
||||||
|
try:
|
||||||
|
p = Decimal(str(percent))
|
||||||
|
if p > 0:
|
||||||
|
amount = (base_amount * p) / Decimal(100)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif fixed is not None:
|
||||||
|
try:
|
||||||
|
f = Decimal(str(fixed))
|
||||||
|
if f > 0:
|
||||||
|
amount = f
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return (seller_id, amount)
|
||||||
|
|
||||||
|
|
||||||
def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]:
|
def _person_id_from_header(data: Dict[str, Any]) -> Optional[int]:
|
||||||
try:
|
try:
|
||||||
ei = data.get("extra_info") or {}
|
ei = data.get("extra_info") or {}
|
||||||
|
|
@ -492,6 +617,7 @@ def create_invoice(
|
||||||
totals = _extract_totals_from_lines(lines_input)
|
totals = _extract_totals_from_lines(lines_input)
|
||||||
|
|
||||||
# Inventory validation and costing pre-calculation
|
# Inventory validation and costing pre-calculation
|
||||||
|
post_inventory: bool = _is_inventory_posting_enabled(data)
|
||||||
# Determine outgoing lines for stock checks
|
# Determine outgoing lines for stock checks
|
||||||
movement_hint, _ = _movement_from_type(invoice_type)
|
movement_hint, _ = _movement_from_type(invoice_type)
|
||||||
outgoing_lines: List[Dict[str, Any]] = []
|
outgoing_lines: List[Dict[str, Any]] = []
|
||||||
|
|
@ -518,6 +644,17 @@ def create_invoice(
|
||||||
info = dict(ln.get("extra_info") or {})
|
info = dict(ln.get("extra_info") or {})
|
||||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||||
ln["extra_info"] = info
|
ln["extra_info"] = info
|
||||||
|
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
|
||||||
|
if post_inventory:
|
||||||
|
for ln in lines_input:
|
||||||
|
info = ln.get("extra_info") or {}
|
||||||
|
inv_tracked = bool(info.get("inventory_tracked"))
|
||||||
|
mv = info.get("movement") or movement_hint
|
||||||
|
if inv_tracked and mv in ("in", "out"):
|
||||||
|
wh = info.get("warehouse_id")
|
||||||
|
if wh is None:
|
||||||
|
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیفهای دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
# Filter outgoing lines to only inventory-tracked products for stock checks
|
# Filter outgoing lines to only inventory-tracked products for stock checks
|
||||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||||
|
|
@ -527,12 +664,12 @@ def create_invoice(
|
||||||
tracked_outgoing_lines.append(ln)
|
tracked_outgoing_lines.append(ln)
|
||||||
|
|
||||||
# Ensure stock sufficiency for outgoing (only for tracked products)
|
# Ensure stock sufficiency for outgoing (only for tracked products)
|
||||||
if tracked_outgoing_lines:
|
if post_inventory and tracked_outgoing_lines:
|
||||||
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
|
_ensure_stock_sufficient(db, business_id, document_date, tracked_outgoing_lines)
|
||||||
|
|
||||||
# Costing method (only for tracked products)
|
# Costing method (only for tracked products)
|
||||||
costing_method = _get_costing_method(data)
|
costing_method = _get_costing_method(data)
|
||||||
if costing_method == "fifo" and tracked_outgoing_lines:
|
if post_inventory and costing_method == "fifo" and tracked_outgoing_lines:
|
||||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
|
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, business_id, document_date, tracked_outgoing_lines)
|
||||||
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
|
# annotate lines with cogs_amount in the same order as tracked_outgoing_lines
|
||||||
i = 0
|
i = 0
|
||||||
|
|
@ -580,7 +717,9 @@ def create_invoice(
|
||||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||||
if not product_id or qty <= 0:
|
if not product_id or qty <= 0:
|
||||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
||||||
extra_info = line.get("extra_info") or {}
|
extra_info = dict(line.get("extra_info") or {})
|
||||||
|
# علامتگذاری اینکه این خط در انبار پست شده/نشده است
|
||||||
|
extra_info["inventory_posted"] = bool(post_inventory)
|
||||||
db.add(DocumentLine(
|
db.add(DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
product_id=int(product_id),
|
product_id=int(product_id),
|
||||||
|
|
@ -599,7 +738,7 @@ def create_invoice(
|
||||||
tax = Decimal(str(totals["tax"]))
|
tax = Decimal(str(totals["tax"]))
|
||||||
total_with_tax = net + tax
|
total_with_tax = net + tax
|
||||||
|
|
||||||
# COGS when applicable
|
# COGS when applicable (خطوط غیرپست انبار، در COGS لحاظ نمیشوند)
|
||||||
cogs_total = _extract_cogs_total(lines_input)
|
cogs_total = _extract_cogs_total(lines_input)
|
||||||
|
|
||||||
# Sales
|
# Sales
|
||||||
|
|
@ -646,6 +785,51 @@ def create_invoice(
|
||||||
description="خروج از موجودی بابت فروش",
|
description="خروج از موجودی بابت فروش",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# --- پورسانت فروشنده/بازاریاب (در صورت وجود) ---
|
||||||
|
# محاسبه و ثبت پورسانت برای فروش و برگشت از فروش
|
||||||
|
if invoice_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||||
|
seller_id, commission_amount = _calculate_seller_commission(db, invoice_type, header_extra, totals)
|
||||||
|
if seller_id and commission_amount > 0:
|
||||||
|
# هزینه پورسانت: 70702، بستانکار: پرداختنی به فروشنده 20201
|
||||||
|
commission_expense = _get_fixed_account_by_code(db, "70702")
|
||||||
|
seller_payable = _get_fixed_account_by_code(db, "20201")
|
||||||
|
if invoice_type == INVOICE_SALES:
|
||||||
|
# بدهکار هزینه، بستانکار فروشنده
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_expense.id,
|
||||||
|
debit=commission_amount,
|
||||||
|
credit=Decimal(0),
|
||||||
|
description="هزینه پورسانت فروش",
|
||||||
|
))
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=seller_payable.id,
|
||||||
|
person_id=int(seller_id),
|
||||||
|
debit=Decimal(0),
|
||||||
|
credit=commission_amount,
|
||||||
|
description="بابت پورسانت فروشنده/بازاریاب",
|
||||||
|
extra_info={"seller_id": int(seller_id)},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# برگشت از فروش: معکوس
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=seller_payable.id,
|
||||||
|
person_id=int(seller_id),
|
||||||
|
debit=commission_amount,
|
||||||
|
credit=Decimal(0),
|
||||||
|
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
|
||||||
|
extra_info={"seller_id": int(seller_id)},
|
||||||
|
))
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_expense.id,
|
||||||
|
debit=Decimal(0),
|
||||||
|
credit=commission_amount,
|
||||||
|
description="تعدیل هزینه پورسانت",
|
||||||
|
))
|
||||||
|
|
||||||
# Sales Return
|
# Sales Return
|
||||||
elif invoice_type == INVOICE_SALES_RETURN:
|
elif invoice_type == INVOICE_SALES_RETURN:
|
||||||
if person_id:
|
if person_id:
|
||||||
|
|
@ -957,6 +1141,16 @@ def update_invoice(
|
||||||
info = dict(ln.get("extra_info") or {})
|
info = dict(ln.get("extra_info") or {})
|
||||||
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
info["inventory_tracked"] = bool(track_map.get(int(pid), False))
|
||||||
ln["extra_info"] = info
|
ln["extra_info"] = info
|
||||||
|
# اگر ثبت انبار فعال است، اطمینان از وجود انبار برای خطوط دارای حرکت
|
||||||
|
if post_inventory_update:
|
||||||
|
for ln in lines_input:
|
||||||
|
info = ln.get("extra_info") or {}
|
||||||
|
inv_tracked = bool(info.get("inventory_tracked"))
|
||||||
|
mv = info.get("movement") or movement_hint
|
||||||
|
if inv_tracked and mv in ("in", "out"):
|
||||||
|
wh = info.get("warehouse_id")
|
||||||
|
if wh is None:
|
||||||
|
raise ApiError("WAREHOUSE_REQUIRED", "برای ردیفهای دارای حرکت انبار، انتخاب انبار الزامی است", http_status=400)
|
||||||
|
|
||||||
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
tracked_outgoing_lines: List[Dict[str, Any]] = []
|
||||||
for ln in outgoing_lines:
|
for ln in outgoing_lines:
|
||||||
|
|
@ -964,12 +1158,13 @@ def update_invoice(
|
||||||
if pid and track_map.get(int(pid)):
|
if pid and track_map.get(int(pid)):
|
||||||
tracked_outgoing_lines.append(ln)
|
tracked_outgoing_lines.append(ln)
|
||||||
|
|
||||||
if tracked_outgoing_lines:
|
header_for_costing = data if data else {"extra_info": document.extra_info}
|
||||||
|
post_inventory_update: bool = _is_inventory_posting_enabled(header_for_costing)
|
||||||
|
if post_inventory_update and tracked_outgoing_lines:
|
||||||
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
_ensure_stock_sufficient(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||||
|
|
||||||
header_for_costing = data if data else {"extra_info": document.extra_info}
|
|
||||||
costing_method = _get_costing_method(header_for_costing)
|
costing_method = _get_costing_method(header_for_costing)
|
||||||
if costing_method == "fifo" and tracked_outgoing_lines:
|
if post_inventory_update and costing_method == "fifo" and tracked_outgoing_lines:
|
||||||
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
fifo_costs = _calculate_fifo_cogs_for_outgoing(db, document.business_id, document.document_date, tracked_outgoing_lines, exclude_document_id=document.id)
|
||||||
i = 0
|
i = 0
|
||||||
for ln in lines_input:
|
for ln in lines_input:
|
||||||
|
|
@ -987,7 +1182,8 @@ def update_invoice(
|
||||||
qty = Decimal(str(line.get("quantity", 0) or 0))
|
qty = Decimal(str(line.get("quantity", 0) or 0))
|
||||||
if not product_id or qty <= 0:
|
if not product_id or qty <= 0:
|
||||||
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
raise ApiError("INVALID_LINE", "line.product_id and positive quantity are required", http_status=400)
|
||||||
extra_info = line.get("extra_info") or {}
|
extra_info = dict(line.get("extra_info") or {})
|
||||||
|
extra_info["inventory_posted"] = bool(post_inventory_update)
|
||||||
db.add(DocumentLine(
|
db.add(DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
product_id=int(product_id),
|
product_id=int(product_id),
|
||||||
|
|
@ -1000,7 +1196,8 @@ def update_invoice(
|
||||||
|
|
||||||
# Accounting lines if finalized
|
# Accounting lines if finalized
|
||||||
if not document.is_proforma:
|
if not document.is_proforma:
|
||||||
accounts = _resolve_accounts_for_invoice(db, data if data else {"extra_info": document.extra_info})
|
header_for_accounts: Dict[str, Any] = {"invoice_type": inv_type, **(data or {"extra_info": document.extra_info})}
|
||||||
|
accounts = _resolve_accounts_for_invoice(db, header_for_accounts)
|
||||||
header_extra = data.get("extra_info") or document.extra_info or {}
|
header_extra = data.get("extra_info") or document.extra_info or {}
|
||||||
totals = (header_extra.get("totals") or {})
|
totals = (header_extra.get("totals") or {})
|
||||||
if not totals:
|
if not totals:
|
||||||
|
|
@ -1059,6 +1256,47 @@ def update_invoice(
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساختهشده"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["inventory_finished"].id, debit=finished_cost, credit=Decimal(0), description="ورود ساختهشده"))
|
||||||
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان"))
|
db.add(DocumentLine(document_id=document.id, account_id=accounts["wip"].id, debit=Decimal(0), credit=finished_cost, description="انتقال از کاردرجریان"))
|
||||||
|
|
||||||
|
# --- پورسانت فروشنده/بازاریاب (بهصورت تکمیلی) ---
|
||||||
|
if inv_type in (INVOICE_SALES, INVOICE_SALES_RETURN):
|
||||||
|
seller_id, commission_amount = _calculate_seller_commission(db, inv_type, header_extra, totals)
|
||||||
|
if seller_id and commission_amount > 0:
|
||||||
|
commission_expense = _get_fixed_account_by_code(db, "70702")
|
||||||
|
seller_payable = _get_fixed_account_by_code(db, "20201")
|
||||||
|
if inv_type == INVOICE_SALES:
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_expense.id,
|
||||||
|
debit=commission_amount,
|
||||||
|
credit=Decimal(0),
|
||||||
|
description="هزینه پورسانت فروش",
|
||||||
|
))
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=seller_payable.id,
|
||||||
|
person_id=int(seller_id),
|
||||||
|
debit=Decimal(0),
|
||||||
|
credit=commission_amount,
|
||||||
|
description="بابت پورسانت فروشنده/بازاریاب",
|
||||||
|
extra_info={"seller_id": int(seller_id)},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=seller_payable.id,
|
||||||
|
person_id=int(seller_id),
|
||||||
|
debit=commission_amount,
|
||||||
|
credit=Decimal(0),
|
||||||
|
description="تعدیل پورسانت فروشنده بابت برگشت از فروش",
|
||||||
|
extra_info={"seller_id": int(seller_id)},
|
||||||
|
))
|
||||||
|
db.add(DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_expense.id,
|
||||||
|
debit=Decimal(0),
|
||||||
|
credit=commission_amount,
|
||||||
|
description="تعدیل هزینه پورسانت",
|
||||||
|
))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(document)
|
db.refresh(document)
|
||||||
return invoice_document_to_dict(db, document)
|
return invoice_document_to_dict(db, document)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_, exists, select
|
import logging
|
||||||
|
from sqlalchemy import and_, or_, exists, select, Integer, cast
|
||||||
|
|
||||||
from adapters.db.models.document import Document
|
from adapters.db.models.document import Document
|
||||||
from adapters.db.models.document_line import DocumentLine
|
from adapters.db.models.document_line import DocumentLine
|
||||||
from adapters.db.models.fiscal_year import FiscalYear
|
from adapters.db.models.fiscal_year import FiscalYear
|
||||||
|
from adapters.db.models.warehouse import Warehouse
|
||||||
|
|
||||||
|
|
||||||
# Helpers (reuse existing helpers from other services when possible)
|
# Helpers (reuse existing helpers from other services when possible)
|
||||||
|
|
@ -42,6 +44,14 @@ def _collect_ids(query: Dict[str, Any], key: str) -> List[int]:
|
||||||
|
|
||||||
|
|
||||||
def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
try:
|
||||||
|
logger.debug("KARDEX list_kardex_lines called | business_id=%s | keys=%s", business_id, list(query.keys()))
|
||||||
|
logger.debug("KARDEX filters | person_ids=%s product_ids=%s account_ids=%s match_mode=%s result_scope=%s from=%s to=%s fy=%s",
|
||||||
|
query.get('person_ids'), query.get('product_ids'), query.get('account_ids'),
|
||||||
|
query.get('match_mode'), query.get('result_scope'), query.get('from_date'), query.get('to_date'), query.get('fiscal_year_id'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
"""لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالتهای تطابق.
|
"""لیست خطوط اسناد (کاردکس) با پشتیبانی از انتخاب چندگانه و حالتهای تطابق.
|
||||||
|
|
||||||
پارامترهای ورودی مورد انتظار در query:
|
پارامترهای ورودی مورد انتظار در query:
|
||||||
|
|
@ -97,6 +107,7 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
|
||||||
petty_cash_ids = _collect_ids(query, "petty_cash_ids")
|
petty_cash_ids = _collect_ids(query, "petty_cash_ids")
|
||||||
account_ids = _collect_ids(query, "account_ids")
|
account_ids = _collect_ids(query, "account_ids")
|
||||||
check_ids = _collect_ids(query, "check_ids")
|
check_ids = _collect_ids(query, "check_ids")
|
||||||
|
warehouse_ids = _collect_ids(query, "warehouse_ids")
|
||||||
|
|
||||||
# Match mode
|
# Match mode
|
||||||
match_mode = str(query.get("match_mode") or "any").lower()
|
match_mode = str(query.get("match_mode") or "any").lower()
|
||||||
|
|
@ -176,6 +187,17 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
|
||||||
# any: OR across groups on the same line
|
# any: OR across groups on the same line
|
||||||
q = q.filter(or_(*group_filters))
|
q = q.filter(or_(*group_filters))
|
||||||
|
|
||||||
|
# Warehouse filter (JSON attribute inside extra_info)
|
||||||
|
if warehouse_ids:
|
||||||
|
try:
|
||||||
|
q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].as_string(), Integer).in_(warehouse_ids))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
q = q.filter(cast(DocumentLine.extra_info["warehouse_id"].astext, Integer).in_(warehouse_ids))
|
||||||
|
except Exception:
|
||||||
|
# در صورت عدم پشتیبانی از عملگر JSON، از فیلتر نرمافزاری بعد از واکشی استفاده خواهد شد
|
||||||
|
pass
|
||||||
|
|
||||||
# Sorting
|
# Sorting
|
||||||
sort_by = (query.get("sort_by") or "document_date")
|
sort_by = (query.get("sort_by") or "document_date")
|
||||||
sort_desc = bool(query.get("sort_desc", True))
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
|
@ -206,6 +228,10 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
|
||||||
take = 20
|
take = 20
|
||||||
|
|
||||||
total = q.count()
|
total = q.count()
|
||||||
|
try:
|
||||||
|
logger.debug("KARDEX query total=%s (after filters)", total)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all()
|
rows: List[Tuple[DocumentLine, Document]] = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
# Running balance (optional)
|
# Running balance (optional)
|
||||||
|
|
@ -213,6 +239,38 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
|
||||||
running_amount: float = 0.0
|
running_amount: float = 0.0
|
||||||
running_quantity: float = 0.0
|
running_quantity: float = 0.0
|
||||||
|
|
||||||
|
# گردآوری شناسههای انبار جهت نامگذاری
|
||||||
|
wh_ids_in_page: set[int] = set()
|
||||||
|
for line, _ in rows:
|
||||||
|
try:
|
||||||
|
info = line.extra_info or {}
|
||||||
|
wid = info.get("warehouse_id")
|
||||||
|
if wid is not None:
|
||||||
|
wh_ids_in_page.add(int(wid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
wh_map: Dict[int, str] = {}
|
||||||
|
if wh_ids_in_page:
|
||||||
|
for w in db.query(Warehouse).filter(Warehouse.business_id == business_id, Warehouse.id.in_(list(wh_ids_in_page))).all():
|
||||||
|
try:
|
||||||
|
name = (w.name or "").strip()
|
||||||
|
code = (w.code or "").strip()
|
||||||
|
wh_map[int(w.id)] = f"{code} - {name}" if code else name
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _movement_from_type(inv_type: str | None) -> str | None:
|
||||||
|
t = (inv_type or "").strip()
|
||||||
|
if t in ("invoice_sales",):
|
||||||
|
return "out"
|
||||||
|
if t in ("invoice_sales_return", "invoice_purchase"):
|
||||||
|
return "in"
|
||||||
|
if t in ("invoice_purchase_return", "invoice_direct_consumption", "invoice_waste"):
|
||||||
|
return "out"
|
||||||
|
# production: both in/out ممکن است
|
||||||
|
return None
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
for line, doc in rows:
|
for line, doc in rows:
|
||||||
item: Dict[str, Any] = {
|
item: Dict[str, Any] = {
|
||||||
|
|
@ -234,6 +292,20 @@ def list_kardex_lines(db: Session, business_id: int, query: Dict[str, Any]) -> D
|
||||||
"check_id": line.check_id,
|
"check_id": line.check_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# movement & warehouse
|
||||||
|
try:
|
||||||
|
info = line.extra_info or {}
|
||||||
|
mv = info.get("movement")
|
||||||
|
if mv is None:
|
||||||
|
mv = _movement_from_type(getattr(doc, "document_type", None))
|
||||||
|
wid = info.get("warehouse_id")
|
||||||
|
item["movement"] = mv
|
||||||
|
item["warehouse_id"] = int(wid) if wid is not None else None
|
||||||
|
if wid is not None:
|
||||||
|
item["warehouse_name"] = wh_map.get(int(wid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if include_running:
|
if include_running:
|
||||||
try:
|
try:
|
||||||
running_amount += float(line.debit or 0) - float(line.credit or 0)
|
running_amount += float(line.debit or 0) - float(line.credit or 0)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ DB_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=hesabixpy
|
DB_NAME=hesabixpy
|
||||||
SQLALCHEMY_ECHO=false
|
SQLALCHEMY_ECHO=false
|
||||||
|
DB_POOL_SIZE=10
|
||||||
|
DB_MAX_OVERFLOW=20
|
||||||
|
DB_POOL_TIMEOUT=10
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ adapters/api/v1/documents.py
|
||||||
adapters/api/v1/expense_income.py
|
adapters/api/v1/expense_income.py
|
||||||
adapters/api/v1/fiscal_years.py
|
adapters/api/v1/fiscal_years.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
|
adapters/api/v1/inventory_transfers.py
|
||||||
adapters/api/v1/invoices.py
|
adapters/api/v1/invoices.py
|
||||||
adapters/api/v1/kardex.py
|
adapters/api/v1/kardex.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
|
|
@ -146,6 +147,7 @@ app/services/document_service.py
|
||||||
app/services/email_service.py
|
app/services/email_service.py
|
||||||
app/services/expense_income_service.py
|
app/services/expense_income_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
|
app/services/inventory_transfer_service.py
|
||||||
app/services/invoice_service.py
|
app/services/invoice_service.py
|
||||||
app/services/kardex_service.py
|
app/services/kardex_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import 'pages/business/expense_income_list_page.dart';
|
||||||
import 'pages/business/transfers_page.dart';
|
import 'pages/business/transfers_page.dart';
|
||||||
import 'pages/business/documents_page.dart';
|
import 'pages/business/documents_page.dart';
|
||||||
import 'pages/business/warehouses_page.dart';
|
import 'pages/business/warehouses_page.dart';
|
||||||
|
import 'pages/business/inventory_transfers_page.dart';
|
||||||
import 'pages/error_404_page.dart';
|
import 'pages/error_404_page.dart';
|
||||||
import 'core/locale_controller.dart';
|
import 'core/locale_controller.dart';
|
||||||
import 'core/calendar_controller.dart';
|
import 'core/calendar_controller.dart';
|
||||||
|
|
@ -639,10 +640,37 @@ class _MyAppState extends State<MyApp> {
|
||||||
name: 'business_reports_kardex',
|
name: 'business_reports_kardex',
|
||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
// Parse person_id(s) from query
|
||||||
|
final qp = state.uri.queryParameters;
|
||||||
|
final qpAll = state.uri.queryParametersAll;
|
||||||
|
final Set<int> initialPersonIds = <int>{};
|
||||||
|
final single = int.tryParse(qp['person_id'] ?? '');
|
||||||
|
if (single != null) initialPersonIds.add(single);
|
||||||
|
final multi = (qpAll['person_id'] ?? const <String>[])
|
||||||
|
.map((e) => int.tryParse(e))
|
||||||
|
.whereType<int>();
|
||||||
|
initialPersonIds.addAll(multi);
|
||||||
|
// Also parse from extra
|
||||||
|
try {
|
||||||
|
if (state.extra is Map) {
|
||||||
|
final extra = state.extra as Map;
|
||||||
|
final list = extra['person_ids'];
|
||||||
|
if (list is List) {
|
||||||
|
for (final v in list) {
|
||||||
|
if (v is int) initialPersonIds.add(v);
|
||||||
|
else {
|
||||||
|
final p = int.tryParse('$v');
|
||||||
|
if (p != null) initialPersonIds.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
return NoTransitionPage(
|
return NoTransitionPage(
|
||||||
child: KardexPage(
|
child: KardexPage(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
calendarController: _calendarController!,
|
calendarController: _calendarController!,
|
||||||
|
initialPersonIds: initialPersonIds.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -808,6 +836,19 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/business/:business_id/inventory-transfers',
|
||||||
|
name: 'business_inventory_transfers',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return NoTransitionPage(
|
||||||
|
child: InventoryTransfersPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/business/:business_id/documents',
|
path: '/business/:business_id/documents',
|
||||||
name: 'business_documents',
|
name: 'business_documents',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class InvoiceLineItem {
|
||||||
// inventory/constraints
|
// inventory/constraints
|
||||||
final int? minOrderQty;
|
final int? minOrderQty;
|
||||||
final bool trackInventory;
|
final bool trackInventory;
|
||||||
|
final int? warehouseId; // انبار انتخابی برای ردیف
|
||||||
|
|
||||||
// presentation
|
// presentation
|
||||||
String? description;
|
String? description;
|
||||||
|
|
@ -52,6 +53,7 @@ class InvoiceLineItem {
|
||||||
this.basePurchasePriceMainUnit,
|
this.basePurchasePriceMainUnit,
|
||||||
this.minOrderQty,
|
this.minOrderQty,
|
||||||
this.trackInventory = false,
|
this.trackInventory = false,
|
||||||
|
this.warehouseId,
|
||||||
});
|
});
|
||||||
|
|
||||||
InvoiceLineItem copyWith({
|
InvoiceLineItem copyWith({
|
||||||
|
|
@ -73,6 +75,7 @@ class InvoiceLineItem {
|
||||||
num? basePurchasePriceMainUnit,
|
num? basePurchasePriceMainUnit,
|
||||||
int? minOrderQty,
|
int? minOrderQty,
|
||||||
bool? trackInventory,
|
bool? trackInventory,
|
||||||
|
int? warehouseId,
|
||||||
}) {
|
}) {
|
||||||
return InvoiceLineItem(
|
return InvoiceLineItem(
|
||||||
productId: productId ?? this.productId,
|
productId: productId ?? this.productId,
|
||||||
|
|
@ -93,6 +96,7 @@ class InvoiceLineItem {
|
||||||
basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit,
|
basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit,
|
||||||
minOrderQty: minOrderQty ?? this.minOrderQty,
|
minOrderQty: minOrderQty ?? this.minOrderQty,
|
||||||
trackInventory: trackInventory ?? this.trackInventory,
|
trackInventory: trackInventory ?? this.trackInventory,
|
||||||
|
warehouseId: warehouseId ?? this.warehouseId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class AccountNode {
|
||||||
final String code;
|
final String code;
|
||||||
final String name;
|
final String name;
|
||||||
final String? accountType;
|
final String? accountType;
|
||||||
|
final int? businessId;
|
||||||
final List<AccountNode> children;
|
final List<AccountNode> children;
|
||||||
final bool hasChildren;
|
final bool hasChildren;
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ class AccountNode {
|
||||||
required this.code,
|
required this.code,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.accountType,
|
this.accountType,
|
||||||
|
this.businessId,
|
||||||
this.children = const [],
|
this.children = const [],
|
||||||
this.hasChildren = false,
|
this.hasChildren = false,
|
||||||
});
|
});
|
||||||
|
|
@ -30,6 +32,9 @@ class AccountNode {
|
||||||
code: json['code']?.toString() ?? '',
|
code: json['code']?.toString() ?? '',
|
||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
accountType: json['account_type']?.toString(),
|
accountType: json['account_type']?.toString(),
|
||||||
|
businessId: json['business_id'] is int
|
||||||
|
? (json['business_id'] as int)
|
||||||
|
: (json['business_id'] != null ? int.tryParse(json['business_id'].toString()) : null),
|
||||||
children: parsedChildren,
|
children: parsedChildren,
|
||||||
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
|
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
|
||||||
);
|
);
|
||||||
|
|
@ -86,6 +91,8 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
items.add({
|
items.add({
|
||||||
"id": n.id,
|
"id": n.id,
|
||||||
"title": ("\u200f" * level) + n.code + " - " + n.name,
|
"title": ("\u200f" * level) + n.code + " - " + n.name,
|
||||||
|
"business_id": n.businessId?.toString() ?? "",
|
||||||
|
"has_children": n.hasChildren ? "1" : "0",
|
||||||
});
|
});
|
||||||
for (final c in n.children) {
|
for (final c in n.children) {
|
||||||
dfs(c, level + 1);
|
dfs(c, level + 1);
|
||||||
|
|
@ -97,46 +104,115 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openCreateDialog() async {
|
String? _suggestNextCode({String? parentId}) {
|
||||||
|
List<String> codes = <String>[];
|
||||||
|
if (parentId == null || parentId.isEmpty) {
|
||||||
|
codes = _roots.map((e) => e.code).toList();
|
||||||
|
} else {
|
||||||
|
AccountNode? find(AccountNode n) {
|
||||||
|
if (n.id == parentId) return n;
|
||||||
|
for (final c in n.children) {
|
||||||
|
final x = find(c);
|
||||||
|
if (x != null) return x;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
AccountNode? parent;
|
||||||
|
for (final r in _roots) {
|
||||||
|
parent = find(r);
|
||||||
|
if (parent != null) break;
|
||||||
|
}
|
||||||
|
if (parent != null) codes = parent.children.map((e) => e.code).toList();
|
||||||
|
}
|
||||||
|
final numeric = codes.map((c) => int.tryParse(c)).whereType<int>().toList();
|
||||||
|
if (numeric.isEmpty) return null;
|
||||||
|
final next = (numeric..sort()).last + 1;
|
||||||
|
return next.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCreateDialog({AccountNode? parent}) async {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
final codeCtrl = TextEditingController();
|
final codeCtrl = TextEditingController();
|
||||||
final nameCtrl = TextEditingController();
|
final nameCtrl = TextEditingController();
|
||||||
final typeCtrl = TextEditingController();
|
String? selectedType;
|
||||||
String? selectedParentId;
|
String? selectedParentId = parent?.id;
|
||||||
final parents = _flattenNodes();
|
final parents = _flattenNodes();
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(t.addAccount),
|
title: Text(t.addAccount),
|
||||||
content: SingleChildScrollView(
|
content: ConstrainedBox(
|
||||||
child: Column(
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
TextField(
|
mainAxisSize: MainAxisSize.min,
|
||||||
controller: codeCtrl,
|
children: [
|
||||||
decoration: InputDecoration(labelText: t.code),
|
Row(children: [
|
||||||
),
|
Expanded(child: TextField(
|
||||||
TextField(
|
controller: codeCtrl,
|
||||||
controller: nameCtrl,
|
decoration: InputDecoration(labelText: t.code, prefixIcon: const Icon(Icons.numbers)),
|
||||||
decoration: InputDecoration(labelText: t.title),
|
)),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
TextField(
|
OutlinedButton.icon(
|
||||||
controller: typeCtrl,
|
onPressed: () {
|
||||||
decoration: InputDecoration(labelText: t.type),
|
final s = _suggestNextCode(parentId: selectedParentId);
|
||||||
),
|
if (s != null) codeCtrl.text = s;
|
||||||
DropdownButtonFormField<String>(
|
},
|
||||||
value: selectedParentId,
|
icon: const Icon(Icons.auto_fix_high, size: 18),
|
||||||
items: [
|
label: const Text('پیشنهاد کد'),
|
||||||
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
|
),
|
||||||
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
|
]),
|
||||||
],
|
const SizedBox(height: 10),
|
||||||
onChanged: (v) {
|
TextField(
|
||||||
selectedParentId = v;
|
controller: nameCtrl,
|
||||||
},
|
decoration: InputDecoration(labelText: t.title, prefixIcon: const Icon(Icons.title)),
|
||||||
decoration: const InputDecoration(labelText: 'حساب والد'),
|
),
|
||||||
),
|
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: [
|
actions: [
|
||||||
|
|
@ -145,8 +221,8 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final name = nameCtrl.text.trim();
|
final name = nameCtrl.text.trim();
|
||||||
final code = codeCtrl.text.trim();
|
final code = codeCtrl.text.trim();
|
||||||
final atype = typeCtrl.text.trim();
|
final atype = (selectedType ?? '').trim();
|
||||||
if (name.isEmpty || code.isEmpty || atype.isEmpty) {
|
if (name.isEmpty || code.isEmpty || atype.isEmpty || selectedParentId == null || selectedParentId!.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final Map<String, dynamic> payload = {
|
final Map<String, dynamic> payload = {
|
||||||
|
|
@ -158,24 +234,24 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
final pid = int.tryParse(selectedParentId!);
|
final pid = int.tryParse(selectedParentId!);
|
||||||
if (pid != null) payload["parent_id"] = pid;
|
if (pid != null) payload["parent_id"] = pid;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final api = ApiClient();
|
final api = ApiClient();
|
||||||
await api.post(
|
await api.post(
|
||||||
'/api/v1/accounts/business/${widget.businessId}/create',
|
'/api/v1/accounts/business/${widget.businessId}/create',
|
||||||
data: payload,
|
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),
|
child: Text(t.add),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -216,7 +292,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
final codeCtrl = TextEditingController(text: node.code);
|
final codeCtrl = TextEditingController(text: node.code);
|
||||||
final nameCtrl = TextEditingController(text: node.name);
|
final nameCtrl = TextEditingController(text: node.name);
|
||||||
final typeCtrl = TextEditingController(text: node.accountType ?? '');
|
String? selectedType = node.accountType;
|
||||||
final parents = _flattenNodes();
|
final parents = _flattenNodes();
|
||||||
String? selectedParentId;
|
String? selectedParentId;
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
|
|
@ -230,12 +306,29 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
children: [
|
children: [
|
||||||
TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)),
|
TextField(controller: codeCtrl, decoration: InputDecoration(labelText: t.code)),
|
||||||
TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)),
|
TextField(controller: nameCtrl, decoration: InputDecoration(labelText: t.title)),
|
||||||
TextField(controller: typeCtrl, decoration: InputDecoration(labelText: t.type)),
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedType,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'bank', child: Text('بانک')),
|
||||||
|
DropdownMenuItem(value: 'cash_register', child: Text('صندوق')),
|
||||||
|
DropdownMenuItem(value: 'petty_cash', child: Text('تنخواه')),
|
||||||
|
DropdownMenuItem(value: 'check', child: Text('چک')),
|
||||||
|
DropdownMenuItem(value: 'person', child: Text('شخص')),
|
||||||
|
DropdownMenuItem(value: 'product', child: Text('کالا')),
|
||||||
|
DropdownMenuItem(value: 'service', child: Text('خدمت')),
|
||||||
|
DropdownMenuItem(value: 'accounting_document', child: Text('سند حسابداری')),
|
||||||
|
],
|
||||||
|
onChanged: (v) { selectedType = v; },
|
||||||
|
decoration: InputDecoration(labelText: t.type),
|
||||||
|
),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: selectedParentId,
|
value: selectedParentId,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
|
DropdownMenuItem<String>(value: null, child: Text('بدون والد')),
|
||||||
...parents.map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
|
...parents.where((p) {
|
||||||
|
final bid = p['business_id'];
|
||||||
|
return (bid == null || bid.isEmpty) || bid == widget.businessId.toString();
|
||||||
|
}).map((p) => DropdownMenuItem<String>(value: p["id"], child: Text(p["title"]!))).toList(),
|
||||||
],
|
],
|
||||||
onChanged: (v) { selectedParentId = v; },
|
onChanged: (v) { selectedParentId = v; },
|
||||||
decoration: const InputDecoration(labelText: 'حساب والد'),
|
decoration: const InputDecoration(labelText: 'حساب والد'),
|
||||||
|
|
@ -249,7 +342,7 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final name = nameCtrl.text.trim();
|
final name = nameCtrl.text.trim();
|
||||||
final code = codeCtrl.text.trim();
|
final code = codeCtrl.text.trim();
|
||||||
final atype = typeCtrl.text.trim();
|
final atype = (selectedType ?? '').trim();
|
||||||
if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
|
if (name.isEmpty || code.isEmpty || atype.isEmpty) return;
|
||||||
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
|
final Map<String, dynamic> payload = {"name": name, "code": code, "account_type": atype};
|
||||||
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
|
if (selectedParentId != null && selectedParentId!.isNotEmpty) {
|
||||||
|
|
@ -412,26 +505,44 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
||||||
onPressed: () => _toggleExpand(node),
|
onPressed: () => _toggleExpand(node),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: 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: 5, child: Text(node.name)),
|
||||||
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
onSelected: (v) {
|
onSelected: (v) {
|
||||||
if (v == 'edit') _openEditDialog(node);
|
if (v == 'add_child') _openCreateDialog(parent: node);
|
||||||
if (v == 'delete') _confirmDelete(node);
|
if (v == 'edit') _openEditDialog(node);
|
||||||
},
|
if (v == 'delete') _confirmDelete(node);
|
||||||
itemBuilder: (context) => [
|
},
|
||||||
const PopupMenuItem<String>(value: 'edit', child: Text('ویرایش')),
|
itemBuilder: (context) {
|
||||||
const PopupMenuItem<String>(value: 'delete', child: Text('حذف')),
|
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,
|
label: t.shipments,
|
||||||
icon: Icons.local_shipping,
|
icon: Icons.local_shipping,
|
||||||
selectedIcon: Icons.local_shipping,
|
selectedIcon: Icons.local_shipping,
|
||||||
path: '/business/${widget.businessId}/shipments',
|
path: '/business/${widget.businessId}/inventory-transfers',
|
||||||
type: _MenuItemType.simple,
|
type: _MenuItemType.simple,
|
||||||
hasAddButton: true,
|
hasAddButton: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
|
||||||
|
// تنظیمات انبار
|
||||||
|
bool _postInventory = true; // ثبت اسناد انبار
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
|
||||||
InvoiceType? _selectedInvoiceType;
|
InvoiceType? _selectedInvoiceType;
|
||||||
|
|
@ -360,13 +362,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
_selectedSeller = seller;
|
_selectedSeller = seller;
|
||||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
if (seller != null) {
|
if (seller != null) {
|
||||||
if (seller.commissionSalePercent != null) {
|
final isSales = _selectedInvoiceType == InvoiceType.sales;
|
||||||
|
final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn;
|
||||||
|
final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null);
|
||||||
|
final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null);
|
||||||
|
if (percent != null) {
|
||||||
_commissionType = CommissionType.percentage;
|
_commissionType = CommissionType.percentage;
|
||||||
_commissionPercentage = seller.commissionSalePercent;
|
_commissionPercentage = percent;
|
||||||
_commissionAmount = null;
|
_commissionAmount = null;
|
||||||
} else if (seller.commissionSalesAmount != null) {
|
} else if (amount != null) {
|
||||||
_commissionType = CommissionType.amount;
|
_commissionType = CommissionType.amount;
|
||||||
_commissionAmount = seller.commissionSalesAmount;
|
_commissionAmount = amount;
|
||||||
_commissionPercentage = null;
|
_commissionPercentage = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -678,13 +684,17 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
_selectedSeller = seller;
|
_selectedSeller = seller;
|
||||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||||
if (seller != null) {
|
if (seller != null) {
|
||||||
if (seller.commissionSalePercent != null) {
|
final isSales = _selectedInvoiceType == InvoiceType.sales;
|
||||||
|
final isSalesReturn = _selectedInvoiceType == InvoiceType.salesReturn;
|
||||||
|
final percent = isSales ? seller.commissionSalePercent : (isSalesReturn ? seller.commissionSalesReturnPercent : null);
|
||||||
|
final amount = isSales ? seller.commissionSalesAmount : (isSalesReturn ? seller.commissionSalesReturnAmount : null);
|
||||||
|
if (percent != null) {
|
||||||
_commissionType = CommissionType.percentage;
|
_commissionType = CommissionType.percentage;
|
||||||
_commissionPercentage = seller.commissionSalePercent;
|
_commissionPercentage = percent;
|
||||||
_commissionAmount = null;
|
_commissionAmount = null;
|
||||||
} else if (seller.commissionSalesAmount != null) {
|
} else if (amount != null) {
|
||||||
_commissionType = CommissionType.amount;
|
_commissionType = CommissionType.amount;
|
||||||
_commissionAmount = seller.commissionSalesAmount;
|
_commissionAmount = amount;
|
||||||
_commissionPercentage = null;
|
_commissionPercentage = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -837,6 +847,18 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
if (r.taxRate < 0 || r.taxRate > 100) {
|
if (r.taxRate < 0 || r.taxRate > 100) {
|
||||||
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
return 'درصد مالیات ردیف ${i + 1} باید بین 0 تا 100 باشد';
|
||||||
}
|
}
|
||||||
|
// الزام انبار در حالت ثبت اسناد انبار و کالاهای تحت کنترل موجودی
|
||||||
|
if (_postInventory && r.trackInventory) {
|
||||||
|
final isOut = _selectedInvoiceType == InvoiceType.sales ||
|
||||||
|
_selectedInvoiceType == InvoiceType.purchaseReturn ||
|
||||||
|
_selectedInvoiceType == InvoiceType.directConsumption ||
|
||||||
|
_selectedInvoiceType == InvoiceType.waste;
|
||||||
|
final isIn = _selectedInvoiceType == InvoiceType.purchase ||
|
||||||
|
_selectedInvoiceType == InvoiceType.salesReturn;
|
||||||
|
if ((isOut || isIn) && r.warehouseId == null) {
|
||||||
|
return 'انبار ردیف ${i + 1} الزامی است';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
|
final isSalesOrReturn = _selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn;
|
||||||
|
|
@ -877,6 +899,8 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
'net': _sumTotal,
|
'net': _sumTotal,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// سوییچ ثبت اسناد انبار
|
||||||
|
extraInfo['post_inventory'] = _postInventory;
|
||||||
|
|
||||||
// افزودن person_id بر اساس نوع فاکتور
|
// افزودن person_id بر اساس نوع فاکتور
|
||||||
if (isSalesOrReturn && _selectedCustomer != null) {
|
if (isSalesOrReturn && _selectedCustomer != null) {
|
||||||
|
|
@ -947,6 +971,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
'tax_amount': taxAmount,
|
'tax_amount': taxAmount,
|
||||||
'line_total': lineTotal,
|
'line_total': lineTotal,
|
||||||
if (movement != null) 'movement': movement,
|
if (movement != null) 'movement': movement,
|
||||||
|
if (_postInventory && e.warehouseId != null) 'warehouse_id': e.warehouseId,
|
||||||
// اطلاعات اضافی برای ردیابی
|
// اطلاعات اضافی برای ردیابی
|
||||||
'unit': e.selectedUnit ?? e.mainUnit,
|
'unit': e.selectedUnit ?? e.mainUnit,
|
||||||
'unit_price_source': e.unitPriceSource,
|
'unit_price_source': e.unitPriceSource,
|
||||||
|
|
@ -979,6 +1004,7 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
selectedCurrencyId: _selectedCurrencyId,
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
invoiceType: (_selectedInvoiceType?.value ?? 'sales'),
|
||||||
|
postInventory: _postInventory,
|
||||||
onChanged: (rows) {
|
onChanged: (rows) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lineItems = rows;
|
_lineItems = rows;
|
||||||
|
|
@ -1057,6 +1083,30 @@ class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProvid
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// تنظیمات انبار
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('ثبت اسناد انبار'),
|
||||||
|
subtitle: const Text('در صورت غیرفعالسازی، حرکات موجودی ثبت نمیشوند و کنترل کسری انجام نمیگردد'),
|
||||||
|
value: _postInventory,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_postInventory = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// چاپ فاکتور بعد از صدور
|
// چاپ فاکتور بعد از صدور
|
||||||
Card(
|
Card(
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,12 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (person.id != null) {
|
if (person.id != null) {
|
||||||
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
|
context.go(
|
||||||
|
'/business/${widget.businessId}/reports/kardex',
|
||||||
|
extra: {
|
||||||
|
'person_ids': [person.id]
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -337,11 +342,21 @@ class _PersonsPageState extends State<PersonsPage> {
|
||||||
label: 'کاردکس',
|
label: 'کاردکس',
|
||||||
onTap: (person) {
|
onTap: (person) {
|
||||||
if (person is Person && person.id != null) {
|
if (person is Person && person.id != null) {
|
||||||
context.go('/business/${widget.businessId}/reports/kardex?person_id=${person.id}');
|
context.go(
|
||||||
|
'/business/${widget.businessId}/reports/kardex',
|
||||||
|
extra: {
|
||||||
|
'person_ids': [person.id]
|
||||||
|
},
|
||||||
|
);
|
||||||
} else if (person is Map<String, dynamic>) {
|
} else if (person is Map<String, dynamic>) {
|
||||||
final id = person['id'];
|
final id = person['id'];
|
||||||
if (id is int) {
|
if (id is int) {
|
||||||
context.go('/business/${widget.businessId}/reports/kardex?person_id=$id');
|
context.go(
|
||||||
|
'/business/${widget.businessId}/reports/kardex',
|
||||||
|
extra: {
|
||||||
|
'person_ids': [id]
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 List<String> _columnOrder;
|
||||||
late Map<String, double> _columnWidths;
|
late Map<String, double> _columnWidths;
|
||||||
late List<DataTableColumn> _columns; // Local copy of columns
|
late List<DataTableColumn> _columns; // Local copy of columns
|
||||||
|
late Set<String> _pinnedLeft;
|
||||||
|
late Set<String> _pinnedRight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -34,6 +36,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
||||||
_columnOrder = List.from(widget.currentSettings.columnOrder);
|
_columnOrder = List.from(widget.currentSettings.columnOrder);
|
||||||
_columnWidths = Map.from(widget.currentSettings.columnWidths);
|
_columnWidths = Map.from(widget.currentSettings.columnWidths);
|
||||||
_columns = List.from(widget.columns); // Create local copy
|
_columns = List.from(widget.columns); // Create local copy
|
||||||
|
_pinnedLeft = Set<String>.from(widget.currentSettings.pinnedLeft);
|
||||||
|
_pinnedRight = Set<String>.from(widget.currentSettings.pinnedRight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -172,6 +176,13 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'پین',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
t.order,
|
t.order,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
|
@ -269,6 +280,52 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Tooltip(
|
||||||
|
message: 'پین چپ',
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.push_pin,
|
||||||
|
size: 16,
|
||||||
|
color: _pinnedLeft.contains(column.key)
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_pinnedRight.remove(column.key);
|
||||||
|
if (_pinnedLeft.contains(column.key)) {
|
||||||
|
_pinnedLeft.remove(column.key);
|
||||||
|
} else {
|
||||||
|
_pinnedLeft.add(column.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: 'پین راست',
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(Icons.push_pin_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: _pinnedRight.contains(column.key)
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_pinnedLeft.remove(column.key);
|
||||||
|
if (_pinnedRight.contains(column.key)) {
|
||||||
|
_pinnedRight.remove(column.key);
|
||||||
|
} else {
|
||||||
|
_pinnedRight.add(column.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|
@ -312,6 +369,8 @@ class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
|
||||||
visibleColumns: _visibleColumns,
|
visibleColumns: _visibleColumns,
|
||||||
columnOrder: _columnOrder,
|
columnOrder: _columnOrder,
|
||||||
columnWidths: _columnWidths,
|
columnWidths: _columnWidths,
|
||||||
|
pinnedLeft: _pinnedLeft.toList(),
|
||||||
|
pinnedRight: _pinnedRight.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigator.of(context).pop(newSettings);
|
Navigator.of(context).pop(newSettings);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' show FontFeature;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:file_saver/file_saver.dart';
|
import 'package:file_saver/file_saver.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:data_table_2/data_table_2.dart';
|
import 'package:data_table_2/data_table_2.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
|
@ -76,6 +79,14 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
// Scroll controller for horizontal scrolling
|
// Scroll controller for horizontal scrolling
|
||||||
late ScrollController _horizontalScrollController;
|
late ScrollController _horizontalScrollController;
|
||||||
|
|
||||||
|
// Density (row height)
|
||||||
|
bool _dense = false;
|
||||||
|
|
||||||
|
// Keyboard focus and navigation
|
||||||
|
final FocusNode _tableFocusNode = FocusNode(debugLabel: 'DataTableFocus');
|
||||||
|
int _activeRowIndex = -1;
|
||||||
|
int? _lastSelectedRowIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -83,6 +94,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
_limit = widget.config.defaultPageSize;
|
_limit = widget.config.defaultPageSize;
|
||||||
_setupSearchListener();
|
_setupSearchListener();
|
||||||
_loadColumnSettings();
|
_loadColumnSettings();
|
||||||
|
_loadDensityPreference();
|
||||||
_fetchData();
|
_fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +124,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
_horizontalScrollController.dispose();
|
_horizontalScrollController.dispose();
|
||||||
|
_tableFocusNode.dispose();
|
||||||
for (var controller in _columnSearchControllers.values) {
|
for (var controller in _columnSearchControllers.values) {
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +141,23 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDensityPreference() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = 'data_table_density_${widget.config.effectiveTableId}';
|
||||||
|
final dense = prefs.getBool(key) ?? false;
|
||||||
|
if (mounted) setState(() => _dense = dense);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveDensityPreference() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final key = 'data_table_density_${widget.config.effectiveTableId}';
|
||||||
|
await prefs.setBool(key, _dense);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadColumnSettings() async {
|
Future<void> _loadColumnSettings() async {
|
||||||
if (!widget.config.enableColumnSettings) {
|
if (!widget.config.enableColumnSettings) {
|
||||||
_visibleColumns = List.from(widget.config.columns);
|
_visibleColumns = List.from(widget.config.columns);
|
||||||
|
|
@ -227,6 +257,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
_total = response.total;
|
_total = response.total;
|
||||||
_totalPages = response.totalPages;
|
_totalPages = response.totalPages;
|
||||||
_selectedRows.clear(); // Clear selection when data changes
|
_selectedRows.clear(); // Clear selection when data changes
|
||||||
|
_activeRowIndex = _items.isNotEmpty ? 0 : -1;
|
||||||
|
_lastSelectedRowIndex = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,15 +443,27 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
if (!widget.config.enableRowSelection) return;
|
if (!widget.config.enableRowSelection) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (widget.config.enableMultiRowSelection) {
|
final bool isShift = HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) ||
|
||||||
if (_selectedRows.contains(rowIndex)) {
|
HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight);
|
||||||
_selectedRows.remove(rowIndex);
|
|
||||||
} else {
|
if (widget.config.enableMultiRowSelection && isShift && _lastSelectedRowIndex != null) {
|
||||||
_selectedRows.add(rowIndex);
|
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 {
|
} else {
|
||||||
_selectedRows.clear();
|
if (widget.config.enableMultiRowSelection) {
|
||||||
_selectedRows.add(rowIndex);
|
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(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12),
|
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),
|
padding: widget.config.padding ?? const EdgeInsets.all(16),
|
||||||
margin: widget.config.margin,
|
margin: widget.config.margin,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -818,6 +906,53 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Selection toolbar
|
||||||
|
if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_box, size: 18, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('${_selectedRows.length} مورد انتخاب شده',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _clearRowSelection,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
label: const Text('لغو انتخاب'),
|
||||||
|
),
|
||||||
|
if (widget.config.excelEndpoint != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _exportData('excel', true),
|
||||||
|
icon: const Icon(Icons.table_chart),
|
||||||
|
label: const Text('خروجی اکسل انتخابها'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.config.pdfEndpoint != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _exportData('pdf', true),
|
||||||
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
|
label: const Text('PDF انتخابها'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
|
||||||
// Data Table
|
// Data Table
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDataTable(t, theme),
|
child: _buildDataTable(t, theme),
|
||||||
|
|
@ -829,6 +964,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
_buildFooter(t, theme),
|
_buildFooter(t, theme),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -922,6 +1060,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
case 'columnSettings':
|
case 'columnSettings':
|
||||||
_openColumnSettingsDialog();
|
_openColumnSettingsDialog();
|
||||||
break;
|
break;
|
||||||
|
case 'toggleDensity':
|
||||||
|
setState(() {
|
||||||
|
_dense = !_dense;
|
||||||
|
});
|
||||||
|
_saveDensityPreference();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
|
|
@ -954,6 +1098,17 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'toggleDensity',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_dense ? Icons.check_box : Icons.check_box_outline_blank, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('حالت فشرده'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1253,21 +1408,36 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
|
|
||||||
Widget _buildDataTable(AppLocalizations t, ThemeData theme) {
|
Widget _buildDataTable(AppLocalizations t, ThemeData theme) {
|
||||||
if (_loadingList) {
|
if (_loadingList) {
|
||||||
return Center(
|
return Column(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Expanded(
|
||||||
children: [
|
child: ListView.builder(
|
||||||
if (widget.config.loadingWidget != null)
|
itemCount: 8,
|
||||||
widget.config.loadingWidget!
|
itemBuilder: (context, index) {
|
||||||
else
|
return Padding(
|
||||||
const CircularProgressIndicator(),
|
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
const SizedBox(height: 16),
|
child: Row(
|
||||||
Text(
|
children: List.generate(5, (i) {
|
||||||
widget.config.loadingMessage ?? t.loading,
|
return Expanded(
|
||||||
style: theme.textTheme.bodyMedium,
|
child: Container(
|
||||||
|
height: _dense ? 28 : 36,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
|
Text(widget.config.loadingMessage ?? t.loading, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1322,6 +1492,23 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _fetchData,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(t.refresh),
|
||||||
|
),
|
||||||
|
if (_hasActiveFilters())
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _clearAllFilters,
|
||||||
|
icon: const Icon(Icons.filter_alt_off),
|
||||||
|
label: Text(t.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1395,7 +1582,19 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty
|
||||||
? _visibleColumns
|
? _visibleColumns
|
||||||
: widget.config.columns;
|
: widget.config.columns;
|
||||||
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
|
List<DataTableColumn> dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
|
||||||
|
// Reorder by pinning if settings available
|
||||||
|
if (widget.config.enableColumnSettings && _columnSettings != null) {
|
||||||
|
final visibleKeys = _columnSettings!.visibleColumns.toSet();
|
||||||
|
final order = _columnSettings!.columnOrder;
|
||||||
|
List<String> middleKeys = order.where((k) => visibleKeys.contains(k)).toList();
|
||||||
|
final leftKeys = _columnSettings!.pinnedLeft.where((k) => middleKeys.contains(k)).toList();
|
||||||
|
final rightKeys = _columnSettings!.pinnedRight.where((k) => middleKeys.contains(k)).toList();
|
||||||
|
middleKeys.removeWhere((k) => leftKeys.contains(k) || rightKeys.contains(k));
|
||||||
|
List<String> finalOrder = [...leftKeys, ...middleKeys, ...rightKeys];
|
||||||
|
final mapByKey = {for (final c in dataColumnsToShow) c.key: c};
|
||||||
|
dataColumnsToShow = finalOrder.map((k) => mapByKey[k]).whereType<DataTableColumn>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
columns.addAll(dataColumnsToShow.map((column) {
|
columns.addAll(dataColumnsToShow.map((column) {
|
||||||
final headerTextStyle = theme.textTheme.titleSmall?.copyWith(
|
final headerTextStyle = theme.textTheme.titleSmall?.copyWith(
|
||||||
|
|
@ -1403,9 +1602,12 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
|
) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
|
||||||
final double baseWidth = DataTableUtils.getColumnWidth(column.width);
|
final double baseWidth = DataTableUtils.getColumnWidth(column.width);
|
||||||
final double affordancePadding = 48.0;
|
final double affordancePadding = 64.0; // space for icons + resize handle
|
||||||
final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding;
|
final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding;
|
||||||
final double computedWidth = math.max(baseWidth, headerTextWidth);
|
final double minWidth = 96.0;
|
||||||
|
final double defaultWidth = math.max(baseWidth, headerTextWidth);
|
||||||
|
final double savedWidth = _columnSettings?.columnWidths[column.key] ?? defaultWidth;
|
||||||
|
final double computedWidth = math.max(savedWidth, minWidth);
|
||||||
|
|
||||||
return DataColumn2(
|
return DataColumn2(
|
||||||
label: _ColumnHeaderWithSearch(
|
label: _ColumnHeaderWithSearch(
|
||||||
|
|
@ -1419,6 +1621,74 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
: () { },
|
: () { },
|
||||||
hasActiveFilter: _columnSearchValues.containsKey(column.key),
|
hasActiveFilter: _columnSearchValues.containsKey(column.key),
|
||||||
enabled: widget.config.enableSorting && column.sortable,
|
enabled: widget.config.enableSorting && column.sortable,
|
||||||
|
onResizeDrag: widget.config.enableColumnSettings ? (dx) {
|
||||||
|
if (_columnSettings == null) return;
|
||||||
|
final current = _columnSettings!.columnWidths[column.key] ?? savedWidth;
|
||||||
|
final next = math.max(minWidth, current + dx);
|
||||||
|
final updated = _columnSettings!.copyWith(
|
||||||
|
columnWidths: {
|
||||||
|
..._columnSettings!.columnWidths,
|
||||||
|
column.key: next,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_columnSettings = updated;
|
||||||
|
});
|
||||||
|
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
|
||||||
|
} : null,
|
||||||
|
onPinLeft: widget.config.enableColumnSettings ? () {
|
||||||
|
if (_columnSettings == null) return;
|
||||||
|
final updated = _columnSettings!.copyWith(
|
||||||
|
pinnedLeft: {
|
||||||
|
..._columnSettings!.pinnedLeft,
|
||||||
|
column.key,
|
||||||
|
}.toList(),
|
||||||
|
pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_columnSettings = updated;
|
||||||
|
_visibleColumns = _getVisibleColumnsFromSettings(updated);
|
||||||
|
});
|
||||||
|
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
|
||||||
|
} : null,
|
||||||
|
onPinRight: widget.config.enableColumnSettings ? () {
|
||||||
|
if (_columnSettings == null) return;
|
||||||
|
final updated = _columnSettings!.copyWith(
|
||||||
|
pinnedRight: {
|
||||||
|
..._columnSettings!.pinnedRight,
|
||||||
|
column.key,
|
||||||
|
}.toList(),
|
||||||
|
pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_columnSettings = updated;
|
||||||
|
_visibleColumns = _getVisibleColumnsFromSettings(updated);
|
||||||
|
});
|
||||||
|
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
|
||||||
|
} : null,
|
||||||
|
onUnpin: widget.config.enableColumnSettings ? () {
|
||||||
|
if (_columnSettings == null) return;
|
||||||
|
final updated = _columnSettings!.copyWith(
|
||||||
|
pinnedLeft: _columnSettings!.pinnedLeft.where((k) => k != column.key).toList(),
|
||||||
|
pinnedRight: _columnSettings!.pinnedRight.where((k) => k != column.key).toList(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_columnSettings = updated;
|
||||||
|
_visibleColumns = _getVisibleColumnsFromSettings(updated);
|
||||||
|
});
|
||||||
|
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
|
||||||
|
} : null,
|
||||||
|
onHide: widget.config.enableColumnSettings ? () {
|
||||||
|
if (_columnSettings == null) return;
|
||||||
|
final updated = _columnSettings!.copyWith(
|
||||||
|
visibleColumns: _columnSettings!.visibleColumns.where((k) => k != column.key).toList(),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_columnSettings = updated;
|
||||||
|
_visibleColumns = _getVisibleColumnsFromSettings(updated);
|
||||||
|
});
|
||||||
|
ColumnSettingsService.saveColumnSettings(widget.config.effectiveTableId, updated);
|
||||||
|
} : null,
|
||||||
),
|
),
|
||||||
size: DataTableUtils.getColumnSize(column.width),
|
size: DataTableUtils.getColumnSize(column.width),
|
||||||
fixedWidth: computedWidth,
|
fixedWidth: computedWidth,
|
||||||
|
|
@ -1444,7 +1714,8 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
horizontalMargin: 8,
|
horizontalMargin: 8,
|
||||||
minWidth: widget.config.minTableWidth ?? 600,
|
minWidth: widget.config.minTableWidth ?? 600,
|
||||||
horizontalScrollController: _horizontalScrollController,
|
horizontalScrollController: _horizontalScrollController,
|
||||||
headingRowHeight: 44,
|
headingRowHeight: _dense ? 40 : 44,
|
||||||
|
dataRowHeight: _dense ? 38 : 48,
|
||||||
columns: columns,
|
columns: columns,
|
||||||
rows: _items.asMap().entries.map((entry) {
|
rows: _items.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
|
|
@ -1510,6 +1781,21 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return DataRow2(
|
return DataRow2(
|
||||||
|
color: WidgetStateProperty.resolveWith<Color?>((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return theme.colorScheme.primary.withValues(alpha: 0.08);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.hovered)) {
|
||||||
|
return theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
|
||||||
|
}
|
||||||
|
if (index == _activeRowIndex && _tableFocusNode.hasFocus) {
|
||||||
|
return theme.colorScheme.primary.withValues(alpha: 0.06);
|
||||||
|
}
|
||||||
|
final Color? base = widget.config.rowBackgroundColor;
|
||||||
|
final Color? alt = widget.config.alternateRowBackgroundColor ??
|
||||||
|
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15);
|
||||||
|
return (index % 2 == 1) ? alt : base;
|
||||||
|
}),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onTap: widget.config.onRowTap != null
|
onTap: widget.config.onRowTap != null
|
||||||
? () => widget.config.onRowTap!(item)
|
? () => widget.config.onRowTap!(item)
|
||||||
|
|
@ -1540,41 +1826,101 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
// This allows working with strongly-typed objects (not just Map)
|
// This allows working with strongly-typed objects (not just Map)
|
||||||
if (column is TextColumn && column.formatter != null) {
|
if (column is TextColumn && column.formatter != null) {
|
||||||
final text = column.formatter!(item) ?? '';
|
final text = column.formatter!(item) ?? '';
|
||||||
return Text(
|
final overflow = _getOverflow(column);
|
||||||
|
final textWidget = Text(
|
||||||
text,
|
text,
|
||||||
textAlign: _getTextAlign(column),
|
textAlign: _getTextAlign(column),
|
||||||
maxLines: _getMaxLines(column),
|
maxLines: _getMaxLines(column),
|
||||||
overflow: _getOverflow(column),
|
overflow: overflow,
|
||||||
);
|
);
|
||||||
|
final wrapped = GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('متن کپی شد')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: textWidget,
|
||||||
|
);
|
||||||
|
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
|
||||||
|
? Tooltip(message: text, child: wrapped)
|
||||||
|
: wrapped;
|
||||||
}
|
}
|
||||||
if (column is NumberColumn && column.formatter != null) {
|
if (column is NumberColumn && column.formatter != null) {
|
||||||
final text = column.formatter!(item) ?? '';
|
final text = column.formatter!(item) ?? '';
|
||||||
return Text(
|
final overflow = _getOverflow(column);
|
||||||
|
final textWidget = Text(
|
||||||
text,
|
text,
|
||||||
textAlign: _getTextAlign(column),
|
textAlign: _getTextAlign(column),
|
||||||
maxLines: _getMaxLines(column),
|
maxLines: _getMaxLines(column),
|
||||||
overflow: _getOverflow(column),
|
overflow: overflow,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
final wrapped = GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('عدد کپی شد')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: textWidget,
|
||||||
|
);
|
||||||
|
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
|
||||||
|
? Tooltip(message: text, child: wrapped)
|
||||||
|
: wrapped;
|
||||||
}
|
}
|
||||||
if (column is DateColumn && column.formatter != null) {
|
if (column is DateColumn && column.formatter != null) {
|
||||||
final text = column.formatter!(item) ?? '';
|
final text = column.formatter!(item) ?? '';
|
||||||
return Text(
|
final overflow = _getOverflow(column);
|
||||||
|
final textWidget = Text(
|
||||||
text,
|
text,
|
||||||
textAlign: _getTextAlign(column),
|
textAlign: _getTextAlign(column),
|
||||||
maxLines: _getMaxLines(column),
|
maxLines: _getMaxLines(column),
|
||||||
overflow: _getOverflow(column),
|
overflow: overflow,
|
||||||
);
|
);
|
||||||
|
final wrapped = GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('تاریخ کپی شد')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: textWidget,
|
||||||
|
);
|
||||||
|
return (overflow == TextOverflow.ellipsis && text.isNotEmpty)
|
||||||
|
? Tooltip(message: text, child: wrapped)
|
||||||
|
: wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Fallback: get property value from Map items by key
|
// 4) Fallback: get property value from Map items by key
|
||||||
final value = DataTableUtils.getCellValue(item, column.key);
|
final value = DataTableUtils.getCellValue(item, column.key);
|
||||||
final formattedValue = DataTableUtils.formatCellValue(value, column);
|
final formattedValue = DataTableUtils.formatCellValue(value, column);
|
||||||
return Text(
|
final overflow = _getOverflow(column);
|
||||||
|
final textWidget = Text(
|
||||||
formattedValue,
|
formattedValue,
|
||||||
textAlign: _getTextAlign(column),
|
textAlign: _getTextAlign(column),
|
||||||
maxLines: _getMaxLines(column),
|
maxLines: _getMaxLines(column),
|
||||||
overflow: _getOverflow(column),
|
overflow: overflow,
|
||||||
|
style: column is NumberColumn
|
||||||
|
? Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
final wrapped = GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: formattedValue));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('مقدار کپی شد')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: textWidget,
|
||||||
|
);
|
||||||
|
return (overflow == TextOverflow.ellipsis && formattedValue.isNotEmpty)
|
||||||
|
? Tooltip(message: formattedValue, child: wrapped)
|
||||||
|
: wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActionButtons(dynamic item, ActionColumn column) {
|
Widget _buildActionButtons(dynamic item, ActionColumn column) {
|
||||||
|
|
@ -1642,6 +1988,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
||||||
final VoidCallback onSearch;
|
final VoidCallback onSearch;
|
||||||
final bool hasActiveFilter;
|
final bool hasActiveFilter;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
|
final void Function(double dx)? onResizeDrag;
|
||||||
|
final VoidCallback? onPinLeft;
|
||||||
|
final VoidCallback? onPinRight;
|
||||||
|
final VoidCallback? onUnpin;
|
||||||
|
final VoidCallback? onHide;
|
||||||
|
|
||||||
const _ColumnHeaderWithSearch({
|
const _ColumnHeaderWithSearch({
|
||||||
required this.text,
|
required this.text,
|
||||||
|
|
@ -1652,6 +2003,11 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.hasActiveFilter,
|
required this.hasActiveFilter,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
|
this.onResizeDrag,
|
||||||
|
this.onPinLeft,
|
||||||
|
this.onPinRight,
|
||||||
|
this.onUnpin,
|
||||||
|
this.onHide,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1726,6 +2082,55 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (onResizeDrag != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeLeftRight,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onHorizontalDragUpdate: (details) => onResizeDrag!(details.delta.dx),
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (onPinLeft != null || onPinRight != null || onUnpin != null || onHide != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tooltip: 'تنظیمات ستون',
|
||||||
|
icon: Icon(Icons.more_vert, size: 16, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7)),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'pinLeft':
|
||||||
|
onPinLeft?.call();
|
||||||
|
break;
|
||||||
|
case 'pinRight':
|
||||||
|
onPinRight?.call();
|
||||||
|
break;
|
||||||
|
case 'unpin':
|
||||||
|
onUnpin?.call();
|
||||||
|
break;
|
||||||
|
case 'hide':
|
||||||
|
onHide?.call();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
if (onPinLeft != null)
|
||||||
|
const PopupMenuItem(value: 'pinLeft', child: Text('پین چپ')),
|
||||||
|
if (onPinRight != null)
|
||||||
|
const PopupMenuItem(value: 'pinRight', child: Text('پین راست')),
|
||||||
|
if (onUnpin != null)
|
||||||
|
const PopupMenuItem(value: 'unpin', child: Text('برداشتن پین')),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
if (onHide != null)
|
||||||
|
const PopupMenuItem(value: 'hide', child: Text('مخفی کردن ستون')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1733,3 +2138,25 @@ class _ColumnHeaderWithSearch extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard intents
|
||||||
|
class MoveRowIntent extends Intent {
|
||||||
|
final int delta;
|
||||||
|
const MoveRowIntent(this.delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivateRowIntent extends Intent {
|
||||||
|
const ActivateRowIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToggleSelectionIntent extends Intent {
|
||||||
|
const ToggleSelectionIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClearSelectionIntent extends Intent {
|
||||||
|
const ClearSelectionIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelectAllIntent extends Intent {
|
||||||
|
const SelectAllIntent();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ class ColumnSettings {
|
||||||
final List<String> visibleColumns;
|
final List<String> visibleColumns;
|
||||||
final List<String> columnOrder;
|
final List<String> columnOrder;
|
||||||
final Map<String, double> columnWidths;
|
final Map<String, double> columnWidths;
|
||||||
|
final List<String> pinnedLeft;
|
||||||
|
final List<String> pinnedRight;
|
||||||
|
|
||||||
const ColumnSettings({
|
const ColumnSettings({
|
||||||
required this.visibleColumns,
|
required this.visibleColumns,
|
||||||
required this.columnOrder,
|
required this.columnOrder,
|
||||||
this.columnWidths = const {},
|
this.columnWidths = const {},
|
||||||
|
this.pinnedLeft = const [],
|
||||||
|
this.pinnedRight = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -19,6 +23,8 @@ class ColumnSettings {
|
||||||
'visibleColumns': visibleColumns,
|
'visibleColumns': visibleColumns,
|
||||||
'columnOrder': columnOrder,
|
'columnOrder': columnOrder,
|
||||||
'columnWidths': columnWidths,
|
'columnWidths': columnWidths,
|
||||||
|
'pinnedLeft': pinnedLeft,
|
||||||
|
'pinnedRight': pinnedRight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +33,8 @@ class ColumnSettings {
|
||||||
visibleColumns: List<String>.from(json['visibleColumns'] ?? []),
|
visibleColumns: List<String>.from(json['visibleColumns'] ?? []),
|
||||||
columnOrder: List<String>.from(json['columnOrder'] ?? []),
|
columnOrder: List<String>.from(json['columnOrder'] ?? []),
|
||||||
columnWidths: Map<String, double>.from(json['columnWidths'] ?? {}),
|
columnWidths: Map<String, double>.from(json['columnWidths'] ?? {}),
|
||||||
|
pinnedLeft: List<String>.from(json['pinnedLeft'] ?? []),
|
||||||
|
pinnedRight: List<String>.from(json['pinnedRight'] ?? []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,11 +42,15 @@ class ColumnSettings {
|
||||||
List<String>? visibleColumns,
|
List<String>? visibleColumns,
|
||||||
List<String>? columnOrder,
|
List<String>? columnOrder,
|
||||||
Map<String, double>? columnWidths,
|
Map<String, double>? columnWidths,
|
||||||
|
List<String>? pinnedLeft,
|
||||||
|
List<String>? pinnedRight,
|
||||||
}) {
|
}) {
|
||||||
return ColumnSettings(
|
return ColumnSettings(
|
||||||
visibleColumns: visibleColumns ?? this.visibleColumns,
|
visibleColumns: visibleColumns ?? this.visibleColumns,
|
||||||
columnOrder: columnOrder ?? this.columnOrder,
|
columnOrder: columnOrder ?? this.columnOrder,
|
||||||
columnWidths: columnWidths ?? this.columnWidths,
|
columnWidths: columnWidths ?? this.columnWidths,
|
||||||
|
pinnedLeft: pinnedLeft ?? this.pinnedLeft,
|
||||||
|
pinnedRight: pinnedRight ?? this.pinnedRight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +104,8 @@ class ColumnSettingsService {
|
||||||
return ColumnSettings(
|
return ColumnSettings(
|
||||||
visibleColumns: List.from(columnKeys),
|
visibleColumns: List.from(columnKeys),
|
||||||
columnOrder: List.from(columnKeys),
|
columnOrder: List.from(columnKeys),
|
||||||
|
pinnedLeft: const [],
|
||||||
|
pinnedRight: const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,11 +156,22 @@ class ColumnSettingsService {
|
||||||
validColumnWidths[entry.key] = entry.value;
|
validColumnWidths[entry.key] = entry.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sanitize pins to only include visible columns
|
||||||
|
final leftPins = <String>[];
|
||||||
|
for (final key in userSettings.pinnedLeft) {
|
||||||
|
if (visibleColumns.contains(key)) leftPins.add(key);
|
||||||
|
}
|
||||||
|
final rightPins = <String>[];
|
||||||
|
for (final key in userSettings.pinnedRight) {
|
||||||
|
if (visibleColumns.contains(key)) rightPins.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
return userSettings.copyWith(
|
return userSettings.copyWith(
|
||||||
visibleColumns: visibleColumns,
|
visibleColumns: visibleColumns,
|
||||||
columnOrder: columnOrder,
|
columnOrder: columnOrder,
|
||||||
columnWidths: validColumnWidths,
|
columnWidths: validColumnWidths,
|
||||||
|
pinnedLeft: leftPins,
|
||||||
|
pinnedRight: rightPins,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import './product_combobox_widget.dart';
|
||||||
// import './price_list_combobox_widget.dart';
|
// import './price_list_combobox_widget.dart';
|
||||||
import '../../services/price_list_service.dart';
|
import '../../services/price_list_service.dart';
|
||||||
import '../../core/api_client.dart';
|
import '../../core/api_client.dart';
|
||||||
|
import './warehouse_combobox_widget.dart';
|
||||||
|
|
||||||
class InvoiceLineItemsTable extends StatefulWidget {
|
class InvoiceLineItemsTable extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final int? selectedCurrencyId; // از تب ارز فاکتور
|
final int? selectedCurrencyId; // از تب ارز فاکتور
|
||||||
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
final ValueChanged<List<InvoiceLineItem>>? onChanged;
|
||||||
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
final String invoiceType; // sales | purchase | sales_return | purchase_return | ...
|
||||||
|
final bool postInventory;
|
||||||
|
|
||||||
const InvoiceLineItemsTable({
|
const InvoiceLineItemsTable({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -18,6 +20,7 @@ class InvoiceLineItemsTable extends StatefulWidget {
|
||||||
this.selectedCurrencyId,
|
this.selectedCurrencyId,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.invoiceType = 'sales',
|
this.invoiceType = 'sales',
|
||||||
|
this.postInventory = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -272,6 +275,15 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
if (widget.postInventory)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Tooltip(
|
||||||
|
message: 'انبار',
|
||||||
|
child: Text('انبار', style: style),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
|
|
@ -392,6 +404,24 @@ class _InvoiceLineItemsTableState extends State<InvoiceLineItemsTable> {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
if (widget.postInventory)
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: WarehouseComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedWarehouseId: item.warehouseId,
|
||||||
|
onChanged: (wid) {
|
||||||
|
_updateRow(index, item.copyWith(warehouseId: wid));
|
||||||
|
},
|
||||||
|
label: 'انبار',
|
||||||
|
hintText: 'انتخاب انبار',
|
||||||
|
isRequired: item.trackInventory,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.postInventory) const SizedBox(width: 8),
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
serve)
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
;;
|
;;
|
||||||
|
serve-workers)
|
||||||
|
# اجرای uvicorn با چند worker (بدون reload)
|
||||||
|
WORKERS=${WORKERS:-4}
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers "$WORKERS"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: $0 [serve|migrate|test]"
|
echo "Usage: $0 [serve|serve-workers|migrate|test]"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue