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:
parent
320d9cd547
commit
97a363fc53
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
342
frontend/app/accounting/inventory/issues/page.tsx
Normal file
342
frontend/app/accounting/inventory/issues/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,11 +6,13 @@ export default function Page() {
|
||||
return (
|
||||
<OperationalDocumentPage
|
||||
title="رسید ورود و خروج"
|
||||
description="طرف حساب و انبار جداگانه مشخص میشوند — ورود یا خروج کالا به/از انبار."
|
||||
createLabel="رسید جدید"
|
||||
module="inventory"
|
||||
docType="receipt_issue"
|
||||
partyLabel="انبار"
|
||||
partyLabel="طرف حساب"
|
||||
kind="receipt"
|
||||
enableWarehouseContext
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: "ثبت خودکار استهلاک دورهای" },
|
||||
];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"))}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: "گزارشهای انبار" },
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user