Add Phase 17 common native Tally voucher engine

This commit is contained in:
A R R R Associates
2026-08-23 15:26:37 +05:30
parent 7d11236eed
commit 1111a52917
11 changed files with 1809 additions and 3 deletions
@@ -0,0 +1,572 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from app.modules.accounting.chart_models import AccountingChartLedger
from app.modules.accounting.chart_service import effective_role
from app.modules.accounting.voucher_engine_models import AccountingVoucherDraft, AccountingVoucherLine
from app.modules.documents.agent_jobs import enqueue_agent_job
from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
VOUCHER_TYPES = ("Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal")
BUSINESS_NATURES = (
("PURCHASE", "Purchase"),
("DIRECT_EXPENSE", "Direct Expense"),
("INDIRECT_EXPENSE", "Indirect Expense"),
("SALES", "Sales"),
("DIRECT_INCOME", "Direct Income"),
("INDIRECT_INCOME", "Indirect / Other Income"),
("RECEIPT", "Receipt"),
("PAYMENT", "Payment"),
("CONTRA", "Contra"),
("ADJUSTMENT", "Adjustment / Journal"),
)
PREFLIGHT_ACTION = "accounting_native_voucher_preflight"
POST_ACTION = "accounting_post_native_voucher"
def _utcnow():
return datetime.now(timezone.utc)
def _s(value):
return str(value or "").strip()
def _loads(value, default=None):
try:
return json.loads(value or "")
except Exception:
return default if default is not None else {}
def _dumps(value):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
def suggested_voucher_type(business_nature: str) -> str:
nature = _s(business_nature).upper()
return {
"PURCHASE": "Purchase",
"DIRECT_EXPENSE": "Purchase",
"INDIRECT_EXPENSE": "Purchase",
"SALES": "Sales",
"DIRECT_INCOME": "Sales",
"INDIRECT_INCOME": "Sales",
"RECEIPT": "Receipt",
"PAYMENT": "Payment",
"CONTRA": "Contra",
"ADJUSTMENT": "Journal",
}.get(nature, "Journal")
def chart_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str):
return list(
db.execute(
select(AccountingChartLedger)
.where(
AccountingChartLedger.tenant_id == int(tenant_id),
AccountingChartLedger.client_id == int(client_id),
AccountingChartLedger.tally_guid == _s(tally_guid),
)
.order_by(AccountingChartLedger.name)
).scalars().all()
)
def _ledger_map(db, *, tenant_id: int, client_id: int, tally_guid: str):
return {
row.name.casefold(): row
for row in chart_ledgers(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
}
def _normalize_lines(
db,
*,
tenant_id: int,
client_id: int,
tally_guid: str,
ledger_names,
dr_cr_values,
amounts,
notes,
):
ledger_map = _ledger_map(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
if not ledger_map:
raise ValueError(
"No Chart of Accounts is available for this mapped Tally company. "
"Refresh Chart of Accounts first."
)
ledger_names = list(ledger_names or [])
dr_cr_values = list(dr_cr_values or [])
amounts = list(amounts or [])
notes = list(notes or [])
result = []
for index, ledger_name in enumerate(ledger_names):
name = _s(ledger_name)
if not name:
continue
side = _s(dr_cr_values[index] if index < len(dr_cr_values) else "").upper()
if side not in {"DR", "CR"}:
raise ValueError(f"Line {index + 1}: debit/credit side is invalid.")
try:
amount = round(abs(float(amounts[index] if index < len(amounts) else 0)), 2)
except Exception:
amount = 0
if amount <= 0:
raise ValueError(f"Line {index + 1}: amount must be greater than zero.")
ledger = ledger_map.get(name.casefold())
if not ledger:
raise ValueError(
f"Line {index + 1}: ledger '{name}' is not in the synchronized Chart of Accounts."
)
result.append(
{
"line_no": len(result) + 1,
"ledger_name": ledger.name,
"ledger_role_code": effective_role(ledger),
"dr_cr": side,
"amount": amount,
"line_note": _s(notes[index] if index < len(notes) else ""),
}
)
if len(result) < 2:
raise ValueError("At least two voucher lines are required.")
total_dr = round(sum(row["amount"] for row in result if row["dr_cr"] == "DR"), 2)
total_cr = round(sum(row["amount"] for row in result if row["dr_cr"] == "CR"), 2)
if total_dr <= 0 or total_cr <= 0:
raise ValueError("The voucher must contain at least one Debit and one Credit line.")
if abs(total_dr - total_cr) > 0.01:
raise ValueError(
f"Voucher is not balanced. Debit ₹{total_dr:.2f} != Credit ₹{total_cr:.2f}."
)
return result, total_dr, total_cr
def _validate_voucher_semantics(voucher_type: str, rows: list[dict]):
vtype = _s(voucher_type).title()
if vtype not in VOUCHER_TYPES:
raise ValueError("Voucher type must be Purchase, Sales, Receipt, Payment, Contra or Journal.")
debit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "DR"}
credit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "CR"}
all_roles = debit_roles | credit_roles
bank_cash = {"BANK", "CASH"}
if vtype == "Receipt" and not (debit_roles & bank_cash):
raise ValueError("Receipt voucher requires Bank/Cash on the Debit side.")
if vtype == "Payment" and not (credit_roles & bank_cash):
raise ValueError("Payment voucher requires Bank/Cash on the Credit side.")
if vtype == "Contra":
if not all_roles or not all_roles.issubset(bank_cash):
raise ValueError("Contra voucher may contain only Bank/Cash ledgers.")
if not (debit_roles & bank_cash) or not (credit_roles & bank_cash):
raise ValueError("Contra voucher requires Bank/Cash on both Debit and Credit sides.")
if vtype == "Purchase":
allowed = {
"PURCHASE",
"DIRECT_EXPENSE",
"INDIRECT_EXPENSE",
"FIXED_ASSET",
"CURRENT_ASSET",
"INVENTORY",
"GST_INPUT",
}
if not (debit_roles & allowed):
raise ValueError(
"Purchase voucher requires a Purchase/Expense/Asset/Input-tax ledger on the Debit side."
)
if vtype == "Sales":
allowed = {"SALES", "DIRECT_INCOME", "OTHER_INCOME", "GST_OUTPUT", "FREIGHT_CHARGE"}
if not (credit_roles & allowed):
raise ValueError(
"Sales voucher requires a Sales/Income/Output-tax/charge ledger on the Credit side."
)
def _audit_projection(voucher_type: str, rows: list[dict]):
roles = {row["ledger_role_code"] for row in rows}
areas = []
flags = []
if roles & {"SALES", "DIRECT_INCOME", "OTHER_INCOME"}:
areas.append("Revenue & Other Income")
if roles & {"PURCHASE", "DIRECT_EXPENSE", "INDIRECT_EXPENSE", "SUPPLIER"}:
areas.append("Purchases & Expenses")
if roles & {"BANK", "CASH"}:
areas.append("Cash & Bank")
if roles & {"FIXED_ASSET", "DEPRECIATION"}:
areas.append("Property, Plant & Equipment")
if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
areas.append("GST & Taxes")
flags.append("tax-ledger-present")
if roles & {"CUSTOMER"}:
areas.append("Trade Receivables")
if roles & {"LOAN", "CAPITAL", "RESERVE"}:
areas.append("Equity & Borrowings")
if voucher_type == "Journal":
flags.append("journal-adjustment")
if not areas:
areas.append("General Ledger")
return " · ".join(dict.fromkeys(areas)), flags
def create_draft(
db,
*,
tenant_id: int,
client_id: int,
tally_guid: str,
company_name: str,
voucher_type: str,
business_nature: str,
voucher_date: str,
reference: str,
narration: str,
source_type: str,
ledger_names,
dr_cr_values,
amounts,
notes,
user_id: int,
):
vtype = _s(voucher_type).title()
rows, total_dr, total_cr = _normalize_lines(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
ledger_names=ledger_names,
dr_cr_values=dr_cr_values,
amounts=amounts,
notes=notes,
)
_validate_voucher_semantics(vtype, rows)
audit_area, audit_flags = _audit_projection(vtype, rows)
draft = AccountingVoucherDraft(
tenant_id=int(tenant_id),
client_id=int(client_id),
tally_guid=_s(tally_guid),
company_name=_s(company_name),
voucher_type=vtype,
business_nature=_s(business_nature).upper(),
voucher_date=_s(voucher_date),
reference=_s(reference),
narration=_s(narration),
source_type=_s(source_type) or "manual",
audit_area=audit_area,
audit_flags_json=_dumps(audit_flags),
total_debit=total_dr,
total_credit=total_cr,
status="draft",
created_by_user_id=int(user_id),
updated_at_utc=_utcnow(),
)
for row in rows:
draft.lines.append(AccountingVoucherLine(**row))
db.add(draft)
db.commit()
db.refresh(draft)
return draft
def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int):
return db.execute(
select(AccountingVoucherDraft)
.options(selectinload(AccountingVoucherDraft.lines))
.where(
AccountingVoucherDraft.id == int(draft_id),
AccountingVoucherDraft.tenant_id == int(tenant_id),
AccountingVoucherDraft.client_id == int(client_id),
)
).scalar_one_or_none()
def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: int = 1, per_page: int = 20):
where = [
AccountingVoucherDraft.tenant_id == int(tenant_id),
AccountingVoucherDraft.client_id == int(client_id),
]
if _s(status):
where.append(AccountingVoucherDraft.status == _s(status))
total = int(db.scalar(select(func.count(AccountingVoucherDraft.id)).where(*where)) or 0)
per_page = max(10, min(100, int(per_page or 20)))
pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(int(page or 1), pages))
rows = list(
db.execute(
select(AccountingVoucherDraft)
.options(selectinload(AccountingVoucherDraft.lines))
.where(*where)
.order_by(AccountingVoucherDraft.id.desc())
.offset((page - 1) * per_page)
.limit(per_page)
).scalars().all()
)
return rows, total, page, pages
def review_draft(db, *, draft: AccountingVoucherDraft, note: str, user_id: int):
if draft.status != "draft":
raise ValueError("Only Draft vouchers can be marked Reviewed.")
draft.status = "reviewed"
draft.review_note = _s(note)
draft.reviewed_by_user_id = int(user_id)
draft.reviewed_at_utc = _utcnow()
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def approve_draft(db, *, draft: AccountingVoucherDraft, user_id: int):
if draft.status != "reviewed":
raise ValueError("Voucher must be Reviewed before Approval.")
draft.status = "approved"
draft.approved_by_user_id = int(user_id)
draft.approved_at_utc = _utcnow()
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def void_draft(db, *, draft: AccountingVoucherDraft):
if draft.status == "posted":
raise ValueError("A posted voucher cannot be voided from the ERP draft queue.")
if draft.status in {"preflight_queued", "preflight_claimed", "posting_queued", "posting_claimed"}:
raise ValueError("An active Local Agent job exists for this voucher.")
draft.status = "void"
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None):
stmt = select(ERPWorkstationAgent).where(
ERPWorkstationAgent.tenant_id == int(tenant_id),
ERPWorkstationAgent.is_active.is_(True),
ERPWorkstationAgent.tally_connected.is_(True),
)
if branch_id is not None:
stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id))
return list(
db.execute(
stmt.order_by(
ERPWorkstationAgent.last_seen_at_utc.desc(),
ERPWorkstationAgent.id.desc(),
)
).scalars().all()
)
def _idempotency(draft: AccountingVoucherDraft):
raw = "|".join(
[
str(draft.tenant_id),
str(draft.client_id),
str(draft.id),
draft.tally_guid,
draft.voucher_type,
draft.voucher_date,
draft.reference,
f"{draft.total_debit:.2f}",
]
)
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()[:32]
def _payload(draft: AccountingVoucherDraft):
return {
"client_id": int(draft.client_id),
"tenant_id": int(draft.tenant_id),
"tally_guid": draft.tally_guid,
"company_name": draft.company_name,
"voucher_type": draft.voucher_type,
"voucher_date": draft.voucher_date,
"reference": draft.reference,
"narration": draft.narration,
"business_nature": draft.business_nature,
"erp_voucher_draft_id": int(draft.id),
"total_amount": float(draft.total_debit),
"lines": [
{
"ledger_name": line.ledger_name,
"ledger_role_code": line.ledger_role_code,
"dr_cr": line.dr_cr,
"amount": float(line.amount),
"line_note": line.line_note,
}
for line in draft.lines
],
}
def queue_preflight(db, *, draft: AccountingVoucherDraft, workstation_id: int, user_id: int):
if draft.status != "approved":
raise ValueError("Voucher must be Approved before Local Agent preflight.")
workstation = db.get(ERPWorkstationAgent, int(workstation_id))
if (
not workstation
or not workstation.is_active
or int(workstation.tenant_id) != int(draft.tenant_id)
or not workstation.tally_connected
):
raise ValueError("Selected workstation is unavailable or Tally is not connected.")
job = enqueue_agent_job(
db,
workstation_agent_id=workstation.id,
action=PREFLIGHT_ACTION,
payload=_payload(draft),
idempotency_key=f"native-voucher:{_idempotency(draft)}:preflight:{workstation.id}",
priority=9,
max_attempts=2,
created_by_user_id=user_id,
)
draft.workstation_agent_id = workstation.id
draft.preflight_job_id = job.id
draft.preflight_result_json = "{}"
draft.last_error = ""
draft.status = "preflight_queued"
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def queue_post(db, *, draft: AccountingVoucherDraft, user_id: int):
sync_draft(db, draft)
if draft.status != "preflight_ready":
raise ValueError("Successful workstation/Tally preflight is required before posting.")
preflight = _loads(draft.preflight_result_json, {})
if preflight.get("duplicate_candidates"):
raise ValueError("Possible duplicate voucher exists in Tally. Posting is blocked.")
workstation = db.get(ERPWorkstationAgent, int(draft.workstation_agent_id or 0))
if not workstation or not workstation.is_active or not workstation.tally_connected:
raise ValueError("The preflight workstation is no longer available or connected to Tally.")
job = enqueue_agent_job(
db,
workstation_agent_id=workstation.id,
action=POST_ACTION,
payload=_payload(draft),
idempotency_key=f"native-voucher:{_idempotency(draft)}:post",
priority=10,
max_attempts=1,
created_by_user_id=user_id,
)
draft.posting_job_id = job.id
draft.status = "posting_queued"
draft.last_error = ""
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def sync_draft(db, draft: AccountingVoucherDraft):
changed = False
if draft.preflight_job_id and draft.status.startswith("preflight"):
job = db.get(ERPAgentJob, int(draft.preflight_job_id))
if job:
if job.status == "claimed" and draft.status != "preflight_claimed":
draft.status = "preflight_claimed"
changed = True
elif job.status == "succeeded":
result = _loads(job.result_json, {})
draft.preflight_result_json = _dumps(result)
draft.company_name = _s(result.get("company_name")) or draft.company_name
draft.status = "preflight_ready"
draft.last_error = ""
changed = True
elif job.status in {"failed", "cancelled"}:
draft.status = "preflight_failed"
draft.last_error = _s(job.last_error) or f"Preflight job {job.status}."
changed = True
if draft.posting_job_id and draft.status.startswith("posting"):
job = db.get(ERPAgentJob, int(draft.posting_job_id))
if job:
if job.status == "claimed" and draft.status != "posting_claimed":
draft.status = "posting_claimed"
changed = True
elif job.status == "succeeded":
result = _loads(job.result_json, {})
tally = result.get("tally_result") or result
draft.posting_result_json = _dumps(result)
draft.tally_voucher_id = _s(
tally.get("last_voucher_id") or tally.get("voucher_id")
)[:120]
draft.tally_voucher_number = _s(
tally.get("voucher_number")
or tally.get("last_voucher_id")
or tally.get("voucher_id")
)[:160]
draft.status = "posted"
draft.last_error = ""
changed = True
elif job.status in {"failed", "cancelled"}:
draft.status = "posting_failed"
draft.last_error = _s(job.last_error) or f"Posting job {job.status}."
changed = True
if changed:
draft.updated_at_utc = _utcnow()
db.add(draft)
db.commit()
return draft
def sync_drafts(db, drafts):
for draft in drafts:
if draft.status.startswith("preflight") or draft.status.startswith("posting"):
sync_draft(db, draft)