Add Phase 17 common native Tally voucher engine
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user