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,
|
doc_id: UUID,
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
entity = await BizDocRepo(db).get(tenant_id, doc_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
|
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.commit()
|
||||||
await db.refresh(entity)
|
await db.refresh(entity)
|
||||||
return entity
|
return entity
|
||||||
|
|||||||
@ -27,6 +27,45 @@ from shared.security import CurrentUser
|
|||||||
router = APIRouter()
|
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])
|
@router.get("/vouchers", response_model=list[VoucherRead])
|
||||||
async def list_vouchers(
|
async def list_vouchers(
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
@ -58,6 +97,7 @@ async def create_voucher(
|
|||||||
):
|
):
|
||||||
from app.services.document_number_service import DocumentNumberService
|
from app.services.document_number_service import DocumentNumberService
|
||||||
|
|
||||||
|
_assert_lines_balanced(body.lines)
|
||||||
repo = VoucherRepository(db)
|
repo = VoucherRepository(db)
|
||||||
number = await DocumentNumberService(db).allocate_if_blank(
|
number = await DocumentNumberService(db).allocate_if_blank(
|
||||||
tenant_id, "voucher", body.voucher_number
|
tenant_id, "voucher", body.voucher_number
|
||||||
@ -120,6 +160,8 @@ async def update_draft_voucher(
|
|||||||
)
|
)
|
||||||
data = body.model_dump(exclude_unset=True)
|
data = body.model_dump(exclude_unset=True)
|
||||||
lines_data = data.pop("lines", None)
|
lines_data = data.pop("lines", None)
|
||||||
|
if lines_data is not None:
|
||||||
|
_assert_lines_balanced(lines_data)
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
setattr(voucher, key, value)
|
setattr(voucher, key, value)
|
||||||
if lines_data is not None:
|
if lines_data is not None:
|
||||||
|
|||||||
@ -98,7 +98,70 @@ async def post_goods_receipt(
|
|||||||
profile_id=body.profile_id,
|
profile_id=body.profile_id,
|
||||||
)
|
)
|
||||||
await db.commit()
|
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")
|
@router.post("/valuation")
|
||||||
|
|||||||
@ -108,3 +108,40 @@ async def allocate_number(
|
|||||||
number = await svc.allocate(tenant_id, body.document_type)
|
number = await svc.allocate(tenant_id, body.document_type)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"document_type": body.document_type, "number": number}
|
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"
|
payment_method: str = "cash"
|
||||||
cash_box_id: UUID | None = None
|
cash_box_id: UUID | None = None
|
||||||
bank_account_id: UUID | None = None
|
bank_account_id: UUID | None = None
|
||||||
|
counter_account_id: UUID | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -270,6 +271,7 @@ class PaymentCreate(BaseModel):
|
|||||||
payment_method: str = "cash"
|
payment_method: str = "cash"
|
||||||
cash_box_id: UUID | None = None
|
cash_box_id: UUID | None = None
|
||||||
bank_account_id: UUID | None = None
|
bank_account_id: UUID | None = None
|
||||||
|
counter_account_id: UUID | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@ -365,6 +367,7 @@ async def list_receipts(
|
|||||||
"amount": str(r.amount),
|
"amount": str(r.amount),
|
||||||
"payment_method": r.payment_method,
|
"payment_method": r.payment_method,
|
||||||
"description": r.description,
|
"description": r.description,
|
||||||
|
"voucher_id": str(r.voucher_id) if r.voucher_id else None,
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -375,18 +378,72 @@ async def create_receipt(
|
|||||||
body: ReceiptCreate,
|
body: ReceiptCreate,
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
db: AsyncSession = Depends(get_db),
|
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.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()
|
data = body.model_dump()
|
||||||
|
counter_account_id = data.pop("counter_account_id", None)
|
||||||
data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank(
|
data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank(
|
||||||
tenant_id, "receipt", body.receipt_number
|
tenant_id, "receipt", body.receipt_number
|
||||||
)
|
)
|
||||||
entity = ReceiptVoucher(tenant_id=tenant_id, **data)
|
entity = ReceiptVoucher(tenant_id=tenant_id, **data)
|
||||||
await ReceiptRepo(db).add(entity)
|
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()
|
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")
|
@router.get("/payments")
|
||||||
@ -404,6 +461,7 @@ async def list_payments(
|
|||||||
"amount": str(p.amount),
|
"amount": str(p.amount),
|
||||||
"payment_method": p.payment_method,
|
"payment_method": p.payment_method,
|
||||||
"description": p.description,
|
"description": p.description,
|
||||||
|
"voucher_id": str(p.voucher_id) if p.voucher_id else None,
|
||||||
}
|
}
|
||||||
for p in rows
|
for p in rows
|
||||||
]
|
]
|
||||||
@ -414,18 +472,113 @@ async def create_payment(
|
|||||||
body: PaymentCreate,
|
body: PaymentCreate,
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
db: AsyncSession = Depends(get_db),
|
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.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()
|
data = body.model_dump()
|
||||||
|
counter_account_id = data.pop("counter_account_id", None)
|
||||||
data["payment_number"] = await DocumentNumberService(db).allocate_if_blank(
|
data["payment_number"] = await DocumentNumberService(db).allocate_if_blank(
|
||||||
tenant_id, "payment", body.payment_number
|
tenant_id, "payment", body.payment_number
|
||||||
)
|
)
|
||||||
entity = PaymentVoucher(tenant_id=tenant_id, **data)
|
entity = PaymentVoucher(tenant_id=tenant_id, **data)
|
||||||
await PaymentRepo(db).add(entity)
|
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()
|
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")
|
@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:
|
) -> Voucher:
|
||||||
from datetime import date
|
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)
|
profile = await self._resolve_purchase_profile(tenant_id, profile_id)
|
||||||
period = await self.period_repo.get_current(tenant_id)
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
if period is None:
|
if period is None:
|
||||||
@ -142,9 +152,217 @@ class PurchaseInventoryAccountingService:
|
|||||||
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
|
account_id=profile.grni_account_id, debit=Decimal("0"), credit=amount,
|
||||||
))
|
))
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return await self.posting_engine.post_voucher(
|
if await guard.should_post_immediately(tenant_id):
|
||||||
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="purchase"
|
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(
|
async def record_valuation(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -90,6 +90,16 @@ class SalesAccountingService:
|
|||||||
) -> Voucher:
|
) -> Voucher:
|
||||||
from datetime import date
|
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)
|
profile = await self._resolve_profile(tenant_id, document_type, profile_id)
|
||||||
period = await self.period_repo.get_current(tenant_id)
|
period = await self.period_repo.get_current(tenant_id)
|
||||||
if period is None:
|
if period is None:
|
||||||
@ -138,9 +148,18 @@ class SalesAccountingService:
|
|||||||
))
|
))
|
||||||
|
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return await self.posting_engine.post_voucher(
|
if await guard.should_post_immediately(tenant_id):
|
||||||
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="sales"
|
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(
|
async def _resolve_profile(
|
||||||
self, tenant_id: UUID, document_type: SalesDocumentType, profile_id: UUID | None
|
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
|
cash_box.current_balance -= amount
|
||||||
return tx
|
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(
|
async def _create_treasury_voucher(
|
||||||
self,
|
self,
|
||||||
tenant_id: UUID,
|
tenant_id: UUID,
|
||||||
@ -144,6 +201,9 @@ class TreasuryService:
|
|||||||
description: str,
|
description: str,
|
||||||
actor_user_id: str,
|
actor_user_id: str,
|
||||||
source_module: str,
|
source_module: str,
|
||||||
|
*,
|
||||||
|
post_immediately: bool = True,
|
||||||
|
reference_number: str | None = None,
|
||||||
) -> Voucher:
|
) -> Voucher:
|
||||||
voucher = Voucher(
|
voucher = Voucher(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@ -153,6 +213,7 @@ class TreasuryService:
|
|||||||
status=VoucherStatus.DRAFT,
|
status=VoucherStatus.DRAFT,
|
||||||
description=description,
|
description=description,
|
||||||
source_module=source_module,
|
source_module=source_module,
|
||||||
|
reference_number=reference_number,
|
||||||
created_by=actor_user_id,
|
created_by=actor_user_id,
|
||||||
)
|
)
|
||||||
await self.voucher_repo.add(voucher)
|
await self.voucher_repo.add(voucher)
|
||||||
@ -168,6 +229,8 @@ class TreasuryService:
|
|||||||
description=description,
|
description=description,
|
||||||
))
|
))
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
|
if not post_immediately:
|
||||||
|
return voucher
|
||||||
return await self.posting_engine.post_voucher(
|
return await self.posting_engine.post_voucher(
|
||||||
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module=source_module
|
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"
|
docType="goods_receipt"
|
||||||
partyLabel="تأمینکننده"
|
partyLabel="تأمینکننده"
|
||||||
kind="receipt"
|
kind="receipt"
|
||||||
enableAccountingPost="purchase"
|
enableAccountingPost="purchase_gr"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export default function Page() {
|
|||||||
docType="invoice"
|
docType="invoice"
|
||||||
partyLabel="تأمینکننده"
|
partyLabel="تأمینکننده"
|
||||||
kind="invoice"
|
kind="invoice"
|
||||||
enableAccountingPost="purchase"
|
enableAccountingPost="purchase_invoice"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export default function Page() {
|
|||||||
docType="return"
|
docType="return"
|
||||||
partyLabel="تأمینکننده"
|
partyLabel="تأمینکننده"
|
||||||
kind="invoice"
|
kind="invoice"
|
||||||
|
enableAccountingPost="purchase_return"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export default function Page() {
|
|||||||
docType="return"
|
docType="return"
|
||||||
partyLabel="مشتری"
|
partyLabel="مشتری"
|
||||||
kind="invoice"
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -11,6 +11,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { accountingApi } from "@/lib/accounting-api";
|
import { accountingApi } from "@/lib/accounting-api";
|
||||||
import { useTenantId } from "@/hooks/useTenantId";
|
import { useTenantId } from "@/hooks/useTenantId";
|
||||||
import { formatMoney, toRialInteger } from "@/lib/utils";
|
import { formatMoney, toRialInteger } from "@/lib/utils";
|
||||||
|
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
|
||||||
import {
|
import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Button,
|
Button,
|
||||||
@ -116,8 +117,14 @@ export default function VoucherDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateM = useMutation({
|
const updateM = useMutation({
|
||||||
mutationFn: (data: EditForm) =>
|
mutationFn: (data: EditForm) => {
|
||||||
accountingApi.vouchers.update(tenantId!, id, {
|
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,
|
voucher_date: data.voucher_date,
|
||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
lines: data.lines.map((l) => ({
|
lines: data.lines.map((l) => ({
|
||||||
@ -128,7 +135,8 @@ export default function VoucherDetailPage() {
|
|||||||
cost_center_id: l.cost_center_id || null,
|
cost_center_id: l.cost_center_id || null,
|
||||||
project_id: l.project_id || null,
|
project_id: l.project_id || null,
|
||||||
})),
|
})),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success("سند بهروز شد");
|
toast.success("سند بهروز شد");
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@ -183,6 +191,9 @@ export default function VoucherDetailPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
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 (!tenantId || voucherQ.isLoading) return <LoadingState />;
|
||||||
if (voucherQ.error) return <ErrorState message={voucherQ.error.message} onRetry={() => voucherQ.refetch()} />;
|
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 postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
|
||||||
const activeCostCenters = (costCentersQ.data ?? []).filter((c) => c.is_active);
|
const activeCostCenters = (costCentersQ.data ?? []).filter((c) => c.is_active);
|
||||||
const activeProjects = (projectsQ.data ?? []).filter((p) => p.is_active);
|
const activeProjects = (projectsQ.data ?? []).filter((p) => p.is_active);
|
||||||
|
|
||||||
const isDraft = v.status === "draft";
|
const isDraft = v.status === "draft";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -319,10 +331,17 @@ export default function VoucherDetailPage() {
|
|||||||
ردیف
|
ردیف
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
|
const lineErr = lineBothSidesError(
|
||||||
|
watchedEditLines?.[index]?.debit ?? "0",
|
||||||
|
watchedEditLines?.[index]?.credit ?? "0"
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
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">
|
<div className="sm:col-span-3">
|
||||||
<Label>حساب</Label>
|
<Label>حساب</Label>
|
||||||
@ -377,19 +396,40 @@ export default function VoucherDetailPage() {
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{lineErr ? (
|
||||||
|
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<FieldError>
|
<FieldError>
|
||||||
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
|
{editForm.formState.errors.lines?.message || editForm.formState.errors.lines?.root?.message}
|
||||||
</FieldError>
|
</FieldError>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={updateM.isPending}>
|
<Button type="submit" disabled={updateM.isPending || !balance.isBalanced || (watchedEditLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
|
||||||
ذخیره تغییرات
|
ذخیره تغییرات
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -11,6 +11,8 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { accountingApi } from "@/lib/accounting-api";
|
import { accountingApi } from "@/lib/accounting-api";
|
||||||
import { useTenantId } from "@/hooks/useTenantId";
|
import { useTenantId } from "@/hooks/useTenantId";
|
||||||
import { todayIso } from "@/lib/jalali";
|
import { todayIso } from "@/lib/jalali";
|
||||||
|
import { formatMoney } from "@/lib/utils";
|
||||||
|
import { lineBothSidesError, sumVoucherLines, unbalancedMessage } from "@/lib/voucher-balance";
|
||||||
import {
|
import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Button,
|
Button,
|
||||||
@ -96,8 +98,16 @@ export default function NewVoucherPage() {
|
|||||||
}, [nextNumberQ.data, form]);
|
}, [nextNumberQ.data, form]);
|
||||||
|
|
||||||
const createM = useMutation({
|
const createM = useMutation({
|
||||||
mutationFn: (data: FormValues) =>
|
mutationFn: (data: FormValues) => {
|
||||||
accountingApi.vouchers.create(tenantId!, {
|
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,
|
fiscal_period_id: data.fiscal_period_id,
|
||||||
voucher_number: data.voucher_number?.trim() || undefined,
|
voucher_number: data.voucher_number?.trim() || undefined,
|
||||||
voucher_date: data.voucher_date,
|
voucher_date: data.voucher_date,
|
||||||
@ -111,7 +121,8 @@ export default function NewVoucherPage() {
|
|||||||
cost_center_id: l.cost_center_id || null,
|
cost_center_id: l.cost_center_id || null,
|
||||||
project_id: l.project_id || null,
|
project_id: l.project_id || null,
|
||||||
})),
|
})),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: (v) => {
|
onSuccess: (v) => {
|
||||||
toast.success("سند ایجاد شد");
|
toast.success("سند ایجاد شد");
|
||||||
router.push(`/accounting/vouchers/${v.id}`);
|
router.push(`/accounting/vouchers/${v.id}`);
|
||||||
@ -119,6 +130,9 @@ export default function NewVoucherPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
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 (!tenantId || periodsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
|
||||||
if (periodsQ.error || accountsQ.error) {
|
if (periodsQ.error || accountsQ.error) {
|
||||||
return (
|
return (
|
||||||
@ -219,10 +233,17 @@ export default function NewVoucherPage() {
|
|||||||
ردیف
|
ردیف
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => {
|
||||||
|
const lineErr = lineBothSidesError(
|
||||||
|
watchedLines?.[index]?.debit ?? "0",
|
||||||
|
watchedLines?.[index]?.credit ?? "0"
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
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">
|
<div className="sm:col-span-3">
|
||||||
<Label>حساب</Label>
|
<Label>حساب</Label>
|
||||||
@ -281,9 +302,30 @@ export default function NewVoucherPage() {
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{lineErr ? (
|
||||||
|
<p className="sm:col-span-12 text-xs text-red-700">ردیف {index + 1}: {lineErr}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<FieldError>{form.formState.errors.lines?.message || form.formState.errors.lines?.root?.message}</FieldError>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -291,7 +333,7 @@ export default function NewVoucherPage() {
|
|||||||
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
|
<Button type="button" variant="outline" onClick={() => router.push("/accounting/vouchers")}>
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createM.isPending}>
|
<Button type="submit" disabled={createM.isPending || !balance.isBalanced || (watchedLines ?? []).some((l) => !!lineBothSidesError(l.debit, l.credit))}>
|
||||||
ذخیره پیشنویس
|
ذخیره پیشنویس
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -713,6 +713,7 @@ export function ReceiptsPaymentsPage() {
|
|||||||
payment_method: "cash",
|
payment_method: "cash",
|
||||||
cash_box_id: "",
|
cash_box_id: "",
|
||||||
bank_account_id: "",
|
bank_account_id: "",
|
||||||
|
counter_account_id: "",
|
||||||
description: "",
|
description: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -737,6 +738,21 @@ export function ReceiptsPaymentsPage() {
|
|||||||
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
|
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
|
||||||
enabled: !!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({
|
const createM = useMutation({
|
||||||
mutationFn: async (d: {
|
mutationFn: async (d: {
|
||||||
@ -746,8 +762,13 @@ export function ReceiptsPaymentsPage() {
|
|||||||
payment_method: string;
|
payment_method: string;
|
||||||
cash_box_id?: string;
|
cash_box_id?: string;
|
||||||
bank_account_id?: string;
|
bank_account_id?: string;
|
||||||
|
counter_account_id?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (autoPost && !d.counter_account_id) {
|
||||||
|
throw new Error("حساب مقابل برای ثبت خودکار سند حسابداری الزامی است");
|
||||||
|
}
|
||||||
|
const counter = d.counter_account_id || undefined;
|
||||||
if (mode === "receipt") {
|
if (mode === "receipt") {
|
||||||
return accountingApi.treasury.createReceipt(tenantId!, {
|
return accountingApi.treasury.createReceipt(tenantId!, {
|
||||||
receipt_number: d.number,
|
receipt_number: d.number,
|
||||||
@ -756,6 +777,7 @@ export function ReceiptsPaymentsPage() {
|
|||||||
payment_method: d.payment_method,
|
payment_method: d.payment_method,
|
||||||
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
|
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,
|
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
|
||||||
|
counter_account_id: counter,
|
||||||
description: d.description,
|
description: d.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -766,20 +788,34 @@ export function ReceiptsPaymentsPage() {
|
|||||||
payment_method: d.payment_method,
|
payment_method: d.payment_method,
|
||||||
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
|
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,
|
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
|
||||||
|
counter_account_id: counter,
|
||||||
description: d.description,
|
description: d.description,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (res) => {
|
||||||
toast.success("ثبت شد");
|
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);
|
setOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "receipts"] });
|
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "receipts"] });
|
||||||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
|
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
|
||||||
|
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
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) {
|
if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
@ -792,6 +828,7 @@ export function ReceiptsPaymentsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
|
||||||
const rows =
|
const rows =
|
||||||
mode === "receipt"
|
mode === "receipt"
|
||||||
? (receiptsQ.data ?? []).map((r) => ({
|
? (receiptsQ.data ?? []).map((r) => ({
|
||||||
@ -801,6 +838,7 @@ export function ReceiptsPaymentsPage() {
|
|||||||
amount: r.amount,
|
amount: r.amount,
|
||||||
payment_method: r.payment_method,
|
payment_method: r.payment_method,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
voucher_id: r.voucher_id,
|
||||||
}))
|
}))
|
||||||
: (paymentsQ.data ?? []).map((p) => ({
|
: (paymentsQ.data ?? []).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@ -809,13 +847,18 @@ export function ReceiptsPaymentsPage() {
|
|||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
payment_method: p.payment_method,
|
payment_method: p.payment_method,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
|
voucher_id: p.voucher_id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="دریافت و پرداخت"
|
title="دریافت و پرداخت"
|
||||||
description="ثبت دریافت/پرداخت خزانه."
|
description={
|
||||||
|
autoPost
|
||||||
|
? "ثبت دریافت/پرداخت با صدور خودکار سند حسابداری (قابل تنظیم در اسناد اتوماتیک)."
|
||||||
|
: "ثبت دریافت/پرداخت خزانه — ثبت خودکار دفتر کل فعلاً خاموش است."
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button type="button" onClick={() => setOpen(true)}>
|
<Button type="button" onClick={() => setOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@ -846,6 +889,11 @@ export function ReceiptsPaymentsPage() {
|
|||||||
header: "مبلغ",
|
header: "مبلغ",
|
||||||
render: (r) => formatMoney(String(r.amount)),
|
render: (r) => formatMoney(String(r.amount)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "voucher_id",
|
||||||
|
header: "سند GL",
|
||||||
|
render: (r) => (r.voucher_id ? <Badge tone="success">ثبتشده</Badge> : "—"),
|
||||||
|
},
|
||||||
{ key: "description", header: "توضیح" },
|
{ key: "description", header: "توضیح" },
|
||||||
]}
|
]}
|
||||||
rows={rows as unknown as Record<string, unknown>[]}
|
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))}>
|
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<FormField label="نوع عملیات">
|
<FormField label="نوع عملیات">
|
||||||
<Select
|
<Select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}>
|
||||||
value={mode}
|
|
||||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
|
||||||
>
|
|
||||||
<option value="receipt">دریافت</option>
|
<option value="receipt">دریافت</option>
|
||||||
<option value="payment">پرداخت</option>
|
<option value="payment">پرداخت</option>
|
||||||
</Select>
|
</Select>
|
||||||
@ -894,7 +939,9 @@ export function ReceiptsPaymentsPage() {
|
|||||||
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
|
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
|
||||||
<option value="">انتخاب صندوق…</option>
|
<option value="">انتخاب صندوق…</option>
|
||||||
{(cashBoxesQ.data ?? []).map((cashBox) => (
|
{(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>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
@ -902,10 +949,29 @@ export function ReceiptsPaymentsPage() {
|
|||||||
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
|
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
|
||||||
<option value="">انتخاب حساب…</option>
|
<option value="">انتخاب حساب…</option>
|
||||||
{(bankAccountsQ.data ?? []).map((account) => (
|
{(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>
|
</Select>
|
||||||
</FormField>
|
</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">
|
<div className="sm:col-span-2">
|
||||||
<FormField label="توضیح">
|
<FormField label="توضیح">
|
||||||
<Input {...form.register("description")} />
|
<Input {...form.register("description")} />
|
||||||
|
|||||||
@ -144,8 +144,8 @@ export function OperationalDocumentPage({
|
|||||||
createLabel: string;
|
createLabel: string;
|
||||||
partyLabel?: string;
|
partyLabel?: string;
|
||||||
kind?: DocKind;
|
kind?: DocKind;
|
||||||
/** When true, draft rows get "ثبت حسابداری" using sales/purchase engines. */
|
/** When set, draft rows get "ثبت حسابداری" using the matching engine. */
|
||||||
enableAccountingPost?: "sales" | "purchase";
|
enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return";
|
||||||
}) {
|
}) {
|
||||||
const { tenantId } = useTenantId();
|
const { tenantId } = useTenantId();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@ -173,7 +173,16 @@ export function OperationalDocumentPage({
|
|||||||
const purchaseProfilesQ = useQuery({
|
const purchaseProfilesQ = useQuery({
|
||||||
queryKey: ["accounting", tenantId, "purchase-profiles"],
|
queryKey: ["accounting", tenantId, "purchase-profiles"],
|
||||||
queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!),
|
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(
|
const defaults: FormValues = useMemo(
|
||||||
@ -259,8 +268,21 @@ export function OperationalDocumentPage({
|
|||||||
|
|
||||||
const confirmM = useMutation({
|
const confirmM = useMutation({
|
||||||
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
|
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
|
||||||
onSuccess: async () => {
|
onSuccess: async (doc) => {
|
||||||
toast.success("تأیید شد");
|
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);
|
setConfirmId(null);
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
@ -274,13 +296,20 @@ export function OperationalDocumentPage({
|
|||||||
const profileId = postForm.getValues("profile_id") || undefined;
|
const profileId = postForm.getValues("profile_id") || undefined;
|
||||||
if (enableAccountingPost === "sales") {
|
if (enableAccountingPost === "sales") {
|
||||||
return accountingApi.salesAccounting.preview(tenantId!, {
|
return accountingApi.salesAccounting.preview(tenantId!, {
|
||||||
document_type: "invoice",
|
document_type: docType === "return" ? "return" : "invoice",
|
||||||
total_amount: amount,
|
total_amount: amount,
|
||||||
discount_amount: postForm.getValues("discount_amount") || "0",
|
discount_amount: postForm.getValues("discount_amount") || "0",
|
||||||
tax_amount: postForm.getValues("tax_amount") || "0",
|
tax_amount: postForm.getValues("tax_amount") || "0",
|
||||||
profile_id: profileId,
|
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!, {
|
return accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, {
|
||||||
source_document_id: String(postRow.id),
|
source_document_id: String(postRow.id),
|
||||||
amount,
|
amount,
|
||||||
@ -301,7 +330,7 @@ export function OperationalDocumentPage({
|
|||||||
const profileId = postForm.getValues("profile_id") || undefined;
|
const profileId = postForm.getValues("profile_id") || undefined;
|
||||||
if (enableAccountingPost === "sales") {
|
if (enableAccountingPost === "sales") {
|
||||||
return accountingApi.salesAccounting.post(tenantId!, {
|
return accountingApi.salesAccounting.post(tenantId!, {
|
||||||
document_type: "invoice",
|
document_type: docType === "return" ? "return" : "invoice",
|
||||||
source_document_id: String(postRow.id),
|
source_document_id: String(postRow.id),
|
||||||
total_amount: amount,
|
total_amount: amount,
|
||||||
discount_amount: postForm.getValues("discount_amount") || "0",
|
discount_amount: postForm.getValues("discount_amount") || "0",
|
||||||
@ -309,6 +338,20 @@ export function OperationalDocumentPage({
|
|||||||
profile_id: profileId,
|
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!, {
|
return accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, {
|
||||||
source_document_id: String(postRow.id),
|
source_document_id: String(postRow.id),
|
||||||
amount,
|
amount,
|
||||||
@ -716,7 +759,27 @@ export function OperationalDocumentPage({
|
|||||||
onClose={() => setConfirmId(null)}
|
onClose={() => setConfirmId(null)}
|
||||||
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
|
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
|
||||||
title="تأیید سند؟"
|
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="تأیید"
|
confirmLabel="تأیید"
|
||||||
loading={confirmM.isPending}
|
loading={confirmM.isPending}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -254,6 +254,41 @@ export const accountingApi = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ document_type: documentType }),
|
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: {
|
charts: {
|
||||||
@ -818,6 +853,7 @@ export const accountingApi = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
voucher_id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
>("/api/v1/treasury/receipts", { tenantId }),
|
>("/api/v1/treasury/receipts", { tenantId }),
|
||||||
createReceipt: (
|
createReceipt: (
|
||||||
@ -829,14 +865,18 @@ export const accountingApi = {
|
|||||||
payment_method?: string;
|
payment_method?: string;
|
||||||
cash_box_id?: string;
|
cash_box_id?: string;
|
||||||
bank_account_id?: string;
|
bank_account_id?: string;
|
||||||
|
counter_account_id?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) =>
|
) =>
|
||||||
request<{ id: string }>("/api/v1/treasury/receipts", {
|
request<{ id: string; receipt_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
|
||||||
tenantId,
|
"/api/v1/treasury/receipts",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify(body),
|
tenantId,
|
||||||
}),
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
),
|
||||||
listPayments: (tenantId: string) =>
|
listPayments: (tenantId: string) =>
|
||||||
request<
|
request<
|
||||||
{
|
{
|
||||||
@ -846,6 +886,7 @@ export const accountingApi = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
voucher_id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
>("/api/v1/treasury/payments", { tenantId }),
|
>("/api/v1/treasury/payments", { tenantId }),
|
||||||
createPayment: (
|
createPayment: (
|
||||||
@ -857,14 +898,18 @@ export const accountingApi = {
|
|||||||
payment_method?: string;
|
payment_method?: string;
|
||||||
cash_box_id?: string;
|
cash_box_id?: string;
|
||||||
bank_account_id?: string;
|
bank_account_id?: string;
|
||||||
|
counter_account_id?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) =>
|
) =>
|
||||||
request<{ id: string }>("/api/v1/treasury/payments", {
|
request<{ id: string; payment_number?: string; voucher_id?: string | null; voucher_number?: string | null }>(
|
||||||
tenantId,
|
"/api/v1/treasury/payments",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify(body),
|
tenantId,
|
||||||
}),
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
),
|
||||||
listReconciliations: (tenantId: string) =>
|
listReconciliations: (tenantId: string) =>
|
||||||
request<
|
request<
|
||||||
{
|
{
|
||||||
@ -953,11 +998,14 @@ export const accountingApi = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
confirmDocument: (tenantId: string, id: string) =>
|
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 }>(
|
||||||
tenantId,
|
`/api/v1/ops/documents/${id}/confirm`,
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify({}),
|
tenantId,
|
||||||
}),
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
}
|
||||||
|
),
|
||||||
listItems: (tenantId: string) =>
|
listItems: (tenantId: string) =>
|
||||||
request<
|
request<
|
||||||
{
|
{
|
||||||
@ -1628,11 +1676,46 @@ export const accountingApi = {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
body: { source_document_id: string; amount: string; profile_id?: 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 }>(
|
||||||
tenantId,
|
"/api/v1/purchase-inventory/post/goods-receipt",
|
||||||
method: "POST",
|
{
|
||||||
body: JSON.stringify(body),
|
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: (
|
valuation: (
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@ -258,6 +258,7 @@ export const ACCOUNTING_NAV: AccountingNavGroup[] = [
|
|||||||
{ href: "/accounting/fiscal", label: "دورههای مالی" },
|
{ href: "/accounting/fiscal", label: "دورههای مالی" },
|
||||||
{ href: "/accounting/currencies", label: "ارزها و نرخها" },
|
{ href: "/accounting/currencies", label: "ارزها و نرخها" },
|
||||||
{ href: "/accounting/settings", label: "تنظیمات حسابداری" },
|
{ href: "/accounting/settings", label: "تنظیمات حسابداری" },
|
||||||
|
{ href: "/accounting/settings/auto-posting", label: "اسناد اتوماتیک" },
|
||||||
{ href: "/accounting/settings/tax", label: "تنظیمات مالیاتی" },
|
{ href: "/accounting/settings/tax", label: "تنظیمات مالیاتی" },
|
||||||
{ href: "/accounting/settings/general", label: "تنظیمات عمومی" },
|
{ href: "/accounting/settings/general", label: "تنظیمات عمومی" },
|
||||||
{ href: "/dashboard/settings", 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