Add Phase 21 stock item master mapping intelligence

This commit is contained in:
A R R R Associates
2026-08-24 22:07:16 +05:30
parent b8fbaf55ec
commit 0fab925e71
11 changed files with 1777 additions and 10 deletions
@@ -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")
@@ -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),
)
)
+157
View File
@@ -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)
+701
View File
@@ -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),
}
+418
View File
@@ -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()
@@ -0,0 +1,237 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Phase 21</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Stock Item Intelligence</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
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.
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Voucher Engine</a>
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Chart of Accounts</a>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Back to Accounting</a>
</div>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-[1.4fr_1.4fr_auto]">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client and selected_client.id==client.id %}selected{% endif %}>{{ client.client_code }} · {{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-semibold">Mapped Tally Company
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select mapped company</option>
{% for company in companies %}
<option value="{{ company.guid }}" {% if selected_tally_guid==company.guid %}selected{% endif %}>{{ company.name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Open</button></div>
</form>
{% if selected_client and selected_tally_guid %}
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="text-sm text-slate-600">
{% 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 %}
</div>
<form method="post" action="/tools/accounting/stock-items/sync">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white" {% if not node_online %}disabled{% endif %}>Synchronize Stock Master from Tally</button>
</form>
</div>
{% endif %}
</section>
{% if selected_client and selected_tally_guid %}
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs text-slate-500">Stock Items</div><div class="mt-1 text-2xl font-bold">{{ summary.items }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs text-slate-500">Stock Groups</div><div class="mt-1 text-2xl font-bold">{{ summary.groups }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs text-slate-500">Units</div><div class="mt-1 text-2xl font-bold">{{ summary.units }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs text-slate-500">Learned Item Mappings</div><div class="mt-1 text-2xl font-bold">{{ summary.mappings }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs text-slate-500">Unit Mappings</div><div class="mt-1 text-2xl font-bold">{{ summary.unit_mappings }}</div></div>
</section>
<nav class="flex flex-wrap gap-2">
{% for code,label in [('items','Stock Master'),('review','Needs Mapping'),('mappings','Mappings'),('units','Units')] %}
<a href="?client_id={{ selected_client.id }}&tally_guid={{ selected_tally_guid }}&view={{ code }}" class="rounded-xl px-4 py-2 text-sm font-semibold {% if view==code %}bg-brand-600 text-white{% else %}border border-slate-300 bg-white text-slate-700{% endif %}">{{ label }}</a>
{% endfor %}
</nav>
{% if view=='items' %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 p-5">
<h2 class="font-semibold">Tally Stock Item Master</h2>
<p class="mt-1 text-sm text-slate-500">Read-only ERP mirror of the selected Tally company's current Stock Items.</p>
<form method="get" class="mt-4 grid gap-3 md:grid-cols-4">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="view" value="items">
<input name="q" value="{{ q }}" placeholder="Search item / group / HSN" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<select name="group" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All Stock Groups</option>
{% for row in groups %}<option value="{{ row.name }}" {% if group_filter==row.name %}selected{% endif %}>{{ row.name }}</option>{% endfor %}
</select>
<input name="hsn" value="{{ hsn_filter }}" placeholder="HSN filter" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button>
</form>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Stock Item</th><th class="px-4 py-3">Group</th><th class="px-4 py-3">Unit</th><th class="px-4 py-3">HSN</th><th class="px-4 py-3">GST</th><th class="px-4 py-3 text-right">Opening Qty</th><th class="px-4 py-3 text-right">Opening Value</th></tr></thead>
<tbody>
{% for item in items %}
<tr class="border-t border-slate-100">
<td class="px-4 py-3"><div class="font-semibold">{{ item.name }}</div>{% if item.category_name %}<div class="text-xs text-slate-500">Category: {{ item.category_name }}</div>{% endif %}</td>
<td class="px-4 py-3">{{ item.parent_group_name or '-' }}</td>
<td class="px-4 py-3">{{ item.base_units or '-' }}{% if item.additional_units %}<div class="text-xs text-slate-500">Alt: {{ item.additional_units }}</div>{% endif %}</td>
<td class="px-4 py-3">{{ item.hsn_code or '-' }}</td>
<td class="px-4 py-3">{{ item.gst_applicable or '-' }}{% if item.gst_type_of_supply %}<div class="text-xs text-slate-500">{{ item.gst_type_of_supply }}</div>{% endif %}</td>
<td class="px-4 py-3 text-right">{{ item.opening_balance }}</td>
<td class="px-4 py-3 text-right">₹{{ '%.2f'|format(item.opening_value) }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No Stock Items synchronized yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
<div>{{ total }} item(s) · Page {{ page }} of {{ pages }}</div>
<div class="flex gap-2">
{% if page>1 %}<a class="rounded-lg border border-slate-300 px-3 py-2" href="?client_id={{ selected_client.id }}&tally_guid={{ selected_tally_guid }}&view=items&q={{ q }}&group={{ group_filter }}&hsn={{ hsn_filter }}&page={{ page-1 }}">Previous</a>{% endif %}
{% if page<pages %}<a class="rounded-lg border border-slate-300 px-3 py-2" href="?client_id={{ selected_client.id }}&tally_guid={{ selected_tally_guid }}&view=items&q={{ q }}&group={{ group_filter }}&hsn={{ hsn_filter }}&page={{ page+1 }}">Next</a>{% endif %}
</div>
</div>
{% endif %}
</section>
{% elif view=='review' %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 p-5">
<h2 class="font-semibold">Needs Stock Item Mapping</h2>
<p class="mt-1 text-sm text-slate-500">Unresolved item descriptions already present in source-backed voucher evidence. Loose fuzzy matches are never auto-confirmed.</p>
</div>
<div class="divide-y divide-slate-100">
{% for row in review_rows %}
<article class="p-5">
<div class="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<div>
<div class="flex flex-wrap gap-2"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ row.source_type|upper }}</span>{% if row.hsn %}<span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">HSN {{ row.hsn }}</span>{% endif %}</div>
<h3 class="mt-2 font-semibold">{{ row.description }}</h3>
<p class="mt-1 text-sm text-slate-500">{{ row.party or 'No party-specific mapping' }}{% if row.source_unit %} · Source unit {{ row.source_unit }}{% endif %}</p>
<p class="mt-2 text-xs text-amber-700">{{ row.reason }}{% if row.confidence %} · best candidate {{ row.confidence }}%{% endif %}</p>
{% if row.candidates %}
<div class="mt-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs">
<div class="font-semibold">Suggested candidates</div>
{% for candidate in row.candidates[:5] %}<div class="mt-1">{{ candidate.confidence }}% · {{ candidate.name }} · {{ candidate.group or '-' }} · HSN {{ candidate.hsn or '-' }} · {{ candidate.unit or '-' }}</div>{% endfor %}
</div>
{% endif %}
</div>
<form method="post" action="/tools/accounting/stock-items/mapping" class="rounded-xl border border-slate-200 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="source_type" value="{{ row.source_type }}">
<input type="hidden" name="party_key" value="{{ row.party }}">
<input type="hidden" name="source_description" value="{{ row.description }}">
<input type="hidden" name="hsn_sac" value="{{ row.hsn }}">
<label class="text-xs font-semibold">Map to Tally Stock Item
<select name="stock_item_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Choose existing Tally stock item</option>
{% for candidate in row.candidates %}<option value="{{ candidate.id }}">{{ candidate.name }} · {{ candidate.group or '-' }} · HSN {{ candidate.hsn or '-' }}</option>{% endfor %}
<optgroup label="All synchronized items">
{% for item in all_item_options %}<option value="{{ item.id }}">{{ item.name }} · {{ item.parent_group_name or '-' }} · HSN {{ item.hsn_code or '-' }}</option>{% endfor %}
</optgroup>
</select>
</label>
<label class="mt-3 block text-xs font-semibold">Mapping Note
<input name="note" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2" placeholder="Optional reviewer note">
</label>
<button class="mt-3 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Confirm Mapping</button>
</form>
</div>
</article>
{% else %}
<div class="p-10 text-center text-slate-500">No unresolved stock-item descriptions found in current voucher source evidence.</div>
{% endfor %}
</div>
</section>
{% elif view=='mappings' %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold">Add / Update Mapping Manually</h2>
<p class="mt-1 text-sm text-slate-500">Use this when a source item has not yet generated a review row, or when preparing mappings before E-Way Bill/E-Invoice migration.</p>
<form method="post" action="/tools/accounting/stock-items/mapping" class="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input name="source_type" value="generic" placeholder="Source type" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<input name="party_key" placeholder="Supplier/customer (optional)" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<input name="source_description" required placeholder="Source item description" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<input name="hsn_sac" placeholder="HSN/SAC (optional)" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<select name="stock_item_id" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm lg:col-span-2"><option value="">Mapped Tally Stock Item</option>{% for item in all_item_options %}<option value="{{ item.id }}">{{ item.name }} · {{ item.parent_group_name or '-' }} · HSN {{ item.hsn_code or '-' }}</option>{% endfor %}</select>
<input name="note" placeholder="Mapping note" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<button class="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Mapping</button>
</form>
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 p-5"><h2 class="font-semibold">Learned Stock Item Mappings</h2><p class="mt-1 text-sm text-slate-500">Confirmed mappings are client/company/source scoped and take precedence over name similarity.</p></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Source</th><th class="px-4 py-3">Party</th><th class="px-4 py-3">Source Description</th><th class="px-4 py-3">HSN</th><th class="px-4 py-3">Mapped Tally Item</th><th class="px-4 py-3">Origin</th></tr></thead>
<tbody>
{% for mapping,item in mappings %}
<tr class="border-t border-slate-100"><td class="px-4 py-3">{{ mapping.source_type|upper }}</td><td class="px-4 py-3">{{ mapping.party_key or 'Generic' }}</td><td class="px-4 py-3 font-semibold">{{ mapping.source_description }}</td><td class="px-4 py-3">{{ mapping.hsn_sac or '-' }}</td><td class="px-4 py-3">{{ item.name }}<div class="text-xs text-slate-500">{{ item.parent_group_name }} · {{ item.base_units }}</div></td><td class="px-4 py-3">{{ mapping.mapping_origin|title }} · {{ mapping.confidence }}%</td></tr>
{% else %}<tr><td colspan="6" class="px-4 py-10 text-center text-slate-500">No learned stock-item mappings yet.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</section>
{% elif view=='units' %}
<section class="grid gap-5 lg:grid-cols-2">
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold">Tally Units</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm"><thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Unit</th><th class="px-3 py-2">Original</th><th class="px-3 py-2">Conversion</th></tr></thead><tbody>{% for unit in units %}<tr class="border-t"><td class="px-3 py-2 font-semibold">{{ unit.name }}</td><td class="px-3 py-2">{{ unit.original_name or '-' }}</td><td class="px-3 py-2">{{ unit.conversion or '-' }}{% if unit.base_units or unit.additional_units %}<div class="text-xs text-slate-500">{{ unit.base_units }} → {{ unit.additional_units }}</div>{% endif %}</td></tr>{% else %}<tr><td colspan="3" class="p-6 text-center text-slate-500">No units synchronized.</td></tr>{% endfor %}</tbody></table>
</div>
</div>
<div class="space-y-5">
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold">Add / Update Source Unit Mapping</h2>
<p class="mt-1 text-sm text-slate-500">Quantity factor means: source quantity × factor = Tally quantity. Example: 1 MT → 1000 Kg uses factor 1000.</p>
<form method="post" action="/tools/accounting/stock-items/unit-mapping" class="mt-4 space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input name="source_unit" required placeholder="Source unit, e.g. MT" class="w-full rounded-lg border border-slate-300 px-3 py-2">
<select name="target_unit_id" required class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2"><option value="">Target Tally unit</option>{% for unit in units %}<option value="{{ unit.id }}">{{ unit.name }}</option>{% endfor %}</select>
<input type="number" step="0.000001" min="0.000001" name="quantity_factor" value="1" required class="w-full rounded-lg border border-slate-300 px-3 py-2">
<input name="note" placeholder="Optional note" class="w-full rounded-lg border border-slate-300 px-3 py-2">
<button class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save Unit Mapping</button>
</form>
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold">Saved Unit Mappings</h2>
<div class="mt-3 space-y-2">{% for mapping,unit in unit_mappings %}<div class="rounded-lg border border-slate-200 p-3 text-sm"><span class="font-semibold">{{ mapping.source_unit }}</span> × {{ mapping.quantity_factor }} → <span class="font-semibold">{{ unit.name }}</span>{% if mapping.note %}<div class="text-xs text-slate-500">{{ mapping.note }}</div>{% endif %}</div>{% else %}<div class="text-sm text-slate-500">No explicit unit mappings yet. Exact normalized units still resolve automatically.</div>{% endfor %}</div>
</div>
</div>
</section>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -20,6 +20,7 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
<a href="/tools/accounting/stock-items{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-white px-3 py-2 text-sm font-semibold text-indigo-800">Stock Item Intelligence</a>
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-blue-300 bg-white px-3 py-2 text-sm font-semibold text-blue-800">Accounting Voucher Engine</a>
</div>
</div>
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.19.0"
__version__ = "1.21.0"
AGENT_NAME = "ERP Local Agent"
@@ -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)
+2
View File
@@ -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)