Add Phase 21A opening balance comparison and controlled Tally correction
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
"""Phase 21A Opening Balance Comparison + Controlled Tally Correction.
|
||||||
|
|
||||||
|
Revision ID: 20260825_opening_balance_p21a
|
||||||
|
Revises: 20260824_stock_item_intel_p21
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20260825_opening_balance_p21a"
|
||||||
|
down_revision = "20260824_stock_item_intel_p21"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"accounting_opening_balance_runs",
|
||||||
|
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("previous_company_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("previous_company_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_company_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("current_company_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="completed"),
|
||||||
|
sa.Column("ledger_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("stock_item_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("summary_json", sa.Text(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("created_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()),
|
||||||
|
)
|
||||||
|
for name in ("tenant_id", "client_id", "status", "created_at_utc"):
|
||||||
|
op.create_index(f"ix_accounting_opening_balance_runs_{name}", "accounting_opening_balance_runs", [name])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"accounting_opening_ledger_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("run_id", sa.Integer(), sa.ForeignKey("accounting_opening_balance_runs.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("previous_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_group", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_closing_balance", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("current_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_group", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_opening_balance", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("difference", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("match_status", sa.String(40), nullable=False),
|
||||||
|
sa.Column("match_method", sa.String(50), nullable=False, server_default=""),
|
||||||
|
sa.Column("confidence", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("correction_status", sa.String(40), nullable=False, server_default="not_requested"),
|
||||||
|
sa.Column("correction_note", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("verified_opening_balance", sa.Float(), nullable=True),
|
||||||
|
sa.Column("corrected_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("corrected_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
for name in ("run_id", "previous_name", "current_name", "match_status", "correction_status"):
|
||||||
|
op.create_index(f"ix_accounting_opening_ledger_items_{name}", "accounting_opening_ledger_items", [name])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"accounting_opening_stock_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("run_id", sa.Integer(), sa.ForeignKey("accounting_opening_balance_runs.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("previous_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_group", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_hsn", sa.String(40), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_unit", sa.String(80), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_closing_qty", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("previous_closing_value", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("previous_closing_rate", sa.String(100), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_group", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_hsn", sa.String(40), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_unit", sa.String(80), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_opening_qty", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("current_opening_value", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("quantity_difference", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("value_difference", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("match_status", sa.String(50), nullable=False),
|
||||||
|
sa.Column("match_method", sa.String(50), nullable=False, server_default=""),
|
||||||
|
sa.Column("confidence", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("correction_status", sa.String(40), nullable=False, server_default="not_requested"),
|
||||||
|
sa.Column("correction_note", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("verified_opening_qty", sa.Float(), nullable=True),
|
||||||
|
sa.Column("verified_opening_value", sa.Float(), nullable=True),
|
||||||
|
sa.Column("corrected_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("corrected_at_utc", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
for name in ("run_id", "previous_name", "current_name", "match_status", "correction_status"):
|
||||||
|
op.create_index(f"ix_accounting_opening_stock_items_{name}", "accounting_opening_stock_items", [name])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"accounting_opening_master_mappings",
|
||||||
|
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("master_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("previous_company_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("previous_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("previous_name_norm", sa.String(255), nullable=False),
|
||||||
|
sa.Column("current_company_guid", sa.String(120), nullable=False, server_default=""),
|
||||||
|
sa.Column("current_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("note", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("created_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.UniqueConstraint("tenant_id", "client_id", "master_type", "previous_company_guid", "previous_name_norm", "current_company_guid", name="uq_accounting_opening_master_mapping"),
|
||||||
|
)
|
||||||
|
for name in ("tenant_id", "client_id", "master_type", "previous_name_norm"):
|
||||||
|
op.create_index(f"ix_accounting_opening_master_mappings_{name}", "accounting_opening_master_mappings", [name])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("accounting_opening_master_mappings")
|
||||||
|
op.drop_table("accounting_opening_stock_items")
|
||||||
|
op.drop_table("accounting_opening_ledger_items")
|
||||||
|
op.drop_table("accounting_opening_balance_runs")
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db.common import CommonBase
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingOpeningBalanceRun(CommonBase):
|
||||||
|
__tablename__ = "accounting_opening_balance_runs"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
previous_company_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
previous_company_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
current_company_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
current_company_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False, default="completed", index=True)
|
||||||
|
ledger_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
stock_item_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
summary_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||||
|
created_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), nullable=False, default=_utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingOpeningLedgerItem(CommonBase):
|
||||||
|
__tablename__ = "accounting_opening_ledger_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_id: Mapped[int] = mapped_column(ForeignKey("accounting_opening_balance_runs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
previous_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True)
|
||||||
|
previous_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
previous_group: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
previous_closing_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
current_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True)
|
||||||
|
current_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
current_group: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
current_opening_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
difference: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
match_status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
match_method: Mapped[str] = mapped_column(String(50), nullable=False, default="")
|
||||||
|
confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
correction_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_requested", index=True)
|
||||||
|
correction_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
verified_opening_balance: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
corrected_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
corrected_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingOpeningStockItem(CommonBase):
|
||||||
|
__tablename__ = "accounting_opening_stock_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_id: Mapped[int] = mapped_column(ForeignKey("accounting_opening_balance_runs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
previous_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True)
|
||||||
|
previous_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
previous_group: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
previous_hsn: Mapped[str] = mapped_column(String(40), nullable=False, default="")
|
||||||
|
previous_unit: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||||
|
previous_closing_qty: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
previous_closing_value: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
previous_closing_rate: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||||
|
|
||||||
|
current_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True)
|
||||||
|
current_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
current_group: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
current_hsn: Mapped[str] = mapped_column(String(40), nullable=False, default="")
|
||||||
|
current_unit: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||||
|
current_opening_qty: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
current_opening_value: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
quantity_difference: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
value_difference: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
match_status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
match_method: Mapped[str] = mapped_column(String(50), nullable=False, default="")
|
||||||
|
confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
correction_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_requested", index=True)
|
||||||
|
correction_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
verified_opening_qty: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
verified_opening_value: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
corrected_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
corrected_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingOpeningMasterMapping(CommonBase):
|
||||||
|
__tablename__ = "accounting_opening_master_mappings"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"client_id",
|
||||||
|
"master_type",
|
||||||
|
"previous_company_guid",
|
||||||
|
"previous_name_norm",
|
||||||
|
"current_company_guid",
|
||||||
|
name="uq_accounting_opening_master_mapping",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
master_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
|
||||||
|
previous_company_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
previous_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
previous_name_norm: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
|
||||||
|
current_company_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||||
|
current_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
created_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), nullable=False, default=_utcnow)
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
from app.modules.accounting.opening_balance_models import (
|
||||||
|
AccountingOpeningBalanceRun,
|
||||||
|
AccountingOpeningLedgerItem,
|
||||||
|
AccountingOpeningMasterMapping,
|
||||||
|
AccountingOpeningStockItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _s(value):
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _n(value):
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value):
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", _s(value).casefold()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _near(a, b, tolerance=0.01):
|
||||||
|
return abs(float(a or 0) - float(b or 0)) <= tolerance
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_index(db, *, tenant_id, client_id, master_type, previous_company_guid, current_company_guid):
|
||||||
|
rows = list(
|
||||||
|
db.execute(
|
||||||
|
select(AccountingOpeningMasterMapping).where(
|
||||||
|
AccountingOpeningMasterMapping.tenant_id == int(tenant_id),
|
||||||
|
AccountingOpeningMasterMapping.client_id == int(client_id),
|
||||||
|
AccountingOpeningMasterMapping.master_type == master_type,
|
||||||
|
AccountingOpeningMasterMapping.previous_company_guid == _s(previous_company_guid),
|
||||||
|
AccountingOpeningMasterMapping.current_company_guid == _s(current_company_guid),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
return {row.previous_name_norm: row.current_name for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def save_master_mapping(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
tenant_id,
|
||||||
|
client_id,
|
||||||
|
master_type,
|
||||||
|
previous_company_guid,
|
||||||
|
previous_name,
|
||||||
|
current_company_guid,
|
||||||
|
current_name,
|
||||||
|
user_id,
|
||||||
|
note="",
|
||||||
|
):
|
||||||
|
master_type = _s(master_type).lower()
|
||||||
|
if master_type not in {"ledger", "stock_item"}:
|
||||||
|
raise ValueError("Master mapping type must be ledger or stock_item.")
|
||||||
|
previous_norm = _norm(previous_name)
|
||||||
|
if not previous_norm or not _s(current_name):
|
||||||
|
raise ValueError("Both previous-year and current-year master names are required.")
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
select(AccountingOpeningMasterMapping).where(
|
||||||
|
AccountingOpeningMasterMapping.tenant_id == int(tenant_id),
|
||||||
|
AccountingOpeningMasterMapping.client_id == int(client_id),
|
||||||
|
AccountingOpeningMasterMapping.master_type == master_type,
|
||||||
|
AccountingOpeningMasterMapping.previous_company_guid == _s(previous_company_guid),
|
||||||
|
AccountingOpeningMasterMapping.previous_name_norm == previous_norm,
|
||||||
|
AccountingOpeningMasterMapping.current_company_guid == _s(current_company_guid),
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
row = AccountingOpeningMasterMapping(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
client_id=int(client_id),
|
||||||
|
master_type=master_type,
|
||||||
|
previous_company_guid=_s(previous_company_guid),
|
||||||
|
previous_name=_s(previous_name),
|
||||||
|
previous_name_norm=previous_norm,
|
||||||
|
current_company_guid=_s(current_company_guid),
|
||||||
|
current_name=_s(current_name),
|
||||||
|
created_by_user_id=int(user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.previous_name = _s(previous_name)
|
||||||
|
row.current_name = _s(current_name)
|
||||||
|
row.note = _s(note)
|
||||||
|
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _match_ledgers(previous, current, learned):
|
||||||
|
current_by_norm = {}
|
||||||
|
for row in current:
|
||||||
|
current_by_norm.setdefault(_norm(row.get("name")), []).append(row)
|
||||||
|
|
||||||
|
used = set()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for py in previous:
|
||||||
|
py_name = _s(py.get("name"))
|
||||||
|
py_norm = _norm(py_name)
|
||||||
|
target_name = learned.get(py_norm)
|
||||||
|
match = None
|
||||||
|
method = ""
|
||||||
|
confidence = 0
|
||||||
|
|
||||||
|
if target_name:
|
||||||
|
candidates = [row for row in current if _s(row.get("name")).casefold() == target_name.casefold()]
|
||||||
|
if len(candidates) == 1:
|
||||||
|
match = candidates[0]
|
||||||
|
method = "learned_mapping"
|
||||||
|
confidence = 100
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
exact = current_by_norm.get(py_norm, [])
|
||||||
|
if len(exact) == 1:
|
||||||
|
match = exact[0]
|
||||||
|
method = "exact_normalized_name"
|
||||||
|
confidence = 98
|
||||||
|
|
||||||
|
if match is not None:
|
||||||
|
used.add(id(match))
|
||||||
|
py_close = _n(py.get("closing_balance"))
|
||||||
|
cy_open = _n(match.get("opening_balance"))
|
||||||
|
diff = round(py_close - cy_open, 2)
|
||||||
|
status = "matched" if _near(py_close, cy_open) else "difference"
|
||||||
|
result.append({
|
||||||
|
"previous": py, "current": match, "difference": diff,
|
||||||
|
"status": status, "method": method, "confidence": confidence,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result.append({
|
||||||
|
"previous": py, "current": None, "difference": _n(py.get("closing_balance")),
|
||||||
|
"status": "missing_in_current_year", "method": "", "confidence": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
for cy in current:
|
||||||
|
if id(cy) in used:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"previous": None, "current": cy, "difference": -_n(cy.get("opening_balance")),
|
||||||
|
"status": "new_in_current_year", "method": "", "confidence": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_score(py, cy):
|
||||||
|
py_name = _norm(py.get("name"))
|
||||||
|
cy_name = _norm(cy.get("name"))
|
||||||
|
score = int(round(SequenceMatcher(None, py_name, cy_name).ratio() * 60))
|
||||||
|
|
||||||
|
py_hsn = _s(py.get("hsn_code"))
|
||||||
|
cy_hsn = _s(cy.get("hsn_code"))
|
||||||
|
if py_hsn and cy_hsn:
|
||||||
|
if py_hsn == cy_hsn:
|
||||||
|
score += 25
|
||||||
|
elif py_hsn[:4] and py_hsn[:4] == cy_hsn[:4]:
|
||||||
|
score += 12
|
||||||
|
else:
|
||||||
|
score -= 20
|
||||||
|
|
||||||
|
if _norm(py.get("parent")) == _norm(cy.get("parent")) and _norm(py.get("parent")):
|
||||||
|
score += 10
|
||||||
|
if _norm(py.get("base_units")) == _norm(cy.get("base_units")) and _norm(py.get("base_units")):
|
||||||
|
score += 10
|
||||||
|
return max(0, min(100, score))
|
||||||
|
|
||||||
|
|
||||||
|
def _match_stock(previous, current, learned):
|
||||||
|
current_by_norm = {}
|
||||||
|
for row in current:
|
||||||
|
current_by_norm.setdefault(_norm(row.get("name")), []).append(row)
|
||||||
|
|
||||||
|
used = set()
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for py in previous:
|
||||||
|
py_name = _s(py.get("name"))
|
||||||
|
py_norm = _norm(py_name)
|
||||||
|
match = None
|
||||||
|
method = ""
|
||||||
|
confidence = 0
|
||||||
|
|
||||||
|
target_name = learned.get(py_norm)
|
||||||
|
if target_name:
|
||||||
|
candidates = [row for row in current if _s(row.get("name")).casefold() == target_name.casefold()]
|
||||||
|
if len(candidates) == 1:
|
||||||
|
match = candidates[0]
|
||||||
|
method = "learned_mapping"
|
||||||
|
confidence = 100
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
exact = current_by_norm.get(py_norm, [])
|
||||||
|
if len(exact) == 1:
|
||||||
|
match = exact[0]
|
||||||
|
method = "exact_normalized_name"
|
||||||
|
confidence = 98
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
scored = sorted(
|
||||||
|
[(_stock_score(py, cy), cy) for cy in current if id(cy) not in used],
|
||||||
|
key=lambda row: (-row[0], _s(row[1].get("name"))),
|
||||||
|
)
|
||||||
|
if scored:
|
||||||
|
top = scored[0][0]
|
||||||
|
second = scored[1][0] if len(scored) > 1 else 0
|
||||||
|
if top >= 90 and top - second >= 10:
|
||||||
|
match = scored[0][1]
|
||||||
|
method = "unique_hsn_name_match"
|
||||||
|
confidence = top
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
result.append({
|
||||||
|
"previous": py, "current": None,
|
||||||
|
"qty_difference": _n(py.get("closing_balance")),
|
||||||
|
"value_difference": _n(py.get("closing_value")),
|
||||||
|
"status": "missing_in_current_year", "method": "", "confidence": 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
used.add(id(match))
|
||||||
|
py_qty = _n(py.get("closing_balance"))
|
||||||
|
py_val = _n(py.get("closing_value"))
|
||||||
|
cy_qty = _n(match.get("opening_balance"))
|
||||||
|
cy_val = _n(match.get("opening_value"))
|
||||||
|
qty_diff = round(py_qty - cy_qty, 6)
|
||||||
|
val_diff = round(py_val - cy_val, 2)
|
||||||
|
|
||||||
|
py_unit = _norm(py.get("base_units"))
|
||||||
|
cy_unit = _norm(match.get("base_units"))
|
||||||
|
if py_unit and cy_unit and py_unit != cy_unit:
|
||||||
|
status = "unit_difference"
|
||||||
|
elif _near(py_qty, cy_qty, 0.000001) and _near(py_val, cy_val):
|
||||||
|
status = "matched"
|
||||||
|
elif not _near(py_qty, cy_qty, 0.000001) and not _near(py_val, cy_val):
|
||||||
|
status = "quantity_and_value_difference"
|
||||||
|
elif not _near(py_qty, cy_qty, 0.000001):
|
||||||
|
status = "quantity_difference"
|
||||||
|
else:
|
||||||
|
status = "value_difference"
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"previous": py, "current": match,
|
||||||
|
"qty_difference": qty_diff, "value_difference": val_diff,
|
||||||
|
"status": status, "method": method, "confidence": confidence,
|
||||||
|
})
|
||||||
|
|
||||||
|
for cy in current:
|
||||||
|
if id(cy) in used:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"previous": None, "current": cy,
|
||||||
|
"qty_difference": -_n(cy.get("opening_balance")),
|
||||||
|
"value_difference": -_n(cy.get("opening_value")),
|
||||||
|
"status": "new_in_current_year", "method": "", "confidence": 0,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_comparison_run(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
tenant_id,
|
||||||
|
client_id,
|
||||||
|
previous_company,
|
||||||
|
current_company,
|
||||||
|
previous_masters,
|
||||||
|
current_masters,
|
||||||
|
user_id,
|
||||||
|
):
|
||||||
|
py_guid = _s(previous_company.get("guid"))
|
||||||
|
cy_guid = _s(current_company.get("guid"))
|
||||||
|
|
||||||
|
ledger_map = _mapping_index(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
client_id=client_id,
|
||||||
|
master_type="ledger",
|
||||||
|
previous_company_guid=py_guid,
|
||||||
|
current_company_guid=cy_guid,
|
||||||
|
)
|
||||||
|
stock_map = _mapping_index(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
client_id=client_id,
|
||||||
|
master_type="stock_item",
|
||||||
|
previous_company_guid=py_guid,
|
||||||
|
current_company_guid=cy_guid,
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger_matches = _match_ledgers(
|
||||||
|
list(previous_masters.get("ledgers") or []),
|
||||||
|
list(current_masters.get("ledgers") or []),
|
||||||
|
ledger_map,
|
||||||
|
)
|
||||||
|
stock_matches = _match_stock(
|
||||||
|
list(previous_masters.get("stock_items") or []),
|
||||||
|
list(current_masters.get("stock_items") or []),
|
||||||
|
stock_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
run = AccountingOpeningBalanceRun(
|
||||||
|
tenant_id=int(tenant_id),
|
||||||
|
client_id=int(client_id),
|
||||||
|
previous_company_name=_s(previous_company.get("name")),
|
||||||
|
previous_company_guid=py_guid,
|
||||||
|
current_company_name=_s(current_company.get("name")),
|
||||||
|
current_company_guid=cy_guid,
|
||||||
|
status="completed",
|
||||||
|
ledger_count=len(ledger_matches),
|
||||||
|
stock_item_count=len(stock_matches),
|
||||||
|
created_by_user_id=int(user_id),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for row in ledger_matches:
|
||||||
|
py = row["previous"] or {}
|
||||||
|
cy = row["current"] or {}
|
||||||
|
db.add(
|
||||||
|
AccountingOpeningLedgerItem(
|
||||||
|
run_id=run.id,
|
||||||
|
previous_name=_s(py.get("name")),
|
||||||
|
previous_guid=_s(py.get("guid")),
|
||||||
|
previous_group=_s(py.get("parent")),
|
||||||
|
previous_closing_balance=_n(py.get("closing_balance")),
|
||||||
|
current_name=_s(cy.get("name")),
|
||||||
|
current_guid=_s(cy.get("guid")),
|
||||||
|
current_group=_s(cy.get("parent")),
|
||||||
|
current_opening_balance=_n(cy.get("opening_balance")),
|
||||||
|
difference=float(row["difference"]),
|
||||||
|
match_status=row["status"],
|
||||||
|
match_method=row["method"],
|
||||||
|
confidence=int(row["confidence"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in stock_matches:
|
||||||
|
py = row["previous"] or {}
|
||||||
|
cy = row["current"] or {}
|
||||||
|
db.add(
|
||||||
|
AccountingOpeningStockItem(
|
||||||
|
run_id=run.id,
|
||||||
|
previous_name=_s(py.get("name")),
|
||||||
|
previous_guid=_s(py.get("guid")),
|
||||||
|
previous_group=_s(py.get("parent")),
|
||||||
|
previous_hsn=_s(py.get("hsn_code")),
|
||||||
|
previous_unit=_s(py.get("base_units")),
|
||||||
|
previous_closing_qty=_n(py.get("closing_balance")),
|
||||||
|
previous_closing_value=_n(py.get("closing_value")),
|
||||||
|
previous_closing_rate=_s(py.get("closing_rate")),
|
||||||
|
current_name=_s(cy.get("name")),
|
||||||
|
current_guid=_s(cy.get("guid")),
|
||||||
|
current_group=_s(cy.get("parent")),
|
||||||
|
current_hsn=_s(cy.get("hsn_code")),
|
||||||
|
current_unit=_s(cy.get("base_units")),
|
||||||
|
current_opening_qty=_n(cy.get("opening_balance")),
|
||||||
|
current_opening_value=_n(cy.get("opening_value")),
|
||||||
|
quantity_difference=float(row["qty_difference"]),
|
||||||
|
value_difference=float(row["value_difference"]),
|
||||||
|
match_status=row["status"],
|
||||||
|
match_method=row["method"],
|
||||||
|
confidence=int(row["confidence"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"ledgers": {},
|
||||||
|
"stock_items": {},
|
||||||
|
}
|
||||||
|
for key in {
|
||||||
|
"matched", "difference", "missing_in_current_year", "new_in_current_year"
|
||||||
|
}:
|
||||||
|
summary["ledgers"][key] = sum(1 for row in ledger_matches if row["status"] == key)
|
||||||
|
|
||||||
|
for key in {
|
||||||
|
"matched", "quantity_difference", "value_difference",
|
||||||
|
"quantity_and_value_difference", "unit_difference",
|
||||||
|
"missing_in_current_year", "new_in_current_year",
|
||||||
|
}:
|
||||||
|
summary["stock_items"][key] = sum(1 for row in stock_matches if row["status"] == key)
|
||||||
|
|
||||||
|
run.summary_json = json.dumps(summary, ensure_ascii=False)
|
||||||
|
db.add(run)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def list_runs(db, *, tenant_id, client_id, limit=30):
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(AccountingOpeningBalanceRun)
|
||||||
|
.where(
|
||||||
|
AccountingOpeningBalanceRun.tenant_id == int(tenant_id),
|
||||||
|
AccountingOpeningBalanceRun.client_id == int(client_id),
|
||||||
|
)
|
||||||
|
.order_by(AccountingOpeningBalanceRun.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_items(db, *, run_id, status=""):
|
||||||
|
stmt = select(AccountingOpeningLedgerItem).where(
|
||||||
|
AccountingOpeningLedgerItem.run_id == int(run_id)
|
||||||
|
)
|
||||||
|
if _s(status):
|
||||||
|
stmt = stmt.where(AccountingOpeningLedgerItem.match_status == _s(status))
|
||||||
|
return list(db.execute(stmt.order_by(AccountingOpeningLedgerItem.previous_name, AccountingOpeningLedgerItem.current_name)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def stock_items(db, *, run_id, status=""):
|
||||||
|
stmt = select(AccountingOpeningStockItem).where(
|
||||||
|
AccountingOpeningStockItem.run_id == int(run_id)
|
||||||
|
)
|
||||||
|
if _s(status):
|
||||||
|
stmt = stmt.where(AccountingOpeningStockItem.match_status == _s(status))
|
||||||
|
return list(db.execute(stmt.order_by(AccountingOpeningStockItem.previous_name, AccountingOpeningStockItem.current_name)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def correction_payload(db, *, run, ledger_ids, stock_ids):
|
||||||
|
ledger_rows = []
|
||||||
|
stock_rows = []
|
||||||
|
|
||||||
|
for row in db.execute(
|
||||||
|
select(AccountingOpeningLedgerItem).where(
|
||||||
|
AccountingOpeningLedgerItem.run_id == run.id,
|
||||||
|
AccountingOpeningLedgerItem.id.in_([int(x) for x in ledger_ids] or [-1]),
|
||||||
|
)
|
||||||
|
).scalars().all():
|
||||||
|
if row.match_status != "difference" or not row.current_name:
|
||||||
|
continue
|
||||||
|
ledger_rows.append({
|
||||||
|
"item_id": row.id,
|
||||||
|
"name": row.current_name,
|
||||||
|
"guid": row.current_guid,
|
||||||
|
"expected_opening": row.current_opening_balance,
|
||||||
|
"target_opening": row.previous_closing_balance,
|
||||||
|
})
|
||||||
|
|
||||||
|
for row in db.execute(
|
||||||
|
select(AccountingOpeningStockItem).where(
|
||||||
|
AccountingOpeningStockItem.run_id == run.id,
|
||||||
|
AccountingOpeningStockItem.id.in_([int(x) for x in stock_ids] or [-1]),
|
||||||
|
)
|
||||||
|
).scalars().all():
|
||||||
|
if row.match_status not in {
|
||||||
|
"quantity_difference", "value_difference", "quantity_and_value_difference"
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
if not row.current_name or _norm(row.previous_unit) != _norm(row.current_unit):
|
||||||
|
continue
|
||||||
|
|
||||||
|
qty = row.previous_closing_qty
|
||||||
|
value = row.previous_closing_value
|
||||||
|
rate = abs(value / qty) if abs(qty) > 0.0000001 else 0
|
||||||
|
stock_rows.append({
|
||||||
|
"item_id": row.id,
|
||||||
|
"name": row.current_name,
|
||||||
|
"guid": row.current_guid,
|
||||||
|
"unit": row.current_unit or row.previous_unit,
|
||||||
|
"expected_opening_qty": row.current_opening_qty,
|
||||||
|
"expected_opening_value": row.current_opening_value,
|
||||||
|
"target_opening_qty": qty,
|
||||||
|
"target_opening_value": value,
|
||||||
|
"target_opening_rate": rate,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ledger_rows, stock_rows
|
||||||
|
|
||||||
|
|
||||||
|
def apply_correction_results(db, *, run_id, result, user_id):
|
||||||
|
now = _utcnow()
|
||||||
|
for row in result.get("ledgers") or []:
|
||||||
|
item = db.get(AccountingOpeningLedgerItem, int(row.get("item_id") or 0))
|
||||||
|
if not item or item.run_id != int(run_id):
|
||||||
|
continue
|
||||||
|
item.correction_status = "verified" if row.get("verified") else "failed"
|
||||||
|
item.correction_note = _s(row.get("message"))
|
||||||
|
item.verified_opening_balance = _n(row.get("verified_opening"))
|
||||||
|
item.corrected_by_user_id = int(user_id)
|
||||||
|
item.corrected_at_utc = now
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
for row in result.get("stock_items") or []:
|
||||||
|
item = db.get(AccountingOpeningStockItem, int(row.get("item_id") or 0))
|
||||||
|
if not item or item.run_id != int(run_id):
|
||||||
|
continue
|
||||||
|
item.correction_status = "verified" if row.get("verified") else "failed"
|
||||||
|
item.correction_note = _s(row.get("message"))
|
||||||
|
item.verified_opening_qty = _n(row.get("verified_opening_qty"))
|
||||||
|
item.verified_opening_value = _n(row.get("verified_opening_value"))
|
||||||
|
item.corrected_by_user_id = int(user_id)
|
||||||
|
item.corrected_at_utc = now
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
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.agent_bridge import request_agent_command
|
||||||
|
from app.modules.accounting.opening_balance_models import AccountingOpeningBalanceRun
|
||||||
|
from app.modules.accounting.opening_balance_service import (
|
||||||
|
apply_correction_results,
|
||||||
|
correction_payload,
|
||||||
|
create_comparison_run,
|
||||||
|
ledger_items,
|
||||||
|
list_runs,
|
||||||
|
save_master_mapping,
|
||||||
|
stock_items,
|
||||||
|
)
|
||||||
|
from app.modules.accounting.ui import (
|
||||||
|
_accounting_storage_payload,
|
||||||
|
_find_visible_client,
|
||||||
|
_node_online,
|
||||||
|
_require_partner,
|
||||||
|
_visible_clients,
|
||||||
|
)
|
||||||
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
|
from app.modules.documents.services import get_active_storage_node_for_branch
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/tools/accounting/opening-balances",
|
||||||
|
tags=["accounting-opening-balance-ui"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _go(client_id=0, message="", error="", **extra):
|
||||||
|
q = {}
|
||||||
|
if client_id:
|
||||||
|
q["client_id"] = client_id
|
||||||
|
if message:
|
||||||
|
q["message"] = message[:500]
|
||||||
|
if error:
|
||||||
|
q["error"] = error[:500]
|
||||||
|
for key, value in extra.items():
|
||||||
|
if value not in (None, ""):
|
||||||
|
q[key] = value
|
||||||
|
return RedirectResponse(
|
||||||
|
"/tools/accounting/opening-balances" + ("?" + urlencode(q) if q else ""),
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _company_rows(tally):
|
||||||
|
rows = []
|
||||||
|
seen = set()
|
||||||
|
for row in (tally or {}).get("companies") or []:
|
||||||
|
if isinstance(row, str):
|
||||||
|
item = {"name": row, "guid": ""}
|
||||||
|
else:
|
||||||
|
item = {
|
||||||
|
"name": str(row.get("name") or "").strip(),
|
||||||
|
"guid": str(row.get("guid") or "").strip(),
|
||||||
|
}
|
||||||
|
if item["name"] and item["name"].casefold() not in seen:
|
||||||
|
seen.add(item["name"].casefold())
|
||||||
|
rows.append(item)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def page(
|
||||||
|
request: Request,
|
||||||
|
client_id: int | None = None,
|
||||||
|
run_id: int | None = None,
|
||||||
|
tab: str = "ledgers",
|
||||||
|
status: str = "",
|
||||||
|
message: str = "",
|
||||||
|
error: str = "",
|
||||||
|
):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, denied = _require_partner(request, db, "accounting.tally.view")
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
clients, scope = _visible_clients(db, request, user)
|
||||||
|
selected = next(
|
||||||
|
(row for row in clients if client_id and int(row.id) == int(client_id)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||||
|
online = bool(node and _node_online(node))
|
||||||
|
tally = None
|
||||||
|
companies = []
|
||||||
|
command_error = error or ""
|
||||||
|
|
||||||
|
if selected and node and online:
|
||||||
|
try:
|
||||||
|
response = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"phase6_status",
|
||||||
|
_accounting_storage_payload(selected),
|
||||||
|
timeout_seconds=20,
|
||||||
|
)
|
||||||
|
if response.get("ok"):
|
||||||
|
tally = (response.get("result") or {}).get("tally")
|
||||||
|
companies = _company_rows(tally)
|
||||||
|
else:
|
||||||
|
command_error = str(response.get("error") or "Local Agent status failed.")
|
||||||
|
except Exception as exc:
|
||||||
|
command_error = str(exc)
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
selected_run = None
|
||||||
|
ledgers = []
|
||||||
|
stocks = []
|
||||||
|
summary = {}
|
||||||
|
|
||||||
|
if selected:
|
||||||
|
runs = list_runs(
|
||||||
|
db,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
client_id=selected.id,
|
||||||
|
limit=30,
|
||||||
|
)
|
||||||
|
if run_id:
|
||||||
|
selected_run = next((row for row in runs if int(row.id) == int(run_id)), None)
|
||||||
|
elif runs:
|
||||||
|
selected_run = runs[0]
|
||||||
|
|
||||||
|
if selected_run:
|
||||||
|
try:
|
||||||
|
summary = json.loads(selected_run.summary_json or "{}")
|
||||||
|
except Exception:
|
||||||
|
summary = {}
|
||||||
|
if tab == "stock":
|
||||||
|
stocks = stock_items(db, run_id=selected_run.id, status=status)
|
||||||
|
else:
|
||||||
|
ledgers = ledger_items(db, run_id=selected_run.id, status=status)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"modules/accounting/templates/accounting/opening_balances.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": "Opening Balance Review",
|
||||||
|
"clients": clients,
|
||||||
|
"selected_client": selected,
|
||||||
|
"node_online": online,
|
||||||
|
"tally": tally,
|
||||||
|
"companies": companies,
|
||||||
|
"runs": runs,
|
||||||
|
"selected_run": selected_run,
|
||||||
|
"ledgers": ledgers,
|
||||||
|
"stocks": stocks,
|
||||||
|
"summary": summary,
|
||||||
|
"tab": tab if tab in {"ledgers", "stock"} else "ledgers",
|
||||||
|
"status_filter": status,
|
||||||
|
"message": message,
|
||||||
|
"error": command_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compare")
|
||||||
|
def compare(
|
||||||
|
request: Request,
|
||||||
|
client_id: int = Form(...),
|
||||||
|
previous_company_name: str = Form(...),
|
||||||
|
current_company_name: str = Form(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, denied = _require_partner(request, db, "accounting.tally.sync_masters")
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||||
|
if not client:
|
||||||
|
return _go(error="Client is not visible.")
|
||||||
|
if previous_company_name.strip().casefold() == current_company_name.strip().casefold():
|
||||||
|
raise ValueError(
|
||||||
|
"Previous-year and current-year companies must be different in Phase 21A. "
|
||||||
|
"This protects against using the current company's latest closing balance as the prior-year closing."
|
||||||
|
)
|
||||||
|
|
||||||
|
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||||
|
if not node or not _node_online(node):
|
||||||
|
raise ValueError("ERP Local Agent is offline for the active branch.")
|
||||||
|
|
||||||
|
response = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"accounting_opening_balance_snapshot",
|
||||||
|
{
|
||||||
|
**_accounting_storage_payload(client),
|
||||||
|
"previous_company_name": previous_company_name,
|
||||||
|
"current_company_name": current_company_name,
|
||||||
|
},
|
||||||
|
timeout_seconds=180,
|
||||||
|
)
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise RuntimeError(str(response.get("error") or "Opening balance snapshot failed."))
|
||||||
|
|
||||||
|
result = response.get("result") or {}
|
||||||
|
run = create_comparison_run(
|
||||||
|
db,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
client_id=client.id,
|
||||||
|
previous_company=result.get("previous_company") or {},
|
||||||
|
current_company=result.get("current_company") or {},
|
||||||
|
previous_masters=result.get("previous_masters") or {},
|
||||||
|
current_masters=result.get("current_masters") or {},
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
return _go(
|
||||||
|
client.id,
|
||||||
|
run_id=run.id,
|
||||||
|
tab="ledgers",
|
||||||
|
message=(
|
||||||
|
f"Opening balance comparison #{run.id} completed: "
|
||||||
|
f"{run.ledger_count} ledger row(s), {run.stock_item_count} stock row(s)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
return _go(client_id, error=str(exc))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mapping")
|
||||||
|
def mapping(
|
||||||
|
request: Request,
|
||||||
|
client_id: int = Form(...),
|
||||||
|
run_id: int = Form(...),
|
||||||
|
master_type: str = Form(...),
|
||||||
|
previous_name: str = Form(...),
|
||||||
|
current_name: str = Form(...),
|
||||||
|
note: str = Form(""),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, denied = _require_partner(request, db, "accounting.learning.manage")
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||||
|
if not client:
|
||||||
|
return _go(error="Client is not visible.")
|
||||||
|
|
||||||
|
run = db.get(AccountingOpeningBalanceRun, int(run_id))
|
||||||
|
if not run or run.client_id != client.id or run.tenant_id != scope.tenant_id:
|
||||||
|
raise ValueError("Opening balance comparison run was not found.")
|
||||||
|
|
||||||
|
save_master_mapping(
|
||||||
|
db,
|
||||||
|
tenant_id=scope.tenant_id,
|
||||||
|
client_id=client.id,
|
||||||
|
master_type=master_type,
|
||||||
|
previous_company_guid=run.previous_company_guid,
|
||||||
|
previous_name=previous_name,
|
||||||
|
current_company_guid=run.current_company_guid,
|
||||||
|
current_name=current_name,
|
||||||
|
user_id=user.id,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
return _go(
|
||||||
|
client.id,
|
||||||
|
run_id=run.id,
|
||||||
|
tab=("stock" if master_type == "stock_item" else "ledgers"),
|
||||||
|
message="Carry-forward master mapping saved. Re-run the comparison to apply it.",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
return _go(client_id, run_id=run_id, error=str(exc))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply")
|
||||||
|
async def apply_corrections(
|
||||||
|
request: Request,
|
||||||
|
client_id: int = Form(...),
|
||||||
|
run_id: int = Form(...),
|
||||||
|
confirm_text: str = Form(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
):
|
||||||
|
validate_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
ledger_ids = [int(value) for value in form.getlist("ledger_ids") if str(value).isdigit()]
|
||||||
|
stock_ids = [int(value) for value in form.getlist("stock_ids") if str(value).isdigit()]
|
||||||
|
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, denied = _require_partner(request, db, "accounting.tally.sync_masters")
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
if confirm_text.strip().upper() != "APPLY OPENING BALANCES":
|
||||||
|
raise ValueError('Type "APPLY OPENING BALANCES" to confirm direct Tally master changes.')
|
||||||
|
|
||||||
|
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||||
|
if not client:
|
||||||
|
return _go(error="Client is not visible.")
|
||||||
|
|
||||||
|
run = db.get(AccountingOpeningBalanceRun, int(run_id))
|
||||||
|
if not run or run.client_id != client.id or run.tenant_id != scope.tenant_id:
|
||||||
|
raise ValueError("Opening balance comparison run was not found.")
|
||||||
|
|
||||||
|
ledger_rows, stock_rows = correction_payload(
|
||||||
|
db,
|
||||||
|
run=run,
|
||||||
|
ledger_ids=ledger_ids,
|
||||||
|
stock_ids=stock_ids,
|
||||||
|
)
|
||||||
|
if not ledger_rows and not stock_rows:
|
||||||
|
raise ValueError(
|
||||||
|
"No safe correction rows were selected. Missing masters, unit differences, "
|
||||||
|
"already-matched rows and ambiguous rows cannot be changed automatically."
|
||||||
|
)
|
||||||
|
|
||||||
|
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||||
|
if not node or not _node_online(node):
|
||||||
|
raise ValueError("ERP Local Agent is offline for the active branch.")
|
||||||
|
|
||||||
|
response = request_agent_command(
|
||||||
|
node.node_code,
|
||||||
|
"accounting_opening_balance_apply",
|
||||||
|
{
|
||||||
|
**_accounting_storage_payload(client),
|
||||||
|
"current_company_name": run.current_company_name,
|
||||||
|
"current_company_guid": run.current_company_guid,
|
||||||
|
"ledgers": ledger_rows,
|
||||||
|
"stock_items": stock_rows,
|
||||||
|
},
|
||||||
|
timeout_seconds=240,
|
||||||
|
)
|
||||||
|
if not response.get("ok"):
|
||||||
|
raise RuntimeError(str(response.get("error") or "Tally opening-balance correction failed."))
|
||||||
|
|
||||||
|
result = response.get("result") or {}
|
||||||
|
apply_correction_results(
|
||||||
|
db,
|
||||||
|
run_id=run.id,
|
||||||
|
result=result,
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
verified = sum(1 for row in (result.get("ledgers") or []) if row.get("verified"))
|
||||||
|
verified += sum(1 for row in (result.get("stock_items") or []) if row.get("verified"))
|
||||||
|
failed = sum(1 for row in (result.get("ledgers") or []) if not row.get("verified"))
|
||||||
|
failed += sum(1 for row in (result.get("stock_items") or []) if not row.get("verified"))
|
||||||
|
|
||||||
|
return _go(
|
||||||
|
client.id,
|
||||||
|
run_id=run.id,
|
||||||
|
tab="ledgers",
|
||||||
|
message=f"Tally correction completed: {verified} verified, {failed} failed/blocked.",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
return _go(client_id, run_id=run_id, error=str(exc))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Phase 21A</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Opening Balance Review & Correction</h1>
|
||||||
|
<p class="mt-1 max-w-5xl text-sm text-slate-500">
|
||||||
|
Compare previous-year Tally closing balances with current-year opening balances for Ledgers and Stock Items. Safe selected differences can be applied directly to current-year Tally masters and are re-read for verification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/tools/accounting/stock-items{% 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">Stock Item Intelligence</a>
|
||||||
|
<a href="/tools/accounting/chart-of-accounts{% 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">Chart of Accounts</a>
|
||||||
|
<a href="/tools/tally{% 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">Back to Accounting</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
|
||||||
|
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
|
<form method="get" class="grid gap-4 md:grid-cols-[1.5fr_auto]">
|
||||||
|
<label class="text-sm font-semibold">ERP Client
|
||||||
|
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
|
||||||
|
<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_code }} · {{ client.client_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Tally Companies</button></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if selected_client %}
|
||||||
|
<form method="post" action="/tools/accounting/opening-balances/compare" class="mt-5 grid gap-4 rounded-xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-[1fr_1fr_auto]">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
|
<label class="text-xs font-semibold">Previous-Year Tally Company
|
||||||
|
<select name="previous_company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||||
|
<option value="">Select previous-year company</option>
|
||||||
|
{% for company in companies %}<option value="{{ company.name }}">{{ company.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="text-xs font-semibold">Current-Year Tally Company
|
||||||
|
<select name="current_company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||||
|
<option value="">Select current-year company</option>
|
||||||
|
{% for company in companies %}<option value="{{ company.name }}">{{ company.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-end"><button class="rounded-xl bg-slate-900 px-5 py-2 text-sm font-semibold text-white" {% if not node_online %}disabled{% endif %}>Compare Opening Balances</button></div>
|
||||||
|
</form>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Phase 21A v1 intentionally requires separate previous-year and current-year Tally companies. This avoids treating a current company's latest closing balance as the prior-year audited closing.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if selected_client and runs %}
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold">Comparison Runs</h2></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Run</th><th class="px-4 py-3">Previous Year</th><th class="px-4 py-3">Current Year</th><th class="px-4 py-3">Ledgers</th><th class="px-4 py-3">Stock Items</th><th class="px-4 py-3"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for run in runs %}
|
||||||
|
<tr class="border-t border-slate-100">
|
||||||
|
<td class="px-4 py-3 font-semibold">#{{ run.id }}</td>
|
||||||
|
<td class="px-4 py-3">{{ run.previous_company_name }}</td>
|
||||||
|
<td class="px-4 py-3">{{ run.current_company_name }}</td>
|
||||||
|
<td class="px-4 py-3">{{ run.ledger_count }}</td>
|
||||||
|
<td class="px-4 py-3">{{ run.stock_item_count }}</td>
|
||||||
|
<td class="px-4 py-3 text-right"><a class="font-semibold text-brand-700 hover:underline" href="?client_id={{ selected_client.id }}&run_id={{ run.id }}">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if selected_run %}
|
||||||
|
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
<strong>Direct Tally correction control:</strong> only matched current-year masters with a genuine opening difference are eligible. Missing/new masters, ambiguous matches and stock unit differences are never changed automatically. Every applied row is re-read from Tally and marked Verified or Failed.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex gap-2">
|
||||||
|
<a href="?client_id={{ selected_client.id }}&run_id={{ selected_run.id }}&tab=ledgers" class="rounded-xl px-4 py-2 text-sm font-semibold {% if tab=='ledgers' %}bg-brand-600 text-white{% else %}border border-slate-300 bg-white{% endif %}">Ledger Openings</a>
|
||||||
|
<a href="?client_id={{ selected_client.id }}&run_id={{ selected_run.id }}&tab=stock" class="rounded-xl px-4 py-2 text-sm font-semibold {% if tab=='stock' %}bg-brand-600 text-white{% else %}border border-slate-300 bg-white{% endif %}">Stock Openings</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form method="post" action="/tools/accounting/opening-balances/apply" class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||||
|
<input type="hidden" name="run_id" value="{{ selected_run.id }}">
|
||||||
|
|
||||||
|
{% if tab=='ledgers' %}
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||||
|
<div><h2 class="font-semibold">Ledger Opening Comparison</h2><p class="text-sm text-slate-500">{{ selected_run.previous_company_name }} closing → {{ selected_run.current_company_name }} opening</p></div>
|
||||||
|
<select onchange="location.href='?client_id={{ selected_client.id }}&run_id={{ selected_run.id }}&tab=ledgers&status='+encodeURIComponent(this.value)" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for value,label in [('matched','Matched'),('difference','Difference'),('missing_in_current_year','Missing in Current Year'),('new_in_current_year','New in Current Year')] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Apply</th><th class="px-3 py-2">Previous Ledger</th><th class="px-3 py-2 text-right">PY Closing</th><th class="px-3 py-2">Current Ledger</th><th class="px-3 py-2 text-right">CY Opening</th><th class="px-3 py-2 text-right">Difference</th><th class="px-3 py-2">Status</th><th class="px-3 py-2">Correction</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in ledgers %}
|
||||||
|
<tr class="border-t border-slate-100">
|
||||||
|
<td class="px-3 py-3">{% if item.match_status=='difference' %}<input type="checkbox" name="ledger_ids" value="{{ item.id }}">{% else %}—{% endif %}</td>
|
||||||
|
<td class="px-3 py-3"><div class="font-semibold">{{ item.previous_name or '—' }}</div><div class="text-xs text-slate-500">{{ item.previous_group }}</div></td>
|
||||||
|
<td class="px-3 py-3 text-right">{{ '%.2f'|format(item.previous_closing_balance) }}</td>
|
||||||
|
<td class="px-3 py-3"><div class="font-semibold">{{ item.current_name or '—' }}</div><div class="text-xs text-slate-500">{{ item.current_group }}</div></td>
|
||||||
|
<td class="px-3 py-3 text-right">{{ '%.2f'|format(item.current_opening_balance) }}</td>
|
||||||
|
<td class="px-3 py-3 text-right font-semibold">{{ '%.2f'|format(item.difference) }}</td>
|
||||||
|
<td class="px-3 py-3">{{ item.match_status|replace('_',' ')|title }}{% if item.match_method %}<div class="text-xs text-slate-500">{{ item.match_method|replace('_',' ')|title }} · {{ item.confidence }}%</div>{% endif %}</td>
|
||||||
|
<td class="px-3 py-3">{{ item.correction_status|replace('_',' ')|title }}{% if item.correction_note %}<div class="max-w-xs text-xs text-slate-500">{{ item.correction_note }}</div>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}<tr><td colspan="8" class="p-8 text-center text-slate-500">No ledger rows for this filter.</td></tr>{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||||
|
<div><h2 class="font-semibold">Stock Item Opening Comparison</h2><p class="text-sm text-slate-500">Quantity, unit and value are checked separately.</p></div>
|
||||||
|
<select onchange="location.href='?client_id={{ selected_client.id }}&run_id={{ selected_run.id }}&tab=stock&status='+encodeURIComponent(this.value)" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for value,label in [('matched','Matched'),('quantity_difference','Quantity Difference'),('value_difference','Value Difference'),('quantity_and_value_difference','Quantity + Value Difference'),('unit_difference','Unit Difference'),('missing_in_current_year','Missing in Current Year'),('new_in_current_year','New in Current Year')] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-xs">
|
||||||
|
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Apply</th><th class="px-3 py-2">Previous Stock Item</th><th class="px-3 py-2">PY Closing</th><th class="px-3 py-2">Current Stock Item</th><th class="px-3 py-2">CY Opening</th><th class="px-3 py-2">Difference</th><th class="px-3 py-2">Status</th><th class="px-3 py-2">Correction</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in stocks %}
|
||||||
|
<tr class="border-t border-slate-100 align-top">
|
||||||
|
<td class="px-3 py-3">{% if item.match_status in ['quantity_difference','value_difference','quantity_and_value_difference'] and item.previous_unit|lower == item.current_unit|lower %}<input type="checkbox" name="stock_ids" value="{{ item.id }}">{% else %}—{% endif %}</td>
|
||||||
|
<td class="px-3 py-3"><div class="font-semibold">{{ item.previous_name or '—' }}</div><div class="text-slate-500">{{ item.previous_group }} · HSN {{ item.previous_hsn or '-' }}</div></td>
|
||||||
|
<td class="px-3 py-3"><div>{{ item.previous_closing_qty }} {{ item.previous_unit }}</div><div>₹{{ '%.2f'|format(item.previous_closing_value) }}</div></td>
|
||||||
|
<td class="px-3 py-3"><div class="font-semibold">{{ item.current_name or '—' }}</div><div class="text-slate-500">{{ item.current_group }} · HSN {{ item.current_hsn or '-' }}</div></td>
|
||||||
|
<td class="px-3 py-3"><div>{{ item.current_opening_qty }} {{ item.current_unit }}</div><div>₹{{ '%.2f'|format(item.current_opening_value) }}</div></td>
|
||||||
|
<td class="px-3 py-3"><div>Qty {{ item.quantity_difference }}</div><div>Value ₹{{ '%.2f'|format(item.value_difference) }}</div></td>
|
||||||
|
<td class="px-3 py-3">{{ item.match_status|replace('_',' ')|title }}{% if item.match_method %}<div class="text-slate-500">{{ item.match_method|replace('_',' ')|title }} · {{ item.confidence }}%</div>{% endif %}</td>
|
||||||
|
<td class="px-3 py-3">{{ item.correction_status|replace('_',' ')|title }}{% if item.correction_note %}<div class="max-w-xs text-slate-500">{{ item.correction_note }}</div>{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}<tr><td colspan="8" class="p-8 text-center text-slate-500">No stock rows for this filter.</td></tr>{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-red-200 bg-red-50 p-5">
|
||||||
|
<h3 class="font-semibold text-red-900">Apply selected safe corrections directly to current-year Tally</h3>
|
||||||
|
<p class="mt-1 text-sm text-red-800">The Local Agent preflights the current opening against this comparison, applies only unchanged masters, and re-reads each master afterward. Type the confirmation phrase exactly.</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<input name="confirm_text" required placeholder="APPLY OPENING BALANCES" class="min-w-[280px] rounded-lg border border-red-300 bg-white px-3 py-2 text-sm">
|
||||||
|
<button class="rounded-lg bg-red-700 px-4 py-2 text-sm font-semibold text-white">Apply Selected to Tally</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
|
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
|
||||||
<a href="/tools/accounting/stock-items{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-white px-3 py-2 text-sm font-semibold text-indigo-800">Stock Item Intelligence</a>
|
<a href="/tools/accounting/stock-items{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-white px-3 py-2 text-sm font-semibold text-indigo-800">Stock Item Intelligence</a>
|
||||||
|
<a href="/tools/accounting/opening-balances{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm font-semibold text-amber-800">Opening Balance Review</a>
|
||||||
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-blue-300 bg-white px-3 py-2 text-sm font-semibold text-blue-800">Accounting Voucher Engine</a>
|
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-blue-300 bg-white px-3 py-2 text-sm font-semibold text-blue-800">Accounting Voucher Engine</a>
|
||||||
</div>
|
</div>
|
||||||
</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.21.0"
|
ERP_LOCAL_AGENT_VERSION = "1.21.1"
|
||||||
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,2 +1,2 @@
|
|||||||
__version__ = "1.21.0"
|
__version__ = "1.21.1"
|
||||||
AGENT_NAME = "ERP Local Agent"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class AgentCommandProcessor:
|
|||||||
result = self._chart_of_accounts(payload)
|
result = self._chart_of_accounts(payload)
|
||||||
elif action == "accounting_stock_master_intelligence":
|
elif action == "accounting_stock_master_intelligence":
|
||||||
result = self._stock_master_intelligence(payload)
|
result = self._stock_master_intelligence(payload)
|
||||||
|
elif action == "accounting_opening_balance_snapshot":
|
||||||
|
result = self._opening_balance_snapshot(payload)
|
||||||
|
elif action == "accounting_opening_balance_apply":
|
||||||
|
result = self._opening_balance_apply(payload)
|
||||||
elif action == "accounting_sync_transactions":
|
elif action == "accounting_sync_transactions":
|
||||||
result = self._sync_transactions(payload)
|
result = self._sync_transactions(payload)
|
||||||
elif action == "accounting_historical_evidence":
|
elif action == "accounting_historical_evidence":
|
||||||
@@ -87,7 +91,7 @@ class AgentCommandProcessor:
|
|||||||
return {
|
return {
|
||||||
"name": "ERP Local Agent", "version": __version__,
|
"name": "ERP Local Agent", "version": __version__,
|
||||||
"tally_capability": True, "accounting_act_capability": True,
|
"tally_capability": True, "accounting_act_capability": True,
|
||||||
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "bank_reconciliation_read_capability": True,
|
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "opening_balance_correction_capability": True, "bank_reconciliation_read_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_posting_preflight_capability": True,
|
||||||
@@ -335,6 +339,67 @@ class AgentCommandProcessor:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _opening_balance_company(self, company_name: str):
|
||||||
|
wanted=str(company_name or "").strip()
|
||||||
|
if not wanted: raise ValueError("Tally company name is required.")
|
||||||
|
matches=[row for row in self.tally.get_loaded_companies() if str(row.name or "").strip().casefold()==wanted.casefold()]
|
||||||
|
if len(matches)!=1: raise ValueError(f"Tally company '{wanted}' is not uniquely loaded. Open both previous/current companies in TallyPrime first.")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
def _opening_balance_snapshot(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
previous=self._opening_balance_company(str(payload.get("previous_company_name") or ""))
|
||||||
|
current=self._opening_balance_company(str(payload.get("current_company_name") or ""))
|
||||||
|
if previous.name.casefold()==current.name.casefold(): raise ValueError("Previous-year and current-year companies must be different.")
|
||||||
|
return {"previous_company":previous.as_dict(),"current_company":current.as_dict(),"previous_masters":self.tally.fetch_accounting_masters(previous.name),"current_masters":self.tally.fetch_accounting_masters(current.name),"read_only":True,"agent":self._agent_info()}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _opening_near(left: float, right: float, tolerance: float=0.01) -> bool:
|
||||||
|
return abs(float(left or 0)-float(right or 0))<=tolerance
|
||||||
|
|
||||||
|
def _opening_balance_apply(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current=self._opening_balance_company(str(payload.get("current_company_name") or ""))
|
||||||
|
expected_guid=str(payload.get("current_company_guid") or "").strip()
|
||||||
|
if expected_guid and current.guid and expected_guid!=current.guid: raise ValueError("Current Tally company GUID changed since comparison. No opening balance was altered.")
|
||||||
|
masters=self.tally.fetch_accounting_masters(current.name)
|
||||||
|
ledgers={str(r.get("name") or "").strip().casefold():r for r in masters.get("ledgers") or []}
|
||||||
|
stocks={str(r.get("name") or "").strip().casefold():r for r in masters.get("stock_items") or []}
|
||||||
|
lr=[]; sr=[]; le=[]; se=[]
|
||||||
|
for req in list(payload.get("ledgers") or []):
|
||||||
|
name=str(req.get("name") or "").strip(); row=ledgers.get(name.casefold())
|
||||||
|
if not row: lr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Ledger '{name}' no longer exists."}); continue
|
||||||
|
expected=float(req.get("expected_opening") or 0); actual=float(row.get("opening_balance") or 0)
|
||||||
|
if not self._opening_near(expected,actual): lr.append({"item_id":req.get("item_id"),"verified":False,"verified_opening":actual,"message":"Ledger opening changed after comparison; not altered."}); continue
|
||||||
|
le.append(req)
|
||||||
|
for req in list(payload.get("stock_items") or []):
|
||||||
|
name=str(req.get("name") or "").strip(); row=stocks.get(name.casefold())
|
||||||
|
if not row: sr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Stock Item '{name}' no longer exists."}); continue
|
||||||
|
eq=float(req.get("expected_opening_qty") or 0); ev=float(req.get("expected_opening_value") or 0); aq=float(row.get("opening_balance") or 0); av=float(row.get("opening_value") or 0)
|
||||||
|
if not self._opening_near(eq,aq,0.000001) or not self._opening_near(ev,av): sr.append({"item_id":req.get("item_id"),"verified":False,"verified_opening_qty":aq,"verified_opening_value":av,"message":"Stock opening changed after comparison; not altered."}); continue
|
||||||
|
cu=str(row.get("base_units") or "").strip(); ru=str(req.get("unit") or "").strip()
|
||||||
|
if cu and ru and cu.casefold()!=ru.casefold(): sr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Tally unit is '{cu}', not '{ru}'."}); continue
|
||||||
|
se.append(req)
|
||||||
|
for req in le:
|
||||||
|
try:
|
||||||
|
self.tally.alter_ledger_opening_balance(current.name,ledger_name=str(req.get("name") or "").strip(),opening_balance=float(req.get("target_opening") or 0))
|
||||||
|
lr.append({"item_id":req.get("item_id"),"verified":None,"target_opening":float(req.get("target_opening") or 0),"message":"Altered; verification pending."})
|
||||||
|
except Exception as exc: lr.append({"item_id":req.get("item_id"),"verified":False,"message":str(exc)})
|
||||||
|
for req in se:
|
||||||
|
try:
|
||||||
|
self.tally.alter_stock_item_opening(current.name,stock_item_name=str(req.get("name") or "").strip(),opening_qty=float(req.get("target_opening_qty") or 0),unit=str(req.get("unit") or "").strip(),opening_value=float(req.get("target_opening_value") or 0),opening_rate=float(req.get("target_opening_rate") or 0))
|
||||||
|
sr.append({"item_id":req.get("item_id"),"verified":None,"target_opening_qty":float(req.get("target_opening_qty") or 0),"target_opening_value":float(req.get("target_opening_value") or 0),"message":"Altered; verification pending."})
|
||||||
|
except Exception as exc: sr.append({"item_id":req.get("item_id"),"verified":False,"message":str(exc)})
|
||||||
|
verify=self.tally.fetch_accounting_masters(current.name)
|
||||||
|
vl={str(r.get("name") or "").strip().casefold():r for r in verify.get("ledgers") or []}; vs={str(r.get("name") or "").strip().casefold():r for r in verify.get("stock_items") or []}
|
||||||
|
lem={int(r.get("item_id") or 0):r for r in le}; sem={int(r.get("item_id") or 0):r for r in se}
|
||||||
|
for res in lr:
|
||||||
|
if res.get("verified") is not None: continue
|
||||||
|
req=lem.get(int(res.get("item_id") or 0)); row=vl.get(str(req.get("name") or "").strip().casefold()) if req else None; actual=float((row or {}).get("opening_balance") or 0); target=float((req or {}).get("target_opening") or 0); ok=self._opening_near(actual,target); res.update({"verified":ok,"verified_opening":actual,"message":"Ledger opening re-read and verified." if ok else f"Verification returned {actual:.2f}; expected {target:.2f}."})
|
||||||
|
for res in sr:
|
||||||
|
if res.get("verified") is not None: continue
|
||||||
|
req=sem.get(int(res.get("item_id") or 0)); row=vs.get(str(req.get("name") or "").strip().casefold()) if req else None; aq=float((row or {}).get("opening_balance") or 0); av=float((row or {}).get("opening_value") or 0); tq=float((req or {}).get("target_opening_qty") or 0); tv=float((req or {}).get("target_opening_value") or 0); ok=self._opening_near(aq,tq,0.000001) and self._opening_near(av,tv); res.update({"verified":ok,"verified_opening_qty":aq,"verified_opening_value":av,"message":"Stock opening re-read and verified." if ok else "Stock opening verification did not equal the approved quantity/value."})
|
||||||
|
return {"company_name":current.name,"company_guid":current.guid,"ledgers":lr,"stock_items":sr,"write_operation":True,"verified_after_write":True,"agent":self._agent_info()}
|
||||||
|
|
||||||
def _stock_master_intelligence(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _stock_master_intelligence(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
# Read-only against Tally. Existing local .act master storage remains
|
# Read-only against Tally. Existing local .act master storage remains
|
||||||
# synchronized by the normal master-sync path.
|
# synchronized by the normal master-sync path.
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class TallyLiveConnector:
|
|||||||
"collection": "ARRRAccountingStockItems",
|
"collection": "ARRRAccountingStockItems",
|
||||||
"type": "StockItem",
|
"type": "StockItem",
|
||||||
"tag": "STOCKITEM",
|
"tag": "STOCKITEM",
|
||||||
"fetch": "Name,GUID,Parent,Category,BaseUnits,AdditionalUnits,OpeningBalance,OpeningValue,OpeningRate,GSTApplicable,GSTTypeOfSupply,HSNCode",
|
"fetch": "Name,GUID,Parent,Category,BaseUnits,AdditionalUnits,OpeningBalance,ClosingBalance,OpeningValue,ClosingValue,OpeningRate,ClosingRate,GSTApplicable,GSTTypeOfSupply,HSNCode",
|
||||||
},
|
},
|
||||||
"units": {
|
"units": {
|
||||||
"collection": "ARRRAccountingUnits",
|
"collection": "ARRRAccountingUnits",
|
||||||
@@ -440,6 +440,42 @@ class TallyLiveConnector:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _opening_master_import_result(self, response_xml: str, *, label: str) -> dict:
|
||||||
|
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 {label}: {exc}") from exc
|
||||||
|
def _i(name: str) -> int:
|
||||||
|
try: return int(float(_first_text(root, [name]) or 0))
|
||||||
|
except Exception: return 0
|
||||||
|
created, altered, errors = _i("CREATED"), _i("ALTERED"), _i("ERRORS")
|
||||||
|
line_error = _first_text(root, ["LINEERROR"])
|
||||||
|
if errors > 0 or line_error: raise ValueError(line_error or f"Tally reported {errors} error(s) during {label}.")
|
||||||
|
if altered < 1 and created < 1: raise ValueError(f"Tally did not confirm the {label}.")
|
||||||
|
return {"created": created, "altered": altered, "errors": errors, "raw_response": response_xml}
|
||||||
|
|
||||||
|
def alter_ledger_opening_balance(self, company_name: str, *, ledger_name: str, opening_balance: float) -> dict:
|
||||||
|
company, name = str(company_name or "").strip(), str(ledger_name or "").strip()
|
||||||
|
if not company or not name: raise ValueError("Company and ledger name are required.")
|
||||||
|
amount=float(opening_balance or 0)
|
||||||
|
xml=f"""<ENVELOPE><HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>All Masters</ID></HEADER><BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES></DESC><DATA><TALLYMESSAGE xmlns:UDF="TallyUDF"><LEDGER NAME="{self._xml_escape(name)}" ACTION="Alter"><NAME>{self._xml_escape(name)}</NAME><OPENINGBALANCE>{amount:.2f}</OPENINGBALANCE></LEDGER></TALLYMESSAGE></DATA></BODY></ENVELOPE>"""
|
||||||
|
result=self._opening_master_import_result(self._post_xml(xml),label=f"Ledger opening-balance alteration for '{name}'")
|
||||||
|
result["request_xml"]=xml
|
||||||
|
return result
|
||||||
|
|
||||||
|
def alter_stock_item_opening(self, company_name: str, *, stock_item_name: str, opening_qty: float, unit: str, opening_value: float, opening_rate: float) -> dict:
|
||||||
|
company, name, unit_name = str(company_name or "").strip(), str(stock_item_name or "").strip(), str(unit or "").strip()
|
||||||
|
if not company or not name: raise ValueError("Company and Stock Item name are required.")
|
||||||
|
if not unit_name: raise ValueError(f"Stock Item '{name}' has no current Tally unit; opening quantity cannot be altered safely.")
|
||||||
|
qty=float(opening_qty or 0); value=float(opening_value or 0); rate=abs(float(opening_rate or 0))
|
||||||
|
qty_text=f"{qty:.6f}".rstrip("0").rstrip("."); rate_text=f"{rate:.6f}".rstrip("0").rstrip(".")
|
||||||
|
xml=f"""<ENVELOPE><HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>All Masters</ID></HEADER><BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES></DESC><DATA><TALLYMESSAGE xmlns:UDF="TallyUDF"><STOCKITEM NAME="{self._xml_escape(name)}" ACTION="Alter"><NAME>{self._xml_escape(name)}</NAME><OPENINGBALANCE>{qty_text} {self._xml_escape(unit_name)}</OPENINGBALANCE><OPENINGVALUE>{value:.2f}</OPENINGVALUE><OPENINGRATE>{rate_text}/{self._xml_escape(unit_name)}</OPENINGRATE></STOCKITEM></TALLYMESSAGE></DATA></BODY></ENVELOPE>"""
|
||||||
|
result=self._opening_master_import_result(self._post_xml(xml),label=f"Stock Item opening-balance alteration for '{name}'")
|
||||||
|
result["request_xml"]=xml
|
||||||
|
return result
|
||||||
|
|
||||||
def purchase_posting_ledgers(self, company_name: str) -> list[dict]:
|
def purchase_posting_ledgers(self, company_name: str) -> list[dict]:
|
||||||
company = str(company_name or "").strip()
|
company = str(company_name or "").strip()
|
||||||
if not company:
|
if not company:
|
||||||
@@ -1088,7 +1124,9 @@ class TallyLiveConnector:
|
|||||||
"opening_balance": _to_number(_child_text(element, "OPENINGBALANCE")),
|
"opening_balance": _to_number(_child_text(element, "OPENINGBALANCE")),
|
||||||
"closing_balance": _to_number(_child_text(element, "CLOSINGBALANCE")),
|
"closing_balance": _to_number(_child_text(element, "CLOSINGBALANCE")),
|
||||||
"opening_value": _to_number(_child_text(element, "OPENINGVALUE")),
|
"opening_value": _to_number(_child_text(element, "OPENINGVALUE")),
|
||||||
|
"closing_value": _to_number(_child_text(element, "CLOSINGVALUE")),
|
||||||
"opening_rate": _child_text(element, "OPENINGRATE"),
|
"opening_rate": _child_text(element, "OPENINGRATE"),
|
||||||
|
"closing_rate": _child_text(element, "CLOSINGRATE"),
|
||||||
"numbering_method": _child_text(element, "NUMBERINGMETHOD"),
|
"numbering_method": _child_text(element, "NUMBERINGMETHOD"),
|
||||||
"tax_type": _child_text(element, "TAXTYPE"),
|
"tax_type": _child_text(element, "TAXTYPE"),
|
||||||
"gst_applicable": _child_text(element, "GSTAPPLICABLE"),
|
"gst_applicable": _child_text(element, "GSTAPPLICABLE"),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from app.modules.accounting.sales_menu_ui import router as accounting_sales_menu
|
|||||||
from app.modules.accounting.chart_ui import router as accounting_chart_ui_router
|
from app.modules.accounting.chart_ui import router as accounting_chart_ui_router
|
||||||
from app.modules.accounting.voucher_engine_ui import router as accounting_voucher_engine_ui_router
|
from app.modules.accounting.voucher_engine_ui import router as accounting_voucher_engine_ui_router
|
||||||
from app.modules.accounting.stock_ui import router as accounting_stock_ui_router
|
from app.modules.accounting.stock_ui import router as accounting_stock_ui_router
|
||||||
|
from app.modules.accounting.opening_balance_ui import router as accounting_opening_balance_ui_router
|
||||||
from app.modules.accounting.automatic_voucher_ui import router as accounting_automatic_voucher_ui_router
|
from app.modules.accounting.automatic_voucher_ui import router as accounting_automatic_voucher_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
|
||||||
@@ -95,6 +96,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(accounting_chart_ui_router)
|
app.include_router(accounting_chart_ui_router)
|
||||||
app.include_router(accounting_voucher_engine_ui_router)
|
app.include_router(accounting_voucher_engine_ui_router)
|
||||||
app.include_router(accounting_stock_ui_router)
|
app.include_router(accounting_stock_ui_router)
|
||||||
|
app.include_router(accounting_opening_balance_ui_router)
|
||||||
app.include_router(accounting_automatic_voucher_ui_router)
|
app.include_router(accounting_automatic_voucher_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)
|
||||||
|
|||||||
Reference in New Issue
Block a user