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
++ 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. +
+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 %} +| Run | Previous Year | Current Year | Ledgers | Stock Items | |
|---|---|---|---|---|---|
| #{{ run.id }} | +{{ run.previous_company_name }} | +{{ run.current_company_name }} | +{{ run.ledger_count }} | +{{ run.stock_item_count }} | +Open | +