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)