Add Phase 21 stock item master mapping intelligence
This commit is contained in:
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user