Wire purchase returns, inventory issues, and GL transfers with auto vouchers.

Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 19:51:32 +03:30
parent 320d9cd547
commit 97a363fc53
18 changed files with 1635 additions and 68 deletions

View File

@ -237,17 +237,45 @@ async def confirm_document(
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
party_id=entity.party_id,
party_name=entity.party_name,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "return":
import json as _json
meta: dict = {}
if entity.meta_json:
try:
meta = _json.loads(entity.meta_json)
except Exception:
meta = {}
lines = meta.get("lines") or []
lines_summary = None
if isinstance(lines, list) and lines:
bits = []
for ln in lines[:5]:
if not isinstance(ln, dict):
continue
title = str(ln.get("title") or "")
qty = str(ln.get("qty") or "")
if title:
bits.append(f"{title}×{qty}" if qty else title)
if bits:
lines_summary = "، ".join(bits)
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
source_invoice_id=str(meta.get("source_invoice_id") or "") or None,
source_invoice_number=str(meta.get("source_invoice_number") or "") or None,
party_id=entity.party_id,
party_name=entity.party_name or (str(meta.get("party_name") or "") or None),
lines_summary=lines_summary,
)
voucher_info = {
"voucher_id": str(voucher.id),

View File

@ -31,6 +31,11 @@ class GoodsReceiptRequest(BaseModel):
source_document_id: str
amount: Decimal
profile_id: UUID | None = None
credit_mode: str = "supplier" # supplier | grni
credit_account_id: UUID | None = None
warehouse_name: str | None = None
source_note: str | None = None
party_name: str | None = None
class ValuationRequest(BaseModel):
@ -96,6 +101,11 @@ async def post_goods_receipt(
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
credit_mode=body.credit_mode,
credit_account_id=body.credit_account_id,
warehouse_name=body.warehouse_name,
source_note=body.source_note,
party_name=body.party_name,
)
await db.commit()
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}
@ -141,9 +151,20 @@ async def post_purchase_invoice(
}
class PurchaseReturnRequest(BaseModel):
source_document_id: str
amount: Decimal
profile_id: UUID | None = None
source_invoice_id: str | None = None
source_invoice_number: str | None = None
party_id: UUID | None = None
party_name: str | None = None
lines_summary: str | None = None
@router.post("/post/purchase-return")
async def post_purchase_return(
body: GoodsReceiptRequest,
body: PurchaseReturnRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
@ -155,6 +176,50 @@ async def post_purchase_return(
amount=body.amount,
actor_user_id=user.user_id,
profile_id=body.profile_id,
source_invoice_id=body.source_invoice_id,
source_invoice_number=body.source_invoice_number,
party_id=body.party_id,
party_name=body.party_name,
lines_summary=body.lines_summary,
)
await db.commit()
return {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
"status": voucher.status.value,
}
class InventoryIssueRequest(BaseModel):
source_document_id: str
amount: Decimal
reason: str = "expense" # expense | charity | consumption | barter
debit_account_id: UUID
credit_account_id: UUID | None = None
profile_id: UUID | None = None
barter_debit_account_id: UUID | None = None
barter_amount: Decimal | None = None
@router.post("/post/inventory-issue")
async def post_inventory_issue(
body: InventoryIssueRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
svc = PurchaseInventoryAccountingService(db)
voucher = await svc.post_inventory_issue(
tenant_id,
source_document_id=body.source_document_id,
amount=body.amount,
reason=body.reason,
debit_account_id=body.debit_account_id,
actor_user_id=user.user_id,
profile_id=body.profile_id,
credit_account_id=body.credit_account_id,
barter_debit_account_id=body.barter_debit_account_id,
barter_amount=body.barter_amount,
)
await db.commit()
return {

View File

@ -334,22 +334,114 @@ async def list_transfers(
"to_type": t.to_type,
"to_id": str(t.to_id),
"description": t.description,
"voucher_id": str(t.voucher_id) if t.voucher_id else None,
}
for t in rows
]
async def _resolve_transfer_gl_account(
db: AsyncSession,
tenant_id: UUID,
side_type: str,
side_id: UUID,
) -> UUID:
from shared.exceptions import AppError
if side_type == "account":
return side_id
if side_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, side_id)
if box is None or box.account_id is None:
raise AppError(
"صندوق باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="cash_box_account_missing",
)
return box.account_id
if side_type == "bank":
bank = await BankAccountRepo(db).get(tenant_id, side_id)
if bank is None or bank.account_id is None:
raise AppError(
"حساب بانکی باید به حساب دفتر کل متصل باشد",
status_code=422,
error_code="bank_account_gl_missing",
)
return bank.account_id
raise AppError(
f"نوع مبدأ/مقصد نامعتبر است: {side_type} (account|cash|bank)",
status_code=422,
error_code="transfer_type_invalid",
)
@router.post("/transfers", status_code=status.HTTP_201_CREATED)
async def create_transfer(
body: TransferCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
user: CurrentUser = Depends(get_current_user),
):
"""Transfer between cash/bank/any GL account — auto-posts Dr to / Cr from."""
from app.services.posting_policy_service import PostingPolicyService
from app.services.source_posting_guard import SourcePostingGuard
entity = Transfer(tenant_id=tenant_id, **body.model_dump())
await TransferRepo(db).add(entity)
from_gl = await _resolve_transfer_gl_account(db, tenant_id, body.from_type, body.from_id)
to_gl = await _resolve_transfer_gl_account(db, tenant_id, body.to_type, body.to_id)
policy = await PostingPolicyService(db).get_policy(tenant_id)
voucher_id = None
voucher_number = None
# Transfers always need a GL document when accounts resolve — honor policy flag if present.
auto = bool((policy.get("auto_post") or {}).get("treasury_transfer", True))
if auto:
guard = SourcePostingGuard(db)
await guard.assert_can_post(
tenant_id,
source_module="treasury",
source_document_type="transfer",
source_document_id=str(entity.id),
)
svc = TreasuryService(db)
voucher = await svc.post_gl_transfer(
tenant_id,
amount=body.amount,
from_account_id=from_gl,
to_account_id=to_gl,
actor_user_id=user.user_id,
description=body.description or "انتقال بین حساب‌ها",
transaction_date=body.transfer_date,
reference=str(entity.id),
)
entity.voucher_id = voucher.id
voucher_id = str(voucher.id)
voucher_number = voucher.voucher_number
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="treasury",
source_document_type="transfer",
source_document_id=str(entity.id),
)
# Update cash balances when sides are cash boxes
if body.from_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, body.from_id)
if box is not None:
box.current_balance -= body.amount
if body.to_type == "cash":
box = await CashBoxRepo(db).get(tenant_id, body.to_id)
if box is not None:
box.current_balance += body.amount
await db.commit()
return {"id": str(entity.id)}
return {
"id": str(entity.id),
"voucher_id": voucher_id,
"voucher_number": voucher_number,
}
@router.get("/receipts")

View File

@ -16,9 +16,11 @@ DEFAULT_POLICY: dict[str, str] = {
"auto_post.sales.return": "false",
"auto_post.purchase.goods_receipt": "false",
"auto_post.purchase.invoice": "false",
"auto_post.purchase.return": "false",
"auto_post.purchase.return": "true",
"auto_post.treasury.receipt": "true",
"auto_post.treasury.payment": "true",
"auto_post.treasury.transfer": "true",
"auto_post.inventory.issue": "true",
"auto_post.payroll": "false",
"auto_post.assets.depreciation": "false",
"policy.require_balanced": "true",
@ -61,6 +63,8 @@ class PostingPolicyService:
"purchase_return": _as_bool(merged.get("auto_post.purchase.return")),
"treasury_receipt": _as_bool(merged.get("auto_post.treasury.receipt"), True),
"treasury_payment": _as_bool(merged.get("auto_post.treasury.payment"), True),
"treasury_transfer": _as_bool(merged.get("auto_post.treasury.transfer"), True),
"inventory_issue": _as_bool(merged.get("auto_post.inventory.issue"), True),
"payroll": _as_bool(merged.get("auto_post.payroll")),
"assets_depreciation": _as_bool(merged.get("auto_post.assets.depreciation")),
}
@ -88,6 +92,8 @@ class PostingPolicyService:
"purchase_return": "auto_post.purchase.return",
"treasury_receipt": "auto_post.treasury.receipt",
"treasury_payment": "auto_post.treasury.payment",
"treasury_transfer": "auto_post.treasury.transfer",
"inventory_issue": "auto_post.inventory.issue",
"payroll": "auto_post.payroll",
"assets_depreciation": "auto_post.assets.depreciation",
}
@ -138,6 +144,8 @@ class PostingPolicyService:
"purchase.return": "purchase_return",
"treasury.receipt": "treasury_receipt",
"treasury.payment": "treasury_payment",
"treasury.transfer": "treasury_transfer",
"inventory.issue": "inventory_issue",
"payroll.payroll": "payroll",
"assets.depreciation": "assets_depreciation",
}

View File

@ -111,10 +111,17 @@ class PurchaseInventoryAccountingService:
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
credit_mode: str = "supplier",
credit_account_id: UUID | None = None,
warehouse_name: str | None = None,
source_note: str | None = None,
party_name: str | None = None,
) -> Voucher:
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
@ -129,28 +136,83 @@ class PurchaseInventoryAccountingService:
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
if profile.inventory_account_id is None:
raise AppError(
"حساب موجودی در پروفایل خرید مشخص نیست",
status_code=422,
error_code="inventory_account_missing",
)
mode = (credit_mode or "grni").strip().lower()
if credit_account_id is not None:
credit_id = credit_account_id
credit_label = "حساب انتخاب‌شده"
elif mode == "supplier":
credit_id = profile.liability_account_id
credit_label = "تأمین‌کننده / حساب پرداختنی"
if credit_id is None:
raise AppError(
"حساب پرداختنی (بدهی تأمین‌کننده) در پروفایل خرید مشخص نیست",
status_code=422,
error_code="liability_account_missing",
)
else:
credit_id = profile.grni_account_id or profile.liability_account_id
credit_label = "GRNI" if profile.grni_account_id else "تأمین‌کننده / حساب پرداختنی"
if credit_id is None:
raise AppError(
"حساب GRNI یا حساب پرداختنی در پروفایل خرید مشخص نیست",
status_code=422,
error_code="credit_account_missing",
)
parts = [f"رسید کالا {source_document_id}"]
if party_name:
parts.append(f"از: {party_name}")
if warehouse_name:
parts.append(f"انبار: {warehouse_name}")
parts.append(f"بستانکار: {credit_label}")
if source_note:
parts.append(source_note)
description = "".join(parts)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=f"GR-{source_document_id}",
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Goods receipt {source_document_id}",
description=description,
source_module="purchase",
reference_number=source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
if profile.inventory_account_id and profile.grni_account_id:
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=profile.inventory_account_id, debit=amount, credit=Decimal("0"),
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
))
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=1,
account_id=profile.inventory_account_id,
debit=amount,
credit=Decimal("0"),
description=f"ورود به انبار{f'{warehouse_name}' if warehouse_name else ''}",
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=credit_id,
debit=Decimal("0"),
credit=amount,
description=f"بستانکار {credit_label}"
+ (f"{party_name}" if party_name else ""),
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
@ -201,6 +263,8 @@ class PurchaseInventoryAccountingService:
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
party_id: UUID | None = None,
party_name: str | None = None,
) -> Voucher:
from datetime import date
@ -231,13 +295,16 @@ class PurchaseInventoryAccountingService:
)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
desc = f"فاکتور خرید {source_document_id}"
if party_name:
desc = f"فاکتور خرید — تأمین‌کننده: {party_name}"
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Purchase invoice {source_document_id}",
description=desc,
source_module="purchase",
reference_number=source_document_id,
created_by=actor_user_id,
@ -261,6 +328,7 @@ class PurchaseInventoryAccountingService:
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
description=f"بدهی تأمین‌کننده" + (f"{party_name}" if party_name else ""),
)
)
await self.session.flush()
@ -275,6 +343,8 @@ class PurchaseInventoryAccountingService:
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
source_document_id=source_document_id,
)
if party_id is not None:
await self._adjust_supplier_outstanding(tenant_id, party_id, amount)
return voucher
async def post_purchase_return(
@ -285,8 +355,16 @@ class PurchaseInventoryAccountingService:
amount: Decimal,
actor_user_id: str,
profile_id: UUID | None = None,
source_invoice_id: str | None = None,
source_invoice_number: str | None = None,
party_id: UUID | None = None,
party_name: str | None = None,
lines_summary: str | None = None,
) -> Voucher:
"""Reverse of supplier invoice: Dr AP liability / Cr inventory or GRNI."""
"""Purchase return: Dr AP liability (reduce debt) / Cr inventory.
Must be linked to a purchase invoice so party and items are traceable.
"""
from datetime import date
from app.services.document_number_service import DocumentNumberService
@ -303,6 +381,9 @@ class PurchaseInventoryAccountingService:
source_document_id=source_document_id,
)
if amount <= 0:
raise AppError("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد", status_code=422, error_code="return_amount")
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
period = await self.period_repo.get_current(tenant_id)
if period is None:
@ -317,6 +398,17 @@ class PurchaseInventoryAccountingService:
error_code="purchase_profile_incomplete",
)
parts = ["مرجوعی خرید"]
if source_invoice_number:
parts.append(f"فاکتور {source_invoice_number}")
elif source_invoice_id:
parts.append(f"فاکتور {source_invoice_id}")
if party_name:
parts.append(f"تأمین‌کننده: {party_name}")
if lines_summary:
parts.append(lines_summary)
description = "".join(parts)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
@ -324,9 +416,9 @@ class PurchaseInventoryAccountingService:
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Purchase return {source_document_id}",
description=description,
source_module="purchase",
reference_number=source_document_id,
reference_number=source_invoice_id or source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
@ -338,6 +430,8 @@ class PurchaseInventoryAccountingService:
account_id=debit_account,
debit=amount,
credit=Decimal("0"),
description=f"کاهش بدهی تأمین‌کننده"
+ (f"{party_name}" if party_name else ""),
)
)
self.session.add(
@ -348,6 +442,8 @@ class PurchaseInventoryAccountingService:
account_id=credit_account,
debit=Decimal("0"),
credit=amount,
description="خروج موجودی بابت مرجوعی"
+ (f"{lines_summary}" if lines_summary else ""),
)
)
await self.session.flush()
@ -362,6 +458,187 @@ class PurchaseInventoryAccountingService:
source_document_type=doc_type_value,
source_document_id=source_document_id,
)
# Reduce supplier payable balance (our debt to them goes down)
if party_id is not None:
await self._adjust_supplier_outstanding(tenant_id, party_id, -amount)
return voucher
async def _adjust_supplier_outstanding(
self, tenant_id: UUID, supplier_id: UUID, delta: Decimal
) -> None:
from sqlalchemy import select
from app.models.receivable_payable import SupplierAccount
result = await self.session.execute(
select(SupplierAccount).where(
SupplierAccount.tenant_id == tenant_id,
SupplierAccount.id == supplier_id,
)
)
supplier = result.scalar_one_or_none()
if supplier is None:
return
supplier.outstanding_balance = (supplier.outstanding_balance or Decimal("0")) + delta
if supplier.outstanding_balance < 0:
supplier.outstanding_balance = Decimal("0")
async def post_inventory_issue(
self,
tenant_id: UUID,
*,
source_document_id: str,
amount: Decimal,
reason: str,
debit_account_id: UUID,
actor_user_id: str,
profile_id: UUID | None = None,
credit_account_id: UUID | None = None,
barter_debit_account_id: UUID | None = None,
barter_amount: Decimal | None = None,
):
"""Issue inventory to expense/charity/consumption/barter via Posting Engine.
Standard: Dr expense (or barter inventory) / Cr inventory.
Barter optional second pair when receiving another item of different value.
"""
from datetime import date
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
guard = SourcePostingGuard(self.session)
await guard.assert_can_post(
tenant_id,
source_module="inventory",
source_document_type=f"issue_{reason}",
source_document_id=source_document_id,
)
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
inventory_credit = credit_account_id or profile.inventory_account_id
if inventory_credit is None:
raise AppError(
"حساب موجودی برای خروج کالا مشخص نیست (پروفایل خرید یا انتخاب دستی)",
status_code=422,
error_code="inventory_account_missing",
)
if amount <= 0:
raise AppError("مبلغ خروج باید بزرگ‌تر از صفر باشد", status_code=422, error_code="issue_amount")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
reason_fa = {
"expense": "هزینه",
"charity": "خیرات/اهدایی",
"consumption": "مصرف داخلی",
"barter": "تهاتر/معاوضه",
}.get(reason, reason)
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=number,
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"خروج موجودی — {reason_fa}{source_document_id}",
source_module="inventory",
reference_number=source_document_id,
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
if reason == "barter" and barter_debit_account_id and barter_amount and barter_amount > 0:
# Give stock (Cr inventory), receive other stock/value (Dr), difference to expense/income account
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=1,
account_id=inventory_credit,
debit=Decimal("0"),
credit=amount,
description=f"خروج کالا — {reason_fa}",
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=barter_debit_account_id,
debit=barter_amount,
credit=Decimal("0"),
description="کالای/ارزش دریافتی تهاتر",
)
)
diff = amount - barter_amount
if diff > 0:
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=3,
account_id=debit_account_id,
debit=diff,
credit=Decimal("0"),
description="مابه‌التفاوت تهاتر (هزینه)",
)
)
elif diff < 0:
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=3,
account_id=debit_account_id,
debit=Decimal("0"),
credit=-diff,
description="مابه‌التفاوت تهاتر (درآمد/تخفیف)",
)
)
else:
# expense / charity / consumption: Dr target account, Cr inventory
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=1,
account_id=debit_account_id,
debit=amount,
credit=Decimal("0"),
description=reason_fa,
)
)
self.session.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=2,
account_id=inventory_credit,
debit=Decimal("0"),
credit=amount,
description=reason_fa,
)
)
await self.session.flush()
if await guard.should_post_immediately(tenant_id):
voucher = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="inventory"
)
await guard.mark_posted(
tenant_id,
voucher_id=voucher.id,
source_module="inventory",
source_document_type=f"issue_{reason}",
source_document_id=source_document_id,
)
return voucher
async def record_valuation(

View File

@ -132,6 +132,52 @@ class TreasuryService:
cash_box.current_balance -= amount
return tx
async def post_gl_transfer(
self,
tenant_id: UUID,
*,
amount: Decimal,
from_account_id: UUID,
to_account_id: UUID,
actor_user_id: str,
description: str,
transaction_date: date | None = None,
reference: str | None = None,
) -> Voucher:
"""Any GL → any GL: Dr destination (to), Cr source (from)."""
from app.services.document_number_service import DocumentNumberService
from app.services.source_posting_guard import SourcePostingGuard
from shared.exceptions import AppError
if from_account_id == to_account_id:
raise AppError(
"حساب مبدأ و مقصد نباید یکسان باشند",
status_code=422,
error_code="transfer_same_account",
)
if amount <= 0:
raise AppError("مبلغ انتقال باید بزرگ‌تر از صفر باشد", status_code=422, error_code="transfer_amount")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
return await self._create_treasury_voucher(
tenant_id,
period.id,
number,
transaction_date or date.today(),
to_account_id,
from_account_id,
amount,
description or "انتقال بین حساب‌ها",
actor_user_id,
"treasury",
post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id),
reference_number=reference,
)
async def post_receipt_payment_voucher(
self,
tenant_id: UUID,

View File

@ -49,6 +49,8 @@ const accountSchema = z.object({
});
type ChartForm = z.infer<typeof chartSchema>;
const editChartSchema = chartSchema.omit({ code: true }).extend({ code: z.string().optional() });
type EditChartForm = z.infer<typeof editChartSchema>;
type AccountForm = z.infer<typeof accountSchema>;
const ACCOUNT_TYPES = [
@ -112,8 +114,8 @@ export default function ChartOfAccountsPage() {
description: "",
},
});
const editChartForm = useForm<ChartForm>({
resolver: zodResolver(chartSchema.omit({ code: true }).extend({ code: z.string().optional() })),
const editChartForm = useForm<EditChartForm>({
resolver: zodResolver(editChartSchema),
});
const editAccountForm = useForm({
defaultValues: { name: "", status: "active", description: "", is_postable: true },

View File

@ -0,0 +1,342 @@
"use client";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput, toRialInteger } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
FormField,
MoneyInput,
DatePicker,
JalaliDateText,
Select,
Badge,
} from "@/components/ds";
import { ItemCombobox } from "@/components/accounting/EntityCombobox";
type FormValues = {
number: string;
doc_date: string;
reason: "expense" | "charity" | "consumption" | "barter";
item_label: string;
item_id: string;
qty: string;
unit_cost: string;
warehouse_id: string;
debit_account_id: string;
barter_debit_account_id: string;
barter_amount: string;
description: string;
};
const REASON_FA: Record<string, string> = {
expense: "هزینه",
charity: "خیرات / اهدایی",
consumption: "مصرف داخلی (آبدارخانه و …)",
barter: "تهاتر / معاوضه کالا",
};
export default function InventoryIssuesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "inventory", "issue"),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const warehousesQ = useQuery({
queryKey: ["accounting", tenantId, "warehouses"],
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const form = useForm<FormValues>({
defaultValues: {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
reason: "expense",
item_label: "",
item_id: "",
qty: "1",
unit_cost: "0",
warehouse_id: "",
debit_account_id: "",
barter_debit_account_id: "",
barter_amount: "0",
description: "",
},
});
const reason = form.watch("reason");
const amount = useMemo(() => {
const q = Number(toRialInteger(form.watch("qty")) || "0");
const c = Number(toRialInteger(form.watch("unit_cost")) || "0");
return String(q * c);
}, [form.watch("qty"), form.watch("unit_cost")]);
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
const autoIssue = Boolean(policyQ.data?.auto_post?.inventory_issue ?? true);
const createM = useMutation({
mutationFn: async (d: FormValues) => {
const amt = parseMoneyInput(amount) || "0";
if (Number(amt) <= 0) throw new Error("مبلغ خروج باید بزرگ‌تر از صفر باشد");
if (!d.debit_account_id) throw new Error("حساب هزینه/مقصد الزامی است");
if (d.reason === "barter" && !d.barter_debit_account_id) {
throw new Error("برای تهاتر، حساب کالای دریافتی را انتخاب کنید");
}
const doc = await accountingApi.ops.createDocument(tenantId!, {
module: "inventory",
doc_type: "issue",
number: d.number?.trim() || undefined,
doc_date: d.doc_date,
amount: amt,
description: d.description || REASON_FA[d.reason],
status: "draft",
meta_json: JSON.stringify({
reason: d.reason,
item_id: d.item_id,
item_label: d.item_label,
qty: d.qty,
unit_cost: d.unit_cost,
warehouse_id: d.warehouse_id || undefined,
debit_account_id: d.debit_account_id,
barter_debit_account_id: d.barter_debit_account_id || undefined,
barter_amount: d.barter_amount || undefined,
}),
});
if (autoIssue) {
const posted = await accountingApi.purchaseInventory.postInventoryIssue(tenantId!, {
source_document_id: String(doc.id),
amount: amt,
reason: d.reason,
debit_account_id: d.debit_account_id,
barter_debit_account_id: d.barter_debit_account_id || undefined,
barter_amount:
d.reason === "barter" ? parseMoneyInput(d.barter_amount) || amt : undefined,
});
await accountingApi.ops.confirmDocument(tenantId!, String(doc.id)).catch(() => null);
return posted;
}
return doc;
},
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string }).voucher_number || "")
: "";
toast.success(vn ? `خروج ثبت و سند ${vn} صادر شد` : "خروج کالا ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
return (
<div>
<PageHeader
title="خروج / هزینه‌کرد کالا"
description="خروج موجودی برای هزینه، خیرات، مصرف داخلی یا تهاتر — سند حسابداری اتومات (Dr هزینه / Cr موجودی)."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
خروج کالای جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "number", header: "شماره" },
{
key: "doc_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
},
{
key: "reason",
header: "نوع",
render: (r) => {
try {
const meta = JSON.parse(String(r.meta_json || "{}")) as { reason?: string };
return REASON_FA[meta.reason || ""] || meta.reason || "—";
} catch {
return "—";
}
},
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={r.status === "posted" || r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>,
},
{ key: "description", header: "شرح" },
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="خروجی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="خروج / هزینه‌کرد کالا">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="نوع خروج">
<Select
value={reason}
onChange={(e) => form.setValue("reason", e.target.value as FormValues["reason"])}
>
<option value="expense">هزینه کردن کالا</option>
<option value="charity">خیرات / اهدایی (بدون دریافت وجه)</option>
<option value="consumption">مصرف داخلی (آبدارخانه و )</option>
<option value="barter">تهاتر کالا میدهم، چیز دیگری میگیرم</option>
</Select>
</FormField>
<FormField label="تاریخ">
<Controller
control={form.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<div className="sm:col-span-2">
<FormField label="کالای خروجی" hint="کالایی که از انبار خارج می‌شود">
<ItemCombobox
valueLabel={form.watch("item_label") || undefined}
onSelect={(item) => {
form.setValue("item_id", item.id);
form.setValue("item_label", `${item.code}${item.name}`);
form.setValue("unit_cost", parseMoneyInput(item.unit_cost) || "0");
}}
onClear={() => {
form.setValue("item_id", "");
form.setValue("item_label", "");
}}
/>
</FormField>
</div>
<FormField label="تعداد">
<Input {...form.register("qty", { required: true })} />
</FormField>
<FormField label="بهای واحد">
<MoneyInput {...form.register("unit_cost", { required: true })} />
</FormField>
<FormField label="جمع مبلغ">
<Input value={formatMoney(amount)} readOnly dir="ltr" />
</FormField>
<FormField label="انبار مبدأ">
<Select {...form.register("warehouse_id")}>
<option value="">انتخاب</option>
{(warehousesQ.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} {w.name}
</option>
))}
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField
label={
reason === "charity"
? "حساب هزینه خیرات / اهدایی"
: reason === "consumption"
? "حساب هزینه مصرف (مثلاً آبدارخانه)"
: reason === "barter"
? "حساب مابه‌التفاوت (اگر مبلغ‌ها برابر نباشند)"
: "حساب هزینه مقصد"
}
hint="بدهکار سند اتومات — موجودی بستانکار می‌شود"
>
<Select {...form.register("debit_account_id", { required: true })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
</div>
{reason === "barter" ? (
<>
<div className="sm:col-span-2">
<FormField
label="حساب کالای دریافتی"
hint="مثلاً موجودی چای / ملزومات آبدارخانه که در ازای کالای خروجی می‌گیرید"
>
<Select {...form.register("barter_debit_account_id", { required: true })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
</div>
<FormField label="ارزش کالای دریافتی">
<MoneyInput {...form.register("barter_amount")} />
</FormField>
</>
) : null}
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input
placeholder="مثال: اهدا به خیریه / مصرف آبدارخانه در ازای چای"
{...form.register("description")}
/>
</FormField>
</div>
<p className="sm:col-span-2 text-xs text-[var(--muted)]">
{autoIssue
? "با ذخیره، سند حسابداری خودکار از Posting Engine صادر می‌شود."
: "ثبت خودکار خاموش است — فقط سند عملیاتی ذخیره می‌شود (تنظیمات → اسناد اتوماتیک)."}
</p>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره و صدور سند
</Button>
</div>
</form>
</Dialog>
</div>
);
}

View File

@ -6,11 +6,13 @@ export default function Page() {
return (
<OperationalDocumentPage
title="رسید ورود و خروج"
description="طرف حساب و انبار جداگانه مشخص می‌شوند — ورود یا خروج کالا به/از انبار."
createLabel="رسید جدید"
module="inventory"
docType="receipt_issue"
partyLabel="انبار"
partyLabel="طرف حساب"
kind="receipt"
enableWarehouseContext
/>
);
}

View File

@ -6,12 +6,14 @@ export default function Page() {
return (
<OperationalDocumentPage
title="رسید کالا"
description="ورود کالا به انبار مشخص — تأمین‌کننده بستانکار یا GRNI؛ پس از تأیید در صورت روشن بودن اسناد اتومات، سند دفتر کل صادر می‌شود."
createLabel="رسید کالای جدید"
module="purchase"
docType="goods_receipt"
partyLabel="تأمین‌کننده"
partyLabel="تأمین‌کننده (مبدأ کالا)"
kind="receipt"
enableAccountingPost="purchase_gr"
enableWarehouseContext
/>
);
}

View File

@ -1,17 +1,411 @@
"use client";
import { OperationalDocumentPage } from "@/components/accounting/OperationalDocumentPage";
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
FormField,
DatePicker,
JalaliDateText,
Select,
Badge,
} from "@/components/ds";
type InvoiceLine = {
title: string;
qty: string;
unit_price: string;
note?: string;
item_id?: string;
};
type ReturnLine = InvoiceLine & {
return_qty: string;
selected: boolean;
invoice_line_index: number;
};
type FormValues = {
number: string;
doc_date: string;
source_invoice_id: string;
description: string;
};
function parseMeta(meta: unknown): {
lines?: InvoiceLine[];
source_invoice_id?: string;
source_invoice_number?: string;
accounting_voucher?: { voucher_number?: string };
} {
if (!meta) return {};
try {
const raw = typeof meta === "string" ? JSON.parse(meta) : meta;
return (raw && typeof raw === "object" ? raw : {}) as ReturnType<typeof parseMeta>;
} catch {
return {};
}
}
function lineAmount(qty: string, unitPrice: string): number {
return Number(parseMoneyInput(qty) || "0") * Number(parseMoneyInput(unitPrice) || "0");
}
export default function PurchaseReturnsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [returnLines, setReturnLines] = useState<ReturnLine[]>([]);
const returnsQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "return"),
enabled: !!tenantId,
});
const invoicesQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", "purchase", "invoice"],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "invoice"),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const form = useForm<FormValues>({
defaultValues: {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
source_invoice_id: "",
description: "",
},
});
const invoiceId = form.watch("source_invoice_id");
const selectedInvoice = useMemo(
() => (invoicesQ.data ?? []).find((i) => i.id === invoiceId),
[invoicesQ.data, invoiceId]
);
const returnTotal = useMemo(
() =>
returnLines
.filter((l) => l.selected)
.reduce((sum, l) => sum + lineAmount(l.return_qty, l.unit_price), 0),
[returnLines]
);
const autoReturn = Boolean(policyQ.data?.auto_post?.purchase_return ?? true);
const onInvoiceChange = (id: string) => {
form.setValue("source_invoice_id", id);
const inv = (invoicesQ.data ?? []).find((i) => i.id === id);
if (!inv) {
setReturnLines([]);
return;
}
const meta = parseMeta(inv.meta_json);
const lines = meta.lines ?? [];
if (!lines.length) {
// Invoice without line items: treat whole amount as one returnable row
setReturnLines([
{
title: inv.description || `فاکتور ${inv.number}`,
qty: "1",
unit_price: String(inv.amount || "0"),
return_qty: "1",
selected: true,
invoice_line_index: 0,
},
]);
return;
}
setReturnLines(
lines.map((ln, idx) => ({
title: ln.title || "کالا",
qty: ln.qty || "1",
unit_price: ln.unit_price || "0",
note: ln.note,
item_id: ln.item_id,
return_qty: ln.qty || "1",
selected: true,
invoice_line_index: idx,
}))
);
};
const createM = useMutation({
mutationFn: async (d: FormValues) => {
if (!selectedInvoice) throw new Error("فاکتور خرید را انتخاب کنید");
const selected = returnLines.filter((l) => l.selected && Number(parseMoneyInput(l.return_qty) || "0") > 0);
if (!selected.length) throw new Error("حداقل یک کالا با تعداد مرجوعی مشخص کنید");
for (const ln of selected) {
const maxQty = Number(parseMoneyInput(ln.qty) || "0");
const retQty = Number(parseMoneyInput(ln.return_qty) || "0");
if (retQty > maxQty) {
throw new Error(`تعداد مرجوعی «${ln.title}» بیشتر از فاکتور است`);
}
}
const amount = String(returnTotal);
if (Number(amount) <= 0) throw new Error("مبلغ مرجوعی باید بزرگ‌تر از صفر باشد");
const linesPayload = selected.map((l) => ({
title: l.title,
qty: parseMoneyInput(l.return_qty) || "0",
unit_price: parseMoneyInput(l.unit_price) || "0",
note: l.note,
item_id: l.item_id,
invoice_line_index: l.invoice_line_index,
invoice_qty: l.qty,
}));
const doc = await accountingApi.ops.createDocument(tenantId!, {
module: "purchase",
doc_type: "return",
number: d.number?.trim() || undefined,
doc_date: d.doc_date,
party_name: selectedInvoice.party_name || undefined,
party_id: selectedInvoice.party_id || undefined,
amount,
description:
d.description ||
`مرجوعی از فاکتور ${selectedInvoice.number}` +
(selectedInvoice.party_name ? `${selectedInvoice.party_name}` : ""),
status: "draft",
meta_json: JSON.stringify({
source_invoice_id: selectedInvoice.id,
source_invoice_number: selectedInvoice.number,
party_name: selectedInvoice.party_name,
party_id: selectedInvoice.party_id,
lines: linesPayload,
}),
});
if (autoReturn) {
// Confirm triggers auto GL (Dr AP / Cr inventory) + supplier balance decrease
const confirmed = await accountingApi.ops.confirmDocument(tenantId!, String(doc.id));
return confirmed;
}
return doc;
},
onSuccess: async (res) => {
const meta =
res && typeof res === "object" && "meta_json" in res
? parseMeta((res as { meta_json?: string | null }).meta_json)
: {};
const vn = meta.accounting_voucher?.voucher_number || "";
toast.success(
vn
? `مرجوعی ثبت شد — سند ${vn} (کاهش بدهی تأمین‌کننده)`
: "مرجوعی ثبت شد"
);
setOpen(false);
form.reset();
setReturnLines([]);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "return"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "suppliers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || returnsQ.isLoading) return <LoadingState />;
if (returnsQ.error) return <ErrorState message={returnsQ.error.message} onRetry={() => returnsQ.refetch()} />;
const invoices = (invoicesQ.data ?? []).filter((i) => i.status !== "cancelled");
export default function Page() {
return (
<OperationalDocumentPage
title="مرجوعی خرید"
createLabel="مرجوعی خرید جدید"
module="purchase"
docType="return"
partyLabel="تأمین‌کننده"
kind="invoice"
enableAccountingPost="purchase_return"
/>
<div>
<PageHeader
title="مرجوعی خرید"
description="فاکتور خرید را انتخاب کنید، کالاهای مرجوعی را مشخص کنید — سند اتومات بدهی تأمین‌کننده را کاهش می‌دهد (Dr حساب پرداختنی / Cr موجودی)."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
مرجوعی جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "number", header: "شماره مرجوعی" },
{
key: "doc_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
},
{
key: "invoice",
header: "فاکتور مبدأ",
render: (r) => parseMeta(r.meta_json).source_invoice_number || "—",
},
{ key: "party_name", header: "تأمین‌کننده" },
{
key: "items",
header: "کالاها",
render: (r) => {
const lines = parseMeta(r.meta_json).lines ?? [];
if (!lines.length) return "—";
return lines.map((l) => `${l.title}×${l.qty}`).join("، ");
},
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "posted" || r.status === "confirmed" ? "success" : "default"}>
{String(r.status)}
</Badge>
),
},
{
key: "voucher",
header: "سند GL",
render: (r) => parseMeta(r.meta_json).accounting_voucher?.voucher_number || "—",
},
]}
rows={(returnsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="مرجوعی ثبت نشده" description="از فاکتور خرید، مرجوعی ثبت کنید." />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="مرجوعی خرید از فاکتور" size="xl">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="فاکتور خرید مبدأ" hint="مرجوعی باید به یک فاکتور خرید وصل باشد">
<Select
value={invoiceId}
onChange={(e) => onInvoiceChange(e.target.value)}
required
>
<option value="">انتخاب فاکتور</option>
{invoices.map((inv) => (
<option key={inv.id} value={inv.id}>
{inv.number} {inv.party_name || "بدون تأمین‌کننده"} {formatMoney(String(inv.amount))}
</option>
))}
</Select>
</FormField>
<FormField label="تاریخ مرجوعی">
<Controller
control={form.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
{selectedInvoice ? (
<div className="sm:col-span-2 rounded-xl border border-[var(--border)] bg-[var(--surface-2)] p-3 text-sm">
<div>
تأمینکننده: <strong>{selectedInvoice.party_name || "—"}</strong>
</div>
<div className="mt-1 text-[var(--muted)]">
فاکتور {selectedInvoice.number} مبلغ فاکتور {formatMoney(String(selectedInvoice.amount))}
</div>
</div>
) : null}
{returnLines.length > 0 ? (
<div className="sm:col-span-2 overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-right text-[var(--muted)]">
<th className="p-2 font-medium">انتخاب</th>
<th className="p-2 font-medium">کالا (از فاکتور)</th>
<th className="p-2 font-medium">تعداد فاکتور</th>
<th className="p-2 font-medium">تعداد مرجوعی</th>
<th className="p-2 font-medium">فی</th>
<th className="p-2 font-medium">جمع</th>
</tr>
</thead>
<tbody>
{returnLines.map((ln, idx) => (
<tr key={idx} className="border-b border-[var(--border)]">
<td className="p-2">
<input
type="checkbox"
checked={ln.selected}
onChange={(e) => {
const next = [...returnLines];
next[idx] = { ...ln, selected: e.target.checked };
setReturnLines(next);
}}
/>
</td>
<td className="p-2">{ln.title}</td>
<td className="p-2" dir="ltr">
{ln.qty}
</td>
<td className="p-2">
<Input
value={ln.return_qty}
disabled={!ln.selected}
onChange={(e) => {
const next = [...returnLines];
next[idx] = { ...ln, return_qty: e.target.value };
setReturnLines(next);
}}
/>
</td>
<td className="p-2" dir="ltr">
{formatMoney(parseMoneyInput(ln.unit_price) || "0")}
</td>
<td className="p-2" dir="ltr">
{ln.selected
? formatMoney(String(lineAmount(ln.return_qty, ln.unit_price)))
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<FormField label="جمع مرجوعی">
<Input value={formatMoney(String(returnTotal))} readOnly dir="ltr" />
</FormField>
<FormField label="شماره مرجوعی (اختیاری)">
<Input {...form.register("number")} placeholder="خالی = خودکار" />
</FormField>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...form.register("description")} placeholder="اختیاری" />
</FormField>
</div>
<div className="sm:col-span-2 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={createM.isPending} disabled={!invoiceId || returnTotal <= 0}>
ثبت مرجوعی{autoReturn ? " + سند اتومات" : ""}
</Button>
</div>
</form>
</Dialog>
</div>
);
}

View File

@ -23,9 +23,11 @@ const AUTO_KEYS: { key: string; label: string; hint: string }[] = [
{ key: "sales_return", label: "مرجوعی فروش", hint: "ثبت خودکار برگشت فروش در دفتر" },
{ key: "purchase_goods_receipt", label: "رسید کالا", hint: "Dr موجودی / Cr GRNI هنگام تأیید رسید" },
{ key: "purchase_invoice", label: "فاکتور خرید", hint: "ثبت بدهی تأمین‌کننده هنگام تأیید فاکتور" },
{ key: "purchase_return", label: "مرجوعی خرید", hint: "ثبت خودکار مرجوعی خرید" },
{ key: "purchase_return", label: "مرجوعی خرید", hint: "وصل به فاکتور خرید — کاهش بدهی تأمین‌کننده (Dr AP / Cr موجودی)" },
{ key: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_transfer", label: "انتقال بین حساب‌ها", hint: "از هر حساب به هر حساب — سند اتومات Dr مقصد / Cr مبدأ" },
{ key: "inventory_issue", label: "خروج / هزینه‌کرد کالا", hint: "خیرات، مصرف، هزینه، تهاتر با سند اتومات" },
{ key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" },
{ key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دوره‌ای" },
];

View File

@ -1005,6 +1005,11 @@ export function TransfersPage() {
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "transfers"],
queryFn: () => accountingApi.treasury.listTransfers(tenantId!),
@ -1030,6 +1035,12 @@ export function TransfersPage() {
}) => {
const [from_type, from_id] = d.from_key.split(":");
const [to_type, to_id] = d.to_key.split(":");
if (!from_type || !from_id || !to_type || !to_id) {
throw new Error("حساب مبدأ و مقصد را انتخاب کنید");
}
if (d.from_key === d.to_key) {
throw new Error("مبدأ و مقصد نباید یکسان باشند");
}
return accountingApi.treasury.createTransfer(tenantId!, {
transfer_date: d.transfer_date,
amount: d.amount,
@ -1040,11 +1051,16 @@ export function TransfersPage() {
description: d.description,
});
},
onSuccess: async () => {
toast.success("انتقال ثبت شد");
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string | null }).voucher_number || "")
: "";
toast.success(vn ? `انتقال ثبت و سند ${vn} صادر شد` : "انتقال ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "transfers"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
onError: (e: Error) => toast.error(e.message),
});
@ -1052,19 +1068,38 @@ export function TransfersPage() {
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
const labelMap = new Map<string, string>();
const accountOptions = [
...(cashBoxesQ.data ?? []).map((c) => ({ value: `cash:${c.id}`, label: `صندوق: ${c.name}` })),
...(bankAccountsQ.data ?? []).map((b) => ({
value: `bank:${b.id}`,
label: `بانک: ${b.account_number}`,
})),
...(cashBoxesQ.data ?? []).map((c) => {
const value = `cash:${c.id}`;
const label = `صندوق: ${c.name}`;
labelMap.set(value, label);
return { value, label };
}),
...(bankAccountsQ.data ?? []).map((b) => {
const value = `bank:${b.id}`;
const label = `بانک: ${b.account_number}`;
labelMap.set(value, label);
return { value, label };
}),
...(accountsQ.data ?? [])
.filter((a) => a.is_postable)
.map((a) => {
const value = `account:${a.id}`;
const label = `حساب: ${a.code}${a.name}`;
labelMap.set(value, label);
return { value, label };
}),
];
const sideLabel = (type: unknown, id: unknown) =>
labelMap.get(`${String(type)}:${String(id)}`) || `${String(type)}`;
return (
<div>
<PageHeader
title="انتقال وجه"
description="انتقال بین صندوق‌ها و حساب‌های بانکی."
title="انتقال بین حساب‌ها"
description="از صندوق، بانک یا هر حساب دفتر کل به هر حساب دیگر — سند اتومات: بدهکار مقصد / بستانکار مبدأ."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
@ -1084,8 +1119,21 @@ export function TransfersPage() {
header: "مبلغ",
render: (r) => formatMoney(String(r.amount)),
},
{ key: "from_type", header: "از" },
{ key: "to_type", header: "به" },
{
key: "from_id",
header: "از",
render: (r) => sideLabel(r.from_type, r.from_id),
},
{
key: "to_id",
header: "به",
render: (r) => sideLabel(r.to_type, r.to_id),
},
{
key: "voucher_id",
header: "سند GL",
render: (r) => (r.voucher_id ? <Badge tone="success">صادر شد</Badge> : "—"),
},
{ key: "description", header: "توضیح" },
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
@ -1105,7 +1153,7 @@ export function TransfersPage() {
<FormField label="مبلغ">
<MoneyInput {...form.register("amount", { required: true })} />
</FormField>
<FormField label="از حساب">
<FormField label="از حساب (بستانکار می‌شود)" hint="مثال: بانک ملت، صندوق، اشخاص">
<Select
value={form.watch("from_key")}
onChange={(e) => form.setValue("from_key", e.target.value)}
@ -1118,7 +1166,7 @@ export function TransfersPage() {
))}
</Select>
</FormField>
<FormField label="به حساب">
<FormField label="به حساب (بدهکار می‌شود)" hint="مثال: اشخاص، هزینه، صندوق دیگر">
<Select
value={form.watch("to_key")}
onChange={(e) => form.setValue("to_key", e.target.value)}
@ -1134,12 +1182,15 @@ export function TransfersPage() {
<FormField label="توضیح">
<Input {...form.register("description")} />
</FormField>
<p className="text-xs text-[var(--muted)]">
با ذخیره، سند حسابداری خودکار ساخته میشود نیازی به ثبت دستی سند نیست.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
ذخیره و صدور سند
</Button>
</div>
</form>

View File

@ -48,6 +48,11 @@ const formSchema = z.object({
party_id: z.string().optional(),
description: z.string().optional(),
amount: z.string().optional(),
warehouse_id: z.string().optional(),
warehouse_name: z.string().optional(),
credit_mode: z.string().optional(),
source_note: z.string().optional(),
movement_type: z.string().optional(),
lines: z
.array(
z.object({
@ -110,16 +115,37 @@ function sumLines(lines: LineValues[] | undefined): string {
return String(total);
}
function parseMeta(meta: unknown): { lines?: LineValues[] } {
function parseMeta(meta: unknown): {
lines?: LineValues[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
movement_type?: string;
} {
if (!meta) return {};
if (typeof meta === "string") {
try {
return JSON.parse(meta) as { lines?: LineValues[] };
return JSON.parse(meta) as {
lines?: LineValues[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
};
} catch {
return {};
}
}
if (typeof meta === "object") return meta as { lines?: LineValues[] };
if (typeof meta === "object") {
return meta as {
lines?: LineValues[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
};
}
return {};
}
@ -136,6 +162,7 @@ export function OperationalDocumentPage({
partyLabel = "طرف حساب",
kind = "generic",
enableAccountingPost,
enableWarehouseContext = false,
}: {
title: string;
description?: string;
@ -146,10 +173,14 @@ export function OperationalDocumentPage({
kind?: DocKind;
/** When set, draft rows get "ثبت حسابداری" using the matching engine. */
enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return";
/** Show warehouse select separately from party (طرف حساب). */
enableWarehouseContext?: boolean;
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const withLines = kind === "invoice" || kind === "order" || kind === "receipt" || kind === "request";
const showPurchaseCreditMode = enableAccountingPost === "purchase_gr";
const showMovementType = module === "inventory";
const [createOpen, setCreateOpen] = useState(false);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
@ -184,6 +215,11 @@ export function OperationalDocumentPage({
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const warehousesQ = useQuery({
queryKey: ["accounting", tenantId, "warehouses"],
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
enabled: !!tenantId && enableWarehouseContext,
});
const defaults: FormValues = useMemo(
() => ({
@ -193,6 +229,11 @@ export function OperationalDocumentPage({
party_id: "",
description: "",
amount: "0",
warehouse_id: "",
warehouse_name: "",
credit_mode: "supplier",
source_note: "",
movement_type: "in",
// Always an array so useFieldArray stays stable (even when lines UI is hidden).
lines: withLines ? [{ title: "", qty: "1", unit_price: "0", note: "", item_id: "" }] : [],
}),
@ -224,6 +265,19 @@ export function OperationalDocumentPage({
const createM = useMutation({
mutationFn: (d: FormValues) => {
const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0";
const meta: Record<string, unknown> = {};
if (withLines) meta.lines = d.lines ?? [];
if (enableWarehouseContext) {
meta.warehouse_id = d.warehouse_id || undefined;
meta.warehouse_name = d.warehouse_name || undefined;
meta.source_note = d.source_note || undefined;
if (showPurchaseCreditMode) {
meta.credit_mode = d.credit_mode || "supplier";
}
if (showMovementType) {
meta.movement_type = d.movement_type || "in";
}
}
return accountingApi.ops.createDocument(tenantId!, {
module,
doc_type: docType,
@ -234,7 +288,7 @@ export function OperationalDocumentPage({
amount,
description: d.description || undefined,
status: "draft",
meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined,
meta_json: Object.keys(meta).length ? JSON.stringify(meta) : undefined,
});
},
onSuccess: async () => {
@ -249,13 +303,26 @@ export function OperationalDocumentPage({
const updateM = useMutation({
mutationFn: (d: FormValues) => {
const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0";
const meta: Record<string, unknown> = {};
if (withLines) meta.lines = d.lines ?? [];
if (enableWarehouseContext) {
meta.warehouse_id = d.warehouse_id || undefined;
meta.warehouse_name = d.warehouse_name || undefined;
meta.source_note = d.source_note || undefined;
if (showPurchaseCreditMode) {
meta.credit_mode = d.credit_mode || "supplier";
}
if (showMovementType) {
meta.movement_type = d.movement_type || "in";
}
}
return accountingApi.ops.updateDocument(tenantId!, String(editRow!.id), {
doc_date: d.doc_date,
party_name: d.party_name || undefined,
party_id: d.party_id || undefined,
amount,
description: d.description || undefined,
meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined,
meta_json: Object.keys(meta).length ? JSON.stringify(meta) : undefined,
});
},
onSuccess: async () => {
@ -356,6 +423,10 @@ export function OperationalDocumentPage({
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
credit_mode: parseMeta(postRow.meta_json).credit_mode || "supplier",
warehouse_name: parseMeta(postRow.meta_json).warehouse_name || undefined,
source_note: parseMeta(postRow.meta_json).source_note || undefined,
party_name: postRow.party_name ? String(postRow.party_name) : undefined,
});
},
onSuccess: async (data) => {
@ -394,6 +465,11 @@ export function OperationalDocumentPage({
party_id: String(row.party_id ?? ""),
description: String(row.description ?? ""),
amount: String(row.amount ?? "0"),
warehouse_id: meta.warehouse_id ?? "",
warehouse_name: meta.warehouse_name ?? "",
credit_mode: meta.credit_mode ?? "supplier",
source_note: meta.source_note ?? "",
movement_type: meta.movement_type ?? "in",
lines: withLines
? meta.lines?.length
? meta.lines
@ -441,7 +517,7 @@ export function OperationalDocumentPage({
)}
/>
</FormField>
<FormField label={partyLabel}>
<FormField label={partyLabel} hint="شخص یا شرکت طرف معامله (جدا از انبار)">
<PartyCombobox
module={module}
valueName={f.watch("party_name") || undefined}
@ -452,6 +528,68 @@ export function OperationalDocumentPage({
}}
/>
</FormField>
{enableWarehouseContext ? (
<>
{showMovementType ? (
<FormField label="نوع رسید" hint="ورود به انبار یا خروج از انبار">
<Select {...f.register("movement_type")}>
<option value="in">ورود به انبار</option>
<option value="out">خروج از انبار</option>
</Select>
</FormField>
) : null}
<FormField
label={showMovementType ? "انبار" : "انبار مقصد"}
hint="انبار فیزیکی محل ورود/خروج کالا — جدا از طرف حساب"
error={
!(warehousesQ.data ?? []).length
? "هنوز انباری تعریف نشده — از منوی انبار → تنظیمات انبار بسازید"
: undefined
}
>
<Select
value={f.watch("warehouse_id") || ""}
onChange={(e) => {
const id = e.target.value;
const wh = (warehousesQ.data ?? []).find((w) => w.id === id);
f.setValue("warehouse_id", id, { shouldDirty: true });
f.setValue("warehouse_name", wh ? `${wh.code}${wh.name}` : "", { shouldDirty: true });
}}
>
<option value="">انتخاب انبار</option>
{(warehousesQ.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} {w.name}
</option>
))}
</Select>
</FormField>
{showPurchaseCreditMode ? (
<>
<FormField
label="طرف بستانکار (اعتبار)"
hint="تأمین‌کننده = بدهی خرید؛ GRNI = رسید بدون فاکتور"
>
<Select {...f.register("credit_mode")}>
<option value="supplier">تأمینکننده / حساب پرداختنی</option>
<option value="grni">حساب GRNI (رسید در راه)</option>
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField label="مبدأ کالا / توضیح منبع" hint="از کجا آمده؟ (اختیاری)">
<Input placeholder="مثال: سفارش خرید PO-12 / باربری فلان" {...f.register("source_note")} />
</FormField>
</div>
</>
) : (
<div className="sm:col-span-2">
<FormField label="توضیح منبع / مرجع" hint="اختیاری">
<Input placeholder="مثال: حواله / سفارش / توضیح" {...f.register("source_note")} />
</FormField>
</div>
)}
</>
) : null}
{!withLines ? (
<FormField label="مبلغ" error={f.formState.errors.amount?.message}>
<MoneyInput {...f.register("amount")} />
@ -569,6 +707,38 @@ export function OperationalDocumentPage({
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
},
{ key: "party_name", header: partyLabel },
...(enableWarehouseContext
? [
...(showMovementType
? [
{
key: "movement_type",
header: "نوع",
render: (r: Record<string, unknown>) =>
parseMeta(r.meta_json).movement_type === "out" ? "خروج" : "ورود",
},
]
: []),
{
key: "warehouse",
header: "انبار",
render: (r: Record<string, unknown>) =>
parseMeta(r.meta_json).warehouse_name || "—",
},
...(showPurchaseCreditMode
? [
{
key: "credit_mode",
header: "بستانکار",
render: (r: Record<string, unknown>) => {
const mode = parseMeta(r.meta_json).credit_mode;
return mode === "grni" ? "GRNI" : "تأمین‌کننده";
},
},
]
: []),
]
: []),
{
key: "amount",
header: "مبلغ",
@ -665,6 +835,39 @@ export function OperationalDocumentPage({
<span className="text-[var(--muted)]">{partyLabel}:</span>{" "}
{String(detailRow.party_name || "—")}
</p>
{enableWarehouseContext ? (
<>
{showMovementType ? (
<p>
<span className="text-[var(--muted)]">نوع:</span>{" "}
{parseMeta(detailRow.meta_json).movement_type === "out" ? "خروج از انبار" : "ورود به انبار"}
</p>
) : null}
<p>
<span className="text-[var(--muted)]">انبار:</span>{" "}
{parseMeta(detailRow.meta_json).warehouse_name || "—"}
</p>
{showPurchaseCreditMode ? (
<>
<p>
<span className="text-[var(--muted)]">طرف بستانکار:</span>{" "}
{parseMeta(detailRow.meta_json).credit_mode === "grni"
? "حساب GRNI (رسید در راه)"
: "تأمین‌کننده / پرداختنی"}
</p>
<p className="sm:col-span-2">
<span className="text-[var(--muted)]">مبدأ کالا:</span>{" "}
{parseMeta(detailRow.meta_json).source_note || "—"}
</p>
</>
) : parseMeta(detailRow.meta_json).source_note ? (
<p className="sm:col-span-2">
<span className="text-[var(--muted)]">مرجع:</span>{" "}
{parseMeta(detailRow.meta_json).source_note}
</p>
) : null}
</>
) : null}
<p>
<span className="text-[var(--muted)]">مبلغ:</span>{" "}
{formatMoney(String(detailRow.amount ?? "0"))}

View File

@ -108,10 +108,10 @@ export function SpecializedOpsPage({
doc_type: docType,
number: String(v.number || "").trim() || undefined,
doc_date: String(v.doc_date),
party_name: v.party_name || undefined,
party_id: v.party_id || undefined,
party_name: v.party_name ? String(v.party_name) : undefined,
party_id: v.party_id ? String(v.party_id) : undefined,
amount,
description: v.description || undefined,
description: v.description ? String(v.description) : undefined,
status: "draft",
meta_json: JSON.stringify(meta),
});
@ -203,7 +203,14 @@ export function SpecializedOpsPage({
<Controller
control={form.control}
name="doc_date"
render={({ field }) => <DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />}
render={({ field }) => (
<DatePicker
value={String(field.value ?? "")}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
{fields.map((f) => {

View File

@ -857,7 +857,7 @@ export function ComplianceSodPage() {
const schema = z.object({
code: req,
name: req,
rule_type: z.string().default("sod"),
rule_type: req,
condition_config: req,
action_config: z.string().optional(),
});

View File

@ -881,6 +881,7 @@ export const accountingApi = {
to_type: string;
to_id: string;
description: string | null;
voucher_id?: string | null;
}[]
>("/api/v1/treasury/transfers", { tenantId }),
createTransfer: (
@ -895,11 +896,14 @@ export const accountingApi = {
description?: string;
}
) =>
request<{ id: string }>("/api/v1/treasury/transfers", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
request<{ id: string; voucher_id?: string | null; voucher_number?: string | null }>(
"/api/v1/treasury/transfers",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}
),
listReceipts: (tenantId: string) =>
request<
{
@ -1009,9 +1013,11 @@ export const accountingApi = {
number: string;
doc_date: string;
party_name: string | null;
party_id?: string | null;
amount: string;
status: string;
description: string | null;
meta_json?: string | null;
}[]
>(`/api/v1/ops/documents${qs ? `?${qs}` : ""}`, { tenantId });
},
@ -1730,7 +1736,16 @@ export const accountingApi = {
),
postGoodsReceipt: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
body: {
source_document_id: string;
amount: string;
profile_id?: string;
credit_mode?: string;
credit_account_id?: string;
warehouse_name?: string;
source_note?: string;
party_name?: string;
}
) =>
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/goods-receipt",
@ -1762,7 +1777,16 @@ export const accountingApi = {
),
postPurchaseReturn: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
body: {
source_document_id: string;
amount: string;
profile_id?: string;
source_invoice_id?: string;
source_invoice_number?: string;
party_id?: string;
party_name?: string;
lines_summary?: string;
}
) =>
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/purchase-return",
@ -1772,6 +1796,27 @@ export const accountingApi = {
body: JSON.stringify(body),
}
),
postInventoryIssue: (
tenantId: string,
body: {
source_document_id: string;
amount: string;
reason: string;
debit_account_id: string;
credit_account_id?: string;
profile_id?: string;
barter_debit_account_id?: string;
barter_amount?: string;
}
) =>
request<{ voucher_id: string; voucher_number?: string; status: string }>(
"/api/v1/purchase-inventory/post/inventory-issue",
{
tenantId,
method: "POST",
body: JSON.stringify(body),
}
),
valuation: (
tenantId: string,
body: {

View File

@ -57,7 +57,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
{ href: "/accounting/treasury/receipts-payments", label: "دریافت و پرداخت" },
{ href: "/accounting/treasury/cash-boxes", label: "صندوق‌ها" },
{ href: "/accounting/treasury/banks", label: "بانک‌ها" },
{ href: "/accounting/treasury/transfers", label: "انتقال وجه" },
{ href: "/accounting/treasury/transfers", label: "انتقال بین حساب‌ها" },
{ href: "/accounting/treasury/reconciliation", label: "تطبیق بانکی" },
{ href: "/accounting/treasury/cheques", label: "چک‌ها" },
{ href: "/accounting/treasury/cheques/new", label: "ثبت چک" },
@ -170,6 +170,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
{ href: "/accounting/inventory/settings", label: "تنظیمات انبار" },
{ href: "/accounting/inventory/transfers", label: "انتقال انبار" },
{ href: "/accounting/inventory/receipts-issues", label: "رسید ورود و خروج" },
{ href: "/accounting/inventory/issues", label: "خروج / هزینه‌کرد کالا" },
{ href: "/accounting/inventory/adjustments", label: "تعدیل موجودی" },
{ href: "/accounting/inventory/reports", label: "گزارش‌های انبار" },
],