Add Phase 10 controlled Tally purchase posting
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting Intelligence</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Controlled Tally Purchase Posting</h1>
|
||||||
|
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/tools/accounting/purchase-review{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>
|
||||||
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Back to Tally</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">{{ message }}</div>{% endif %}
|
||||||
|
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
<strong>Controlled write boundary:</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<form method="get" action="/tools/accounting/purchase-posting" class="grid gap-4 md:grid-cols-4">
|
||||||
|
<label class="text-sm font-medium text-slate-700 md:col-span-2">
|
||||||
|
ERP Client
|
||||||
|
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">Select client</option>
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% if selected_client %}
|
||||||
|
<label class="text-sm font-medium text-slate-700">
|
||||||
|
Rows
|
||||||
|
<select name="per_page" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
{% for size in [10,25,50,100] %}<option value="{{ size }}" {% if result and result.per_page == size %}selected{% endif %}>{{ size }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Load Reviewed Purchases</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if selected_client and result %}
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-slate-900">Reviewed Purchase Queue</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ result.total }} reviewed purchase(s) · page {{ result.page }} of {{ result.pages }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}&page={{ result.page }}&per_page={{ result.per_page }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Refresh Job Status</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
{% for tuple_row in result.rows %}
|
||||||
|
{% set row = tuple_row[0] %}
|
||||||
|
{% set attempt = attempts.get(row.id) %}
|
||||||
|
{% set choice = choices.get(row.id) %}
|
||||||
|
<article class="p-5">
|
||||||
|
<div class="grid gap-5 xl:grid-cols-12">
|
||||||
|
<div class="xl:col-span-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="font-semibold text-slate-900">{{ row.supplier_name or row.supplier_gstin or 'Supplier' }}</h3>
|
||||||
|
{% if row.posting_status == 'posted' or (attempt and attempt.status == 'posted') %}
|
||||||
|
<span class="rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700">Posted</span>
|
||||||
|
{% elif attempt %}
|
||||||
|
<span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">{{ attempt.status|replace('_',' ')|title }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">Not started</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-1 text-sm text-slate-600">
|
||||||
|
<div>GSTIN: <span class="font-medium text-slate-900">{{ row.supplier_gstin or '-' }}</span></div>
|
||||||
|
<div>Invoice: <span class="font-medium text-slate-900">{{ row.invoice_number }}</span> · {{ row.invoice_date }}</div>
|
||||||
|
<div>Taxable: <span class="font-medium text-slate-900">₹{{ '%.2f'|format(row.taxable_value) }}</span></div>
|
||||||
|
<div>Invoice Value: <span class="font-medium text-slate-900">₹{{ '%.2f'|format(row.invoice_value) }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xl:col-span-3">
|
||||||
|
<div class="rounded-xl bg-slate-50 p-3">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">Reviewed treatment</div>
|
||||||
|
<div class="mt-1 font-semibold text-slate-900">{{ row.final_ledger_name }}</div>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-2 text-xs text-slate-600">
|
||||||
|
<div>IGST ₹{{ '%.2f'|format(row.igst) }}</div>
|
||||||
|
<div>CGST ₹{{ '%.2f'|format(row.cgst) }}</div>
|
||||||
|
<div>SGST ₹{{ '%.2f'|format(row.sgst) }}</div>
|
||||||
|
<div>Cess ₹{{ '%.2f'|format(row.cess) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xl:col-span-6">
|
||||||
|
{% if not attempt %}
|
||||||
|
<form method="post" action="/tools/accounting/purchase-posting/purchase/{{ row.id }}/preflight" class="rounded-xl border border-slate-200 p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
||||||
|
<input type="hidden" name="page" value="{{ result.page }}" />
|
||||||
|
<input type="hidden" name="per_page" value="{{ result.per_page }}" />
|
||||||
|
<label class="text-sm font-medium text-slate-700">
|
||||||
|
Workstation running the correct open Tally company
|
||||||
|
<select name="workstation_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">Select workstation</option>
|
||||||
|
{% for ws in workstations %}
|
||||||
|
<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }} · {{ 'Tally connected' if ws.tally_connected else 'Tally offline' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="mt-3 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Run Tally Preflight</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% elif attempt.status in ['preflight_queued','preflight_claimed'] %}
|
||||||
|
<div class="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||||
|
Preflight is waiting for workstation <strong>#{{ attempt.workstation_agent_id }}</strong>. Refresh after the Local Agent processes it.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif attempt.status == 'preflight_failed' %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||||
|
<div class="text-sm font-semibold text-red-800">Preflight failed</div>
|
||||||
|
<div class="mt-1 text-sm text-red-700">{{ attempt.last_error }}</div>
|
||||||
|
<form method="post" action="/tools/accounting/purchase-posting/purchase/{{ row.id }}/preflight" class="mt-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
||||||
|
<input type="hidden" name="page" value="{{ result.page }}" />
|
||||||
|
<input type="hidden" name="per_page" value="{{ result.per_page }}" />
|
||||||
|
<select name="workstation_id" required class="w-full rounded-lg border border-red-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">Select workstation</option>
|
||||||
|
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · {{ 'Tally connected' if ws.tally_connected else 'Tally offline' }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="mt-2 rounded-lg bg-red-700 px-3 py-2 text-sm font-semibold text-white">Run New Preflight</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif attempt.status == 'preflight_ready' and choice %}
|
||||||
|
{% if choice.duplicate_candidates %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||||
|
<div class="text-sm font-semibold text-red-800">Possible duplicate found in Tally — posting blocked</div>
|
||||||
|
{% for dup in choice.duplicate_candidates[:5] %}
|
||||||
|
<div class="mt-2 text-xs text-red-700">{{ dup.voucher_number or '-' }} · {{ dup.reference or '-' }} · {{ dup.party_ledger_name or '-' }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif not choice.purchase_ledger_verified %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Reviewed purchase ledger <strong>{{ row.final_ledger_name }}</strong> was not found in the currently opened Tally company. Sync/fix the mapping before posting.</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/tools/accounting/purchase-posting/purchase/{{ row.id }}/post" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
||||||
|
<input type="hidden" name="page" value="{{ result.page }}" />
|
||||||
|
<input type="hidden" name="per_page" value="{{ result.per_page }}" />
|
||||||
|
|
||||||
|
<div class="text-xs font-semibold uppercase text-emerald-700">Preflight passed · {{ choice.company_name }}</div>
|
||||||
|
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="text-xs font-semibold text-slate-700">
|
||||||
|
Creditor / Party Ledger
|
||||||
|
<select name="party_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2 text-sm">
|
||||||
|
<option value="">Select party ledger</option>
|
||||||
|
{% for x in choice.party_candidates %}<option value="{{ x.name }}">{{ x.name }}{% if x.gstin %} · {{ x.gstin }}{% endif %}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% if row.igst > 0 %}
|
||||||
|
<label class="text-xs font-semibold text-slate-700">
|
||||||
|
Input IGST Ledger
|
||||||
|
<select name="input_igst_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2 text-sm">
|
||||||
|
<option value="">Select IGST ledger</option>
|
||||||
|
{% for x in choice.igst_candidates %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% else %}<input type="hidden" name="input_igst_ledger_name" value="" />{% endif %}
|
||||||
|
|
||||||
|
{% if row.cgst > 0 %}
|
||||||
|
<label class="text-xs font-semibold text-slate-700">
|
||||||
|
Input CGST Ledger
|
||||||
|
<select name="input_cgst_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2 text-sm">
|
||||||
|
<option value="">Select CGST ledger</option>
|
||||||
|
{% for x in choice.cgst_candidates %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% else %}<input type="hidden" name="input_cgst_ledger_name" value="" />{% endif %}
|
||||||
|
|
||||||
|
{% if row.sgst > 0 %}
|
||||||
|
<label class="text-xs font-semibold text-slate-700">
|
||||||
|
Input SGST Ledger
|
||||||
|
<select name="input_sgst_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2 text-sm">
|
||||||
|
<option value="">Select SGST ledger</option>
|
||||||
|
{% for x in choice.sgst_candidates %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% else %}<input type="hidden" name="input_sgst_ledger_name" value="" />{% endif %}
|
||||||
|
|
||||||
|
{% if row.cess > 0 %}
|
||||||
|
<label class="text-xs font-semibold text-slate-700">
|
||||||
|
Input Cess Ledger
|
||||||
|
<select name="input_cess_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2 text-sm">
|
||||||
|
<option value="">Select Cess ledger</option>
|
||||||
|
{% for x in choice.cess_candidates %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% else %}<input type="hidden" name="input_cess_ledger_name" value="" />{% endif %}
|
||||||
|
</div>
|
||||||
|
<button class="mt-4 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Post Reviewed Purchase to Tally</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif attempt.status in ['posting_queued','posting_claimed'] %}
|
||||||
|
<div class="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">Purchase posting job is being executed by the selected workstation. Do not submit another posting job.</div>
|
||||||
|
|
||||||
|
{% elif attempt.status == 'posted' %}
|
||||||
|
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
|
<div class="text-sm font-semibold text-emerald-800">Posted successfully to Tally</div>
|
||||||
|
<div class="mt-2 text-sm text-slate-700">Company: {{ attempt.tally_company_name }}</div>
|
||||||
|
<div class="text-sm text-slate-700">Voucher/ID: {{ attempt.tally_voucher_number or attempt.tally_voucher_id or '-' }}</div>
|
||||||
|
<div class="text-sm text-slate-700">Reference: {{ attempt.tally_reference or row.invoice_number }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif attempt.status == 'posting_indeterminate' %}
|
||||||
|
<div class="rounded-xl border border-red-300 bg-red-50 p-4">
|
||||||
|
<div class="text-sm font-bold text-red-800">Manual verification required — do not retry</div>
|
||||||
|
<div class="mt-1 text-sm text-red-700">{{ attempt.last_error }}</div>
|
||||||
|
<div class="mt-2 text-xs text-red-700">The Local Agent began execution but acknowledgement was lost. Check Tally for invoice {{ row.invoice_number }} before any further action.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif attempt.status == 'posting_failed' %}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||||
|
<div class="text-sm font-semibold text-red-800">Tally posting failed</div>
|
||||||
|
<div class="mt-1 text-sm text-red-700">{{ attempt.last_error }}</div>
|
||||||
|
<div class="mt-2 text-xs text-red-700">Run a new preflight after correcting the Tally-side issue. The prior financial write job is never automatically retried.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-10 text-center text-sm text-slate-500">No accountant-reviewed purchases are available for posting.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if result.pages > 1 %}
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-200 px-5 py-4">
|
||||||
|
<span class="text-sm text-slate-500">Page {{ result.page }} of {{ result.pages }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if result.page > 1 %}<a href="?client_id={{ selected_client.id }}&per_page={{ result.per_page }}&page={{ result.page - 1 }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Previous</a>{% endif %}
|
||||||
|
{% if result.page < result.pages %}<a href="?client_id={{ selected_client.id }}&per_page={{ result.per_page }}&page={{ result.page + 1 }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Next</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
||||||
|
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
|
||||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
||||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import io
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.12.0"
|
ERP_LOCAL_AGENT_VERSION = "1.13.0"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.12.0"
|
__version__ = "1.13.0"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
@@ -38,6 +39,10 @@ class AgentCommandProcessor:
|
|||||||
result = self._sync_transactions(payload)
|
result = self._sync_transactions(payload)
|
||||||
elif action == "accounting_historical_evidence":
|
elif action == "accounting_historical_evidence":
|
||||||
result = self._historical_evidence(payload)
|
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":
|
elif action == "accounting_depreciation_preview":
|
||||||
result = self._depreciation_preview(payload)
|
result = self._depreciation_preview(payload)
|
||||||
elif action == "accounting_calculate_it_depreciation":
|
elif action == "accounting_calculate_it_depreciation":
|
||||||
@@ -70,6 +75,8 @@ class AgentCommandProcessor:
|
|||||||
"tally_mapping_capability": True, "tally_master_sync_capability": True,
|
"tally_mapping_capability": True, "tally_master_sync_capability": True,
|
||||||
"tally_transaction_sync_capability": True,
|
"tally_transaction_sync_capability": True,
|
||||||
"historical_learning_read_capability": True,
|
"historical_learning_read_capability": True,
|
||||||
|
"purchase_posting_preflight_capability": True,
|
||||||
|
"purchase_voucher_write_capability": True,
|
||||||
"it_depreciation_capability": True,
|
"it_depreciation_capability": True,
|
||||||
"tally_writeback_capability": True,
|
"tally_writeback_capability": True,
|
||||||
}
|
}
|
||||||
@@ -214,6 +221,154 @@ class AgentCommandProcessor:
|
|||||||
voucher_scope=str(payload.get("voucher_scope") or "purchase"),
|
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]:
|
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
client_id = int(payload.get("client_id"))
|
client_id = int(payload.get("client_id"))
|
||||||
tally_guid = str(payload.get("tally_guid") or "").strip()
|
tally_guid = str(payload.get("tally_guid") or "").strip()
|
||||||
|
|||||||
@@ -439,6 +439,405 @@ class TallyLiveConnector:
|
|||||||
"request_xml": request_xml,
|
"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"""<ENVELOPE>
|
||||||
|
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Export</TALLYREQUEST><TYPE>Collection</TYPE><ID>ARRRPurchasePostingLedgers</ID></HEADER>
|
||||||
|
<BODY><DESC>
|
||||||
|
<STATICVARIABLES>{self._static_variables(company)}</STATICVARIABLES>
|
||||||
|
<TDL><TDLMESSAGE>
|
||||||
|
<COLLECTION NAME="ARRRPurchasePostingLedgers" ISMODIFY="No">
|
||||||
|
<TYPE>Ledger</TYPE>
|
||||||
|
<FETCH>Name,GUID,Parent,PartyGSTIN,GSTIN,GSTRegistrationNumber,TaxType,GSTApplicable,GSTRegistrationType,IsBillWiseOn</FETCH>
|
||||||
|
</COLLECTION>
|
||||||
|
</TDLMESSAGE></TDL>
|
||||||
|
</DESC></BODY>
|
||||||
|
</ENVELOPE>"""
|
||||||
|
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"<REFERENCE>{self._xml_escape(reference)}</REFERENCE>"
|
||||||
|
if str(reference or "").strip()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
debit_entries = [
|
||||||
|
f"""<ALLLEDGERENTRIES.LIST>
|
||||||
|
<LEDGERNAME>{self._xml_escape(purchase)}</LEDGERNAME>
|
||||||
|
<ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
|
||||||
|
<AMOUNT>-{taxable:.2f}</AMOUNT>
|
||||||
|
</ALLLEDGERENTRIES.LIST>"""
|
||||||
|
]
|
||||||
|
for ledger, amount, _ in taxes:
|
||||||
|
if amount > 0:
|
||||||
|
debit_entries.append(
|
||||||
|
f"""<ALLLEDGERENTRIES.LIST>
|
||||||
|
<LEDGERNAME>{self._xml_escape(ledger)}</LEDGERNAME>
|
||||||
|
<ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
|
||||||
|
<AMOUNT>-{amount:.2f}</AMOUNT>
|
||||||
|
</ALLLEDGERENTRIES.LIST>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""<ENVELOPE>
|
||||||
|
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>Vouchers</ID></HEADER>
|
||||||
|
<BODY><DESC>
|
||||||
|
<STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES>
|
||||||
|
</DESC>
|
||||||
|
<DATA><TALLYMESSAGE xmlns:UDF="TallyUDF">
|
||||||
|
<VOUCHER VCHTYPE="Purchase" ACTION="Create" OBJVIEW="Accounting Voucher View">
|
||||||
|
<DATE>{dt}</DATE>
|
||||||
|
<VOUCHERTYPENAME>Purchase</VOUCHERTYPENAME>
|
||||||
|
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
|
||||||
|
<PARTYLEDGERNAME>{self._xml_escape(party)}</PARTYLEDGERNAME>
|
||||||
|
{ref_xml}
|
||||||
|
<NARRATION>{self._xml_escape(narration)}</NARRATION>
|
||||||
|
{''.join(debit_entries)}
|
||||||
|
<ALLLEDGERENTRIES.LIST>
|
||||||
|
<LEDGERNAME>{self._xml_escape(party)}</LEDGERNAME>
|
||||||
|
<ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
|
||||||
|
<AMOUNT>{total:.2f}</AMOUNT>
|
||||||
|
<BILLALLOCATIONS.LIST>
|
||||||
|
<NAME>{self._xml_escape(reference or 'New Ref')}</NAME>
|
||||||
|
<BILLTYPE>New Ref</BILLTYPE>
|
||||||
|
<AMOUNT>{total:.2f}</AMOUNT>
|
||||||
|
</BILLALLOCATIONS.LIST>
|
||||||
|
</ALLLEDGERENTRIES.LIST>
|
||||||
|
</VOUCHER>
|
||||||
|
</TALLYMESSAGE></DATA></BODY>
|
||||||
|
</ENVELOPE>"""
|
||||||
|
|
||||||
|
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
|
@staticmethod
|
||||||
def _parse_vouchers(xml_text: str) -> list[dict]:
|
def _parse_vouchers(xml_text: str) -> list[dict]:
|
||||||
cleaned = _clean_xml_response(xml_text)
|
cleaned = _clean_xml_response(xml_text)
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class StorageAgentTunnel:
|
|||||||
|
|
||||||
def _capabilities(self) -> list[str]:
|
def _capabilities(self) -> list[str]:
|
||||||
return [
|
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.status", "tally.company_identity", "tally.read_groups",
|
||||||
"tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers",
|
"tally.read_ledgers", "tally.read_stock_items", "tally.read_vouchers",
|
||||||
"tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync",
|
"tally.read_trial_balance", "tally.master_sync", "tally.transaction_sync",
|
||||||
|
|||||||
@@ -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.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_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_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.registrations.ui import router as registrations_ui_router
|
||||||
from app.modules.credential_vault.ui import router as credential_vault_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
|
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_gstr2b_ui_router)
|
||||||
app.include_router(accounting_purchase_enrichment_ui_router)
|
app.include_router(accounting_purchase_enrichment_ui_router)
|
||||||
app.include_router(accounting_purchase_review_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(work_tracker_ui_router)
|
||||||
app.include_router(billing_ui_router)
|
app.include_router(billing_ui_router)
|
||||||
app.include_router(platform_billing_ui_router)
|
app.include_router(platform_billing_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user