diff --git a/alembic/versions/20260822_controlled_tally_purchase_posting_phase10.py b/alembic/versions/20260822_controlled_tally_purchase_posting_phase10.py new file mode 100644 index 0000000..935c791 --- /dev/null +++ b/alembic/versions/20260822_controlled_tally_purchase_posting_phase10.py @@ -0,0 +1,64 @@ +"""Phase 10 controlled Tally purchase posting. + +Revision ID: 20260822_purchase_posting_p10 +Revises: 20260822_purchase_enrich_p8 + +Phase 9 is migration-free, so Phase 10 correctly follows the Phase 8 migration head. +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260822_purchase_posting_p10" +down_revision = "20260822_purchase_enrich_p8" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_purchase_posting_attempts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("purchase_id", sa.Integer(), sa.ForeignKey("accounting_gstr2b_purchases.id", ondelete="CASCADE"), nullable=False), + sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True), + sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_company_name", sa.String(240), nullable=False, server_default=""), + sa.Column("status", sa.String(40), nullable=False, server_default="not_started"), + sa.Column("preflight_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("posting_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("preflight_result_json", sa.Text(), nullable=True), + sa.Column("posting_result_json", sa.Text(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("party_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("purchase_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("input_igst_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("input_cgst_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("input_sgst_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("input_cess_ledger_name", sa.String(240), nullable=False, server_default=""), + sa.Column("tally_voucher_id", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""), + sa.Column("tally_reference", sa.String(160), nullable=False, server_default=""), + sa.Column("idempotency_key", sa.String(200), nullable=False, server_default=""), + sa.Column("requested_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("posted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("preflight_completed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("posting_requested_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("tenant_id", "client_id", "purchase_id", name="uq_accounting_purchase_posting_purchase"), + ) + for col in ( + "tenant_id", "client_id", "purchase_id", "workstation_agent_id", "tally_guid", + "status", "preflight_job_id", "posting_job_id", "idempotency_key", "created_at_utc", + ): + op.create_index( + f"ix_accounting_purchase_posting_attempts_{col}", + "accounting_purchase_posting_attempts", + [col], + ) + + +def downgrade(): + op.drop_table("accounting_purchase_posting_attempts") diff --git a/app/modules/accounting/purchase_posting_models.py b/app/modules/accounting/purchase_posting_models.py new file mode 100644 index 0000000..b909d51 --- /dev/null +++ b/app/modules/accounting/purchase_posting_models.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingPurchasePostingAttempt(CommonBase): + """Controlled posting state for one reviewed GSTR-2B purchase. + + Phase 10 intentionally keeps posting separate from the purchase review record so + accounting review history is never overwritten by workstation/Tally execution state. + """ + + __tablename__ = "accounting_purchase_posting_attempts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "client_id", "purchase_id", + name="uq_accounting_purchase_posting_purchase", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + purchase_id: Mapped[int] = mapped_column( + ForeignKey("accounting_gstr2b_purchases.id", ondelete="CASCADE"), nullable=False, index=True + ) + + workstation_agent_id: Mapped[int | None] = mapped_column( + ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True, index=True + ) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + tally_company_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + + status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_started", index=True) + preflight_job_id: Mapped[int | None] = mapped_column( + ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True + ) + posting_job_id: Mapped[int | None] = mapped_column( + ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True + ) + + preflight_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) + posting_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + party_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + purchase_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + input_igst_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + input_cgst_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + input_sgst_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + input_cess_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="") + + tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") + tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="") + tally_reference: Mapped[str] = mapped_column(String(160), nullable=False, default="") + idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False, default="", index=True) + + requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + posted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + preflight_completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + posting_requested_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/app/modules/accounting/purchase_posting_service.py b/app/modules/accounting/purchase_posting_service.py new file mode 100644 index 0000000..9d45151 --- /dev/null +++ b/app/modules/accounting/purchase_posting_service.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase +from app.modules.accounting.purchase_posting_models import AccountingPurchasePostingAttempt +from app.modules.documents.agent_jobs import enqueue_agent_job +from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent + + +PREFLIGHT_ACTION = "accounting_purchase_posting_preflight" +POST_ACTION = "accounting_post_purchase_voucher" +TERMINAL = {"succeeded", "failed", "cancelled"} + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _loads(value, default=None): + if not value: + return default if default is not None else {} + try: + parsed = json.loads(value) + return parsed + except Exception: + return default if default is not None else {} + + +def _dumps(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def _base_key(*, tenant_id: int, client_id: int, purchase: AccountingGSTR2BPurchase) -> str: + raw = "|".join([ + str(tenant_id), + str(client_id), + str(purchase.id), + purchase.supplier_gstin or "", + purchase.invoice_number or "", + purchase.invoice_date or "", + purchase.document_type or "", + ]) + digest = hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()[:32] + return f"purchase:{client_id}:{purchase.id}:{digest}" + + +def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None): + stmt = select(ERPWorkstationAgent).where( + ERPWorkstationAgent.tenant_id == tenant_id, + ERPWorkstationAgent.is_active.is_(True), + ) + if branch_id is not None: + stmt = stmt.where(ERPWorkstationAgent.branch_id == branch_id) + return list(db.execute( + stmt.order_by( + ERPWorkstationAgent.tally_connected.desc(), + ERPWorkstationAgent.last_seen_at_utc.desc(), + ERPWorkstationAgent.id.desc(), + ) + ).scalars().all()) + + +def _purchase(db, *, tenant_id: int, client_id: int, purchase_id: int): + return db.execute(select(AccountingGSTR2BPurchase).where( + AccountingGSTR2BPurchase.id == purchase_id, + AccountingGSTR2BPurchase.tenant_id == tenant_id, + AccountingGSTR2BPurchase.client_id == client_id, + )).scalar_one_or_none() + + +def get_attempt(db, *, tenant_id: int, client_id: int, purchase_id: int): + return db.execute(select(AccountingPurchasePostingAttempt).where( + AccountingPurchasePostingAttempt.tenant_id == tenant_id, + AccountingPurchasePostingAttempt.client_id == client_id, + AccountingPurchasePostingAttempt.purchase_id == purchase_id, + )).scalar_one_or_none() + + +def attempts_for_purchases(db, *, tenant_id: int, client_id: int, purchase_ids): + ids = [int(x) for x in purchase_ids if x] + if not ids: + return {} + rows = list(db.execute(select(AccountingPurchasePostingAttempt).where( + AccountingPurchasePostingAttempt.tenant_id == tenant_id, + AccountingPurchasePostingAttempt.client_id == client_id, + AccountingPurchasePostingAttempt.purchase_id.in_(ids), + )).scalars().all()) + return {r.purchase_id: r for r in rows} + + +def _ensure_reviewed(purchase: AccountingGSTR2BPurchase): + if purchase.review_status != "reviewed": + raise ValueError("Only accountant-reviewed purchases may proceed to Tally posting.") + if not int(purchase.final_nature_id or 0): + raise ValueError("The purchase has no final accounting nature.") + if not (purchase.final_ledger_name or "").strip(): + raise ValueError("The reviewed purchase has no mapped Tally purchase/expense ledger.") + if (purchase.posting_status or "").lower() == "posted": + raise ValueError("This purchase is already marked as posted to Tally.") + + +def _accounting_relative_dir(client_id: int) -> str: + # This matches the existing Local Agent accounting-store binding convention. + return f"Accounting/clients/{int(client_id)}" + + +def queue_preflight( + db, + *, + tenant_id: int, + client_id: int, + purchase_id: int, + workstation_id: int, + user_id: int, +): + purchase = _purchase(db, tenant_id=tenant_id, client_id=client_id, purchase_id=purchase_id) + if not purchase: + raise ValueError("Purchase record was not found.") + _ensure_reviewed(purchase) + + workstation = db.get(ERPWorkstationAgent, int(workstation_id)) + if not workstation or not workstation.is_active or workstation.tenant_id != tenant_id: + raise ValueError("Selected workstation is unavailable.") + if not workstation.tally_connected: + raise ValueError("Selected workstation is not currently reporting a Tally connection.") + + tally_guid = (purchase.tally_guid or "").strip() + if not tally_guid: + raise ValueError("The purchase is not associated with a mapped Tally company.") + + attempt = get_attempt( + db, tenant_id=tenant_id, client_id=client_id, purchase_id=purchase.id + ) + if not attempt: + attempt = AccountingPurchasePostingAttempt( + tenant_id=tenant_id, + client_id=client_id, + purchase_id=purchase.id, + tally_guid=tally_guid, + purchase_ledger_name=(purchase.final_ledger_name or "").strip(), + status="not_started", + requested_by_user_id=user_id, + idempotency_key=_base_key( + tenant_id=tenant_id, client_id=client_id, purchase=purchase + ), + ) + db.add(attempt) + db.flush() + + if attempt.status == "posted": + raise ValueError("This purchase has already been posted.") + if attempt.status in {"posting_queued", "posting_claimed", "posting_indeterminate"}: + raise ValueError("A posting job is already active or requires manual verification.") + + attempt.workstation_agent_id = workstation.id + attempt.tally_guid = tally_guid + attempt.purchase_ledger_name = (purchase.final_ledger_name or "").strip() + attempt.requested_by_user_id = user_id + attempt.last_error = None + + payload = { + "client_id": client_id, + "tenant_id": tenant_id, + "accounting_relative_dir": _accounting_relative_dir(client_id), + "tally_guid": tally_guid, + "supplier_gstin": purchase.supplier_gstin or "", + "supplier_name": purchase.supplier_name or "", + "invoice_number": purchase.invoice_number or "", + "invoice_date": purchase.invoice_date or "", + "invoice_value": float(purchase.invoice_value or 0), + "taxable_value": float(purchase.taxable_value or 0), + "igst": float(purchase.igst or 0), + "cgst": float(purchase.cgst or 0), + "sgst": float(purchase.sgst or 0), + "cess": float(purchase.cess or 0), + "purchase_ledger_name": attempt.purchase_ledger_name, + "erp_purchase_id": purchase.id, + "requested_by_user_id": user_id, + } + + job = enqueue_agent_job( + db, + workstation_agent_id=workstation.id, + action=PREFLIGHT_ACTION, + payload=payload, + idempotency_key=f"{attempt.idempotency_key}:preflight:{workstation.id}", + priority=9, + max_attempts=2, + created_by_user_id=user_id, + ) + attempt.preflight_job_id = job.id + attempt.status = "preflight_queued" + attempt.updated_at_utc = _utcnow() + db.add(attempt) + db.commit() + db.refresh(attempt) + return attempt + + +def _job_result(job: ERPAgentJob | None): + if not job: + return {} + return _loads(job.result_json, {}) + + +def sync_attempt(db, attempt: AccountingPurchasePostingAttempt): + changed = False + + if attempt.preflight_job_id and attempt.status.startswith("preflight"): + job = db.get(ERPAgentJob, attempt.preflight_job_id) + if job: + if job.status == "claimed" and attempt.status != "preflight_claimed": + attempt.status = "preflight_claimed" + changed = True + elif job.status == "succeeded": + result = _job_result(job) + attempt.preflight_result_json = _dumps(result) + attempt.tally_company_name = str( + result.get("company_name") + or (result.get("company") or {}).get("name") + or "" + )[:240] + attempt.status = "preflight_ready" + attempt.preflight_completed_at_utc = job.completed_at_utc or _utcnow() + attempt.last_error = None + changed = True + elif job.status in {"failed", "cancelled"}: + attempt.status = "preflight_failed" + attempt.last_error = job.last_error or f"Preflight job {job.status}." + changed = True + + if attempt.posting_job_id and attempt.status.startswith("posting"): + job = db.get(ERPAgentJob, attempt.posting_job_id) + if job: + if job.status == "claimed" and attempt.status != "posting_claimed": + attempt.status = "posting_claimed" + changed = True + elif job.status == "succeeded": + result = _job_result(job) + attempt.posting_result_json = _dumps(result) + tally_result = result.get("tally_result") or result + attempt.tally_voucher_id = str( + tally_result.get("last_voucher_id") + or tally_result.get("voucher_id") + or "" + )[:120] + attempt.tally_voucher_number = str( + tally_result.get("voucher_number") + or tally_result.get("last_voucher_id") + or "" + )[:160] + attempt.tally_reference = str( + result.get("reference") + or tally_result.get("reference") + or "" + )[:160] + attempt.status = "posted" + attempt.posted_at_utc = job.completed_at_utc or _utcnow() + attempt.last_error = None + + purchase = db.get(AccountingGSTR2BPurchase, attempt.purchase_id) + if purchase: + purchase.posting_status = "posted" + db.add(purchase) + changed = True + elif job.status == "failed": + error = job.last_error or "Tally posting failed." + # Phase 2 agent uses this phrase when execution began but acknowledgement + # was lost. Never automatically requeue a financial write in this case. + if "Verify Tally before retrying" in error or "interrupted before acknowledgement" in error.lower(): + attempt.status = "posting_indeterminate" + else: + attempt.status = "posting_failed" + attempt.last_error = error + changed = True + elif job.status == "cancelled": + attempt.status = "posting_failed" + attempt.last_error = "Posting job was cancelled." + changed = True + + if changed: + attempt.updated_at_utc = _utcnow() + db.add(attempt) + db.commit() + db.refresh(attempt) + return attempt + + +def sync_attempts(db, attempts): + return [sync_attempt(db, a) for a in attempts] + + +def preflight_choices(attempt: AccountingPurchasePostingAttempt): + result = _loads(attempt.preflight_result_json, {}) + return { + "party_candidates": result.get("party_candidates") or [], + "igst_candidates": result.get("igst_candidates") or [], + "cgst_candidates": result.get("cgst_candidates") or [], + "sgst_candidates": result.get("sgst_candidates") or [], + "cess_candidates": result.get("cess_candidates") or [], + "duplicate_candidates": result.get("duplicate_candidates") or [], + "purchase_ledger_verified": bool(result.get("purchase_ledger_verified")), + "company_name": result.get("company_name") or "", + "company_guid": result.get("company_guid") or "", + } + + +def queue_post( + db, + *, + tenant_id: int, + client_id: int, + purchase_id: int, + party_ledger_name: str, + input_igst_ledger_name: str, + input_cgst_ledger_name: str, + input_sgst_ledger_name: str, + input_cess_ledger_name: str, + user_id: int, +): + purchase = _purchase(db, tenant_id=tenant_id, client_id=client_id, purchase_id=purchase_id) + if not purchase: + raise ValueError("Purchase record was not found.") + _ensure_reviewed(purchase) + + attempt = get_attempt( + db, tenant_id=tenant_id, client_id=client_id, purchase_id=purchase.id + ) + if not attempt: + raise ValueError("Run workstation preflight before posting.") + attempt = sync_attempt(db, attempt) + if attempt.status != "preflight_ready": + raise ValueError("Successful workstation preflight is required before posting.") + if not attempt.workstation_agent_id: + raise ValueError("No workstation is attached to this posting attempt.") + + choices = preflight_choices(attempt) + if not choices["purchase_ledger_verified"]: + raise ValueError("The reviewed purchase/expense ledger was not verified in the open Tally company.") + if choices["duplicate_candidates"]: + raise ValueError( + "A possible duplicate Purchase voucher exists in Tally. Verify it before posting." + ) + + def allowed(name, key, required): + value = (name or "").strip() + candidates = {str(x.get("name") or "").strip() for x in choices[key]} + if required and not value: + raise ValueError(f"Select {key.replace('_candidates','').upper()} ledger.") + if value and value not in candidates: + raise ValueError("Selected ledger was not returned by the current workstation preflight.") + return value + + party = allowed(party_ledger_name, "party_candidates", True) + igst = allowed(input_igst_ledger_name, "igst_candidates", float(purchase.igst or 0) > 0) + cgst = allowed(input_cgst_ledger_name, "cgst_candidates", float(purchase.cgst or 0) > 0) + sgst = allowed(input_sgst_ledger_name, "sgst_candidates", float(purchase.sgst or 0) > 0) + cess = allowed(input_cess_ledger_name, "cess_candidates", float(purchase.cess or 0) > 0) + + if not attempt.tally_company_name: + raise ValueError("Preflight did not return a valid Tally company name.") + + reference = (purchase.invoice_number or f"ERP-{purchase.id}")[:160] + narration = ( + f"Purchase imported from reviewed ERP GSTR-2B document #{purchase.id}; " + f"supplier GSTIN {purchase.supplier_gstin or '-'}; invoice {reference}" + )[:1000] + + payload = { + "client_id": client_id, + "tenant_id": tenant_id, + "accounting_relative_dir": _accounting_relative_dir(client_id), + "tally_guid": attempt.tally_guid, + "company_name": attempt.tally_company_name, + "erp_purchase_id": purchase.id, + "invoice_number": purchase.invoice_number or "", + "invoice_date": purchase.invoice_date or "", + "supplier_gstin": purchase.supplier_gstin or "", + "supplier_name": purchase.supplier_name or "", + "party_ledger_name": party, + "purchase_ledger_name": attempt.purchase_ledger_name, + "input_igst_ledger_name": igst, + "input_cgst_ledger_name": cgst, + "input_sgst_ledger_name": sgst, + "input_cess_ledger_name": cess, + "taxable_value": round(float(purchase.taxable_value or 0), 2), + "igst": round(float(purchase.igst or 0), 2), + "cgst": round(float(purchase.cgst or 0), 2), + "sgst": round(float(purchase.sgst or 0), 2), + "cess": round(float(purchase.cess or 0), 2), + "invoice_value": round(float(purchase.invoice_value or 0), 2), + "reference": reference, + "narration": narration, + "requested_by_user_id": user_id, + "posting_attempt_id": attempt.id, + } + + workstation = db.get(ERPWorkstationAgent, attempt.workstation_agent_id) + if not workstation or not workstation.is_active: + raise ValueError("Selected workstation is no longer active.") + + # Deterministic key means clicking Post again returns the same durable job. + job = enqueue_agent_job( + db, + workstation_agent_id=workstation.id, + action=POST_ACTION, + payload=payload, + idempotency_key=f"{attempt.idempotency_key}:post:{workstation.id}", + priority=10, + max_attempts=1, # Financial writes are never automatically retried. + created_by_user_id=user_id, + ) + + attempt.party_ledger_name = party + attempt.input_igst_ledger_name = igst + attempt.input_cgst_ledger_name = cgst + attempt.input_sgst_ledger_name = sgst + attempt.input_cess_ledger_name = cess + attempt.posting_job_id = job.id + attempt.posting_requested_at_utc = _utcnow() + attempt.posted_by_user_id = user_id + attempt.status = "posting_queued" + attempt.last_error = None + attempt.updated_at_utc = _utcnow() + db.add(attempt) + + purchase.posting_status = "queued" + db.add(purchase) + db.commit() + db.refresh(attempt) + return attempt diff --git a/app/modules/accounting/purchase_posting_ui.py b/app/modules/accounting/purchase_posting_ui.py new file mode 100644 index 0000000..a2414cb --- /dev/null +++ b/app/modules/accounting/purchase_posting_ui.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.templating import templates +from app.modules.accounting.purchase_posting_service import ( + attempts_for_purchases, + preflight_choices, + queue_post, + queue_preflight, + sync_attempts, + visible_workstations, +) +from app.modules.accounting.purchase_review_service import review_queue +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles + +router = APIRouter(prefix="/tools/accounting/purchase-posting", tags=["accounting-purchase-posting-ui"]) + + +def _redirect(client_id: int, *, message: str = "", error: str = "", page: int = 1, per_page: int = 25): + params = {"client_id": client_id, "page": page, "per_page": per_page} + if message: + params["message"] = message[:240] + if error: + params["error"] = error[:240] + return RedirectResponse( + url="/tools/accounting/purchase-posting?" + urlencode(params), + status_code=303, + ) + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + page: int = 1, + per_page: int = 25, + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.learning.view") + if response: + return response + clients, scope = _visible_clients(db, request, user) + selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None) + + result = None + attempts = {} + choices = {} + workstations = [] + + if selected: + # Phase 10 intentionally starts only from reviewed purchases. + result = review_queue( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + status="reviewed", + page=page, + per_page=per_page, + ) + ids = [row[0].id for row in result.rows] + attempts = attempts_for_purchases( + db, tenant_id=scope.tenant_id, client_id=selected.id, purchase_ids=ids + ) + sync_attempts(db, list(attempts.values())) + attempts = attempts_for_purchases( + db, tenant_id=scope.tenant_id, client_id=selected.id, purchase_ids=ids + ) + choices = { + pid: preflight_choices(attempt) + for pid, attempt in attempts.items() + if attempt.preflight_result_json + } + + branch_id = getattr(user, "branch_id", None) + workstations = visible_workstations( + db, tenant_id=scope.tenant_id, branch_id=branch_id + ) + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/purchase_posting.html", + { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Controlled Tally Purchase Posting", + "clients": clients, + "selected_client": selected, + "result": result, + "attempts": attempts, + "choices": choices, + "workstations": workstations, + "message": message, + "error": error, + }, + ) + finally: + db.close() + + +@router.post("/purchase/{purchase_id}/preflight") +def preflight( + request: Request, + purchase_id: int, + client_id: int = Form(...), + workstation_id: int = Form(...), + page: int = Form(1), + per_page: int = Form(25), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.learning.manage") + if response: + return response + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + from app.core.http_responses import ui_access_denied + return ui_access_denied() + + queue_preflight( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + purchase_id=purchase_id, + workstation_id=workstation_id, + user_id=user.id, + ) + return _redirect( + client.id, + message="Workstation preflight queued. Refresh this page after the agent processes the job.", + page=page, + per_page=per_page, + ) + except Exception as exc: + db.rollback() + return _redirect(client_id, error=str(exc), page=page, per_page=per_page) + finally: + db.close() + + +@router.post("/purchase/{purchase_id}/post") +def post( + request: Request, + purchase_id: int, + client_id: int = Form(...), + party_ledger_name: str = Form(...), + input_igst_ledger_name: str = Form(""), + input_cgst_ledger_name: str = Form(""), + input_sgst_ledger_name: str = Form(""), + input_cess_ledger_name: str = Form(""), + page: int = Form(1), + per_page: int = Form(25), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, response = _require_partner(request, db, "accounting.learning.manage") + if response: + return response + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + from app.core.http_responses import ui_access_denied + return ui_access_denied() + + queue_post( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + purchase_id=purchase_id, + party_ledger_name=party_ledger_name, + input_igst_ledger_name=input_igst_ledger_name, + input_cgst_ledger_name=input_cgst_ledger_name, + input_sgst_ledger_name=input_sgst_ledger_name, + input_cess_ledger_name=input_cess_ledger_name, + user_id=user.id, + ) + return _redirect( + client.id, + message="Controlled Purchase voucher job queued. Refresh to see the workstation/Tally result.", + page=page, + per_page=per_page, + ) + except Exception as exc: + db.rollback() + return _redirect(client_id, error=str(exc), page=page, per_page=per_page) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/purchase_posting.html b/app/modules/accounting/templates/accounting/purchase_posting.html new file mode 100644 index 0000000..fe862e5 --- /dev/null +++ b/app/modules/accounting/templates/accounting/purchase_posting.html @@ -0,0 +1,252 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Tools · Accounting Intelligence

+

Controlled Tally Purchase Posting

+

Phase 10 posts only accountant-reviewed purchases. Every voucher must pass workstation preflight, open-company verification, Tally ledger validation, duplicate screening and a durable Phase 2 idempotent job. Financial write jobs are never automatically retried.

+
+ +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+ Controlled write boundary: Phase 10 never creates Tally ledgers or stock items. If a creditor, purchase ledger or GST input ledger cannot be verified in the currently opened Tally company, posting is blocked. +
+ +
+
+ + {% if selected_client %} + + {% endif %} +
+
+
+ + {% if selected_client and result %} +
+
+
+

Reviewed Purchase Queue

+

{{ result.total }} reviewed purchase(s) · page {{ result.page }} of {{ result.pages }}

+
+ Refresh Job Status +
+ +
+ {% for tuple_row in result.rows %} + {% set row = tuple_row[0] %} + {% set attempt = attempts.get(row.id) %} + {% set choice = choices.get(row.id) %} +
+
+
+
+

{{ row.supplier_name or row.supplier_gstin or 'Supplier' }}

+ {% if row.posting_status == 'posted' or (attempt and attempt.status == 'posted') %} + Posted + {% elif attempt %} + {{ attempt.status|replace('_',' ')|title }} + {% else %} + Not started + {% endif %} +
+
+
GSTIN: {{ row.supplier_gstin or '-' }}
+
Invoice: {{ row.invoice_number }} · {{ row.invoice_date }}
+
Taxable: ₹{{ '%.2f'|format(row.taxable_value) }}
+
Invoice Value: ₹{{ '%.2f'|format(row.invoice_value) }}
+
+
+ +
+
+
Reviewed treatment
+
{{ row.final_ledger_name }}
+
+
IGST ₹{{ '%.2f'|format(row.igst) }}
+
CGST ₹{{ '%.2f'|format(row.cgst) }}
+
SGST ₹{{ '%.2f'|format(row.sgst) }}
+
Cess ₹{{ '%.2f'|format(row.cess) }}
+
+
+
+ +
+ {% if not attempt %} +
+ + + + + + +
+ + {% elif attempt.status in ['preflight_queued','preflight_claimed'] %} +
+ Preflight is waiting for workstation #{{ attempt.workstation_agent_id }}. Refresh after the Local Agent processes it. +
+ + {% elif attempt.status == 'preflight_failed' %} +
+
Preflight failed
+
{{ attempt.last_error }}
+
+ + + + + + +
+
+ + {% elif attempt.status == 'preflight_ready' and choice %} + {% if choice.duplicate_candidates %} +
+
Possible duplicate found in Tally — posting blocked
+ {% for dup in choice.duplicate_candidates[:5] %} +
{{ dup.voucher_number or '-' }} · {{ dup.reference or '-' }} · {{ dup.party_ledger_name or '-' }}
+ {% endfor %} +
+ {% elif not choice.purchase_ledger_verified %} +
Reviewed purchase ledger {{ row.final_ledger_name }} was not found in the currently opened Tally company. Sync/fix the mapping before posting.
+ {% else %} +
+ + + + + +
Preflight passed · {{ choice.company_name }}
+
+ + + {% if row.igst > 0 %} + + {% else %}{% endif %} + + {% if row.cgst > 0 %} + + {% else %}{% endif %} + + {% if row.sgst > 0 %} + + {% else %}{% endif %} + + {% if row.cess > 0 %} + + {% else %}{% endif %} +
+ +
+ {% endif %} + + {% elif attempt.status in ['posting_queued','posting_claimed'] %} +
Purchase posting job is being executed by the selected workstation. Do not submit another posting job.
+ + {% elif attempt.status == 'posted' %} +
+
Posted successfully to Tally
+
Company: {{ attempt.tally_company_name }}
+
Voucher/ID: {{ attempt.tally_voucher_number or attempt.tally_voucher_id or '-' }}
+
Reference: {{ attempt.tally_reference or row.invoice_number }}
+
+ + {% elif attempt.status == 'posting_indeterminate' %} +
+
Manual verification required — do not retry
+
{{ attempt.last_error }}
+
The Local Agent began execution but acknowledgement was lost. Check Tally for invoice {{ row.invoice_number }} before any further action.
+
+ + {% elif attempt.status == 'posting_failed' %} +
+
Tally posting failed
+
{{ attempt.last_error }}
+
Run a new preflight after correcting the Tally-side issue. The prior financial write job is never automatically retried.
+
+ {% endif %} +
+
+
+ {% else %} +
No accountant-reviewed purchases are available for posting.
+ {% endfor %} +
+ + {% if result.pages > 1 %} +
+ Page {{ result.page }} of {{ result.pages }} +
+ {% if result.page > 1 %}Previous{% endif %} + {% if result.page < result.pages %}Next{% endif %} +
+
+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index afac422..ca6d674 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -19,6 +19,7 @@ {% if selected_client %}GSTR-2B Intelligence{% endif %} {% if selected_client %}E-Invoice / E-Way Bill{% endif %} {% if selected_client %}Purchase Review{% endif %} + {% if selected_client %}Post to Tally{% endif %} {% if selected_client %}Depreciation (IT){% endif %} Refresh Tally Companies diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index d402977..8bd6260 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.12.0" +ERP_LOCAL_AGENT_VERSION = "1.13.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index b518f6e..9a34ccc 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1 +1 @@ -__version__ = "1.12.0" +__version__ = "1.13.0" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 237116f..29ea135 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone +import re from typing import Any from . import __version__ @@ -38,6 +39,10 @@ class AgentCommandProcessor: result = self._sync_transactions(payload) elif action == "accounting_historical_evidence": result = self._historical_evidence(payload) + elif action == "accounting_purchase_posting_preflight": + result = self._purchase_posting_preflight(payload) + elif action == "accounting_post_purchase_voucher": + result = self._post_purchase_voucher(payload) elif action == "accounting_depreciation_preview": result = self._depreciation_preview(payload) elif action == "accounting_calculate_it_depreciation": @@ -70,6 +75,8 @@ class AgentCommandProcessor: "tally_mapping_capability": True, "tally_master_sync_capability": True, "tally_transaction_sync_capability": True, "historical_learning_read_capability": True, + "purchase_posting_preflight_capability": True, + "purchase_voucher_write_capability": True, "it_depreciation_capability": True, "tally_writeback_capability": True, } @@ -214,6 +221,154 @@ class AgentCommandProcessor: voucher_scope=str(payload.get("voucher_scope") or "purchase"), ) + + def _resolve_open_company(self, payload: dict[str, Any]) -> tuple[dict[str, Any], str]: + requested_guid = str(payload.get("tally_guid") or "").strip() + if not requested_guid: + raise ValueError("Mapped Tally company GUID is required.") + tally_status = self.tally.status() + if not tally_status.get("connected"): + raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected.")) + company = next( + ( + row for row in (tally_status.get("companies") or []) + if str(row.get("guid") or "").strip() == requested_guid + ), + None, + ) + if not company: + raise ValueError( + "The mapped Tally company is not currently open on this workstation. " + "Open the correct company and run preflight again." + ) + company_name = str(company.get("name") or "").strip() + if not company_name: + raise ValueError("Tally returned an invalid company name.") + expected_name = str(payload.get("company_name") or "").strip() + if expected_name and expected_name.casefold() != company_name.casefold(): + raise ValueError( + f"Open-company mismatch: ERP expected '{expected_name}' but Tally reports '{company_name}'." + ) + return company, company_name + + def _purchase_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]: + client_id = int(payload.get("client_id")) + company, company_name = self._resolve_open_company(payload) + result = self.tally.purchase_posting_preflight( + company_name, + supplier_gstin=str(payload.get("supplier_gstin") or ""), + supplier_name=str(payload.get("supplier_name") or ""), + purchase_ledger_name=str(payload.get("purchase_ledger_name") or ""), + invoice_number=str(payload.get("invoice_number") or ""), + invoice_date=str(payload.get("invoice_date") or ""), + invoice_value=float(payload.get("invoice_value") or 0), + ) + return { + **result, + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "company_gstin": str(company.get("gstin") or ""), + "client_id": client_id, + "agent": self._agent_info(), + } + + def _post_purchase_voucher(self, payload: dict[str, Any]) -> dict[str, Any]: + client_id = int(payload.get("client_id")) + company, company_name = self._resolve_open_company(payload) + + invoice_number = str(payload.get("invoice_number") or "").strip() + invoice_date = str(payload.get("invoice_date") or "").strip() + party = str(payload.get("party_ledger_name") or "").strip() + purchase_ledger = str(payload.get("purchase_ledger_name") or "").strip() + + rows = self.tally.purchase_posting_ledgers(company_name) + for label, ledger in ( + ("creditor", party), + ("purchase/expense", purchase_ledger), + ("Input IGST", str(payload.get("input_igst_ledger_name") or "").strip()), + ("Input CGST", str(payload.get("input_cgst_ledger_name") or "").strip()), + ("Input SGST", str(payload.get("input_sgst_ledger_name") or "").strip()), + ("Input Cess", str(payload.get("input_cess_ledger_name") or "").strip()), + ): + if ledger and not self.tally._ledger_exists(rows, ledger): + raise ValueError( + f"{label} ledger '{ledger}' is not present in the currently opened Tally company." + ) + + expected_gstin = re.sub( + r"[^A-Z0-9]", "", str(payload.get("supplier_gstin") or "").upper() + )[:15] + party_row = next( + (r for r in rows if str(r.get("name") or "").strip().casefold() == party.casefold()), + None, + ) + if not party_row: + raise ValueError("Selected creditor ledger was not found during final validation.") + party_gstin = str(party_row.get("gstin") or "").strip().upper() + if expected_gstin and party_gstin and expected_gstin != party_gstin: + raise ValueError( + f"Supplier GSTIN mismatch: ERP has {expected_gstin}, " + f"but Tally ledger '{party}' reports {party_gstin}." + ) + + duplicates = self.tally.find_purchase_duplicates( + company_name, + invoice_date=invoice_date, + reference=invoice_number, + party_ledger_names=[party], + invoice_value=float(payload.get("invoice_value") or 0), + ) + if duplicates: + raise ValueError( + "Possible duplicate Purchase voucher already exists in Tally for this " + "supplier/invoice/date. Posting has been blocked." + ) + + tally_result = self.tally.post_purchase_voucher( + company_name, + voucher_date=invoice_date, + party_ledger_name=party, + purchase_ledger_name=purchase_ledger, + taxable_value=float(payload.get("taxable_value") or 0), + input_igst_ledger_name=str(payload.get("input_igst_ledger_name") or "").strip(), + igst=float(payload.get("igst") or 0), + input_cgst_ledger_name=str(payload.get("input_cgst_ledger_name") or "").strip(), + cgst=float(payload.get("cgst") or 0), + input_sgst_ledger_name=str(payload.get("input_sgst_ledger_name") or "").strip(), + sgst=float(payload.get("sgst") or 0), + input_cess_ledger_name=str(payload.get("input_cess_ledger_name") or "").strip(), + cess=float(payload.get("cess") or 0), + reference=str(payload.get("reference") or invoice_number), + narration=str(payload.get("narration") or ""), + ) + + safe_result = { + k: v for k, v in tally_result.items() + if k not in {"raw_response", "request_xml"} + } + self.logger.info( + "CONTROLLED PURCHASE WRITEBACK posted client_id=%s company=%s " + "erp_purchase_id=%s invoice=%s party=%s tally_voucher=%s", + client_id, + company_name, + payload.get("erp_purchase_id"), + invoice_number, + party, + safe_result.get("last_voucher_id"), + ) + return { + "posted": True, + "client_id": client_id, + "erp_purchase_id": payload.get("erp_purchase_id"), + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "reference": str(payload.get("reference") or invoice_number), + "party_ledger_name": party, + "purchase_ledger_name": purchase_ledger, + "tally_result": safe_result, + "agent": self._agent_info(), + } + def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]: client_id = int(payload.get("client_id")) tally_guid = str(payload.get("tally_guid") or "").strip() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 9c7c791..0ef7fb0 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -439,6 +439,405 @@ class TallyLiveConnector: "request_xml": request_xml, } + + def purchase_posting_ledgers(self, company_name: str) -> list[dict]: + company = str(company_name or "").strip() + if not company: + raise ValueError("Tally company name is required.") + xml = f""" +
1ExportCollectionARRRPurchasePostingLedgers
+ + {self._static_variables(company)} + + + Ledger + Name,GUID,Parent,PartyGSTIN,GSTIN,GSTRegistrationNumber,TaxType,GSTApplicable,GSTRegistrationType,IsBillWiseOn + + + +
""" + raw = self._post_xml(xml) + cleaned = _clean_xml_response(raw) + try: + root = ET.fromstring(cleaned.encode("utf-8")) + except Exception as exc: + raise ValueError( + f"Tally returned invalid XML while validating purchase ledgers: {exc}" + ) from exc + + rows = [] + seen = set() + for element in root.iter(): + if _tag(element) != "LEDGER": + continue + name = ( + element.attrib.get("NAME") + or element.attrib.get("name") + or _child_text(element, "NAME") + ).strip() + if not name or name.casefold() in seen: + continue + seen.add(name.casefold()) + gstin = _first_text( + element, + ("PARTYGSTIN", "GSTIN", "GSTREGISTRATIONNUMBER", "GSTNO", "GSTNUMBER"), + ) + rows.append({ + "name": name, + "guid": _child_text(element, "GUID"), + "parent": _child_text(element, "PARENT"), + "gstin": re.sub(r"[^A-Z0-9]", "", str(gstin or "").upper())[:15], + "tax_type": _child_text(element, "TAXTYPE"), + "gst_applicable": _child_text(element, "GSTAPPLICABLE"), + "gst_registration_type": _child_text(element, "GSTREGISTRATIONTYPE"), + "is_billwise_on": _child_text(element, "ISBILLWISEON"), + }) + return rows + + @staticmethod + def _ledger_exists(rows: list[dict], name: str) -> bool: + key = str(name or "").strip().casefold() + return bool( + key + and any( + str(row.get("name") or "").strip().casefold() == key + for row in rows + ) + ) + + @staticmethod + def _tax_ledger_candidates(rows: list[dict], tax: str) -> list[dict]: + target = str(tax or "").strip().upper() + patterns = { + "IGST": ("IGST", "INTEGRATED"), + "CGST": ("CGST", "CENTRAL"), + "SGST": ("SGST", "STATE GST", "STATE TAX", "UTGST", "UT GST"), + "CESS": ("CESS",), + }.get(target, (target,)) + matches = [] + for row in rows: + text = " ".join([ + str(row.get("name") or ""), + str(row.get("parent") or ""), + str(row.get("tax_type") or ""), + ]).upper() + if any(pattern in text for pattern in patterns): + matches.append(row) + matches.sort( + key=lambda row: ( + 0 + if any( + word in str(row.get("name") or "").upper() + for word in ("INPUT", "PURCHASE") + ) + else 1, + str(row.get("name") or "").casefold(), + ) + ) + return matches[:20] + + def purchase_posting_preflight( + self, + company_name: str, + *, + supplier_gstin: str, + supplier_name: str, + purchase_ledger_name: str, + invoice_number: str, + invoice_date: str, + invoice_value: float, + ) -> dict: + rows = self.purchase_posting_ledgers(company_name) + gstin = re.sub( + r"[^A-Z0-9]", "", str(supplier_gstin or "").upper() + )[:15] + supplier_key = re.sub( + r"[^A-Z0-9]", "", str(supplier_name or "").upper() + ) + + gstin_matches = [ + row for row in rows if gstin and row.get("gstin") == gstin + ] + name_matches = [] + if supplier_key: + for row in rows: + key = re.sub( + r"[^A-Z0-9]", "", str(row.get("name") or "").upper() + ) + if key == supplier_key: + name_matches.append(row) + + party = gstin_matches or name_matches + if not party and supplier_key: + party = [ + row + for row in rows + if supplier_key + in re.sub(r"[^A-Z0-9]", "", str(row.get("name") or "").upper()) + or re.sub( + r"[^A-Z0-9]", "", str(row.get("name") or "").upper() + ) + in supplier_key + ][:20] + + duplicates = self.find_purchase_duplicates( + company_name, + invoice_date=invoice_date, + reference=invoice_number, + party_ledger_names=[ + str(row.get("name") or "") for row in party + ], + invoice_value=invoice_value, + ) + + return { + "party_candidates": party[:20], + "purchase_ledger_verified": self._ledger_exists( + rows, purchase_ledger_name + ), + "igst_candidates": self._tax_ledger_candidates(rows, "IGST"), + "cgst_candidates": self._tax_ledger_candidates(rows, "CGST"), + "sgst_candidates": self._tax_ledger_candidates(rows, "SGST"), + "cess_candidates": self._tax_ledger_candidates(rows, "CESS"), + "duplicate_candidates": duplicates, + "ledger_count": len(rows), + } + + def find_purchase_duplicates( + self, + company_name: str, + *, + invoice_date: str, + reference: str, + party_ledger_names: list[str] | None = None, + invoice_value: float = 0, + ) -> list[dict]: + target_ref = re.sub( + r"[^A-Z0-9]", "", str(reference or "").upper() + ) + parties = { + str(value or "").strip().casefold() + for value in (party_ledger_names or []) + if str(value or "").strip() + } + expected = round(abs(float(invoice_value or 0)), 2) + if not invoice_date: + return [] + + vouchers = self.export_vouchers( + company_name, invoice_date, invoice_date + ) + result = [] + for voucher in vouchers: + if "PURCHASE" not in str( + voucher.get("voucher_type_name") or "" + ).upper(): + continue + ref = re.sub( + r"[^A-Z0-9]", + "", + str(voucher.get("reference") or "").upper(), + ) + party = str(voucher.get("party_ledger_name") or "").strip() + if target_ref and ref != target_ref: + continue + if parties and party.casefold() not in parties: + continue + party_amount = 0.0 + for entry in voucher.get("ledger_entries") or []: + if ( + str(entry.get("ledger_name") or "") + .strip() + .casefold() + == party.casefold() + ): + party_amount = max( + party_amount, + abs(float(entry.get("amount") or 0)), + ) + if ( + expected + and party_amount + and abs(party_amount - expected) + > max(2.0, expected * 0.005) + ): + continue + result.append({ + "guid": voucher.get("guid") or "", + "voucher_number": voucher.get("voucher_number") or "", + "reference": voucher.get("reference") or "", + "party_ledger_name": party, + "date": voucher.get("date") or "", + "amount": party_amount, + }) + return result[:20] + + def build_purchase_import_xml( + self, + company_name: str, + *, + voucher_date: str, + party_ledger_name: str, + purchase_ledger_name: str, + taxable_value: float, + input_igst_ledger_name: str = "", + igst: float = 0, + input_cgst_ledger_name: str = "", + cgst: float = 0, + input_sgst_ledger_name: str = "", + sgst: float = 0, + input_cess_ledger_name: str = "", + cess: float = 0, + reference: str = "", + narration: str = "", + ) -> str: + company = str(company_name or "").strip() + party = str(party_ledger_name or "").strip() + purchase = str(purchase_ledger_name or "").strip() + + if not company or not party or not purchase: + raise ValueError( + "Company, creditor ledger and purchase/expense ledger are required." + ) + + taxable = round(abs(float(taxable_value or 0)), 2) + taxes = [ + ( + str(input_igst_ledger_name or "").strip(), + round(abs(float(igst or 0)), 2), + "IGST", + ), + ( + str(input_cgst_ledger_name or "").strip(), + round(abs(float(cgst or 0)), 2), + "CGST", + ), + ( + str(input_sgst_ledger_name or "").strip(), + round(abs(float(sgst or 0)), 2), + "SGST", + ), + ( + str(input_cess_ledger_name or "").strip(), + round(abs(float(cess or 0)), 2), + "Cess", + ), + ] + + if taxable <= 0: + raise ValueError("Taxable value must be greater than zero.") + for ledger, amount, label in taxes: + if amount > 0 and not ledger: + raise ValueError( + f"{label} amount exists but no input tax ledger was selected." + ) + + total = round( + taxable + sum(amount for _, amount, _ in taxes), 2 + ) + dt = _tally_date(voucher_date) + ref_xml = ( + f"{self._xml_escape(reference)}" + if str(reference or "").strip() + else "" + ) + + debit_entries = [ + f""" + {self._xml_escape(purchase)} + Yes + -{taxable:.2f} + """ + ] + for ledger, amount, _ in taxes: + if amount > 0: + debit_entries.append( + f""" + {self._xml_escape(ledger)} + Yes + -{amount:.2f} + """ + ) + + return f""" +
1ImportDataVouchers
+ + {self._xml_escape(company)} + + + + {dt} + Purchase + Accounting Voucher View + {self._xml_escape(party)} + {ref_xml} + {self._xml_escape(narration)} + {''.join(debit_entries)} + + {self._xml_escape(party)} + No + {total:.2f} + + {self._xml_escape(reference or 'New Ref')} + New Ref + {total:.2f} + + + + +
""" + + def post_purchase_voucher( + self, company_name: str, **kwargs + ) -> dict: + request_xml = self.build_purchase_import_xml( + company_name, **kwargs + ) + response_xml = self._post_xml(request_xml) + cleaned = _clean_xml_response(response_xml) + + try: + root = ET.fromstring(cleaned.encode("utf-8")) + except Exception as exc: + raise ValueError( + f"Tally returned invalid XML after Purchase import: {exc}" + ) from exc + + def first_int(tag_name: str) -> int: + text = _first_text(root, [tag_name]) + try: + return int(float(str(text or "0"))) + except Exception: + return 0 + + created = first_int("CREATED") + altered = first_int("ALTERED") + errors = first_int("ERRORS") + last_voucher_id = _first_text( + root, + ["LASTVCHID", "LASTMID", "LASTVOUCHERID"], + ) + line_error = _first_text(root, ["LINEERROR"]) + + if errors > 0 or line_error: + raise ValueError( + line_error + or f"Tally reported {errors} error(s) while creating the Purchase voucher." + ) + if created < 1 and altered < 1: + raise ValueError( + "Tally did not confirm creation of the Purchase voucher." + ) + + return { + "created": created, + "altered": altered, + "errors": errors, + "last_voucher_id": str(last_voucher_id or ""), + "raw_response": response_xml, + "request_xml": request_xml, + } + @staticmethod def _parse_vouchers(xml_text: str) -> list[dict]: cleaned = _clean_xml_response(xml_text) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py index 30bbda4..172a277 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tunnel.py @@ -190,7 +190,7 @@ class StorageAgentTunnel: def _capabilities(self) -> list[str]: return [ - "storage", "local_dashboard", "manual_updates", "historical_learning_read", "accounting_act", "agent.jobs.v2", + "storage", "local_dashboard", "manual_updates", "historical_learning_read", "accounting_act", "agent.jobs.v2", "purchase_posting.preflight", "tally.purchase.write", "tally.status", "tally.company_identity", "tally.read_groups", "tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers", "tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync", diff --git a/app/ui/app.py b/app/ui/app.py index 46bbda0..5698375 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -43,6 +43,7 @@ from app.modules.accounting.ledger_learning_ui import router as accounting_ledge from app.modules.accounting.gstr2b_ui import router as accounting_gstr2b_ui_router from app.modules.accounting.purchase_enrichment_ui import router as accounting_purchase_enrichment_ui_router from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router +from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router from app.modules.registrations.ui import router as registrations_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router @@ -72,6 +73,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_gstr2b_ui_router) app.include_router(accounting_purchase_enrichment_ui_router) app.include_router(accounting_purchase_review_ui_router) + app.include_router(accounting_purchase_posting_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)