From 0fab925e71e8a4881029f252c52b175fda2d6058 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 24 Aug 2026 22:07:16 +0530 Subject: [PATCH] Add Phase 21 stock item master mapping intelligence --- ...tem_master_mapping_intelligence_phase21.py | 150 ++++ .../accounting/automatic_voucher_service.py | 89 ++- app/modules/accounting/stock_models.py | 157 ++++ app/modules/accounting/stock_service.py | 701 ++++++++++++++++++ app/modules/accounting/stock_ui.py | 418 +++++++++++ .../templates/accounting/stock_items.html | 237 ++++++ .../templates/accounting/tally.html | 1 + app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 28 +- app/ui/app.py | 2 + 11 files changed, 1777 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/20260824_stock_item_master_mapping_intelligence_phase21.py create mode 100644 app/modules/accounting/stock_models.py create mode 100644 app/modules/accounting/stock_service.py create mode 100644 app/modules/accounting/stock_ui.py create mode 100644 app/modules/accounting/templates/accounting/stock_items.html diff --git a/alembic/versions/20260824_stock_item_master_mapping_intelligence_phase21.py b/alembic/versions/20260824_stock_item_master_mapping_intelligence_phase21.py new file mode 100644 index 0000000..27d29cb --- /dev/null +++ b/alembic/versions/20260824_stock_item_master_mapping_intelligence_phase21.py @@ -0,0 +1,150 @@ +"""Phase 21 Stock Item Master + Mapping Intelligence. + +Revision ID: 20260824_stock_item_intel_p21 +Revises: 20260824_bank_analyzer_reuse_p20 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260824_stock_item_intel_p21" +down_revision = "20260824_bank_analyzer_reuse_p20" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_stock_sync_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("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("status", sa.String(30), nullable=False, server_default="completed"), + sa.Column("stock_group_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("stock_item_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("unit_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("requested_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","tally_guid","status","created_at_utc"): + op.create_index(f"ix_accounting_stock_sync_runs_{name}", "accounting_stock_sync_runs", [name]) + + op.create_table( + "accounting_stock_groups", + 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("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("master_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("parent_name", sa.String(255), nullable=False, server_default=""), + sa.Column("base_units", sa.String(80), nullable=False, server_default=""), + sa.Column("source_payload_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("synced_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","name", name="uq_accounting_stock_group"), + ) + for name in ("tenant_id","client_id","tally_guid","master_guid","name","parent_name","synced_at_utc"): + op.create_index(f"ix_accounting_stock_groups_{name}", "accounting_stock_groups", [name]) + + op.create_table( + "accounting_stock_items", + 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("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("master_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("parent_group_name", sa.String(255), nullable=False, server_default=""), + sa.Column("category_name", sa.String(255), nullable=False, server_default=""), + sa.Column("base_units", sa.String(80), nullable=False, server_default=""), + sa.Column("additional_units", sa.String(80), nullable=False, server_default=""), + sa.Column("hsn_code", sa.String(40), nullable=False, server_default=""), + sa.Column("gst_applicable", sa.String(80), nullable=False, server_default=""), + sa.Column("gst_type_of_supply", sa.String(80), nullable=False, server_default=""), + sa.Column("opening_balance", sa.Float(), nullable=False, server_default="0"), + sa.Column("opening_value", sa.Float(), nullable=False, server_default="0"), + sa.Column("opening_rate", sa.String(80), nullable=False, server_default=""), + sa.Column("normalized_name", sa.String(255), nullable=False, server_default=""), + sa.Column("source_payload_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("synced_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","name", name="uq_accounting_stock_item"), + ) + for name in ("tenant_id","client_id","tally_guid","master_guid","name","parent_group_name","base_units","hsn_code","normalized_name","synced_at_utc"): + op.create_index(f"ix_accounting_stock_items_{name}", "accounting_stock_items", [name]) + + op.create_table( + "accounting_stock_units", + 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("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("master_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("original_name", sa.String(80), nullable=False, server_default=""), + sa.Column("is_simple_unit", sa.String(30), nullable=False, server_default=""), + sa.Column("base_units", sa.String(80), nullable=False, server_default=""), + sa.Column("additional_units", sa.String(80), nullable=False, server_default=""), + sa.Column("conversion", sa.String(80), nullable=False, server_default=""), + sa.Column("normalized_name", sa.String(80), nullable=False, server_default=""), + sa.Column("source_payload_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("synced_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","name", name="uq_accounting_stock_unit"), + ) + for name in ("tenant_id","client_id","tally_guid","master_guid","name","normalized_name","synced_at_utc"): + op.create_index(f"ix_accounting_stock_units_{name}", "accounting_stock_units", [name]) + + op.create_table( + "accounting_stock_item_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("tally_guid", sa.String(120), nullable=False), + sa.Column("source_type", sa.String(50), nullable=False, server_default="generic"), + sa.Column("party_key", sa.String(255), nullable=False, server_default=""), + sa.Column("source_description", sa.String(500), nullable=False), + sa.Column("source_description_norm", sa.String(500), nullable=False), + sa.Column("hsn_sac", sa.String(40), nullable=False, server_default=""), + sa.Column("stock_item_id", sa.Integer(), sa.ForeignKey("accounting_stock_items.id", ondelete="CASCADE"), nullable=False), + sa.Column("confidence", sa.Integer(), nullable=False, server_default="100"), + sa.Column("mapping_origin", sa.String(30), nullable=False, server_default="manual"), + 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("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","source_type","party_key","source_description_norm","hsn_sac", name="uq_accounting_stock_item_mapping_source"), + ) + for name in ("tenant_id","client_id","tally_guid","source_type","party_key","source_description_norm","hsn_sac","stock_item_id"): + op.create_index(f"ix_accounting_stock_item_mappings_{name}", "accounting_stock_item_mappings", [name]) + + op.create_table( + "accounting_stock_unit_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("tally_guid", sa.String(120), nullable=False), + sa.Column("source_unit", sa.String(80), nullable=False), + sa.Column("source_unit_norm", sa.String(80), nullable=False), + sa.Column("target_unit_id", sa.Integer(), sa.ForeignKey("accounting_stock_units.id", ondelete="CASCADE"), nullable=False), + sa.Column("quantity_factor", sa.Float(), nullable=False, server_default="1"), + 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("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id","client_id","tally_guid","source_unit_norm", name="uq_accounting_stock_unit_mapping_source"), + ) + for name in ("tenant_id","client_id","tally_guid","source_unit_norm","target_unit_id"): + op.create_index(f"ix_accounting_stock_unit_mappings_{name}", "accounting_stock_unit_mappings", [name]) + + +def downgrade(): + op.drop_table("accounting_stock_unit_mappings") + op.drop_table("accounting_stock_item_mappings") + op.drop_table("accounting_stock_units") + op.drop_table("accounting_stock_items") + op.drop_table("accounting_stock_groups") + op.drop_table("accounting_stock_sync_runs") diff --git a/app/modules/accounting/automatic_voucher_service.py b/app/modules/accounting/automatic_voucher_service.py index 9245700..faecf40 100644 --- a/app/modules/accounting/automatic_voucher_service.py +++ b/app/modules/accounting/automatic_voucher_service.py @@ -7,6 +7,7 @@ from sqlalchemy import select from app.modules.accounting.chart_models import AccountingChartLedger from app.modules.accounting.chart_service import effective_role +from app.modules.accounting.stock_service import resolve_stock_item, resolve_unit from app.modules.accounting.voucher_engine_models import ( AccountingVoucherDraft, AccountingVoucherItemLine, @@ -299,6 +300,58 @@ def create_eway_invoice_draft( cess = round(float(invoice.get("cess") or 0), 2) gross = round(taxable + cgst + sgst + igst + cess, 2) + # Phase 21 resolves every source item against the synchronized ERP mirror + # before a Purchase/Sales draft is created. Loose fuzzy-only candidates do + # not silently pass through to Tally. + resolved_items = [] + unresolved = [] + for source_item in invoice.get("items") or []: + source_description = _s(source_item.get("item_name")) + source_hsn = _s(source_item.get("hsn")) + stock = resolve_stock_item( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + source_type="ewaybill", + party_key=party_name, + source_description=source_description, + hsn_sac=source_hsn, + ) + unit = resolve_unit( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + source_unit=_s(source_item.get("unit")), + ) + + if not stock.get("matched"): + unresolved.append( + f"{source_description or '(blank item)'}: {stock.get('reason') or 'stock item not mapped'}" + ) + continue + if not unit.get("matched"): + unresolved.append( + f"{source_description or '(blank item)'}: {unit.get('reason') or 'unit not mapped'}" + ) + continue + + resolved_items.append( + { + "source": source_item, + "stock": stock, + "unit": unit, + } + ) + + if unresolved: + raise ValueError( + "Stock Item Intelligence review is required before this item-wise " + f"{voucher_type} draft can be created. " + + " | ".join(unresolved[:8]) + ) + draft = AccountingVoucherDraft( tenant_id=int(tenant_id), client_id=int(client_id), @@ -340,26 +393,48 @@ def create_eway_invoice_draft( line_no = _append_line(draft, line_no, input_igst, "CR", igst, "Output IGST") _append_line(draft, line_no, cess_ledger, "CR", cess, "Output Cess") - for index, item in enumerate(invoice.get("items") or [], 1): + for index, resolved in enumerate(resolved_items, 1): + item = resolved["source"] + mapped_stock = resolved["stock"]["stock_item"] + unit_result = resolved["unit"] + item_amount = round(float(item.get("taxable_value") or 0), 2) - qty = float(item.get("quantity") or 0) - rate = float(item.get("rate") or 0) + source_qty = float(item.get("quantity") or 0) + source_rate = float(item.get("rate") or 0) + + factor = float(unit_result.get("quantity_factor") or 1.0) + qty = source_qty * factor + rate = source_rate / factor if source_rate and factor else source_rate if not rate and qty: rate = item_amount / qty + evidence = { + **item, + "source_type": "ewaybill", + "party_name": party_name, + "source_stock_item_name": _s(item.get("item_name")), + "mapped_stock_item_id": mapped_stock.id, + "mapped_stock_item_name": mapped_stock.name, + "stock_mapping_confidence": int(resolved["stock"].get("confidence") or 0), + "stock_mapping_reason": resolved["stock"].get("reason") or "", + "source_unit": _s(item.get("unit")), + "mapped_unit": unit_result.get("target_unit") or "", + "quantity_factor": factor, + } + draft.items.append( AccountingVoucherItemLine( line_no=index, - stock_item_name=_s(item.get("item_name")), - hsn_sac=_s(item.get("hsn")), + stock_item_name=mapped_stock.name, + hsn_sac=_s(item.get("hsn") or mapped_stock.hsn_code), quantity=qty, - unit=_s(item.get("unit")), + unit=unit_result.get("target_unit") or mapped_stock.base_units, rate=round(rate, 4), amount=item_amount, dr_cr=item_side, accounting_ledger_name=main.name, godown_name="", - source_item_json=json.dumps(item, ensure_ascii=False, default=str), + source_item_json=json.dumps(evidence, ensure_ascii=False, default=str), ) ) diff --git a/app/modules/accounting/stock_models.py b/app/modules/accounting/stock_models.py new file mode 100644 index 0000000..9b30a87 --- /dev/null +++ b/app/modules/accounting/stock_models.py @@ -0,0 +1,157 @@ +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 AccountingStockSyncRun(CommonBase): + __tablename__ = "accounting_stock_sync_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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + status: Mapped[str] = mapped_column(String(30), nullable=False, default="completed", index=True) + stock_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + stock_item_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + unit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + requested_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 AccountingStockGroup(CommonBase): + __tablename__ = "accounting_stock_groups" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "tally_guid", "name", name="uq_accounting_stock_group"), + ) + + 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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + master_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + parent_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + base_units: Mapped[str] = mapped_column(String(80), nullable=False, default="") + source_payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + synced_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True) + + +class AccountingStockItem(CommonBase): + __tablename__ = "accounting_stock_items" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "tally_guid", "name", name="uq_accounting_stock_item"), + ) + + 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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + master_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + parent_group_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + category_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + base_units: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True) + additional_units: Mapped[str] = mapped_column(String(80), nullable=False, default="") + hsn_code: Mapped[str] = mapped_column(String(40), nullable=False, default="", index=True) + gst_applicable: Mapped[str] = mapped_column(String(80), nullable=False, default="") + gst_type_of_supply: Mapped[str] = mapped_column(String(80), nullable=False, default="") + opening_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0) + opening_value: Mapped[float] = mapped_column(Float, nullable=False, default=0) + opening_rate: Mapped[str] = mapped_column(String(80), nullable=False, default="") + normalized_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + source_payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + synced_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True) + + +class AccountingStockUnit(CommonBase): + __tablename__ = "accounting_stock_units" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "tally_guid", "name", name="uq_accounting_stock_unit"), + ) + + 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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + master_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + original_name: Mapped[str] = mapped_column(String(80), nullable=False, default="") + is_simple_unit: Mapped[str] = mapped_column(String(30), nullable=False, default="") + base_units: Mapped[str] = mapped_column(String(80), nullable=False, default="") + additional_units: Mapped[str] = mapped_column(String(80), nullable=False, default="") + conversion: Mapped[str] = mapped_column(String(80), nullable=False, default="") + normalized_name: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True) + source_payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + synced_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True) + + +class AccountingStockItemMapping(CommonBase): + __tablename__ = "accounting_stock_item_mappings" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "client_id", "tally_guid", "source_type", + "party_key", "source_description_norm", "hsn_sac", + name="uq_accounting_stock_item_mapping_source", + ), + ) + + 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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + source_type: Mapped[str] = mapped_column(String(50), nullable=False, default="generic", index=True) + party_key: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + source_description: Mapped[str] = mapped_column(String(500), nullable=False) + source_description_norm: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + hsn_sac: Mapped[str] = mapped_column(String(40), nullable=False, default="", index=True) + stock_item_id: Mapped[int] = mapped_column(ForeignKey("accounting_stock_items.id", ondelete="CASCADE"), nullable=False, index=True) + confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + mapping_origin: Mapped[str] = mapped_column(String(30), nullable=False, default="manual") + 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) + updated_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) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) + + +class AccountingStockUnitMapping(CommonBase): + __tablename__ = "accounting_stock_unit_mappings" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "client_id", "tally_guid", "source_unit_norm", + name="uq_accounting_stock_unit_mapping_source", + ), + ) + + 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) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + source_unit: Mapped[str] = mapped_column(String(80), nullable=False) + source_unit_norm: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + target_unit_id: Mapped[int] = mapped_column(ForeignKey("accounting_stock_units.id", ondelete="CASCADE"), nullable=False, index=True) + quantity_factor: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + 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) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) diff --git a/app/modules/accounting/stock_service.py b/app/modules/accounting/stock_service.py new file mode 100644 index 0000000..87130b3 --- /dev/null +++ b/app/modules/accounting/stock_service.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from difflib import SequenceMatcher +from typing import Any + +from sqlalchemy import delete, func, or_, select + +from app.modules.accounting.stock_models import ( + AccountingStockGroup, + AccountingStockItem, + AccountingStockItemMapping, + AccountingStockSyncRun, + AccountingStockUnit, + AccountingStockUnitMapping, +) +from app.modules.accounting.voucher_engine_models import AccountingVoucherItemLine + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value: Any) -> str: + return str(value or "").strip() + + +def normalize_text(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", " ", _s(value).casefold()).strip() + + +def normalize_unit(value: Any) -> str: + text = normalize_text(value) + aliases = { + "kgs": "kg", + "kilogram": "kg", + "kilograms": "kg", + "nos": "nos", + "no": "nos", + "numbers": "nos", + "pcs": "pcs", + "pieces": "pcs", + "piece": "pcs", + "mtrs": "mtr", + "meters": "mtr", + "metres": "mtr", + "ton": "mt", + "tons": "mt", + "tonne": "mt", + "tonnes": "mt", + } + return aliases.get(text, text) + + +def _payload(row): + try: + return json.loads(row or "{}") + except Exception: + return {} + + +def persist_stock_snapshot( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + company_name: str, + stock_groups: list[dict], + stock_items: list[dict], + units: list[dict], + user_id: int, +): + tally_guid = _s(tally_guid) + if not tally_guid: + raise ValueError("Tally company GUID is required.") + + db.execute( + delete(AccountingStockGroup).where( + AccountingStockGroup.tenant_id == int(tenant_id), + AccountingStockGroup.client_id == int(client_id), + AccountingStockGroup.tally_guid == tally_guid, + ) + ) + db.execute( + delete(AccountingStockItem).where( + AccountingStockItem.tenant_id == int(tenant_id), + AccountingStockItem.client_id == int(client_id), + AccountingStockItem.tally_guid == tally_guid, + ) + ) + db.execute( + delete(AccountingStockUnit).where( + AccountingStockUnit.tenant_id == int(tenant_id), + AccountingStockUnit.client_id == int(client_id), + AccountingStockUnit.tally_guid == tally_guid, + ) + ) + db.flush() + + synced = _utcnow() + + for row in stock_groups or []: + name = _s(row.get("name")) + if not name: + continue + db.add( + AccountingStockGroup( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=tally_guid, + company_name=company_name, + master_guid=_s(row.get("guid")), + name=name, + parent_name=_s(row.get("parent")), + base_units=_s(row.get("base_units")), + source_payload_json=json.dumps(row, ensure_ascii=False, default=str), + synced_at_utc=synced, + ) + ) + + for row in stock_items or []: + name = _s(row.get("name")) + if not name: + continue + db.add( + AccountingStockItem( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=tally_guid, + company_name=company_name, + master_guid=_s(row.get("guid")), + name=name, + parent_group_name=_s(row.get("parent")), + category_name=_s(row.get("category")), + base_units=_s(row.get("base_units")), + additional_units=_s(row.get("additional_units")), + hsn_code=_s(row.get("hsn_code")), + gst_applicable=_s(row.get("gst_applicable")), + gst_type_of_supply=_s(row.get("gst_type_of_supply")), + opening_balance=float(row.get("opening_balance") or 0), + opening_value=float(row.get("opening_value") or 0), + opening_rate=_s(row.get("opening_rate")), + normalized_name=normalize_text(name), + source_payload_json=json.dumps(row, ensure_ascii=False, default=str), + synced_at_utc=synced, + ) + ) + + for row in units or []: + name = _s(row.get("name")) + if not name: + continue + db.add( + AccountingStockUnit( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=tally_guid, + company_name=company_name, + master_guid=_s(row.get("guid")), + name=name, + original_name=_s(row.get("original_name")), + is_simple_unit=_s(row.get("is_simple_unit")), + base_units=_s(row.get("base_units")), + additional_units=_s(row.get("additional_units")), + conversion=_s(row.get("conversion")), + normalized_name=normalize_unit(name), + source_payload_json=json.dumps(row, ensure_ascii=False, default=str), + synced_at_utc=synced, + ) + ) + + run = AccountingStockSyncRun( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=tally_guid, + company_name=company_name, + status="completed", + stock_group_count=len([x for x in stock_groups or [] if _s(x.get("name"))]), + stock_item_count=len([x for x in stock_items or [] if _s(x.get("name"))]), + unit_count=len([x for x in units or [] if _s(x.get("name"))]), + requested_by_user_id=int(user_id), + ) + db.add(run) + db.commit() + db.refresh(run) + return run + + +def list_stock_items( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + q: str = "", + group: str = "", + hsn: str = "", + page: int = 1, + per_page: int = 50, +): + stmt = select(AccountingStockItem).where( + AccountingStockItem.tenant_id == int(tenant_id), + AccountingStockItem.client_id == int(client_id), + AccountingStockItem.tally_guid == _s(tally_guid), + ) + if _s(q): + like = f"%{_s(q)}%" + stmt = stmt.where( + or_( + AccountingStockItem.name.ilike(like), + AccountingStockItem.parent_group_name.ilike(like), + AccountingStockItem.hsn_code.ilike(like), + ) + ) + if _s(group): + stmt = stmt.where(AccountingStockItem.parent_group_name == _s(group)) + if _s(hsn): + stmt = stmt.where(AccountingStockItem.hsn_code == _s(hsn)) + + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = int(db.execute(count_stmt).scalar_one() or 0) + per_page = max(10, min(200, int(per_page or 50))) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(int(page or 1), pages)) + + rows = list( + db.execute( + stmt.order_by( + AccountingStockItem.parent_group_name, + AccountingStockItem.name, + ) + .offset((page - 1) * per_page) + .limit(per_page) + ).scalars().all() + ) + return rows, total, page, pages + + +def list_stock_groups(db, *, tenant_id: int, client_id: int, tally_guid: str): + return list( + db.execute( + select(AccountingStockGroup) + .where( + AccountingStockGroup.tenant_id == int(tenant_id), + AccountingStockGroup.client_id == int(client_id), + AccountingStockGroup.tally_guid == _s(tally_guid), + ) + .order_by(AccountingStockGroup.parent_name, AccountingStockGroup.name) + ).scalars().all() + ) + + +def list_units(db, *, tenant_id: int, client_id: int, tally_guid: str): + return list( + db.execute( + select(AccountingStockUnit) + .where( + AccountingStockUnit.tenant_id == int(tenant_id), + AccountingStockUnit.client_id == int(client_id), + AccountingStockUnit.tally_guid == _s(tally_guid), + ) + .order_by(AccountingStockUnit.name) + ).scalars().all() + ) + + +def save_stock_mapping( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + source_type: str, + party_key: str, + source_description: str, + hsn_sac: str, + stock_item_id: int, + user_id: int, + note: str = "", +): + item = db.get(AccountingStockItem, int(stock_item_id)) + if ( + not item + or int(item.tenant_id) != int(tenant_id) + or int(item.client_id) != int(client_id) + or item.tally_guid != _s(tally_guid) + ): + raise ValueError("Selected Tally stock item is not valid for this client/company.") + + source_type = _s(source_type).lower() or "generic" + party_key = normalize_text(party_key) + source_description = _s(source_description) + source_norm = normalize_text(source_description) + hsn_sac = _s(hsn_sac) + + if not source_norm: + raise ValueError("Source item description is required.") + + mapping = db.execute( + select(AccountingStockItemMapping).where( + AccountingStockItemMapping.tenant_id == int(tenant_id), + AccountingStockItemMapping.client_id == int(client_id), + AccountingStockItemMapping.tally_guid == _s(tally_guid), + AccountingStockItemMapping.source_type == source_type, + AccountingStockItemMapping.party_key == party_key, + AccountingStockItemMapping.source_description_norm == source_norm, + AccountingStockItemMapping.hsn_sac == hsn_sac, + ) + ).scalar_one_or_none() + + if mapping is None: + mapping = AccountingStockItemMapping( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=_s(tally_guid), + source_type=source_type, + party_key=party_key, + source_description=source_description, + source_description_norm=source_norm, + hsn_sac=hsn_sac, + stock_item_id=item.id, + confidence=100, + mapping_origin="manual", + created_by_user_id=int(user_id), + ) + + mapping.source_description = source_description + mapping.stock_item_id = item.id + mapping.confidence = 100 + mapping.mapping_origin = "manual" + mapping.note = _s(note) + mapping.updated_by_user_id = int(user_id) + mapping.updated_at_utc = _utcnow() + db.add(mapping) + db.commit() + db.refresh(mapping) + return mapping + + +def save_unit_mapping( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + source_unit: str, + target_unit_id: int, + quantity_factor: float, + user_id: int, + note: str = "", +): + unit = db.get(AccountingStockUnit, int(target_unit_id)) + if ( + not unit + or int(unit.tenant_id) != int(tenant_id) + or int(unit.client_id) != int(client_id) + or unit.tally_guid != _s(tally_guid) + ): + raise ValueError("Selected Tally unit is not valid for this client/company.") + + source_unit = _s(source_unit) + source_norm = normalize_unit(source_unit) + factor = float(quantity_factor or 0) + if not source_norm: + raise ValueError("Source unit is required.") + if factor <= 0: + raise ValueError("Quantity conversion factor must be greater than zero.") + + mapping = db.execute( + select(AccountingStockUnitMapping).where( + AccountingStockUnitMapping.tenant_id == int(tenant_id), + AccountingStockUnitMapping.client_id == int(client_id), + AccountingStockUnitMapping.tally_guid == _s(tally_guid), + AccountingStockUnitMapping.source_unit_norm == source_norm, + ) + ).scalar_one_or_none() + + if mapping is None: + mapping = AccountingStockUnitMapping( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=_s(tally_guid), + source_unit=source_unit, + source_unit_norm=source_norm, + target_unit_id=unit.id, + quantity_factor=factor, + created_by_user_id=int(user_id), + ) + + mapping.source_unit = source_unit + mapping.target_unit_id = unit.id + mapping.quantity_factor = factor + mapping.note = _s(note) + mapping.updated_at_utc = _utcnow() + db.add(mapping) + db.commit() + db.refresh(mapping) + return mapping + + +def resolve_unit( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + source_unit: str, +): + source_norm = normalize_unit(source_unit) + if not source_norm: + return {"matched": True, "target_unit": "", "quantity_factor": 1.0, "reason": "No source unit."} + + mapping = db.execute( + select(AccountingStockUnitMapping, AccountingStockUnit) + .join(AccountingStockUnit, AccountingStockUnit.id == AccountingStockUnitMapping.target_unit_id) + .where( + AccountingStockUnitMapping.tenant_id == int(tenant_id), + AccountingStockUnitMapping.client_id == int(client_id), + AccountingStockUnitMapping.tally_guid == _s(tally_guid), + AccountingStockUnitMapping.source_unit_norm == source_norm, + ) + ).first() + if mapping: + m, unit = mapping + return { + "matched": True, + "target_unit": unit.name, + "quantity_factor": float(m.quantity_factor or 1), + "reason": "Learned unit mapping.", + } + + units = list_units(db, tenant_id=tenant_id, client_id=client_id, tally_guid=tally_guid) + exact = [u for u in units if normalize_unit(u.name) == source_norm or normalize_unit(u.original_name) == source_norm] + if len(exact) == 1: + return { + "matched": True, + "target_unit": exact[0].name, + "quantity_factor": 1.0, + "reason": "Exact normalized Tally unit.", + } + + return { + "matched": False, + "target_unit": "", + "quantity_factor": 1.0, + "reason": f"Unit '{source_unit}' is not mapped uniquely.", + } + + +def resolve_stock_item( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + source_type: str, + party_key: str, + source_description: str, + hsn_sac: str = "", +): + source_type = _s(source_type).lower() or "generic" + source_norm = normalize_text(source_description) + party_norm = normalize_text(party_key) + hsn_sac = _s(hsn_sac) + + if not source_norm: + return {"matched": False, "confidence": 0, "reason": "Source item description is empty.", "candidates": []} + + learned = list( + db.execute( + select(AccountingStockItemMapping, AccountingStockItem) + .join(AccountingStockItem, AccountingStockItem.id == AccountingStockItemMapping.stock_item_id) + .where( + AccountingStockItemMapping.tenant_id == int(tenant_id), + AccountingStockItemMapping.client_id == int(client_id), + AccountingStockItemMapping.tally_guid == _s(tally_guid), + AccountingStockItemMapping.source_description_norm == source_norm, + AccountingStockItemMapping.source_type.in_([source_type, "generic"]), + ) + .order_by(AccountingStockItemMapping.confidence.desc()) + ).all() + ) + ranked_learned = [] + for mapping, item in learned: + score = int(mapping.confidence or 100) + if mapping.party_key and mapping.party_key == party_norm: + score += 20 + elif mapping.party_key: + score -= 30 + if hsn_sac and mapping.hsn_sac and mapping.hsn_sac == hsn_sac: + score += 10 + elif hsn_sac and mapping.hsn_sac and mapping.hsn_sac != hsn_sac: + score -= 20 + ranked_learned.append((score, item, mapping)) + ranked_learned.sort(key=lambda row: (-row[0], row[1].name)) + if ranked_learned and ranked_learned[0][0] >= 90: + score, item, mapping = ranked_learned[0] + return { + "matched": True, + "stock_item": item, + "confidence": min(100, score), + "reason": "Confirmed client/source stock-item mapping.", + "mapping_id": mapping.id, + "candidates": [], + } + + items = list( + db.execute( + select(AccountingStockItem).where( + AccountingStockItem.tenant_id == int(tenant_id), + AccountingStockItem.client_id == int(client_id), + AccountingStockItem.tally_guid == _s(tally_guid), + ) + ).scalars().all() + ) + + exact = [item for item in items if item.normalized_name == source_norm] + if len(exact) == 1: + return { + "matched": True, + "stock_item": exact[0], + "confidence": 98, + "reason": "Exact normalized Tally stock-item name.", + "candidates": [], + } + + scored = [] + source_tokens = {t for t in source_norm.split() if len(t) >= 2} + for item in items: + item_tokens = {t for t in item.normalized_name.split() if len(t) >= 2} + common = source_tokens & item_tokens + token_score = 0 + if source_tokens: + token_score = int(round((len(common) / len(source_tokens)) * 70)) + similarity = int(round(SequenceMatcher(None, source_norm, item.normalized_name).ratio() * 25)) + hsn_bonus = 0 + if hsn_sac and item.hsn_code: + if hsn_sac == item.hsn_code: + hsn_bonus = 20 + elif hsn_sac[:4] and item.hsn_code[:4] == hsn_sac[:4]: + hsn_bonus = 10 + else: + hsn_bonus = -20 + score = max(0, min(100, token_score + similarity + hsn_bonus)) + if score >= 45: + scored.append((score, item)) + + scored.sort(key=lambda row: (-row[0], row[1].name)) + candidates = [ + { + "id": item.id, + "name": item.name, + "group": item.parent_group_name, + "hsn": item.hsn_code, + "unit": item.base_units, + "confidence": score, + } + for score, item in scored[:8] + ] + + if scored: + top_score = scored[0][0] + same_top = [row for row in scored if row[0] == top_score] + second = scored[1][0] if len(scored) > 1 else 0 + if top_score >= 88 and len(same_top) == 1 and (top_score - second >= 10): + return { + "matched": True, + "stock_item": scored[0][1], + "confidence": top_score, + "reason": "Unique conservative HSN/name match.", + "candidates": candidates, + } + + return { + "matched": False, + "confidence": scored[0][0] if scored else 0, + "reason": "No sufficiently unique stock-item mapping. Reviewer mapping is required.", + "candidates": candidates, + } + + +def list_mappings(db, *, tenant_id: int, client_id: int, tally_guid: str): + return list( + db.execute( + select(AccountingStockItemMapping, AccountingStockItem) + .join(AccountingStockItem, AccountingStockItem.id == AccountingStockItemMapping.stock_item_id) + .where( + AccountingStockItemMapping.tenant_id == int(tenant_id), + AccountingStockItemMapping.client_id == int(client_id), + AccountingStockItemMapping.tally_guid == _s(tally_guid), + ) + .order_by( + AccountingStockItemMapping.source_type, + AccountingStockItemMapping.source_description, + ) + ).all() + ) + + +def list_unit_mappings(db, *, tenant_id: int, client_id: int, tally_guid: str): + return list( + db.execute( + select(AccountingStockUnitMapping, AccountingStockUnit) + .join(AccountingStockUnit, AccountingStockUnit.id == AccountingStockUnitMapping.target_unit_id) + .where( + AccountingStockUnitMapping.tenant_id == int(tenant_id), + AccountingStockUnitMapping.client_id == int(client_id), + AccountingStockUnitMapping.tally_guid == _s(tally_guid), + ) + .order_by(AccountingStockUnitMapping.source_unit) + ).all() + ) + + +def needs_mapping( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + limit: int = 200, +): + # Reuse already-generated voucher source evidence. This avoids creating + # another staging table and works with Phase 18 E-Way Bill drafts today. + rows = list( + db.execute( + select(AccountingVoucherItemLine) + .join(AccountingVoucherItemLine.draft) + .where( + AccountingVoucherItemLine.draft.has( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=_s(tally_guid), + ) + ) + .order_by(AccountingVoucherItemLine.id.desc()) + .limit(max(1, min(1000, int(limit)))) + ).scalars().all() + ) + + result = [] + seen = set() + for line in rows: + payload = _payload(line.source_item_json) + description = _s(payload.get("item_name") or payload.get("product_name") or line.stock_item_name) + hsn = _s(payload.get("hsn") or payload.get("hsn_code") or line.hsn_sac) + party = _s(payload.get("party_name") or payload.get("supplier_name") or payload.get("customer_name")) + source_type = _s(payload.get("source_type") or "ewaybill").lower() + key = (source_type, normalize_text(party), normalize_text(description), hsn) + if not description or key in seen: + continue + seen.add(key) + + resolved = resolve_stock_item( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + source_type=source_type, + party_key=party, + source_description=description, + hsn_sac=hsn, + ) + if resolved.get("matched"): + continue + result.append( + { + "source_type": source_type, + "party": party, + "description": description, + "hsn": hsn, + "source_unit": _s(payload.get("unit")), + "confidence": int(resolved.get("confidence") or 0), + "reason": resolved.get("reason") or "", + "candidates": resolved.get("candidates") or [], + } + ) + return result[:limit] + + +def stock_summary(db, *, tenant_id: int, client_id: int, tally_guid: str): + def count(model): + return int( + db.execute( + select(func.count(model.id)).where( + model.tenant_id == int(tenant_id), + model.client_id == int(client_id), + model.tally_guid == _s(tally_guid), + ) + ).scalar_one() or 0 + ) + return { + "groups": count(AccountingStockGroup), + "items": count(AccountingStockItem), + "units": count(AccountingStockUnit), + "mappings": count(AccountingStockItemMapping), + "unit_mappings": count(AccountingStockUnitMapping), + } diff --git a/app/modules/accounting/stock_ui.py b/app/modules/accounting/stock_ui.py new file mode 100644 index 0000000..31263ff --- /dev/null +++ b/app/modules/accounting/stock_ui.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +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.stock_models import AccountingStockSyncRun +from app.modules.accounting.stock_service import ( + list_mappings, + list_stock_groups, + list_stock_items, + list_unit_mappings, + list_units, + needs_mapping, + persist_stock_snapshot, + save_stock_mapping, + save_unit_mapping, + stock_summary, +) +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/stock-items", + tags=["accounting-stock-item-intelligence-ui"], +) + + +def _go(client_id=0, message="", error="", **filters): + q = {} + if client_id: + q["client_id"] = client_id + if message: + q["message"] = message[:350] + if error: + q["error"] = error[:350] + for key, value in filters.items(): + if value not in (None, "", "all"): + q[key] = value + return RedirectResponse( + "/tools/accounting/stock-items" + ("?" + urlencode(q) if q else ""), + status_code=303, + ) + + +def _mapped_companies(accounting): + rows = [] + for mapping in (accounting or {}).get("mappings") or []: + guid = str(mapping.get("tally_guid") or "").strip() + if guid: + rows.append( + { + "guid": guid, + "name": str(mapping.get("company_name") or guid).strip(), + } + ) + seen = set() + unique = [] + for row in rows: + if row["guid"] not in seen: + seen.add(row["guid"]) + unique.append(row) + return sorted(unique, key=lambda x: x["name"].casefold()) + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + tally_guid: str = "", + view: str = "items", + q: str = "", + group: str = "", + hsn: str = "", + page: int = 1, + per_page: int = 50, + 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( + (client for client in clients if client_id and int(client.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 + accounting = None + 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"): + result = response.get("result") or {} + tally = result.get("tally") + accounting = result.get("accounting") + else: + command_error = str(response.get("error") or "Local Agent status failed.") + except Exception as exc: + command_error = str(exc) + + companies = _mapped_companies(accounting) + valid_guids = {row["guid"] for row in companies} + if tally_guid and tally_guid not in valid_guids: + tally_guid = "" + if not tally_guid and len(companies) == 1: + tally_guid = companies[0]["guid"] + + summary = {"groups": 0, "items": 0, "units": 0, "mappings": 0, "unit_mappings": 0} + items = [] + groups = [] + units = [] + mappings = [] + unit_mappings = [] + review_rows = [] + all_item_options = [] + total = 0 + pages = 1 + last_sync = None + + if selected and tally_guid: + summary = stock_summary( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + groups = list_stock_groups( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + all_item_options, _all_total, _all_page, _all_pages = list_stock_items( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + page=1, + per_page=200, + ) + units = list_units( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + + if view == "items": + items, total, page, pages = list_stock_items( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + q=q, + group=group, + hsn=hsn, + page=page, + per_page=per_page, + ) + elif view == "mappings": + mappings = list_mappings( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + elif view == "units": + unit_mappings = list_unit_mappings( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + elif view == "review": + review_rows = needs_mapping( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + limit=200, + ) + + last_sync = db.execute( + select(AccountingStockSyncRun) + .where( + AccountingStockSyncRun.tenant_id == scope.tenant_id, + AccountingStockSyncRun.client_id == selected.id, + AccountingStockSyncRun.tally_guid == tally_guid, + ) + .order_by(AccountingStockSyncRun.id.desc()) + .limit(1) + ).scalar_one_or_none() + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/stock_items.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": "Stock Item Intelligence", + "clients": clients, + "selected_client": selected, + "node_online": online, + "tally": tally, + "accounting": accounting, + "companies": companies, + "selected_tally_guid": tally_guid, + "summary": summary, + "items": items, + "groups": groups, + "units": units, + "mappings": mappings, + "unit_mappings": unit_mappings, + "review_rows": review_rows, + "all_item_options": all_item_options, + "view": view if view in {"items", "mappings", "review", "units"} else "items", + "q": q, + "group_filter": group, + "hsn_filter": hsn, + "page": page, + "pages": pages, + "per_page": per_page, + "total": total, + "last_sync": last_sync, + "message": message, + "error": command_error, + }, + ) + finally: + db.close() + + +@router.post("/sync") +def sync_stock_items( + request: Request, + client_id: int = Form(...), + tally_guid: 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.") + + node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) + if not node or not _node_online(node): + return _go(client.id, error="ERP Local Agent is offline for the active branch.") + + response = request_agent_command( + node.node_code, + "accounting_stock_master_intelligence", + { + **_accounting_storage_payload(client), + "tally_guid": str(tally_guid or "").strip(), + "requested_by_user_id": int(user.id), + }, + timeout_seconds=180, + ) + if not response.get("ok"): + raise RuntimeError(str(response.get("error") or "Stock master synchronization failed.")) + + result = response.get("result") or {} + snapshot = result.get("stock_master") or {} + run = persist_stock_snapshot( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=str(tally_guid or "").strip(), + company_name=str(snapshot.get("company_name") or "").strip(), + stock_groups=list(snapshot.get("stock_groups") or []), + stock_items=list(snapshot.get("stock_items") or []), + units=list(snapshot.get("units") or []), + user_id=user.id, + ) + + return _go( + client.id, + tally_guid=tally_guid, + view="items", + message=( + f"Stock master synchronized: {run.stock_item_count} item(s), " + f"{run.stock_group_count} group(s), {run.unit_count} unit(s)." + ), + ) + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, error=str(exc)) + finally: + db.close() + + +@router.post("/mapping") +def save_mapping( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + source_type: str = Form("generic"), + party_key: str = Form(""), + source_description: str = Form(...), + hsn_sac: str = Form(""), + stock_item_id: int = 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.") + + mapping = save_stock_mapping( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=tally_guid, + source_type=source_type, + party_key=party_key, + source_description=source_description, + hsn_sac=hsn_sac, + stock_item_id=stock_item_id, + user_id=user.id, + note=note, + ) + return _go( + client.id, + tally_guid=tally_guid, + view="review", + message=f"Stock-item mapping saved for '{mapping.source_description}'.", + ) + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, view="review", error=str(exc)) + finally: + db.close() + + +@router.post("/unit-mapping") +def unit_mapping( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + source_unit: str = Form(...), + target_unit_id: int = Form(...), + quantity_factor: float = Form(1.0), + 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.") + + mapping = save_unit_mapping( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=tally_guid, + source_unit=source_unit, + target_unit_id=target_unit_id, + quantity_factor=quantity_factor, + user_id=user.id, + note=note, + ) + return _go( + client.id, + tally_guid=tally_guid, + view="units", + message=f"Unit mapping saved for '{mapping.source_unit}'.", + ) + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, view="units", error=str(exc)) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/stock_items.html b/app/modules/accounting/templates/accounting/stock_items.html new file mode 100644 index 0000000..4e0d585 --- /dev/null +++ b/app/modules/accounting/templates/accounting/stock_items.html @@ -0,0 +1,237 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting · Phase 21

+

Stock Item Intelligence

+

+ Mirror Tally Stock Groups, Stock Items and Units into ERP, learn client-specific source-description mappings, and convert source UQC/units safely before item-wise Purchase or Sales vouchers reach Tally. +

+
+ +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + +
+
+ + {% if selected_client and selected_tally_guid %} +
+
+ {% if last_sync %}Last sync {{ last_sync.created_at_utc }} · {{ last_sync.stock_item_count }} items · {{ last_sync.stock_group_count }} groups · {{ last_sync.unit_count }} units{% else %}No ERP Stock Master snapshot yet.{% endif %} +
+
+ + + + +
+
+ {% endif %} +
+ + {% if selected_client and selected_tally_guid %} +
+
Stock Items
{{ summary.items }}
+
Stock Groups
{{ summary.groups }}
+
Units
{{ summary.units }}
+
Learned Item Mappings
{{ summary.mappings }}
+
Unit Mappings
{{ summary.unit_mappings }}
+
+ + + + {% if view=='items' %} +
+
+

Tally Stock Item Master

+

Read-only ERP mirror of the selected Tally company's current Stock Items.

+
+ + + + + + + +
+
+
+ + + + {% for item in items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
Stock ItemGroupUnitHSNGSTOpening QtyOpening Value
{{ item.name }}
{% if item.category_name %}
Category: {{ item.category_name }}
{% endif %}
{{ item.parent_group_name or '-' }}{{ item.base_units or '-' }}{% if item.additional_units %}
Alt: {{ item.additional_units }}
{% endif %}
{{ item.hsn_code or '-' }}{{ item.gst_applicable or '-' }}{% if item.gst_type_of_supply %}
{{ item.gst_type_of_supply }}
{% endif %}
{{ item.opening_balance }}₹{{ '%.2f'|format(item.opening_value) }}
No Stock Items synchronized yet.
+
+ {% if pages > 1 %} +
+
{{ total }} item(s) · Page {{ page }} of {{ pages }}
+
+ {% if page>1 %}Previous{% endif %} + {% if pageNext{% endif %} +
+
+ {% endif %} +
+ + {% elif view=='review' %} +
+
+

Needs Stock Item Mapping

+

Unresolved item descriptions already present in source-backed voucher evidence. Loose fuzzy matches are never auto-confirmed.

+
+
+ {% for row in review_rows %} +
+
+
+
{{ row.source_type|upper }}{% if row.hsn %}HSN {{ row.hsn }}{% endif %}
+

{{ row.description }}

+

{{ row.party or 'No party-specific mapping' }}{% if row.source_unit %} · Source unit {{ row.source_unit }}{% endif %}

+

{{ row.reason }}{% if row.confidence %} · best candidate {{ row.confidence }}%{% endif %}

+ {% if row.candidates %} +
+
Suggested candidates
+ {% for candidate in row.candidates[:5] %}
{{ candidate.confidence }}% · {{ candidate.name }} · {{ candidate.group or '-' }} · HSN {{ candidate.hsn or '-' }} · {{ candidate.unit or '-' }}
{% endfor %} +
+ {% endif %} +
+
+ + + + + + + + + + +
+
+
+ {% else %} +
No unresolved stock-item descriptions found in current voucher source evidence.
+ {% endfor %} +
+
+ + {% elif view=='mappings' %} +
+

Add / Update Mapping Manually

+

Use this when a source item has not yet generated a review row, or when preparing mappings before E-Way Bill/E-Invoice migration.

+
+ + + + + + + + + + +
+
+
+

Learned Stock Item Mappings

Confirmed mappings are client/company/source scoped and take precedence over name similarity.

+
+ + + + {% for mapping,item in mappings %} + + {% else %}{% endfor %} + +
SourcePartySource DescriptionHSNMapped Tally ItemOrigin
{{ mapping.source_type|upper }}{{ mapping.party_key or 'Generic' }}{{ mapping.source_description }}{{ mapping.hsn_sac or '-' }}{{ item.name }}
{{ item.parent_group_name }} · {{ item.base_units }}
{{ mapping.mapping_origin|title }} · {{ mapping.confidence }}%
No learned stock-item mappings yet.
+
+
+ + {% elif view=='units' %} +
+
+

Tally Units

+
+ {% for unit in units %}{% else %}{% endfor %}
UnitOriginalConversion
{{ unit.name }}{{ unit.original_name or '-' }}{{ unit.conversion or '-' }}{% if unit.base_units or unit.additional_units %}
{{ unit.base_units }} → {{ unit.additional_units }}
{% endif %}
No units synchronized.
+
+
+
+
+

Add / Update Source Unit Mapping

+

Quantity factor means: source quantity × factor = Tally quantity. Example: 1 MT → 1000 Kg uses factor 1000.

+
+ + + + + + + + +
+
+
+

Saved Unit Mappings

+
{% for mapping,unit in unit_mappings %}
{{ mapping.source_unit }} × {{ mapping.quantity_factor }} → {{ unit.name }}{% if mapping.note %}
{{ mapping.note }}
{% endif %}
{% else %}
No explicit unit mappings yet. Exact normalized units still resolve automatically.
{% endfor %}
+
+
+
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index ae92b8c..09194d3 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -20,6 +20,7 @@
Chart of Accounts - Groups & Ledgers + Stock Item Intelligence Accounting Voucher Engine
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index c36631d..8de3a00 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.19.0" +ERP_LOCAL_AGENT_VERSION = "1.21.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 4be0995..e9a7603 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.19.0" +__version__ = "1.21.0" 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 ee2cc05..68dddcf 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 @@ -38,6 +38,8 @@ class AgentCommandProcessor: result = self._sync_masters(payload) elif action == "accounting_chart_of_accounts": result = self._chart_of_accounts(payload) + elif action == "accounting_stock_master_intelligence": + result = self._stock_master_intelligence(payload) elif action == "accounting_sync_transactions": result = self._sync_transactions(payload) elif action == "accounting_historical_evidence": @@ -85,7 +87,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, "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, "bank_reconciliation_read_capability": True, "tally_transaction_sync_capability": True, "historical_learning_read_capability": True, "purchase_posting_preflight_capability": True, @@ -332,6 +334,30 @@ class AgentCommandProcessor: + + 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. + sync_result = self._sync_masters(payload) + company_name = str( + (sync_result.get("sync") or {}).get("company_name") + or "" + ).strip() + if not company_name: + raise ValueError("Tally company name was not resolved during master synchronization.") + + masters = self.tally.fetch_accounting_masters(company_name) + return { + **sync_result, + "stock_master": { + "company_name": company_name, + "stock_groups": list(masters.get("stock_groups") or []), + "stock_items": list(masters.get("stock_items") or []), + "units": list(masters.get("units") or []), + }, + "read_only": True, + } + def _chart_of_accounts(self, payload: dict[str, Any]) -> dict[str, Any]: # Read-only: refresh the existing master snapshot, then return Groups/Ledgers. sync_result = self._sync_masters(payload) diff --git a/app/ui/app.py b/app/ui/app.py index feb4958..c82596d 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -53,6 +53,7 @@ from app.modules.accounting.sales_learning_ui import router as accounting_sales_ from app.modules.accounting.sales_menu_ui import router as accounting_sales_menu_ui_router from app.modules.accounting.chart_ui import router as accounting_chart_ui_router from app.modules.accounting.voucher_engine_ui import router as accounting_voucher_engine_ui_router +from app.modules.accounting.stock_ui import router as accounting_stock_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 @@ -93,6 +94,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_sales_menu_ui_router) 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_automatic_voucher_ui_router) app.include_router(work_tracker_ui_router) app.include_router(billing_ui_router)