diff --git a/alembic/versions/20260825_opening_balance_comparison_controlled_tally_phase21a.py b/alembic/versions/20260825_opening_balance_comparison_controlled_tally_phase21a.py new file mode 100644 index 0000000..1f4a8d6 --- /dev/null +++ b/alembic/versions/20260825_opening_balance_comparison_controlled_tally_phase21a.py @@ -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") diff --git a/app/modules/accounting/opening_balance_models.py b/app/modules/accounting/opening_balance_models.py new file mode 100644 index 0000000..b542c32 --- /dev/null +++ b/app/modules/accounting/opening_balance_models.py @@ -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) diff --git a/app/modules/accounting/opening_balance_service.py b/app/modules/accounting/opening_balance_service.py new file mode 100644 index 0000000..106e88f --- /dev/null +++ b/app/modules/accounting/opening_balance_service.py @@ -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() diff --git a/app/modules/accounting/opening_balance_ui.py b/app/modules/accounting/opening_balance_ui.py new file mode 100644 index 0000000..158c810 --- /dev/null +++ b/app/modules/accounting/opening_balance_ui.py @@ -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() diff --git a/app/modules/accounting/templates/accounting/opening_balances.html b/app/modules/accounting/templates/accounting/opening_balances.html new file mode 100644 index 0000000..c9a9906 --- /dev/null +++ b/app/modules/accounting/templates/accounting/opening_balances.html @@ -0,0 +1,167 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting · Phase 21A

+

Opening Balance Review & Correction

+

+ 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. +

+
+ +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ +
+
+ + {% if selected_client %} +
+ + + + +
+
+

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.

+ {% endif %} +
+ + {% if selected_client and runs %} +
+

Comparison Runs

+
+ + + + {% for run in runs %} + + + + + + + + + {% endfor %} + +
RunPrevious YearCurrent YearLedgersStock Items
#{{ run.id }}{{ run.previous_company_name }}{{ run.current_company_name }}{{ run.ledger_count }}{{ run.stock_item_count }}Open
+
+
+ {% endif %} + + {% if selected_run %} +
+ Direct Tally correction control: 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. +
+ + + +
+ + + + + {% if tab=='ledgers' %} +
+
+

Ledger Opening Comparison

{{ selected_run.previous_company_name }} closing → {{ selected_run.current_company_name }} opening

+ +
+
+ + + + {% for item in ledgers %} + + + + + + + + + + + {% else %}{% endfor %} + +
ApplyPrevious LedgerPY ClosingCurrent LedgerCY OpeningDifferenceStatusCorrection
{% if item.match_status=='difference' %}{% else %}—{% endif %}
{{ item.previous_name or '—' }}
{{ item.previous_group }}
{{ '%.2f'|format(item.previous_closing_balance) }}
{{ item.current_name or '—' }}
{{ item.current_group }}
{{ '%.2f'|format(item.current_opening_balance) }}{{ '%.2f'|format(item.difference) }}{{ item.match_status|replace('_',' ')|title }}{% if item.match_method %}
{{ item.match_method|replace('_',' ')|title }} · {{ item.confidence }}%
{% endif %}
{{ item.correction_status|replace('_',' ')|title }}{% if item.correction_note %}
{{ item.correction_note }}
{% endif %}
No ledger rows for this filter.
+
+
+ + {% else %} +
+
+

Stock Item Opening Comparison

Quantity, unit and value are checked separately.

+ +
+
+ + + + {% for item in stocks %} + + + + + + + + + + + {% else %}{% endfor %} + +
ApplyPrevious Stock ItemPY ClosingCurrent Stock ItemCY OpeningDifferenceStatusCorrection
{% if item.match_status in ['quantity_difference','value_difference','quantity_and_value_difference'] and item.previous_unit|lower == item.current_unit|lower %}{% else %}—{% endif %}
{{ item.previous_name or '—' }}
{{ item.previous_group }} · HSN {{ item.previous_hsn or '-' }}
{{ item.previous_closing_qty }} {{ item.previous_unit }}
₹{{ '%.2f'|format(item.previous_closing_value) }}
{{ item.current_name or '—' }}
{{ item.current_group }} · HSN {{ item.current_hsn or '-' }}
{{ item.current_opening_qty }} {{ item.current_unit }}
₹{{ '%.2f'|format(item.current_opening_value) }}
Qty {{ item.quantity_difference }}
Value ₹{{ '%.2f'|format(item.value_difference) }}
{{ item.match_status|replace('_',' ')|title }}{% if item.match_method %}
{{ item.match_method|replace('_',' ')|title }} · {{ item.confidence }}%
{% endif %}
{{ item.correction_status|replace('_',' ')|title }}{% if item.correction_note %}
{{ item.correction_note }}
{% endif %}
No stock rows for this filter.
+
+
+ {% endif %} + +
+

Apply selected safe corrections directly to current-year Tally

+

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.

+
+ + +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 09194d3..c8e759b 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -21,6 +21,7 @@
Chart of Accounts - Groups & Ledgers Stock Item Intelligence + Opening Balance Review Accounting Voucher Engine
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 8de3a00..be191ff 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.21.0" +ERP_LOCAL_AGENT_VERSION = "1.21.1" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index e9a7603..8d9c67e 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.21.0" +__version__ = "1.21.1" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 68dddcf..f2b7c8e 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -40,6 +40,10 @@ class AgentCommandProcessor: result = self._chart_of_accounts(payload) elif action == "accounting_stock_master_intelligence": 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": result = self._sync_transactions(payload) elif action == "accounting_historical_evidence": @@ -87,7 +91,7 @@ class AgentCommandProcessor: return { "name": "ERP Local Agent", "version": __version__, "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, "historical_learning_read_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]: # Read-only against Tally. Existing local .act master storage remains # synchronized by the normal master-sync path. diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 8e661c7..7a3e437 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -138,7 +138,7 @@ class TallyLiveConnector: "collection": "ARRRAccountingStockItems", "type": "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": { "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"""
1ImportDataAll Masters
{self._xml_escape(company)}{self._xml_escape(name)}{amount:.2f}
""" + 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"""
1ImportDataAll Masters
{self._xml_escape(company)}{self._xml_escape(name)}{qty_text} {self._xml_escape(unit_name)}{value:.2f}{rate_text}/{self._xml_escape(unit_name)}
""" + 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]: company = str(company_name or "").strip() if not company: @@ -1088,7 +1124,9 @@ class TallyLiveConnector: "opening_balance": _to_number(_child_text(element, "OPENINGBALANCE")), "closing_balance": _to_number(_child_text(element, "CLOSINGBALANCE")), "opening_value": _to_number(_child_text(element, "OPENINGVALUE")), + "closing_value": _to_number(_child_text(element, "CLOSINGVALUE")), "opening_rate": _child_text(element, "OPENINGRATE"), + "closing_rate": _child_text(element, "CLOSINGRATE"), "numbering_method": _child_text(element, "NUMBERINGMETHOD"), "tax_type": _child_text(element, "TAXTYPE"), "gst_applicable": _child_text(element, "GSTAPPLICABLE"), diff --git a/app/ui/app.py b/app/ui/app.py index c82596d..20aff43 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -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.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.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.registrations.ui import router as registrations_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_voucher_engine_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(work_tracker_ui_router) app.include_router(billing_ui_router)