Add automatic GL posting policy with inline voucher balance validation.
Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7953e47f8b
commit
d92e1df332
@ -171,12 +171,115 @@ async def confirm_document(
|
||||
doc_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Confirm ops document; when posting-policy auto_post is on, also post to GL."""
|
||||
from decimal import Decimal
|
||||
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
|
||||
from app.services.sales_accounting_service import SalesAccountingService
|
||||
from app.models.types import SalesDocumentType
|
||||
from shared.exceptions import AppError
|
||||
|
||||
entity = await BizDocRepo(db).get(tenant_id, doc_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
|
||||
entity.status = "confirmed"
|
||||
|
||||
policy_svc = PostingPolicyService(db)
|
||||
policy = await policy_svc.get_policy(tenant_id)
|
||||
should_auto = policy_svc.is_auto_post_enabled(policy, entity.module, entity.doc_type)
|
||||
|
||||
voucher_info: dict | None = None
|
||||
if should_auto:
|
||||
amount = Decimal(str(entity.amount or 0))
|
||||
if amount <= 0:
|
||||
raise AppError(
|
||||
"برای ثبت خودکار حسابداری مبلغ سند باید بزرگتر از صفر باشد",
|
||||
status_code=422,
|
||||
error_code="auto_post_amount_required",
|
||||
)
|
||||
try:
|
||||
if entity.module == "sales" and entity.doc_type in ("invoice", "return"):
|
||||
doc_type = (
|
||||
SalesDocumentType.RETURN
|
||||
if entity.doc_type == "return"
|
||||
else SalesDocumentType.INVOICE
|
||||
)
|
||||
voucher = await SalesAccountingService(db).post_sales_invoice(
|
||||
tenant_id,
|
||||
document_type=doc_type,
|
||||
source_document_id=str(entity.id),
|
||||
total_amount=amount,
|
||||
actor_user_id=user.user_id,
|
||||
)
|
||||
voucher_info = {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
}
|
||||
elif entity.module == "purchase" and entity.doc_type in (
|
||||
"goods_receipt",
|
||||
"goods-receipts",
|
||||
):
|
||||
voucher = await PurchaseInventoryAccountingService(db).post_goods_receipt(
|
||||
tenant_id,
|
||||
source_document_id=str(entity.id),
|
||||
amount=amount,
|
||||
actor_user_id=user.user_id,
|
||||
)
|
||||
voucher_info = {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
}
|
||||
elif entity.module == "purchase" and entity.doc_type == "invoice":
|
||||
voucher = await PurchaseInventoryAccountingService(db).post_purchase_invoice(
|
||||
tenant_id,
|
||||
source_document_id=str(entity.id),
|
||||
amount=amount,
|
||||
actor_user_id=user.user_id,
|
||||
)
|
||||
voucher_info = {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
}
|
||||
elif entity.module == "purchase" and entity.doc_type == "return":
|
||||
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
|
||||
tenant_id,
|
||||
source_document_id=str(entity.id),
|
||||
amount=amount,
|
||||
actor_user_id=user.user_id,
|
||||
)
|
||||
voucher_info = {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
}
|
||||
else:
|
||||
# Policy on but no engine mapping — leave as confirmed only
|
||||
voucher_info = None
|
||||
except AppError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise AppError(
|
||||
f"ثبت خودکار حسابداری ناموفق بود: {exc}",
|
||||
status_code=422,
|
||||
error_code="auto_post_failed",
|
||||
) from exc
|
||||
|
||||
entity.status = "posted" if voucher_info else "confirmed"
|
||||
if voucher_info and entity.meta_json:
|
||||
import json
|
||||
|
||||
try:
|
||||
meta = json.loads(entity.meta_json)
|
||||
except Exception:
|
||||
meta = {}
|
||||
meta["accounting_voucher"] = voucher_info
|
||||
entity.meta_json = json.dumps(meta, ensure_ascii=False)
|
||||
elif voucher_info:
|
||||
import json
|
||||
|
||||
entity.meta_json = json.dumps({"accounting_voucher": voucher_info}, ensure_ascii=False)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entity)
|
||||
return entity
|
||||
|
||||
@ -27,6 +27,45 @@ from shared.security import CurrentUser
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _assert_lines_balanced(lines: list) -> None:
|
||||
from decimal import Decimal
|
||||
|
||||
if len(lines) < 2:
|
||||
raise AppError(
|
||||
"حداقل دو ردیف برای سند لازم است",
|
||||
status_code=422,
|
||||
error_code="voucher_lines_min",
|
||||
)
|
||||
total_debit = Decimal("0")
|
||||
total_credit = Decimal("0")
|
||||
for i, line in enumerate(lines, start=1):
|
||||
data = line.model_dump() if hasattr(line, "model_dump") else dict(line)
|
||||
debit = Decimal(str(data.get("debit") or 0))
|
||||
credit = Decimal(str(data.get("credit") or 0))
|
||||
if debit > 0 and credit > 0:
|
||||
raise AppError(
|
||||
f"ردیف {i}: نمیتوان همزمان بدهکار و بستانکار داشت",
|
||||
status_code=422,
|
||||
error_code="voucher_line_both_sides",
|
||||
)
|
||||
if debit == 0 and credit == 0:
|
||||
raise AppError(
|
||||
f"ردیف {i}: یکی از بدهکار یا بستانکار باید مقدار داشته باشد",
|
||||
status_code=422,
|
||||
error_code="voucher_line_empty",
|
||||
)
|
||||
total_debit += debit
|
||||
total_credit += credit
|
||||
if total_debit != total_credit:
|
||||
diff = abs(total_debit - total_credit)
|
||||
raise AppError(
|
||||
f"سند نامتوازن است — جمع بدهکار {total_debit} و بستانکار {total_credit} "
|
||||
f"(اختلاف {diff}). ثبت مجاز نیست.",
|
||||
status_code=422,
|
||||
error_code="voucher_unbalanced",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vouchers", response_model=list[VoucherRead])
|
||||
async def list_vouchers(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
@ -58,6 +97,7 @@ async def create_voucher(
|
||||
):
|
||||
from app.services.document_number_service import DocumentNumberService
|
||||
|
||||
_assert_lines_balanced(body.lines)
|
||||
repo = VoucherRepository(db)
|
||||
number = await DocumentNumberService(db).allocate_if_blank(
|
||||
tenant_id, "voucher", body.voucher_number
|
||||
@ -120,6 +160,8 @@ async def update_draft_voucher(
|
||||
)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
lines_data = data.pop("lines", None)
|
||||
if lines_data is not None:
|
||||
_assert_lines_balanced(lines_data)
|
||||
for key, value in data.items():
|
||||
setattr(voucher, key, value)
|
||||
if lines_data is not None:
|
||||
|
||||
@ -98,7 +98,70 @@ async def post_goods_receipt(
|
||||
profile_id=body.profile_id,
|
||||
)
|
||||
await db.commit()
|
||||
return {"voucher_id": str(voucher.id), "status": voucher.status.value}
|
||||
return {"voucher_id": str(voucher.id), "voucher_number": voucher.voucher_number, "status": voucher.status.value}
|
||||
|
||||
|
||||
@router.post("/preview/purchase-invoice")
|
||||
async def preview_purchase_invoice(
|
||||
body: GoodsReceiptRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
svc = PurchaseInventoryAccountingService(db)
|
||||
preview = await svc.preview_purchase_invoice(tenant_id, body.amount, body.profile_id)
|
||||
await db.commit()
|
||||
return {
|
||||
"is_balanced": preview.is_balanced,
|
||||
"total_debit": str(preview.total_debit),
|
||||
"total_credit": str(preview.total_credit),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/post/purchase-invoice")
|
||||
async def post_purchase_invoice(
|
||||
body: GoodsReceiptRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
svc = PurchaseInventoryAccountingService(db)
|
||||
voucher = await svc.post_purchase_invoice(
|
||||
tenant_id,
|
||||
source_document_id=body.source_document_id,
|
||||
amount=body.amount,
|
||||
actor_user_id=user.user_id,
|
||||
profile_id=body.profile_id,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
"status": voucher.status.value,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/post/purchase-return")
|
||||
async def post_purchase_return(
|
||||
body: GoodsReceiptRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
svc = PurchaseInventoryAccountingService(db)
|
||||
voucher = await svc.post_purchase_return(
|
||||
tenant_id,
|
||||
source_document_id=body.source_document_id,
|
||||
amount=body.amount,
|
||||
actor_user_id=user.user_id,
|
||||
profile_id=body.profile_id,
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"voucher_id": str(voucher.id),
|
||||
"voucher_number": voucher.voucher_number,
|
||||
"status": voucher.status.value,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/valuation")
|
||||
|
||||
@ -108,3 +108,40 @@ async def allocate_number(
|
||||
number = await svc.allocate(tenant_id, body.document_type)
|
||||
await db.commit()
|
||||
return {"document_type": body.document_type, "number": number}
|
||||
|
||||
|
||||
class PostingPolicyUpdate(BaseModel):
|
||||
auto_post: dict[str, bool] | None = None
|
||||
require_balanced: bool | None = None
|
||||
require_source_reference: bool | None = None
|
||||
lock_after_post: bool | None = None
|
||||
sequential_numbers: bool | None = None
|
||||
idempotent_source: bool | None = None
|
||||
posting_mode: str | None = None
|
||||
|
||||
|
||||
@router.get("/posting-policy")
|
||||
async def get_posting_policy(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
|
||||
return await PostingPolicyService(db).get_policy(tenant_id)
|
||||
|
||||
|
||||
@router.put("/posting-policy")
|
||||
async def update_posting_policy(
|
||||
body: PostingPolicyUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
|
||||
policy = await PostingPolicyService(db).update_policy(
|
||||
tenant_id, body.model_dump(exclude_none=True)
|
||||
)
|
||||
await db.commit()
|
||||
return policy
|
||||
|
||||
@ -260,6 +260,7 @@ class ReceiptCreate(BaseModel):
|
||||
payment_method: str = "cash"
|
||||
cash_box_id: UUID | None = None
|
||||
bank_account_id: UUID | None = None
|
||||
counter_account_id: UUID | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@ -270,6 +271,7 @@ class PaymentCreate(BaseModel):
|
||||
payment_method: str = "cash"
|
||||
cash_box_id: UUID | None = None
|
||||
bank_account_id: UUID | None = None
|
||||
counter_account_id: UUID | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@ -365,6 +367,7 @@ async def list_receipts(
|
||||
"amount": str(r.amount),
|
||||
"payment_method": r.payment_method,
|
||||
"description": r.description,
|
||||
"voucher_id": str(r.voucher_id) if r.voucher_id else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@ -375,18 +378,72 @@ async def create_receipt(
|
||||
body: ReceiptCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
from app.services.document_number_service import DocumentNumberService
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
from app.services.source_posting_guard import SourcePostingGuard
|
||||
from shared.exceptions import AppError
|
||||
|
||||
data = body.model_dump()
|
||||
counter_account_id = data.pop("counter_account_id", None)
|
||||
data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank(
|
||||
tenant_id, "receipt", body.receipt_number
|
||||
)
|
||||
entity = ReceiptVoucher(tenant_id=tenant_id, **data)
|
||||
await ReceiptRepo(db).add(entity)
|
||||
|
||||
policy = await PostingPolicyService(db).get_policy(tenant_id)
|
||||
voucher_id = None
|
||||
voucher_number = None
|
||||
if policy.get("auto_post", {}).get("treasury_receipt", True):
|
||||
if counter_account_id is None:
|
||||
raise AppError(
|
||||
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بستانکار) الزامی است",
|
||||
status_code=422,
|
||||
error_code="counter_account_required",
|
||||
)
|
||||
treasury_account_id = await _resolve_treasury_gl_account(
|
||||
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
|
||||
)
|
||||
guard = SourcePostingGuard(db)
|
||||
await guard.assert_can_post(
|
||||
tenant_id,
|
||||
source_module="treasury",
|
||||
source_document_type="receipt",
|
||||
source_document_id=str(entity.id),
|
||||
)
|
||||
svc = TreasuryService(db)
|
||||
voucher = await svc.post_receipt_payment_voucher(
|
||||
tenant_id,
|
||||
amount=body.amount,
|
||||
treasury_account_id=treasury_account_id,
|
||||
counter_account_id=counter_account_id,
|
||||
is_receipt=True,
|
||||
actor_user_id=user.user_id,
|
||||
description=body.description or f"دریافت {entity.receipt_number}",
|
||||
transaction_date=body.receipt_date,
|
||||
reference=entity.receipt_number,
|
||||
cash_box_id=body.cash_box_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="receipt",
|
||||
source_document_id=str(entity.id),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"id": str(entity.id), "receipt_number": entity.receipt_number}
|
||||
return {
|
||||
"id": str(entity.id),
|
||||
"receipt_number": entity.receipt_number,
|
||||
"voucher_id": voucher_id,
|
||||
"voucher_number": voucher_number,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payments")
|
||||
@ -404,6 +461,7 @@ async def list_payments(
|
||||
"amount": str(p.amount),
|
||||
"payment_method": p.payment_method,
|
||||
"description": p.description,
|
||||
"voucher_id": str(p.voucher_id) if p.voucher_id else None,
|
||||
}
|
||||
for p in rows
|
||||
]
|
||||
@ -414,18 +472,113 @@ async def create_payment(
|
||||
body: PaymentCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
from app.services.document_number_service import DocumentNumberService
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
from app.services.source_posting_guard import SourcePostingGuard
|
||||
from shared.exceptions import AppError
|
||||
|
||||
data = body.model_dump()
|
||||
counter_account_id = data.pop("counter_account_id", None)
|
||||
data["payment_number"] = await DocumentNumberService(db).allocate_if_blank(
|
||||
tenant_id, "payment", body.payment_number
|
||||
)
|
||||
entity = PaymentVoucher(tenant_id=tenant_id, **data)
|
||||
await PaymentRepo(db).add(entity)
|
||||
|
||||
policy = await PostingPolicyService(db).get_policy(tenant_id)
|
||||
voucher_id = None
|
||||
voucher_number = None
|
||||
if policy.get("auto_post", {}).get("treasury_payment", True):
|
||||
if counter_account_id is None:
|
||||
raise AppError(
|
||||
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بدهکار) الزامی است",
|
||||
status_code=422,
|
||||
error_code="counter_account_required",
|
||||
)
|
||||
treasury_account_id = await _resolve_treasury_gl_account(
|
||||
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
|
||||
)
|
||||
guard = SourcePostingGuard(db)
|
||||
await guard.assert_can_post(
|
||||
tenant_id,
|
||||
source_module="treasury",
|
||||
source_document_type="payment",
|
||||
source_document_id=str(entity.id),
|
||||
)
|
||||
svc = TreasuryService(db)
|
||||
voucher = await svc.post_receipt_payment_voucher(
|
||||
tenant_id,
|
||||
amount=body.amount,
|
||||
treasury_account_id=treasury_account_id,
|
||||
counter_account_id=counter_account_id,
|
||||
is_receipt=False,
|
||||
actor_user_id=user.user_id,
|
||||
description=body.description or f"پرداخت {entity.payment_number}",
|
||||
transaction_date=body.payment_date,
|
||||
reference=entity.payment_number,
|
||||
cash_box_id=body.cash_box_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="payment",
|
||||
source_document_id=str(entity.id),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"id": str(entity.id), "payment_number": entity.payment_number}
|
||||
return {
|
||||
"id": str(entity.id),
|
||||
"payment_number": entity.payment_number,
|
||||
"voucher_id": voucher_id,
|
||||
"voucher_number": voucher_number,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_treasury_gl_account(
|
||||
db: AsyncSession,
|
||||
tenant_id: UUID,
|
||||
payment_method: str,
|
||||
cash_box_id: UUID | None,
|
||||
bank_account_id: UUID | None,
|
||||
) -> UUID:
|
||||
from shared.exceptions import AppError
|
||||
|
||||
if payment_method == "cash" or cash_box_id:
|
||||
if cash_box_id is None:
|
||||
raise AppError(
|
||||
"برای دریافت/پرداخت نقدی انتخاب صندوق الزامی است",
|
||||
status_code=422,
|
||||
error_code="cash_box_required",
|
||||
)
|
||||
box = await CashBoxRepo(db).get(tenant_id, cash_box_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 bank_account_id is None:
|
||||
raise AppError(
|
||||
"برای حواله بانکی انتخاب حساب بانکی الزامی است",
|
||||
status_code=422,
|
||||
error_code="bank_account_required",
|
||||
)
|
||||
bank = await BankAccountRepo(db).get(tenant_id, bank_account_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
|
||||
|
||||
|
||||
@router.get("/reconciliations")
|
||||
|
||||
@ -0,0 +1,149 @@
|
||||
"""Tenant posting policy for automatic GL documents (intl. controls)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.foundation import AccountingSettings
|
||||
|
||||
# Defaults follow conservative international practice:
|
||||
# balanced vouchers, immutable posted docs, sequential numbers, source refs,
|
||||
# idempotent re-post prevention. Auto-post modules default OFF until enabled.
|
||||
DEFAULT_POLICY: dict[str, str] = {
|
||||
"auto_post.sales.invoice": "false",
|
||||
"auto_post.sales.return": "false",
|
||||
"auto_post.purchase.goods_receipt": "false",
|
||||
"auto_post.purchase.invoice": "false",
|
||||
"auto_post.purchase.return": "false",
|
||||
"auto_post.treasury.receipt": "true",
|
||||
"auto_post.treasury.payment": "true",
|
||||
"auto_post.payroll": "false",
|
||||
"auto_post.assets.depreciation": "false",
|
||||
"policy.require_balanced": "true",
|
||||
"policy.require_source_reference": "true",
|
||||
"policy.lock_after_post": "true",
|
||||
"policy.sequential_numbers": "true",
|
||||
"policy.idempotent_source": "true",
|
||||
"policy.posting_mode": "direct_post", # direct_post | draft_then_post
|
||||
}
|
||||
|
||||
BOOL_TRUE = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _as_bool(value: str | None, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in BOOL_TRUE
|
||||
|
||||
|
||||
class PostingPolicyService:
|
||||
PREFIX = "posting."
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_policy(self, tenant_id: UUID) -> dict:
|
||||
stmt = select(AccountingSettings).where(
|
||||
AccountingSettings.tenant_id == tenant_id,
|
||||
AccountingSettings.key.like(f"{self.PREFIX}%"),
|
||||
)
|
||||
rows = list((await self.session.execute(stmt)).scalars().all())
|
||||
stored = {r.key[len(self.PREFIX) :]: (r.value or "") for r in rows}
|
||||
merged = {**DEFAULT_POLICY, **stored}
|
||||
|
||||
auto_post = {
|
||||
"sales_invoice": _as_bool(merged.get("auto_post.sales.invoice")),
|
||||
"sales_return": _as_bool(merged.get("auto_post.sales.return")),
|
||||
"purchase_goods_receipt": _as_bool(merged.get("auto_post.purchase.goods_receipt")),
|
||||
"purchase_invoice": _as_bool(merged.get("auto_post.purchase.invoice")),
|
||||
"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),
|
||||
"payroll": _as_bool(merged.get("auto_post.payroll")),
|
||||
"assets_depreciation": _as_bool(merged.get("auto_post.assets.depreciation")),
|
||||
}
|
||||
return {
|
||||
"auto_post": auto_post,
|
||||
"require_balanced": _as_bool(merged.get("policy.require_balanced"), True),
|
||||
"require_source_reference": _as_bool(
|
||||
merged.get("policy.require_source_reference"), True
|
||||
),
|
||||
"lock_after_post": _as_bool(merged.get("policy.lock_after_post"), True),
|
||||
"sequential_numbers": _as_bool(merged.get("policy.sequential_numbers"), True),
|
||||
"idempotent_source": _as_bool(merged.get("policy.idempotent_source"), True),
|
||||
"posting_mode": merged.get("policy.posting_mode") or "direct_post",
|
||||
"raw": merged,
|
||||
}
|
||||
|
||||
async def update_policy(self, tenant_id: UUID, body: dict) -> dict:
|
||||
updates: dict[str, str] = {}
|
||||
auto = body.get("auto_post") or {}
|
||||
mapping = {
|
||||
"sales_invoice": "auto_post.sales.invoice",
|
||||
"sales_return": "auto_post.sales.return",
|
||||
"purchase_goods_receipt": "auto_post.purchase.goods_receipt",
|
||||
"purchase_invoice": "auto_post.purchase.invoice",
|
||||
"purchase_return": "auto_post.purchase.return",
|
||||
"treasury_receipt": "auto_post.treasury.receipt",
|
||||
"treasury_payment": "auto_post.treasury.payment",
|
||||
"payroll": "auto_post.payroll",
|
||||
"assets_depreciation": "auto_post.assets.depreciation",
|
||||
}
|
||||
for fe_key, store_key in mapping.items():
|
||||
if fe_key in auto:
|
||||
updates[store_key] = "true" if bool(auto[fe_key]) else "false"
|
||||
|
||||
for key in (
|
||||
"require_balanced",
|
||||
"require_source_reference",
|
||||
"lock_after_post",
|
||||
"sequential_numbers",
|
||||
"idempotent_source",
|
||||
):
|
||||
if key in body:
|
||||
updates[f"policy.{key}"] = "true" if bool(body[key]) else "false"
|
||||
if "posting_mode" in body and body["posting_mode"] in ("direct_post", "draft_then_post"):
|
||||
updates["policy.posting_mode"] = str(body["posting_mode"])
|
||||
|
||||
for short_key, value in updates.items():
|
||||
full_key = f"{self.PREFIX}{short_key}"
|
||||
stmt = select(AccountingSettings).where(
|
||||
AccountingSettings.tenant_id == tenant_id,
|
||||
AccountingSettings.key == full_key,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = AccountingSettings(
|
||||
tenant_id=tenant_id,
|
||||
key=full_key,
|
||||
value=value,
|
||||
value_type="boolean" if short_key.startswith("auto_post") or short_key.startswith("policy.") and short_key != "policy.posting_mode" else "string",
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.value = value
|
||||
await self.session.flush()
|
||||
return await self.get_policy(tenant_id)
|
||||
|
||||
def is_auto_post_enabled(self, policy: dict, module: str, doc_type: str) -> bool:
|
||||
auto = policy.get("auto_post") or {}
|
||||
key = f"{module}.{doc_type}".replace("-", "_")
|
||||
aliases = {
|
||||
"sales.invoice": "sales_invoice",
|
||||
"sales.return": "sales_return",
|
||||
"purchase.goods_receipt": "purchase_goods_receipt",
|
||||
"purchase.invoice": "purchase_invoice",
|
||||
"purchase.return": "purchase_return",
|
||||
"treasury.receipt": "treasury_receipt",
|
||||
"treasury.payment": "treasury_payment",
|
||||
"payroll.payroll": "payroll",
|
||||
"assets.depreciation": "assets_depreciation",
|
||||
}
|
||||
mapped = aliases.get(key) or aliases.get(f"{module}.{doc_type}")
|
||||
if mapped:
|
||||
return bool(auto.get(mapped))
|
||||
# snake variants
|
||||
snake = key.replace(".", "_")
|
||||
return bool(auto.get(snake))
|
||||
@ -114,6 +114,16 @@ class PurchaseInventoryAccountingService:
|
||||
) -> Voucher:
|
||||
from datetime import date
|
||||
|
||||
from app.services.source_posting_guard import SourcePostingGuard
|
||||
|
||||
guard = SourcePostingGuard(self.session)
|
||||
await guard.assert_can_post(
|
||||
tenant_id,
|
||||
source_module="purchase",
|
||||
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
|
||||
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||
period = await self.period_repo.get_current(tenant_id)
|
||||
if period is None:
|
||||
@ -142,9 +152,217 @@ class PurchaseInventoryAccountingService:
|
||||
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
|
||||
))
|
||||
await self.session.flush()
|
||||
return await self.posting_engine.post_voucher(
|
||||
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="purchase"
|
||||
)
|
||||
await guard.mark_posted(
|
||||
tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
source_module="purchase",
|
||||
source_document_type=PurchaseDocumentType.GOODS_RECEIPT.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
return voucher
|
||||
|
||||
async def preview_purchase_invoice(
|
||||
self, tenant_id: UUID, amount: Decimal, profile_id: UUID | None = None
|
||||
) -> AccountingPreview:
|
||||
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||
lines = []
|
||||
# Clear GRNI / expense and credit AP liability
|
||||
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
|
||||
credit_account = profile.liability_account_id
|
||||
if debit_account:
|
||||
lines.append({"account_id": str(debit_account), "debit": str(amount), "credit": "0"})
|
||||
if credit_account:
|
||||
lines.append({"account_id": str(credit_account), "debit": "0", "credit": str(amount)})
|
||||
total_debit = sum(Decimal(l["debit"]) for l in lines)
|
||||
total_credit = sum(Decimal(l["credit"]) for l in lines)
|
||||
preview = AccountingPreview(
|
||||
tenant_id=tenant_id,
|
||||
source_module="purchase",
|
||||
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
|
||||
source_document_id="preview",
|
||||
preview_data=json.dumps(lines),
|
||||
is_balanced=total_debit == total_credit and len(lines) >= 2,
|
||||
total_debit=total_debit,
|
||||
total_credit=total_credit,
|
||||
)
|
||||
self.session.add(preview)
|
||||
await self.session.flush()
|
||||
return preview
|
||||
|
||||
async def post_purchase_invoice(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
source_document_id: str,
|
||||
amount: Decimal,
|
||||
actor_user_id: str,
|
||||
profile_id: UUID | 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(
|
||||
tenant_id,
|
||||
source_module="purchase",
|
||||
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
|
||||
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||
period = await self.period_repo.get_current(tenant_id)
|
||||
if period is None:
|
||||
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||
|
||||
debit_account = profile.grni_account_id or profile.expense_account_id or profile.inventory_account_id
|
||||
credit_account = profile.liability_account_id
|
||||
if not debit_account or not credit_account:
|
||||
raise AppError(
|
||||
"پروفایل خرید ناقص است: حساب بدهکار (GRNI/هزینه) و بستانکار (بدهی) لازم است",
|
||||
status_code=422,
|
||||
error_code="purchase_profile_incomplete",
|
||||
)
|
||||
|
||||
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"Purchase invoice {source_document_id}",
|
||||
source_module="purchase",
|
||||
reference_number=source_document_id,
|
||||
created_by=actor_user_id,
|
||||
)
|
||||
await self.voucher_repo.add(voucher)
|
||||
self.session.add(
|
||||
VoucherLine(
|
||||
tenant_id=tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
line_number=1,
|
||||
account_id=debit_account,
|
||||
debit=amount,
|
||||
credit=Decimal("0"),
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
VoucherLine(
|
||||
tenant_id=tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
line_number=2,
|
||||
account_id=credit_account,
|
||||
debit=Decimal("0"),
|
||||
credit=amount,
|
||||
)
|
||||
)
|
||||
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="purchase"
|
||||
)
|
||||
await guard.mark_posted(
|
||||
tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
source_module="purchase",
|
||||
source_document_type=PurchaseDocumentType.SUPPLIER_INVOICE.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
return voucher
|
||||
|
||||
async def post_purchase_return(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
source_document_id: str,
|
||||
amount: Decimal,
|
||||
actor_user_id: str,
|
||||
profile_id: UUID | None = None,
|
||||
) -> Voucher:
|
||||
"""Reverse of supplier invoice: Dr AP liability / Cr inventory or GRNI."""
|
||||
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
|
||||
|
||||
doc_type_value = PurchaseDocumentType.PURCHASE_RETURN.value
|
||||
|
||||
guard = SourcePostingGuard(self.session)
|
||||
await guard.assert_can_post(
|
||||
tenant_id,
|
||||
source_module="purchase",
|
||||
source_document_type=doc_type_value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
|
||||
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||
period = await self.period_repo.get_current(tenant_id)
|
||||
if period is None:
|
||||
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||
|
||||
debit_account = profile.liability_account_id
|
||||
credit_account = profile.inventory_account_id or profile.grni_account_id or profile.expense_account_id
|
||||
if not debit_account or not credit_account:
|
||||
raise AppError(
|
||||
"پروفایل خرید ناقص است: حساب بدهی و موجودی/GRNI برای مرجوعی لازم است",
|
||||
status_code=422,
|
||||
error_code="purchase_profile_incomplete",
|
||||
)
|
||||
|
||||
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"Purchase return {source_document_id}",
|
||||
source_module="purchase",
|
||||
reference_number=source_document_id,
|
||||
created_by=actor_user_id,
|
||||
)
|
||||
await self.voucher_repo.add(voucher)
|
||||
self.session.add(
|
||||
VoucherLine(
|
||||
tenant_id=tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
line_number=1,
|
||||
account_id=debit_account,
|
||||
debit=amount,
|
||||
credit=Decimal("0"),
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
VoucherLine(
|
||||
tenant_id=tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
line_number=2,
|
||||
account_id=credit_account,
|
||||
debit=Decimal("0"),
|
||||
credit=amount,
|
||||
)
|
||||
)
|
||||
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="purchase"
|
||||
)
|
||||
await guard.mark_posted(
|
||||
tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
source_module="purchase",
|
||||
source_document_type=doc_type_value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
return voucher
|
||||
|
||||
async def record_valuation(
|
||||
self,
|
||||
|
||||
@ -90,6 +90,16 @@ class SalesAccountingService:
|
||||
) -> Voucher:
|
||||
from datetime import date
|
||||
|
||||
from app.services.source_posting_guard import SourcePostingGuard
|
||||
|
||||
guard = SourcePostingGuard(self.session)
|
||||
await guard.assert_can_post(
|
||||
tenant_id,
|
||||
source_module="sales",
|
||||
source_document_type=document_type.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
|
||||
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
|
||||
period = await self.period_repo.get_current(tenant_id)
|
||||
if period is None:
|
||||
@ -138,9 +148,18 @@ class SalesAccountingService:
|
||||
))
|
||||
|
||||
await self.session.flush()
|
||||
return await self.posting_engine.post_voucher(
|
||||
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="sales"
|
||||
)
|
||||
await guard.mark_posted(
|
||||
tenant_id,
|
||||
voucher_id=voucher.id,
|
||||
source_module="sales",
|
||||
source_document_type=document_type.value,
|
||||
source_document_id=source_document_id,
|
||||
)
|
||||
return voucher
|
||||
|
||||
async def _resolve_profile(
|
||||
self, tenant_id: UUID, document_type: SalesDocumentType, profile_id: UUID | None
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
"""Idempotent source-document posting guard (intl. no double-post)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.posting import PostingReference
|
||||
from app.repositories.posting import PostingReferenceRepository
|
||||
from app.services.posting_policy_service import PostingPolicyService
|
||||
from shared.exceptions import AppError
|
||||
|
||||
|
||||
class SourcePostingGuard:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.ref_repo = PostingReferenceRepository(session)
|
||||
self.policy_svc = PostingPolicyService(session)
|
||||
|
||||
async def assert_can_post(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
source_module: str,
|
||||
source_document_type: str,
|
||||
source_document_id: str,
|
||||
) -> None:
|
||||
policy = await self.policy_svc.get_policy(tenant_id)
|
||||
if not policy.get("idempotent_source", True):
|
||||
return
|
||||
existing = await self.ref_repo.get_by_source(
|
||||
tenant_id, source_module, source_document_type, source_document_id
|
||||
)
|
||||
if existing is not None and existing.is_posted:
|
||||
raise AppError(
|
||||
"این سند مبدأ قبلاً در دفتر کل ثبت شده است (جلوگیری از ثبت تکراری).",
|
||||
status_code=409,
|
||||
error_code="source_already_posted",
|
||||
)
|
||||
|
||||
async def mark_posted(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
voucher_id: UUID,
|
||||
source_module: str,
|
||||
source_document_type: str,
|
||||
source_document_id: str,
|
||||
) -> PostingReference:
|
||||
existing = await self.ref_repo.get_by_source(
|
||||
tenant_id, source_module, source_document_type, source_document_id
|
||||
)
|
||||
if existing is None:
|
||||
ref = PostingReference(
|
||||
tenant_id=tenant_id,
|
||||
voucher_id=voucher_id,
|
||||
source_module=source_module,
|
||||
source_document_type=source_document_type,
|
||||
source_document_id=source_document_id,
|
||||
is_posted=True,
|
||||
)
|
||||
return await self.ref_repo.add(ref)
|
||||
existing.voucher_id = voucher_id
|
||||
existing.is_posted = True
|
||||
await self.session.flush()
|
||||
return existing
|
||||
|
||||
async def should_post_immediately(self, tenant_id: UUID) -> bool:
|
||||
policy = await self.policy_svc.get_policy(tenant_id)
|
||||
return (policy.get("posting_mode") or "direct_post") != "draft_then_post"
|
||||
@ -132,6 +132,63 @@ class TreasuryService:
|
||||
cash_box.current_balance -= amount
|
||||
return tx
|
||||
|
||||
async def post_receipt_payment_voucher(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
amount: Decimal,
|
||||
treasury_account_id: UUID,
|
||||
counter_account_id: UUID,
|
||||
is_receipt: bool,
|
||||
actor_user_id: str,
|
||||
description: str,
|
||||
transaction_date: date | None = None,
|
||||
reference: str | None = None,
|
||||
cash_box_id: UUID | None = None,
|
||||
) -> Voucher:
|
||||
"""Post GL for treasury receipt (Dr treasury / Cr counter) or payment (Dr counter / Cr treasury)."""
|
||||
from app.services.document_number_service import DocumentNumberService
|
||||
from app.services.source_posting_guard import SourcePostingGuard
|
||||
|
||||
period = await self.period_repo.get_current(tenant_id)
|
||||
if period is None:
|
||||
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
|
||||
|
||||
tx_date = transaction_date or date.today()
|
||||
number = await DocumentNumberService(self.session).allocate(tenant_id, "voucher")
|
||||
if is_receipt:
|
||||
debit_id, credit_id = treasury_account_id, counter_account_id
|
||||
else:
|
||||
debit_id, credit_id = counter_account_id, treasury_account_id
|
||||
if cash_box_id is not None:
|
||||
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
|
||||
if cash_box is not None and cash_box.current_balance < amount:
|
||||
raise TreasuryError("موجودی صندوق کافی نیست")
|
||||
|
||||
voucher = await self._create_treasury_voucher(
|
||||
tenant_id,
|
||||
period.id,
|
||||
number,
|
||||
tx_date,
|
||||
debit_id,
|
||||
credit_id,
|
||||
amount,
|
||||
description,
|
||||
actor_user_id,
|
||||
"treasury",
|
||||
post_immediately=await SourcePostingGuard(self.session).should_post_immediately(tenant_id),
|
||||
reference_number=reference,
|
||||
)
|
||||
|
||||
if cash_box_id is not None:
|
||||
cash_box = await self.cash_box_repo.get(tenant_id, cash_box_id)
|
||||
if cash_box is not None:
|
||||
if is_receipt:
|
||||
cash_box.current_balance += amount
|
||||
else:
|
||||
cash_box.current_balance -= amount
|
||||
return voucher
|
||||
|
||||
async def _create_treasury_voucher(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
@ -144,6 +201,9 @@ class TreasuryService:
|
||||
description: str,
|
||||
actor_user_id: str,
|
||||
source_module: str,
|
||||
*,
|
||||
post_immediately: bool = True,
|
||||
reference_number: str | None = None,
|
||||
) -> Voucher:
|
||||
voucher = Voucher(
|
||||
tenant_id=tenant_id,
|
||||
@ -153,6 +213,7 @@ class TreasuryService:
|
||||
status=VoucherStatus.DRAFT,
|
||||
description=description,
|
||||
source_module=source_module,
|
||||
reference_number=reference_number,
|
||||
created_by=actor_user_id,
|
||||
)
|
||||
await self.voucher_repo.add(voucher)
|
||||
@ -168,6 +229,8 @@ class TreasuryService:
|
||||
description=description,
|
||||
))
|
||||
await self.session.flush()
|
||||
if not post_immediately:
|
||||
return voucher
|
||||
return await self.posting_engine.post_voucher(
|
||||
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
|
||||
)
|
||||
|
||||
@ -11,7 +11,7 @@ export default function Page() {
|
||||
docType="goods_receipt"
|
||||
partyLabel="تأمینکننده"
|
||||
kind="receipt"
|
||||
enableAccountingPost="purchase"
|
||||
enableAccountingPost="purchase_gr"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export default function Page() {
|
||||
docType="invoice"
|
||||
partyLabel="تأمینکننده"
|
||||
kind="invoice"
|
||||
enableAccountingPost="purchase"
|
||||
enableAccountingPost="purchase_invoice"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export default function Page() {
|
||||
docType="return"
|
||||
partyLabel="تأمینکننده"
|
||||
kind="invoice"
|
||||
enableAccountingPost="purchase_return"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export default function Page() {
|
||||
docType="return"
|
||||
partyLabel="مشتری"
|
||||
kind="invoice"
|
||||
enableAccountingPost="sales"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
185
frontend/app/accounting/settings/auto-posting/page.tsx
Normal file
185
frontend/app/accounting/settings/auto-posting/page.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { accountingApi } from "@/lib/accounting-api";
|
||||
import { useTenantId } from "@/hooks/useTenantId";
|
||||
import {
|
||||
PageHeader,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Button,
|
||||
Checkbox,
|
||||
Select,
|
||||
Badge,
|
||||
} from "@/components/ds";
|
||||
|
||||
const AUTO_KEYS: { key: string; label: string; hint: string }[] = [
|
||||
{ key: "sales_invoice", label: "فاکتور فروش", hint: "با تأیید فاکتور فروش، سند GL خودکار صادر شود" },
|
||||
{ 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: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" },
|
||||
{ key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" },
|
||||
{ key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" },
|
||||
{ key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دورهای" },
|
||||
];
|
||||
|
||||
const POLICY_KEYS: { key: string; label: string; hint: string }[] = [
|
||||
{
|
||||
key: "require_balanced",
|
||||
label: "اجبار تراز بدهکار/بستانکار",
|
||||
hint: "سند نامتوازن هرگز ذخیره یا قطعی نشود (استاندارد دوبلانتری)",
|
||||
},
|
||||
{
|
||||
key: "require_source_reference",
|
||||
label: "الزام مرجع سند مبدأ",
|
||||
hint: "اسناد اتومات باید source_module و reference داشته باشند",
|
||||
},
|
||||
{
|
||||
key: "lock_after_post",
|
||||
label: "قفل پس از ثبت قطعی",
|
||||
hint: "ویرایش مستقیم ممنوع؛ فقط برگشت از طریق Posting Engine",
|
||||
},
|
||||
{
|
||||
key: "sequential_numbers",
|
||||
label: "شمارهگذاری متوالی",
|
||||
hint: "استفاده از Document Number Sequence بهجای شناسه تصادفی",
|
||||
},
|
||||
{
|
||||
key: "idempotent_source",
|
||||
label: "جلوگیری از ثبت تکراری",
|
||||
hint: "یک سند مبدأ بیش از یک بار به GL نرود",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AutoPostingSettingsPage() {
|
||||
const { tenantId } = useTenantId();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const policyQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "posting-policy"],
|
||||
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const saveM = useMutation({
|
||||
mutationFn: (body: Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]) =>
|
||||
accountingApi.setup.updatePostingPolicy(tenantId!, body),
|
||||
onSuccess: async () => {
|
||||
toast.success("سیاست اسناد اتومات ذخیره شد");
|
||||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "posting-policy"] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (!tenantId || policyQ.isLoading) return <LoadingState />;
|
||||
if (policyQ.error) {
|
||||
return <ErrorState message={policyQ.error.message} onRetry={() => policyQ.refetch()} />;
|
||||
}
|
||||
|
||||
const policy = policyQ.data!;
|
||||
|
||||
const toggleAuto = (key: string, value: boolean) => {
|
||||
saveM.mutate({
|
||||
auto_post: { ...policy.auto_post, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const togglePolicy = (key: string, value: boolean) => {
|
||||
saveM.mutate({ [key]: value } as Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="اسناد اتوماتیک حسابداری"
|
||||
description="کنترل ثبت خودکار در دفتر کل مطابق استانداردهای بینالمللی دوبلانتری: تراز، قفل پس از ثبت، شماره متوالی، مرجع مبدأ و جلوگیری از ثبت تکراری."
|
||||
/>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
حالت ثبت
|
||||
<Badge tone="primary">{policy.posting_mode === "direct_post" ? "ثبت مستقیم" : "پیشنویس سپس قطعی"}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-w-md space-y-2">
|
||||
<Select
|
||||
value={policy.posting_mode}
|
||||
disabled={saveM.isPending}
|
||||
onChange={(e) => saveM.mutate({ posting_mode: e.target.value })}
|
||||
>
|
||||
<option value="direct_post">ثبت مستقیم در دفتر (پس از تأیید سند عملیاتی)</option>
|
||||
<option value="draft_then_post">ایجاد پیشنویس و قطعیسازی جداگانه</option>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
در حالت مستقیم، با تأیید سند عملیاتی (اگر ماژول روشن باشد) سند GL بلافاصله از طریق Posting Engine صادر میشود.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>قواعد الزامی (Compliance)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{POLICY_KEYS.map((item) => (
|
||||
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
|
||||
<Checkbox
|
||||
checked={Boolean((policy as Record<string, unknown>)[item.key])}
|
||||
disabled={saveM.isPending}
|
||||
onChange={(e) => togglePolicy(item.key, e.target.checked)}
|
||||
aria-label={item.label}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-secondary">{item.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ثبت خودکار به تفکیک ماژول</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{AUTO_KEYS.map((item) => (
|
||||
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
|
||||
<Checkbox
|
||||
checked={Boolean(policy.auto_post?.[item.key])}
|
||||
disabled={saveM.isPending}
|
||||
onChange={(e) => toggleAuto(item.key, e.target.checked)}
|
||||
aria-label={item.label}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-secondary">{item.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6">
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
قلب حسابداری فقط از مسیر Posting Engine میگذرد (ADR-010). تنظیمات بالا فقط زمان و شرط صدور را کنترل میکنند.
|
||||
</p>
|
||||
<Button type="button" variant="outline" disabled={saveM.isPending} onClick={() => policyQ.refetch()}>
|
||||
بازخوانی
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@ -11,6 +11,7 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { accountingApi } from "@/lib/accounting-api";
|
||||
import { useTenantId } from "@/hooks/useTenantId";
|
||||
import { formatMoney, toRialInteger } from "@/lib/utils";
|
||||
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
@ -116,8 +117,14 @@ export default function VoucherDetailPage() {
|
||||
};
|
||||
|
||||
const updateM = useMutation({
|
||||
mutationFn: (data: EditForm) =>
|
||||
accountingApi.vouchers.update(tenantId!, id, {
|
||||
mutationFn: (data: EditForm) => {
|
||||
const bal = sumVoucherLines(data.lines);
|
||||
if (!bal.isBalanced) throw new Error(unbalancedMessage(bal.debit, bal.credit));
|
||||
for (let i = 0; i < data.lines.length; i++) {
|
||||
const err = lineBothSidesError(data.lines[i].debit, data.lines[i].credit);
|
||||
if (err) throw new Error(`ردیف ${i + 1}: ${err}`);
|
||||
}
|
||||
return accountingApi.vouchers.update(tenantId!, id, {
|
||||
voucher_date: data.voucher_date,
|
||||
description: data.description || undefined,
|
||||
lines: data.lines.map((l) => ({
|
||||
@ -128,7 +135,8 @@ export default function VoucherDetailPage() {
|
||||
cost_center_id: l.cost_center_id || null,
|
||||
project_id: l.project_id || null,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("سند بهروز شد");
|
||||
setEditing(false);
|
||||
@ -183,6 +191,9 @@ export default function VoucherDetailPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const watchedEditLines = editForm.watch("lines");
|
||||
const balance = useMemo(() => sumVoucherLines(watchedEditLines), [watchedEditLines]);
|
||||
|
||||
if (!tenantId || voucherQ.isLoading) return <LoadingState />;
|
||||
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />;
|
||||
|
||||
@ -191,6 +202,7 @@ export default function VoucherDetailPage() {
|
||||
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
|
||||
const activeCostCenters = (costCentersQ.data ?? []).filter((c) => c.is_active);
|
||||
const activeProjects = (projectsQ.data ?? []).filter((p) => p.is_active);
|
||||
|
||||
const isDraft = v.status === "draft";
|
||||
|
||||
return (
|
||||
@ -319,10 +331,17 @@ export default function VoucherDetailPage() {
|
||||
ردیف
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
{fields.map((field, index) => {
|
||||
const lineErr = lineBothSidesError(
|
||||
watchedEditLines?.[index]?.debit ?? "0",
|
||||
watchedEditLines?.[index]?.credit ?? "0"
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid gap-2 rounded-xl border border-[var(--border)] p-3 sm:grid-cols-12"
|
||||
className={`grid gap-2 rounded-xl border p-3 sm:grid-cols-12 ${
|
||||
lineErr ? "border-red-300 bg-red-50/40" : "border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
<div className="sm:col-span-3">
|
||||
<Label>حساب</Label>
|
||||
@ -377,19 +396,40 @@ export default function VoucherDetailPage() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{lineErr ? (
|
||||
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<FieldError>
|
||||
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
|
||||
</FieldError>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-3 text-sm ${
|
||||
balance.isBalanced
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-red-200 bg-red-50 text-red-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
|
||||
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
|
||||
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
|
||||
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"}</span>
|
||||
</div>
|
||||
{!balance.isBalanced ? (
|
||||
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateM.isPending}>
|
||||
<Button type="submit" disabled={updateM.isPending || !balance.isBalanced || (watchedEditLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
|
||||
ذخیره تغییرات
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -11,6 +11,8 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { accountingApi } from "@/lib/accounting-api";
|
||||
import { useTenantId } from "@/hooks/useTenantId";
|
||||
import { todayIso } from "@/lib/jalali";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
|
||||
import {
|
||||
PageHeader,
|
||||
Button,
|
||||
@ -96,8 +98,16 @@ export default function NewVoucherPage() {
|
||||
}, [nextNumberQ.data, form]);
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: (data: FormValues) =>
|
||||
accountingApi.vouchers.create(tenantId!, {
|
||||
mutationFn: (data: FormValues) => {
|
||||
const bal = sumVoucherLines(data.lines);
|
||||
if (!bal.isBalanced) {
|
||||
throw new Error(unbalancedMessage(bal.debit, bal.credit));
|
||||
}
|
||||
for (let i = 0; i < data.lines.length; i++) {
|
||||
const err = lineBothSidesError(data.lines[i].debit, data.lines[i].credit);
|
||||
if (err) throw new Error(`ردیف ${i + 1}: ${err}`);
|
||||
}
|
||||
return accountingApi.vouchers.create(tenantId!, {
|
||||
fiscal_period_id: data.fiscal_period_id,
|
||||
voucher_number: data.voucher_number?.trim() || undefined,
|
||||
voucher_date: data.voucher_date,
|
||||
@ -111,7 +121,8 @@ export default function NewVoucherPage() {
|
||||
cost_center_id: l.cost_center_id || null,
|
||||
project_id: l.project_id || null,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: (v) => {
|
||||
toast.success("سند ایجاد شد");
|
||||
router.push(`/accounting/vouchers/${v.id}`);
|
||||
@ -119,6 +130,9 @@ export default function NewVoucherPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const watchedLines = form.watch("lines");
|
||||
const balance = useMemo(() => sumVoucherLines(watchedLines), [watchedLines]);
|
||||
|
||||
if (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
|
||||
if (periodsQ.error || accountsQ.error) {
|
||||
return (
|
||||
@ -219,10 +233,17 @@ export default function NewVoucherPage() {
|
||||
ردیف
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
{fields.map((field, index) => {
|
||||
const lineErr = lineBothSidesError(
|
||||
watchedLines?.[index]?.debit ?? "0",
|
||||
watchedLines?.[index]?.credit ?? "0"
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid gap-2 rounded-xl border border-[var(--border)] p-3 sm:grid-cols-12"
|
||||
className={`grid gap-2 rounded-xl border p-3 sm:grid-cols-12 ${
|
||||
lineErr ? "border-red-300 bg-red-50/40" : "border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
<div className="sm:col-span-3">
|
||||
<Label>حساب</Label>
|
||||
@ -281,9 +302,30 @@ export default function NewVoucherPage() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{lineErr ? (
|
||||
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<FieldError>{form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message}</FieldError>
|
||||
<div
|
||||
className={`rounded-xl border p-3 text-sm ${
|
||||
balance.isBalanced
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-red-200 bg-red-50 text-red-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<span>جمع بدهکار: {formatMoney(String(balance.debit))}</span>
|
||||
<span>جمع بستانکار: {formatMoney(String(balance.credit))}</span>
|
||||
<span>اختلاف: {formatMoney(String(Math.abs(balance.difference)))}</span>
|
||||
<span className="font-semibold">{balance.isBalanced ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"}</span>
|
||||
</div>
|
||||
{!balance.isBalanced ? (
|
||||
<p className="mt-2 text-xs">{unbalancedMessage(balance.debit, balance.credit)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -291,7 +333,7 @@ export default function NewVoucherPage() {
|
||||
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" disabled={createM.isPending}>
|
||||
<Button type="submit" disabled={createM.isPending || !balance.isBalanced || (watchedLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
|
||||
ذخیره پیشنویس
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -713,6 +713,7 @@ export function ReceiptsPaymentsPage() {
|
||||
payment_method: "cash",
|
||||
cash_box_id: "",
|
||||
bank_account_id: "",
|
||||
counter_account_id: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
@ -737,6 +738,21 @@ export function ReceiptsPaymentsPage() {
|
||||
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
const accountsQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "accounts"],
|
||||
queryFn: () => accountingApi.accounts.list(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
const policyQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "posting-policy"],
|
||||
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const autoPost =
|
||||
mode === "receipt"
|
||||
? Boolean(policyQ.data?.auto_post?.treasury_receipt ?? true)
|
||||
: Boolean(policyQ.data?.auto_post?.treasury_payment ?? true);
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: async (d: {
|
||||
@ -746,8 +762,13 @@ export function ReceiptsPaymentsPage() {
|
||||
payment_method: string;
|
||||
cash_box_id?: string;
|
||||
bank_account_id?: string;
|
||||
counter_account_id?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
if (autoPost && !d.counter_account_id) {
|
||||
throw new Error("حساب مقابل برای ثبت خودکار سند حسابداری الزامی است");
|
||||
}
|
||||
const counter = d.counter_account_id || undefined;
|
||||
if (mode === "receipt") {
|
||||
return accountingApi.treasury.createReceipt(tenantId!, {
|
||||
receipt_number: d.number,
|
||||
@ -756,6 +777,7 @@ export function ReceiptsPaymentsPage() {
|
||||
payment_method: d.payment_method,
|
||||
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
|
||||
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
|
||||
counter_account_id: counter,
|
||||
description: d.description,
|
||||
});
|
||||
}
|
||||
@ -766,20 +788,34 @@ export function ReceiptsPaymentsPage() {
|
||||
payment_method: d.payment_method,
|
||||
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
|
||||
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
|
||||
counter_account_id: counter,
|
||||
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, "receipts"] });
|
||||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
|
||||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading || cashBoxesQ.isLoading || bankAccountsQ.isLoading) return <LoadingState />;
|
||||
if (
|
||||
!tenantId ||
|
||||
receiptsQ.isLoading ||
|
||||
paymentsQ.isLoading ||
|
||||
cashBoxesQ.isLoading ||
|
||||
bankAccountsQ.isLoading
|
||||
) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
@ -792,6 +828,7 @@ export function ReceiptsPaymentsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
|
||||
const rows =
|
||||
mode === "receipt"
|
||||
? (receiptsQ.data ?? []).map((r) => ({
|
||||
@ -801,6 +838,7 @@ export function ReceiptsPaymentsPage() {
|
||||
amount: r.amount,
|
||||
payment_method: r.payment_method,
|
||||
description: r.description,
|
||||
voucher_id: r.voucher_id,
|
||||
}))
|
||||
: (paymentsQ.data ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
@ -809,13 +847,18 @@ export function ReceiptsPaymentsPage() {
|
||||
amount: p.amount,
|
||||
payment_method: p.payment_method,
|
||||
description: p.description,
|
||||
voucher_id: p.voucher_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="دریافت و پرداخت"
|
||||
description="ثبت دریافت/پرداخت خزانه."
|
||||
description={
|
||||
autoPost
|
||||
? "ثبت دریافت/پرداخت با صدور خودکار سند حسابداری (قابل تنظیم در اسناد اتوماتیک)."
|
||||
: "ثبت دریافت/پرداخت خزانه — ثبت خودکار دفتر کل فعلاً خاموش است."
|
||||
}
|
||||
actions={
|
||||
<Button type="button" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -846,6 +889,11 @@ export function ReceiptsPaymentsPage() {
|
||||
header: "مبلغ",
|
||||
render: (r) => formatMoney(String(r.amount)),
|
||||
},
|
||||
{
|
||||
key: "voucher_id",
|
||||
header: "سند GL",
|
||||
render: (r) => (r.voucher_id ? <Badge tone="success">ثبتشده</Badge> : "—"),
|
||||
},
|
||||
{ key: "description", header: "توضیح" },
|
||||
]}
|
||||
rows={rows as unknown as Record<string, unknown>[]}
|
||||
@ -856,10 +904,7 @@ export function ReceiptsPaymentsPage() {
|
||||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||||
<div className="sm:col-span-2">
|
||||
<FormField label="نوع عملیات">
|
||||
<Select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||
>
|
||||
<Select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}>
|
||||
<option value="receipt">دریافت</option>
|
||||
<option value="payment">پرداخت</option>
|
||||
</Select>
|
||||
@ -894,7 +939,9 @@ export function ReceiptsPaymentsPage() {
|
||||
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
|
||||
<option value="">انتخاب صندوق…</option>
|
||||
{(cashBoxesQ.data ?? []).map((cashBox) => (
|
||||
<option key={cashBox.id} value={cashBox.id}>{cashBox.name}</option>
|
||||
<option key={cashBox.id} value={cashBox.id}>
|
||||
{cashBox.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
@ -902,10 +949,29 @@ export function ReceiptsPaymentsPage() {
|
||||
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
|
||||
<option value="">انتخاب حساب…</option>
|
||||
{(bankAccountsQ.data ?? []).map((account) => (
|
||||
<option key={account.id} value={account.id}>{account.account_number}</option>
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.account_number}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="sm:col-span-2">
|
||||
<FormField label={mode === "receipt" ? "حساب مقابل (بستانکار)" : "حساب مقابل (بدهکار)"}>
|
||||
<Select {...form.register("counter_account_id", { required: autoPost })}>
|
||||
<option value="">انتخاب حساب…</option>
|
||||
{postable.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.code} — {a.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormField>
|
||||
{autoPost ? (
|
||||
<p className="mt-1 text-xs text-[var(--muted)]">
|
||||
ثبت خودکار سند دفتر کل فعال است — حساب صندوق/بانک از تنظیمات صندوق/حساب بانکی گرفته میشود.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<FormField label="توضیح">
|
||||
<Input {...form.register("description")} />
|
||||
|
||||
@ -144,8 +144,8 @@ export function OperationalDocumentPage({
|
||||
createLabel: string;
|
||||
partyLabel?: string;
|
||||
kind?: DocKind;
|
||||
/** When true, draft rows get "ثبت حسابداری" using sales/purchase engines. */
|
||||
enableAccountingPost?: "sales" | "purchase";
|
||||
/** When set, draft rows get "ثبت حسابداری" using the matching engine. */
|
||||
enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return";
|
||||
}) {
|
||||
const { tenantId } = useTenantId();
|
||||
const qc = useQueryClient();
|
||||
@ -173,7 +173,16 @@ export function OperationalDocumentPage({
|
||||
const purchaseProfilesQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "purchase-profiles"],
|
||||
queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!),
|
||||
enabled: !!tenantId && enableAccountingPost === "purchase",
|
||||
enabled:
|
||||
!!tenantId &&
|
||||
(enableAccountingPost === "purchase_gr" ||
|
||||
enableAccountingPost === "purchase_invoice" ||
|
||||
enableAccountingPost === "purchase_return"),
|
||||
});
|
||||
const policyQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "posting-policy"],
|
||||
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const defaults: FormValues = useMemo(
|
||||
@ -259,8 +268,21 @@ export function OperationalDocumentPage({
|
||||
|
||||
const confirmM = useMutation({
|
||||
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
|
||||
onSuccess: async () => {
|
||||
toast.success("تأیید شد");
|
||||
onSuccess: async (doc) => {
|
||||
const meta =
|
||||
doc && typeof doc === "object" && "meta_json" in doc && typeof doc.meta_json === "string"
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(doc.meta_json as string) as {
|
||||
accounting_voucher?: { voucher_number?: string };
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
const vn = meta?.accounting_voucher?.voucher_number;
|
||||
toast.success(vn ? `تأیید شد و سند حسابداری ${vn} صادر شد` : "تأیید شد");
|
||||
setConfirmId(null);
|
||||
await invalidate();
|
||||
},
|
||||
@ -274,13 +296,20 @@ export function OperationalDocumentPage({
|
||||
const profileId = postForm.getValues("profile_id") || undefined;
|
||||
if (enableAccountingPost === "sales") {
|
||||
return accountingApi.salesAccounting.preview(tenantId!, {
|
||||
document_type: "invoice",
|
||||
document_type: docType === "return" ? "return" : "invoice",
|
||||
total_amount: amount,
|
||||
discount_amount: postForm.getValues("discount_amount") || "0",
|
||||
tax_amount: postForm.getValues("tax_amount") || "0",
|
||||
profile_id: profileId,
|
||||
});
|
||||
}
|
||||
if (enableAccountingPost === "purchase_invoice" || enableAccountingPost === "purchase_return") {
|
||||
return accountingApi.purchaseInventory.previewPurchaseInvoice(tenantId!, {
|
||||
source_document_id: String(postRow.id),
|
||||
amount,
|
||||
profile_id: profileId,
|
||||
});
|
||||
}
|
||||
return accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, {
|
||||
source_document_id: String(postRow.id),
|
||||
amount,
|
||||
@ -301,7 +330,7 @@ export function OperationalDocumentPage({
|
||||
const profileId = postForm.getValues("profile_id") || undefined;
|
||||
if (enableAccountingPost === "sales") {
|
||||
return accountingApi.salesAccounting.post(tenantId!, {
|
||||
document_type: "invoice",
|
||||
document_type: docType === "return" ? "return" : "invoice",
|
||||
source_document_id: String(postRow.id),
|
||||
total_amount: amount,
|
||||
discount_amount: postForm.getValues("discount_amount") || "0",
|
||||
@ -309,6 +338,20 @@ export function OperationalDocumentPage({
|
||||
profile_id: profileId,
|
||||
});
|
||||
}
|
||||
if (enableAccountingPost === "purchase_return") {
|
||||
return accountingApi.purchaseInventory.postPurchaseReturn(tenantId!, {
|
||||
source_document_id: String(postRow.id),
|
||||
amount,
|
||||
profile_id: profileId,
|
||||
});
|
||||
}
|
||||
if (enableAccountingPost === "purchase_invoice") {
|
||||
return accountingApi.purchaseInventory.postPurchaseInvoice(tenantId!, {
|
||||
source_document_id: String(postRow.id),
|
||||
amount,
|
||||
profile_id: profileId,
|
||||
});
|
||||
}
|
||||
return accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, {
|
||||
source_document_id: String(postRow.id),
|
||||
amount,
|
||||
@ -716,7 +759,27 @@ export function OperationalDocumentPage({
|
||||
onClose={() => setConfirmId(null)}
|
||||
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
|
||||
title="تأیید سند؟"
|
||||
description="پس از تأیید، وضعیت سند به confirmed تغییر میکند."
|
||||
description={
|
||||
(() => {
|
||||
const auto = policyQ.data?.auto_post;
|
||||
const key =
|
||||
module === "sales" && docType === "invoice"
|
||||
? "sales_invoice"
|
||||
: module === "sales" && docType === "return"
|
||||
? "sales_return"
|
||||
: module === "purchase" && (docType === "goods_receipt" || docType === "goods-receipts")
|
||||
? "purchase_goods_receipt"
|
||||
: module === "purchase" && docType === "invoice"
|
||||
? "purchase_invoice"
|
||||
: module === "purchase" && docType === "return"
|
||||
? "purchase_return"
|
||||
: null;
|
||||
if (key && auto?.[key]) {
|
||||
return "با تأیید، سند عملیاتی تأیید و سند حسابداری خودکار از طریق Posting Engine صادر میشود.";
|
||||
}
|
||||
return "پس از تأیید، وضعیت سند به confirmed تغییر میکند. ثبت دفتر کل جداگانه از «ثبت حسابداری» انجام میشود.";
|
||||
})()
|
||||
}
|
||||
confirmLabel="تأیید"
|
||||
loading={confirmM.isPending}
|
||||
/>
|
||||
|
||||
@ -254,6 +254,41 @@ export const accountingApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ document_type: documentType }),
|
||||
}),
|
||||
getPostingPolicy: (tenantId: string) =>
|
||||
request<{
|
||||
auto_post: Record<string, boolean>;
|
||||
require_balanced: boolean;
|
||||
require_source_reference: boolean;
|
||||
lock_after_post: boolean;
|
||||
sequential_numbers: boolean;
|
||||
idempotent_source: boolean;
|
||||
posting_mode: string;
|
||||
}>("/api/v1/setup/posting-policy", { tenantId }),
|
||||
updatePostingPolicy: (
|
||||
tenantId: string,
|
||||
body: {
|
||||
auto_post?: Record<string, boolean>;
|
||||
require_balanced?: boolean;
|
||||
require_source_reference?: boolean;
|
||||
lock_after_post?: boolean;
|
||||
sequential_numbers?: boolean;
|
||||
idempotent_source?: boolean;
|
||||
posting_mode?: string;
|
||||
}
|
||||
) =>
|
||||
request<{
|
||||
auto_post: Record<string, boolean>;
|
||||
require_balanced: boolean;
|
||||
require_source_reference: boolean;
|
||||
lock_after_post: boolean;
|
||||
sequential_numbers: boolean;
|
||||
idempotent_source: boolean;
|
||||
posting_mode: string;
|
||||
}>("/api/v1/setup/posting-policy", {
|
||||
tenantId,
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
|
||||
charts: {
|
||||
@ -818,6 +853,7 @@ export const accountingApi = {
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
description: string | null;
|
||||
voucher_id?: string | null;
|
||||
}[]
|
||||
>("/api/v1/treasury/receipts", { tenantId }),
|
||||
createReceipt: (
|
||||
@ -829,14 +865,18 @@ export const accountingApi = {
|
||||
payment_method?: string;
|
||||
cash_box_id?: string;
|
||||
bank_account_id?: string;
|
||||
counter_account_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
) =>
|
||||
request<{ id: string }>("/api/v1/treasury/receipts", {
|
||||
request<{ id: string; receipt_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
|
||||
"/api/v1/treasury/receipts",
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
}
|
||||
),
|
||||
listPayments: (tenantId: string) =>
|
||||
request<
|
||||
{
|
||||
@ -846,6 +886,7 @@ export const accountingApi = {
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
description: string | null;
|
||||
voucher_id?: string | null;
|
||||
}[]
|
||||
>("/api/v1/treasury/payments", { tenantId }),
|
||||
createPayment: (
|
||||
@ -857,14 +898,18 @@ export const accountingApi = {
|
||||
payment_method?: string;
|
||||
cash_box_id?: string;
|
||||
bank_account_id?: string;
|
||||
counter_account_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
) =>
|
||||
request<{ id: string }>("/api/v1/treasury/payments", {
|
||||
request<{ id: string; payment_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
|
||||
"/api/v1/treasury/payments",
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
}
|
||||
),
|
||||
listReconciliations: (tenantId: string) =>
|
||||
request<
|
||||
{
|
||||
@ -953,11 +998,14 @@ export const accountingApi = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
confirmDocument: (tenantId: string, id: string) =>
|
||||
request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}/confirm`, {
|
||||
request<{ id: string; status: string; meta_json?: string | null }>(
|
||||
`/api/v1/ops/documents/${id}/confirm`,
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
}
|
||||
),
|
||||
listItems: (tenantId: string) =>
|
||||
request<
|
||||
{
|
||||
@ -1628,11 +1676,46 @@ export const accountingApi = {
|
||||
tenantId: string,
|
||||
body: { source_document_id: string; amount: string; profile_id?: string }
|
||||
) =>
|
||||
request<{ voucher_id: string; status: string }>("/api/v1/purchase-inventory/post/goods-receipt", {
|
||||
request<{ voucher_id: string; voucher_number?: string; status: string }>(
|
||||
"/api/v1/purchase-inventory/post/goods-receipt",
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
}
|
||||
),
|
||||
previewPurchaseInvoice: (
|
||||
tenantId: string,
|
||||
body: { source_document_id: string; amount: string; profile_id?: string }
|
||||
) =>
|
||||
request<{ is_balanced: boolean; total_debit: string; total_credit?: string }>(
|
||||
"/api/v1/purchase-inventory/preview/purchase-invoice",
|
||||
{ tenantId, method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
postPurchaseInvoice: (
|
||||
tenantId: string,
|
||||
body: { source_document_id: string; amount: string; profile_id?: string }
|
||||
) =>
|
||||
request<{ voucher_id: string; voucher_number?: string; status: string }>(
|
||||
"/api/v1/purchase-inventory/post/purchase-invoice",
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
postPurchaseReturn: (
|
||||
tenantId: string,
|
||||
body: { source_document_id: string; amount: string; profile_id?: string }
|
||||
) =>
|
||||
request<{ voucher_id: string; voucher_number?: string; status: string }>(
|
||||
"/api/v1/purchase-inventory/post/purchase-return",
|
||||
{
|
||||
tenantId,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
valuation: (
|
||||
tenantId: string,
|
||||
body: {
|
||||
|
||||
@ -258,6 +258,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
|
||||
{ href: "/accounting/fiscal", label: "دورههای مالی" },
|
||||
{ href: "/accounting/currencies", label: "ارزها و نرخها" },
|
||||
{ href: "/accounting/settings", label: "تنظیمات حسابداری" },
|
||||
{ href: "/accounting/settings/auto-posting", label: "اسناد اتوماتیک" },
|
||||
{ href: "/accounting/settings/tax", label: "تنظیمات مالیاتی" },
|
||||
{ href: "/accounting/settings/general", label: "تنظیمات عمومی" },
|
||||
{ href: "/dashboard/settings", label: "کاربران و دسترسی" },
|
||||
|
||||
43
frontend/lib/voucher-balance.ts
Normal file
43
frontend/lib/voucher-balance.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/** Live voucher line balance helpers — Rial integers, zero tolerance. */
|
||||
import { toRialInteger } from "@/lib/utils";
|
||||
|
||||
export type BalanceLine = { debit?: string; credit?: string };
|
||||
|
||||
export function sumVoucherLines(lines: BalanceLine[] | undefined) {
|
||||
let debit = 0;
|
||||
let credit = 0;
|
||||
(lines ?? []).forEach((line, index) => {
|
||||
const d = Number(toRialInteger(line.debit) || "0");
|
||||
const c = Number(toRialInteger(line.credit) || "0");
|
||||
debit += Number.isFinite(d) ? d : 0;
|
||||
credit += Number.isFinite(c) ? c : 0;
|
||||
void index;
|
||||
});
|
||||
const difference = debit - credit;
|
||||
return {
|
||||
debit,
|
||||
credit,
|
||||
difference,
|
||||
isBalanced: difference === 0 && (lines?.length ?? 0) >= 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function unbalancedMessage(debit: number, credit: number): string {
|
||||
const diff = Math.abs(debit - credit);
|
||||
if (debit > credit) {
|
||||
return `سند نامتوازن است: جمع بدهکار ${debit.toLocaleString("fa-IR")} و بستانکار ${credit.toLocaleString("fa-IR")} — بدهکار ${diff.toLocaleString("fa-IR")} ریال بیشتر است.`;
|
||||
}
|
||||
return `سند نامتوازن است: جمع بدهکار ${debit.toLocaleString("fa-IR")} و بستانکار ${credit.toLocaleString("fa-IR")} — بستانکار ${diff.toLocaleString("fa-IR")} ریال بیشتر است.`;
|
||||
}
|
||||
|
||||
export function lineBothSidesError(debit: string, credit: string): string | null {
|
||||
const d = Number(toRialInteger(debit) || "0");
|
||||
const c = Number(toRialInteger(credit) || "0");
|
||||
if (d > 0 && c > 0) {
|
||||
return "در یک ردیف نمیتوان همزمان بدهکار و بستانکار داشت";
|
||||
}
|
||||
if (d === 0 && c === 0) {
|
||||
return "حداقل یکی از بدهکار یا بستانکار باید مقدار داشته باشد";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user