diff --git a/backend/services/accounting/app/api/v1/operational.py b/backend/services/accounting/app/api/v1/operational.py index 8d5379f..3da1751 100644 --- a/backend/services/accounting/app/api/v1/operational.py +++ b/backend/services/accounting/app/api/v1/operational.py @@ -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 diff --git a/backend/services/accounting/app/api/v1/posting.py b/backend/services/accounting/app/api/v1/posting.py index 6264830..27641ce 100644 --- a/backend/services/accounting/app/api/v1/posting.py +++ b/backend/services/accounting/app/api/v1/posting.py @@ -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: diff --git a/backend/services/accounting/app/api/v1/purchase_inventory.py b/backend/services/accounting/app/api/v1/purchase_inventory.py index 4b82e7b..ba220fc 100644 --- a/backend/services/accounting/app/api/v1/purchase_inventory.py +++ b/backend/services/accounting/app/api/v1/purchase_inventory.py @@ -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") diff --git a/backend/services/accounting/app/api/v1/setup.py b/backend/services/accounting/app/api/v1/setup.py index 34b544d..327b363 100644 --- a/backend/services/accounting/app/api/v1/setup.py +++ b/backend/services/accounting/app/api/v1/setup.py @@ -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 diff --git a/backend/services/accounting/app/api/v1/treasury.py b/backend/services/accounting/app/api/v1/treasury.py index 80aef04..1f06114 100644 --- a/backend/services/accounting/app/api/v1/treasury.py +++ b/backend/services/accounting/app/api/v1/treasury.py @@ -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") diff --git a/backend/services/accounting/app/services/posting_policy_service.py b/backend/services/accounting/app/services/posting_policy_service.py new file mode 100644 index 0000000..e4a06f8 --- /dev/null +++ b/backend/services/accounting/app/services/posting_policy_service.py @@ -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)) diff --git a/backend/services/accounting/app/services/purchase_inventory_service.py b/backend/services/accounting/app/services/purchase_inventory_service.py index 325b8d0..a00aa1d 100644 --- a/backend/services/accounting/app/services/purchase_inventory_service.py +++ b/backend/services/accounting/app/services/purchase_inventory_service.py @@ -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( - tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase" + 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, diff --git a/backend/services/accounting/app/services/sales_accounting_service.py b/backend/services/accounting/app/services/sales_accounting_service.py index 4501741..00f52fb 100644 --- a/backend/services/accounting/app/services/sales_accounting_service.py +++ b/backend/services/accounting/app/services/sales_accounting_service.py @@ -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( - tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales" + 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 diff --git a/backend/services/accounting/app/services/source_posting_guard.py b/backend/services/accounting/app/services/source_posting_guard.py new file mode 100644 index 0000000..5cac77f --- /dev/null +++ b/backend/services/accounting/app/services/source_posting_guard.py @@ -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" diff --git a/backend/services/accounting/app/services/treasury_service.py b/backend/services/accounting/app/services/treasury_service.py index 72ccac8..34f71bc 100644 --- a/backend/services/accounting/app/services/treasury_service.py +++ b/backend/services/accounting/app/services/treasury_service.py @@ -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 ) diff --git a/frontend/app/accounting/purchase/goods-receipts/page.tsx b/frontend/app/accounting/purchase/goods-receipts/page.tsx index bf82de9..cb5a8d2 100644 --- a/frontend/app/accounting/purchase/goods-receipts/page.tsx +++ b/frontend/app/accounting/purchase/goods-receipts/page.tsx @@ -11,7 +11,7 @@ export default function Page() { docType="goods_receipt" partyLabel="تأمین‌کننده" kind="receipt" - enableAccountingPost="purchase" + enableAccountingPost="purchase_gr" /> ); } diff --git a/frontend/app/accounting/purchase/invoices/page.tsx b/frontend/app/accounting/purchase/invoices/page.tsx index c84d46e..2d28c2e 100644 --- a/frontend/app/accounting/purchase/invoices/page.tsx +++ b/frontend/app/accounting/purchase/invoices/page.tsx @@ -11,7 +11,7 @@ export default function Page() { docType="invoice" partyLabel="تأمین‌کننده" kind="invoice" - enableAccountingPost="purchase" + enableAccountingPost="purchase_invoice" /> ); } diff --git a/frontend/app/accounting/purchase/returns/page.tsx b/frontend/app/accounting/purchase/returns/page.tsx index 6f8933e..27bdac1 100644 --- a/frontend/app/accounting/purchase/returns/page.tsx +++ b/frontend/app/accounting/purchase/returns/page.tsx @@ -11,6 +11,7 @@ export default function Page() { docType="return" partyLabel="تأمین‌کننده" kind="invoice" + enableAccountingPost="purchase_return" /> ); } diff --git a/frontend/app/accounting/sales/returns/page.tsx b/frontend/app/accounting/sales/returns/page.tsx index f9fa4ef..4bf960c 100644 --- a/frontend/app/accounting/sales/returns/page.tsx +++ b/frontend/app/accounting/sales/returns/page.tsx @@ -11,6 +11,7 @@ export default function Page() { docType="return" partyLabel="مشتری" kind="invoice" + enableAccountingPost="sales" /> ); } diff --git a/frontend/app/accounting/settings/auto-posting/page.tsx b/frontend/app/accounting/settings/auto-posting/page.tsx new file mode 100644 index 0000000..fef4c0f --- /dev/null +++ b/frontend/app/accounting/settings/auto-posting/page.tsx @@ -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[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 ; + if (policyQ.error) { + return 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[1]); + }; + + return ( +
+ + + + + + حالت ثبت + {policy.posting_mode === "direct_post" ? "ثبت مستقیم" : "پیش‌نویس سپس قطعی"} + + + + +

+ در حالت مستقیم، با تأیید سند عملیاتی (اگر ماژول روشن باشد) سند GL بلافاصله از طریق Posting Engine صادر می‌شود. +

+
+
+ +
+ + + قواعد الزامی (Compliance) + + + {POLICY_KEYS.map((item) => ( +
+ )[item.key])} + disabled={saveM.isPending} + onChange={(e) => togglePolicy(item.key, e.target.checked)} + aria-label={item.label} + /> + + {item.label} + {item.hint} + +
+ ))} +
+
+ + + + ثبت خودکار به تفکیک ماژول + + + {AUTO_KEYS.map((item) => ( +
+ toggleAuto(item.key, e.target.checked)} + aria-label={item.label} + /> + + {item.label} + {item.hint} + +
+ ))} +
+
+
+ + + +

+ قلب حسابداری فقط از مسیر Posting Engine می‌گذرد (ADR-010). تنظیمات بالا فقط زمان و شرط صدور را کنترل می‌کنند. +

+ +
+
+
+ ); +} diff --git a/frontend/app/accounting/vouchers/[id]/page.tsx b/frontend/app/accounting/vouchers/[id]/page.tsx index a731b48..2f97b40 100644 --- a/frontend/app/accounting/vouchers/[id]/page.tsx +++ b/frontend/app/accounting/vouchers/[id]/page.tsx @@ -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 ; if (voucherQ.error) return 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() { ردیف - {fields.map((field, index) => ( + {fields.map((field, index) => { + const lineErr = lineBothSidesError( + watchedEditLines?.[index]?.debit ?? "0", + watchedEditLines?.[index]?.credit ?? "0" + ); + return (
@@ -377,19 +396,40 @@ export default function VoucherDetailPage() {
+ {lineErr ? ( +

ردیف {index + 1}: {lineErr}

+ ) : null}
- ))} + ); + })} {editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message} +
+
+ جمع بدهکار: {formatMoney(String(balance.debit))} + جمع بستانکار: {formatMoney(String(balance.credit))} + اختلاف: {formatMoney(String(Math.abs(balance.difference)))} + {balance.isBalanced ? "متوازن ✓" : "نامتوازن — ذخیره مجاز نیست"} +
+ {!balance.isBalanced ? ( +

{unbalancedMessage(balance.debit, balance.credit)}

+ ) : null} +
-
diff --git a/frontend/app/accounting/vouchers/new/page.tsx b/frontend/app/accounting/vouchers/new/page.tsx index 1b3e217..bfeb481 100644 --- a/frontend/app/accounting/vouchers/new/page.tsx +++ b/frontend/app/accounting/vouchers/new/page.tsx @@ -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 ; if (periodsQ.error || accountsQ.error) { return ( @@ -219,10 +233,17 @@ export default function NewVoucherPage() { ردیف - {fields.map((field, index) => ( + {fields.map((field, index) => { + const lineErr = lineBothSidesError( + watchedLines?.[index]?.debit ?? "0", + watchedLines?.[index]?.credit ?? "0" + ); + return (
@@ -281,9 +302,30 @@ export default function NewVoucherPage() {
+ {lineErr ? ( +

ردیف {index + 1}: {lineErr}

+ ) : null}
- ))} + ); + })} {form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message} +
+
+ جمع بدهکار: {formatMoney(String(balance.debit))} + جمع بستانکار: {formatMoney(String(balance.credit))} + اختلاف: {formatMoney(String(Math.abs(balance.difference)))} + {balance.isBalanced ? "متوازن ✓" : "نامتوازن — ثبت مجاز نیست"} +
+ {!balance.isBalanced ? ( +

{unbalancedMessage(balance.debit, balance.credit)}

+ ) : null} +
@@ -291,7 +333,7 @@ export default function NewVoucherPage() { - diff --git a/frontend/components/accounting/BusinessDocsPage.tsx b/frontend/components/accounting/BusinessDocsPage.tsx index c66f2c5..1a8d4a9 100644 --- a/frontend/components/accounting/BusinessDocsPage.tsx +++ b/frontend/components/accounting/BusinessDocsPage.tsx @@ -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 ; + if ( + !tenantId || + receiptsQ.isLoading || + paymentsQ.isLoading || + cashBoxesQ.isLoading || + bankAccountsQ.isLoading + ) { + return ; + } if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) { return ( 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 (
setOpen(true)}> @@ -846,6 +889,11 @@ export function ReceiptsPaymentsPage() { header: "مبلغ", render: (r) => formatMoney(String(r.amount)), }, + { + key: "voucher_id", + header: "سند GL", + render: (r) => (r.voucher_id ? ثبت‌شده : "—"), + }, { key: "description", header: "توضیح" }, ]} rows={rows as unknown as Record[]} @@ -856,10 +904,7 @@ export function ReceiptsPaymentsPage() {
createM.mutate(d))}>
- setMode(e.target.value as typeof mode)}> @@ -894,7 +939,9 @@ export function ReceiptsPaymentsPage() { @@ -902,10 +949,29 @@ export function ReceiptsPaymentsPage() { +
+ + + + {autoPost ? ( +

+ ثبت خودکار سند دفتر کل فعال است — حساب صندوق/بانک از تنظیمات صندوق/حساب بانکی گرفته می‌شود. +

+ ) : null} +
diff --git a/frontend/components/accounting/OperationalDocumentPage.tsx b/frontend/components/accounting/OperationalDocumentPage.tsx index 5b9b74b..29e562c 100644 --- a/frontend/components/accounting/OperationalDocumentPage.tsx +++ b/frontend/components/accounting/OperationalDocumentPage.tsx @@ -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} /> diff --git a/frontend/lib/accounting-api.ts b/frontend/lib/accounting-api.ts index b168be4..be098b7 100644 --- a/frontend/lib/accounting-api.ts +++ b/frontend/lib/accounting-api.ts @@ -254,6 +254,41 @@ export const accountingApi = { method: "POST", body: JSON.stringify({ document_type: documentType }), }), + getPostingPolicy: (tenantId: string) => + request<{ + auto_post: Record; + 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; + require_balanced?: boolean; + require_source_reference?: boolean; + lock_after_post?: boolean; + sequential_numbers?: boolean; + idempotent_source?: boolean; + posting_mode?: string; + } + ) => + request<{ + auto_post: Record; + 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", { - tenantId, - method: "POST", - body: JSON.stringify(body), - }), + 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", { - tenantId, - method: "POST", - body: JSON.stringify(body), - }), + 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`, { - tenantId, - method: "POST", - body: JSON.stringify({}), - }), + 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", { - tenantId, - method: "POST", - body: JSON.stringify(body), - }), + 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: { diff --git a/frontend/lib/accounting-nav.ts b/frontend/lib/accounting-nav.ts index 9cc2a76..b3e94b3 100644 --- a/frontend/lib/accounting-nav.ts +++ b/frontend/lib/accounting-nav.ts @@ -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: "کاربران و دسترسی" }, diff --git a/frontend/lib/voucher-balance.ts b/frontend/lib/voucher-balance.ts new file mode 100644 index 0000000..81dc67e --- /dev/null +++ b/frontend/lib/voucher-balance.ts @@ -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; +}