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 }}
+
+ Learned Item Mappings
{{ summary.mappings }}
+ Unit Mappings
{{ summary.unit_mappings }}
+
+
+
+ {% for code,label in [('items','Stock Master'),('review','Needs Mapping'),('mappings','Mappings'),('units','Units')] %}
+ {{ label }}
+ {% endfor %}
+
+
+ {% if view=='items' %}
+
+
+
Tally Stock Item Master
+
Read-only ERP mirror of the selected Tally company's current Stock Items.
+
+
+
+
+ Stock Item Group Unit HSN GST Opening Qty Opening Value
+
+ {% for item in items %}
+
+ {{ 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) }}
+
+ {% else %}
+ No Stock Items synchronized yet.
+ {% endfor %}
+
+
+
+ {% if pages > 1 %}
+
+
{{ total }} item(s) · Page {{ page }} of {{ pages }}
+
+ {% if page>1 %}
Previous {% endif %}
+ {% if page
Next{% 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.
+
+
+ Source Party Source Description HSN Mapped Tally Item Origin
+
+ {% for mapping,item in mappings %}
+ {{ 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 }}%
+ {% else %}No learned stock-item mappings yet. {% endfor %}
+
+
+
+
+
+ {% elif view=='units' %}
+
+
+
Tally Units
+
+
Unit Original Conversion {% for unit in units %}{{ unit.name }} {{ unit.original_name or '-' }} {{ unit.conversion or '-' }}{% if unit.base_units or unit.additional_units %}{{ unit.base_units }} → {{ unit.additional_units }}
{% endif %} {% else %}No units synchronized. {% endfor %}
+
+
+
+
+
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 @@
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)