Add Phase 12 server AI semantic accounting layer

This commit is contained in:
A R R R Associates
2026-08-22 21:36:27 +05:30
parent 016299cf0c
commit ef2b0e1f83
14 changed files with 1023 additions and 5 deletions
@@ -0,0 +1,70 @@
"""Phase 12 server AI semantic accounting layer.
Revision ID: 20260822_accounting_ai_p12
Revises: 20260822_bank_client_archive_p11b
"""
from alembic import op
import sqlalchemy as sa
revision = "20260822_accounting_ai_p12"
down_revision = "20260822_bank_client_archive_p11b"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"accounting_ai_semantic_decisions",
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("source_type", sa.String(30), nullable=False),
sa.Column("source_record_id", sa.Integer(), nullable=False),
sa.Column("source_fingerprint", sa.String(80), nullable=False, server_default=""),
sa.Column("provider_name", sa.String(60), nullable=False, server_default=""),
sa.Column("model_name", sa.String(120), nullable=False, server_default=""),
sa.Column("prompt_version", sa.String(40), nullable=False, server_default="phase12-v1"),
sa.Column("deterministic_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
sa.Column("deterministic_confidence", sa.Integer(), nullable=False, server_default="0"),
sa.Column("ai_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
sa.Column("ai_nature_code", sa.String(80), nullable=False, server_default=""),
sa.Column("ai_confidence", sa.Integer(), nullable=False, server_default="0"),
sa.Column("combined_confidence", sa.Integer(), nullable=False, server_default="0"),
sa.Column("capital_revenue", sa.String(20), nullable=False, server_default=""),
sa.Column("business_personal", sa.String(20), nullable=False, server_default="business"),
sa.Column("concise_reason", sa.Text(), nullable=False, server_default=""),
sa.Column("alternatives_json", sa.Text(), nullable=True),
sa.Column("review_flags_json", sa.Text(), nullable=True),
sa.Column("context_json", sa.Text(), nullable=True),
sa.Column("raw_response_json", sa.Text(), nullable=True),
sa.Column("applied_to_source", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("skipped_reason", sa.Text(), nullable=True),
sa.Column("status", sa.String(30), nullable=False, server_default="completed"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("final_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
sa.Column("final_ledger_name", sa.String(255), nullable=False, server_default=""),
sa.Column("suggestion_accepted", sa.Boolean(), nullable=True),
sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("input_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("latency_ms", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
for name in (
"tenant_id", "client_id", "source_type", "source_record_id", "source_fingerprint",
"deterministic_nature_id", "ai_nature_id", "applied_to_source", "status",
"final_nature_id", "suggestion_accepted", "created_at_utc",
):
op.create_index(
f"ix_accounting_ai_semantic_decisions_{name}",
"accounting_ai_semantic_decisions",
[name],
)
def downgrade():
op.drop_table("accounting_ai_semantic_decisions")
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class AccountingAISemanticDecision(CommonBase):
__tablename__ = "accounting_ai_semantic_decisions"
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)
source_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
source_record_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
source_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True)
provider_name: Mapped[str] = mapped_column(String(60), nullable=False, default="")
model_name: Mapped[str] = mapped_column(String(120), nullable=False, default="")
prompt_version: Mapped[str] = mapped_column(String(40), nullable=False, default="phase12-v1")
deterministic_nature_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
)
deterministic_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ai_nature_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
)
ai_nature_code: Mapped[str] = mapped_column(String(80), nullable=False, default="")
ai_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
combined_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
capital_revenue: Mapped[str] = mapped_column(String(20), nullable=False, default="")
business_personal: Mapped[str] = mapped_column(String(20), nullable=False, default="business")
concise_reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
alternatives_json: Mapped[str | None] = mapped_column(Text, nullable=True)
review_flags_json: Mapped[str | None] = mapped_column(Text, nullable=True)
context_json: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
applied_to_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
skipped_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="completed", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
final_nature_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
)
final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
suggestion_accepted: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
reviewed_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True
)
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any
class AccountingAIProviderError(RuntimeError):
pass
@dataclass
class ProviderResult:
provider: str
model: str
data: dict[str, Any]
raw: dict[str, Any]
input_tokens: int = 0
output_tokens: int = 0
latency_ms: int = 0
class AccountingSemanticProvider:
provider_name = "base"
def is_configured(self) -> bool:
raise NotImplementedError
def model_name(self) -> str:
raise NotImplementedError
def classify(self, *, system_text: str, user_payload: dict[str, Any], schema: dict[str, Any]) -> ProviderResult:
raise NotImplementedError
class OpenAIResponsesProvider(AccountingSemanticProvider):
provider_name = "openai"
def __init__(self):
self.api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
self.model = (os.getenv("ACCOUNTING_AI_MODEL") or "gpt-5-mini").strip()
self.base_url = (os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
self.timeout = max(10, min(120, int(os.getenv("ACCOUNTING_AI_TIMEOUT_SECONDS", "45") or 45)))
def is_configured(self) -> bool:
return bool(self.api_key and self.model)
def model_name(self) -> str:
return self.model
@staticmethod
def _extract_output_text(response: dict[str, Any]) -> str:
value = response.get("output_text")
if isinstance(value, str) and value.strip():
return value.strip()
for item in response.get("output") or []:
if not isinstance(item, dict) or item.get("type") != "message":
continue
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
if content.get("type") in {"output_text", "text"}:
text = content.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
raise AccountingAIProviderError("AI response did not contain structured output text.")
def classify(self, *, system_text: str, user_payload: dict[str, Any], schema: dict[str, Any]) -> ProviderResult:
if not self.is_configured():
raise AccountingAIProviderError(
"Server AI is not configured. Set OPENAI_API_KEY and ACCOUNTING_AI_MODEL on the ERP server."
)
body = {
"model": self.model,
"store": False,
"input": [
{
"role": "system",
"content": [{"type": "input_text", "text": system_text}],
},
{
"role": "user",
"content": [{"type": "input_text", "text": json.dumps(user_payload, ensure_ascii=False)}],
},
],
"text": {
"format": {
"type": "json_schema",
"name": "accounting_semantic_classification",
"strict": True,
"schema": schema,
}
},
}
request = urllib.request.Request(
self.base_url + "/responses",
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"User-Agent": "ARRR-ERP-Accounting-AI/12",
},
)
started = time.monotonic()
last_error = None
for attempt in range(2):
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = json.loads(response.read().decode("utf-8"))
text = self._extract_output_text(raw)
data = json.loads(text)
usage = raw.get("usage") or {}
return ProviderResult(
provider=self.provider_name,
model=str(raw.get("model") or self.model),
data=data,
raw=raw,
input_tokens=int(usage.get("input_tokens") or 0),
output_tokens=int(usage.get("output_tokens") or 0),
latency_ms=int((time.monotonic() - started) * 1000),
)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:2000]
last_error = AccountingAIProviderError(
f"AI provider HTTP {exc.code}: {detail or exc.reason}"
)
if exc.code not in {429, 500, 502, 503, 504} or attempt:
raise last_error
time.sleep(1.0)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
last_error = AccountingAIProviderError(f"AI provider request failed: {exc}")
if attempt:
raise last_error
time.sleep(1.0)
raise last_error or AccountingAIProviderError("AI provider request failed.")
def get_accounting_semantic_provider() -> AccountingSemanticProvider:
provider = (os.getenv("ACCOUNTING_AI_PROVIDER") or "openai").strip().lower()
if provider == "openai":
return OpenAIResponsesProvider()
raise AccountingAIProviderError(
f"Unsupported ACCOUNTING_AI_PROVIDER '{provider}'. Current Phase 12 provider: openai."
)
+460
View File
@@ -0,0 +1,460 @@
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from app.modules.accounting.ai_models import AccountingAISemanticDecision
from app.modules.accounting.ai_provider import AccountingAIProviderError, get_accounting_semantic_provider
from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase
from app.modules.accounting.taxonomy_models import AccountingNature
from app.modules.clients.models import Client, ClientBusinessProfile
PROMPT_VERSION = "phase12-v1"
DEFAULT_AI_GATE = 90
def _utcnow():
return datetime.now(timezone.utc)
def _s(value) -> str:
return str(value or "").strip()
def _i(value, default=0) -> int:
try:
return int(round(float(value)))
except Exception:
return int(default)
def ai_gate() -> int:
return max(50, min(99, _i(os.getenv("ACCOUNTING_AI_GATE_CONFIDENCE", DEFAULT_AI_GATE), DEFAULT_AI_GATE)))
def provider_status() -> dict[str, Any]:
try:
provider = get_accounting_semantic_provider()
return {
"provider": provider.provider_name,
"model": provider.model_name(),
"configured": bool(provider.is_configured()),
"gate": ai_gate(),
}
except Exception as exc:
return {"provider": "", "model": "", "configured": False, "gate": ai_gate(), "error": str(exc)}
def _profile_context(db, client_id: int) -> dict[str, Any]:
client = db.get(Client, int(client_id))
profile = db.execute(
select(ClientBusinessProfile).where(ClientBusinessProfile.client_id == int(client_id))
).scalar_one_or_none()
data = {
"client_name": _s(getattr(client, "client_name", "")),
"trade_name": _s(getattr(client, "trade_name", "")),
"primary_industry": "",
"primary_business_activity": "",
"secondary_business_activities": "",
"business_model": "",
"main_products": "",
"main_services": "",
"inventory_maintained": None,
"project_job_based": None,
"capital_intensive": None,
"vehicle_intensive": None,
"profile_status": "",
"profile_confidence": 0,
}
if profile:
for key in (
"primary_industry", "primary_business_activity", "secondary_business_activities",
"business_model", "main_products", "main_services", "inventory_maintained",
"project_job_based", "capital_intensive", "vehicle_intensive", "profile_status",
):
data[key] = getattr(profile, key, None)
data["profile_confidence"] = int(getattr(profile, "confidence_score", 0) or 0)
return data
def _taxonomy(db, tenant_id: int) -> list[AccountingNature]:
return list(db.execute(
select(AccountingNature).where(
AccountingNature.tenant_id == int(tenant_id),
AccountingNature.is_active.is_(True),
AccountingNature.is_posting_nature.is_(True),
).order_by(AccountingNature.sort_order, AccountingNature.name)
).scalars().all())
def _taxonomy_payload(rows: list[AccountingNature]) -> list[dict[str, Any]]:
return [{
"code": row.code,
"name": row.name,
"group": row.classification_group,
"capital_revenue": row.capital_revenue,
"description": _s(row.description)[:500],
} for row in rows]
def _schema(codes: list[str]) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"nature_code": {"type": "string", "enum": codes},
"confidence": {"type": "integer", "minimum": 0, "maximum": 100},
"capital_revenue": {"type": "string", "enum": ["capital", "revenue", "uncertain"]},
"business_personal": {"type": "string", "enum": ["business", "personal", "uncertain"]},
"reason": {"type": "string", "maxLength": 500},
"alternatives": {
"type": "array",
"maxItems": 3,
"items": {
"type": "object",
"properties": {
"nature_code": {"type": "string", "enum": codes},
"confidence": {"type": "integer", "minimum": 0, "maximum": 100},
},
"required": ["nature_code", "confidence"],
"additionalProperties": False,
},
},
"review_flags": {
"type": "array",
"maxItems": 5,
"items": {"type": "string", "maxLength": 120},
},
},
"required": [
"nature_code", "confidence", "capital_revenue", "business_personal",
"reason", "alternatives", "review_flags",
],
"additionalProperties": False,
}
SYSTEM_TEXT = """You classify accounting transactions for an Indian accounting/audit ERP.
You are a semantic fallback layer, not the primary rules engine.
Choose exactly one accounting nature from ALLOWED_TAXONOMY. Never invent a nature or ledger.
Use the client business profile, supplier/party information, HSN/description, source type,
amount and deterministic evidence. Distinguish revenue expense from capital acquisition
conservatively. Flag uncertainty, possible personal expenditure, mixed-purpose transactions,
or insufficient evidence. Return only the required structured object. Do not create ledgers,
do not decide GST eligibility, and do not authorize posting to Tally."""
def _combined_confidence(*, ai_conf: int, deterministic_conf: int, profile_conf: int, agreement: bool) -> int:
ai_conf = max(0, min(100, int(ai_conf)))
deterministic_conf = max(0, min(100, int(deterministic_conf)))
profile_conf = max(0, min(100, int(profile_conf)))
value = (0.70 * ai_conf) + (0.20 * profile_conf) + (0.10 * deterministic_conf)
if agreement and deterministic_conf > 0:
value += 5
return max(0, min(99, int(round(value))))
def _source_fingerprint(source_type: str, source_record_id: int, payload: dict[str, Any]) -> str:
raw = f"{source_type}|{source_record_id}|{json.dumps(payload, sort_keys=True, ensure_ascii=False)}"
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()
def latest_decision(db, *, tenant_id: int, source_type: str, source_record_id: int):
return db.execute(
select(AccountingAISemanticDecision).where(
AccountingAISemanticDecision.tenant_id == int(tenant_id),
AccountingAISemanticDecision.source_type == source_type,
AccountingAISemanticDecision.source_record_id == int(source_record_id),
).order_by(AccountingAISemanticDecision.id.desc()).limit(1)
).scalar_one_or_none()
def _run(
db, *,
tenant_id: int,
client_id: int,
source_type: str,
source_record_id: int,
deterministic_nature_id: int | None,
deterministic_confidence: int,
transaction: dict[str, Any],
created_by_user_id: int,
):
gate = ai_gate()
if int(deterministic_confidence or 0) >= gate:
raise ValueError(
f"AI is not required because deterministic confidence is already "
f"{int(deterministic_confidence or 0)}% (AI gate {gate}%)."
)
natures = _taxonomy(db, tenant_id)
if not natures:
raise ValueError("Accounting taxonomy is empty. Configure Phase 4 taxonomy first.")
profile = _profile_context(db, client_id)
nature_by_code = {row.code: row for row in natures}
nature_by_id = {row.id: row for row in natures}
deterministic = nature_by_id.get(int(deterministic_nature_id)) if deterministic_nature_id else None
payload = {
"source_type": source_type,
"client_business_profile": profile,
"transaction": transaction,
"deterministic_evidence": {
"nature_code": deterministic.code if deterministic else "",
"nature_name": deterministic.name if deterministic else "",
"confidence": int(deterministic_confidence or 0),
},
"allowed_taxonomy": _taxonomy_payload(natures),
}
provider = get_accounting_semantic_provider()
decision = AccountingAISemanticDecision(
tenant_id=tenant_id,
client_id=client_id,
source_type=source_type,
source_record_id=source_record_id,
source_fingerprint=_source_fingerprint(source_type, source_record_id, payload),
provider_name=provider.provider_name,
model_name=provider.model_name(),
prompt_version=PROMPT_VERSION,
deterministic_nature_id=deterministic_nature_id,
deterministic_confidence=int(deterministic_confidence or 0),
context_json=json.dumps(payload, ensure_ascii=False),
status="running",
created_by_user_id=created_by_user_id,
)
db.add(decision)
db.commit()
db.refresh(decision)
try:
result = provider.classify(
system_text=SYSTEM_TEXT,
user_payload=payload,
schema=_schema(list(nature_by_code)),
)
data = result.data
nature = nature_by_code.get(_s(data.get("nature_code")))
if not nature:
raise AccountingAIProviderError("AI returned a nature outside the active ERP taxonomy.")
ai_conf = max(0, min(100, _i(data.get("confidence"))))
agreement = bool(deterministic and deterministic.id == nature.id)
combined = _combined_confidence(
ai_conf=ai_conf,
deterministic_conf=int(deterministic_confidence or 0),
profile_conf=int(profile.get("profile_confidence") or 0),
agreement=agreement,
)
decision.provider_name = result.provider
decision.model_name = result.model
decision.ai_nature_id = nature.id
decision.ai_nature_code = nature.code
decision.ai_confidence = ai_conf
decision.combined_confidence = combined
decision.capital_revenue = _s(data.get("capital_revenue"))
decision.business_personal = _s(data.get("business_personal")) or "business"
decision.concise_reason = _s(data.get("reason"))[:1000]
decision.alternatives_json = json.dumps(data.get("alternatives") or [], ensure_ascii=False)
decision.review_flags_json = json.dumps(data.get("review_flags") or [], ensure_ascii=False)
decision.raw_response_json = json.dumps(result.raw, ensure_ascii=False)[:20000]
decision.input_tokens = int(result.input_tokens or 0)
decision.output_tokens = int(result.output_tokens or 0)
decision.latency_ms = int(result.latency_ms or 0)
decision.status = "completed"
db.add(decision)
db.commit()
db.refresh(decision)
return decision, nature
except Exception as exc:
decision.status = "failed"
decision.error_message = str(exc)[:4000]
db.add(decision)
db.commit()
raise
def ai_assist_purchase(db, *, row: AccountingGSTR2BPurchase, user_id: int):
if row.review_status == "reviewed":
raise ValueError("This purchase is already reviewed.")
transaction = {
"supplier_name": row.supplier_name,
"supplier_gstin": row.supplier_gstin,
"invoice_number": row.invoice_number,
"invoice_date": row.invoice_date,
"document_type": row.document_type,
"invoice_type": row.invoice_type,
"hsn_code": row.hsn_code,
"description": row.description_text or "",
"taxable_value": row.taxable_value,
"invoice_value": row.invoice_value,
"place_of_supply": row.place_of_supply,
"reverse_charge": row.reverse_charge,
"itc_availability": row.itc_availability,
}
decision, nature = _run(
db,
tenant_id=row.tenant_id,
client_id=row.client_id,
source_type="gstr2b",
source_record_id=row.id,
deterministic_nature_id=row.suggested_nature_id,
deterministic_confidence=row.suggested_confidence,
transaction=transaction,
created_by_user_id=user_id,
)
# AI may strengthen/change the accounting nature, but it never invents a ledger.
# Preserve any existing mapped ledger only when the nature did not change.
if row.suggested_nature_id != nature.id:
row.suggested_ledger_name = ""
row.suggested_nature_id = nature.id
row.suggested_confidence = decision.combined_confidence
existing = []
try:
existing = json.loads(row.suggestion_explanation_json or "[]")
except Exception:
pass
existing.append(
f"AI semantic fallback ({decision.model_name}): {decision.concise_reason} "
f"Combined confidence {decision.combined_confidence}%."
)
row.suggestion_explanation_json = json.dumps(existing[-8:], ensure_ascii=False)
row.review_status = "suggested" if decision.combined_confidence >= 60 else "review_required"
decision.applied_to_source = True
db.add(row)
db.add(decision)
db.commit()
return decision
def ai_assist_bank(db, *, tx, user_id: int):
if tx.review_status == "reviewed":
raise ValueError("This bank transaction is already reviewed.")
if _s(getattr(tx, "contra_pair_id", "")):
raise ValueError("Matched inter-bank contra does not require AI classification.")
transaction = {
"bank_name": tx.bank_name,
"account_number": tx.account_number,
"transaction_date": tx.transaction_date,
"direction": tx.direction,
"debit": tx.debit,
"credit": tx.credit,
"amount": tx.amount,
"narration": tx.narration,
"reference_no": tx.reference_no,
"transfer_reference": tx.transfer_reference,
"detected_party": tx.auto_party,
"analyzer_category": tx.analyzer_category,
"analyzer_nature": tx.analyzer_nature,
}
decision, nature = _run(
db,
tenant_id=tx.tenant_id,
client_id=tx.client_id,
source_type="bank",
source_record_id=tx.id,
deterministic_nature_id=tx.suggested_nature_id,
deterministic_confidence=tx.suggested_confidence,
transaction=transaction,
created_by_user_id=user_id,
)
if tx.suggested_nature_id != nature.id:
tx.suggested_ledger_name = ""
tx.suggested_nature_id = nature.id
tx.suggested_confidence = decision.combined_confidence
reasons = []
try:
reasons = json.loads(tx.suggestion_reason_json or "[]")
except Exception:
pass
reasons.append(
f"AI semantic fallback ({decision.model_name}): {decision.concise_reason} "
f"Combined confidence {decision.combined_confidence}%."
)
tx.suggestion_reason_json = json.dumps(reasons[-8:], ensure_ascii=False)
decision.applied_to_source = True
db.add(tx)
db.add(decision)
db.commit()
return decision
def mark_review_outcome(
db, *,
tenant_id: int,
source_type: str,
source_record_id: int,
final_nature_id: int | None,
final_ledger_name: str,
user_id: int,
):
decision = latest_decision(
db,
tenant_id=tenant_id,
source_type=source_type,
source_record_id=source_record_id,
)
if not decision or decision.status != "completed":
return None
decision.final_nature_id = final_nature_id
decision.final_ledger_name = _s(final_ledger_name)
decision.suggestion_accepted = bool(
final_nature_id and decision.ai_nature_id and int(final_nature_id) == int(decision.ai_nature_id)
)
decision.reviewed_by_user_id = user_id
decision.reviewed_at_utc = _utcnow()
db.add(decision)
db.commit()
return decision
def usage_summary(db, *, tenant_id: int, client_id: int | None = None):
where = [AccountingAISemanticDecision.tenant_id == int(tenant_id)]
if client_id:
where.append(AccountingAISemanticDecision.client_id == int(client_id))
total = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where(*where)) or 0)
completed = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where(
*where, AccountingAISemanticDecision.status == "completed"
)) or 0)
accepted = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where(
*where, AccountingAISemanticDecision.suggestion_accepted.is_(True)
)) or 0)
corrected = int(db.scalar(select(func.count(AccountingAISemanticDecision.id)).where(
*where, AccountingAISemanticDecision.suggestion_accepted.is_(False)
)) or 0)
input_tokens = int(db.scalar(select(func.coalesce(func.sum(AccountingAISemanticDecision.input_tokens), 0)).where(*where)) or 0)
output_tokens = int(db.scalar(select(func.coalesce(func.sum(AccountingAISemanticDecision.output_tokens), 0)).where(*where)) or 0)
return {
"total": total,
"completed": completed,
"accepted": accepted,
"corrected": corrected,
"reviewed": accepted + corrected,
"acceptance_rate": round((accepted * 100 / (accepted + corrected)), 1) if (accepted + corrected) else 0,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}
def recent_decisions(db, *, tenant_id: int, client_id: int | None = None, limit: int = 100):
stmt = select(AccountingAISemanticDecision).where(
AccountingAISemanticDecision.tenant_id == int(tenant_id)
)
if client_id:
stmt = stmt.where(AccountingAISemanticDecision.client_id == int(client_id))
return list(db.execute(
stmt.order_by(AccountingAISemanticDecision.id.desc()).limit(max(1, min(500, int(limit))))
).scalars().all())
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token
from app.core.templating import templates
from app.modules.accounting.ai_service import provider_status, recent_decisions, usage_summary
from app.modules.accounting.ui import _require_partner, _visible_clients
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
router = APIRouter(prefix="/tools/accounting/ai", tags=["accounting-ai-ui"])
@router.get("")
def page(request: Request, client_id: int | None = None):
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.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,
)
summary = usage_summary(
db,
tenant_id=scope.tenant_id,
client_id=selected.id if selected else None,
)
rows = recent_decisions(
db,
tenant_id=scope.tenant_id,
client_id=selected.id if selected else None,
limit=100,
)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/ai_dashboard.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": "Accounting AI",
"clients": clients,
"selected_client": selected,
"provider_status": provider_status(),
"summary": summary,
"decisions": rows,
},
)
finally:
db.close()
+18
View File
@@ -9,6 +9,7 @@ import pandas as pd
from sqlalchemy import func, select
from app.modules.accounting.bank_models import AccountingBankTransaction
from app.modules.accounting.ai_service import ai_assist_bank as run_ai_assist_bank, mark_review_outcome
from app.modules.accounting.ledger_learning_service import (
active_natures,
available_tally_guids,
@@ -239,9 +240,26 @@ def confirm_review(db, *, tx_id, tenant_id, client_id, nature_id, ledger_name, v
db.add(tx)
db.commit()
mark_review_outcome(
db,
tenant_id=tenant_id,
source_type="bank",
source_record_id=tx.id,
final_nature_id=tx.final_nature_id,
final_ledger_name=tx.final_ledger_name,
user_id=user_id,
)
return tx
def ai_assist_transaction(db, *, tx_id, tenant_id, client_id, user_id):
tx = db.get(AccountingBankTransaction, int(tx_id))
if not tx or int(tx.tenant_id) != int(tenant_id) or int(tx.client_id) != int(client_id):
raise ValueError("Bank transaction was not found.")
return run_ai_assist_bank(db, tx=tx, user_id=user_id)
def visible_workstations(db, tenant_id, branch_id=None):
stmt = select(ERPWorkstationAgent).where(
ERPWorkstationAgent.tenant_id == tenant_id,
+35 -1
View File
@@ -9,7 +9,7 @@ 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.bank_service import (
confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight,
ai_assist_transaction, confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight,
queue_rows, sync_posting, visible_workstations,
)
from app.modules.accounting.ledger_learning_service import active_natures
@@ -121,3 +121,37 @@ def post(request: Request, tx_id: int, client_id: int = Form(...), bank_ledger_n
except Exception as exc:
db.rollback(); return _go(client_id, error=str(exc))
finally: db.close()
@router.post("/{tx_id}/ai-assist")
def ai_assist(
request: Request,
tx_id: int,
client_id: int = 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, _, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not visible.")
decision = ai_assist_transaction(
db,
tx_id=tx_id,
tenant_id=scope.tenant_id,
client_id=client.id,
user_id=user.id,
)
return _go(
client.id,
message=f"AI semantic fallback completed at {decision.combined_confidence}% combined confidence.",
)
except Exception as exc:
db.rollback()
return _go(client_id, error=str(exc))
finally:
db.close()
@@ -9,6 +9,7 @@ from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_service import nature_lookup, review_purchase
from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings
from app.modules.accounting.purchase_enrichment_models import AccountingPurchaseEnrichmentRecord
from app.modules.accounting.ai_service import ai_assist_purchase as run_ai_assist_purchase, mark_review_outcome
VALID_STATUSES = {"all", "pending_analysis", "suggested", "review_required", "reviewed"}
VALID_CONFIDENCE = {"all", "high", "medium", "low"}
@@ -133,7 +134,17 @@ def review_one(db, *, tenant_id, client_id, purchase_id, final_nature_id, final_
row=db.execute(select(AccountingGSTR2BPurchase).where(AccountingGSTR2BPurchase.id==purchase_id,AccountingGSTR2BPurchase.tenant_id==tenant_id,AccountingGSTR2BPurchase.client_id==client_id)).scalar_one_or_none()
if not row: raise ValueError("Purchase record was not found.")
_validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=final_nature_id,ledger_name=final_ledger_name)
return review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id)
reviewed = review_purchase(db,row=row,final_nature_id=int(final_nature_id),final_ledger_name=(final_ledger_name or "").strip(),user_id=user_id)
mark_review_outcome(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
final_ledger_name=reviewed.final_ledger_name,
user_id=user_id,
)
return reviewed
def bulk_confirm(db, *, tenant_id, client_id, purchase_ids, user_id):
@@ -148,6 +159,26 @@ def bulk_confirm(db, *, tenant_id, client_id, purchase_ids, user_id):
if row.review_status=="reviewed": continue
if not row.suggested_nature_id or int(row.suggested_confidence or 0)<85: raise ValueError(f"Invoice {row.invoice_number} is not eligible for high-confidence bulk confirmation.")
_validate_choice(db,tenant_id=tenant_id,client_id=client_id,purchase=row,nature_id=row.suggested_nature_id,ledger_name=row.suggested_ledger_name or "")
review_purchase(db,row=row,final_nature_id=int(row.suggested_nature_id),final_ledger_name=row.suggested_ledger_name or "",user_id=user_id)
reviewed = review_purchase(db,row=row,final_nature_id=int(row.suggested_nature_id),final_ledger_name=row.suggested_ledger_name or "",user_id=user_id)
mark_review_outcome(
db,
tenant_id=tenant_id,
source_type="gstr2b",
source_record_id=reviewed.id,
final_nature_id=reviewed.final_nature_id,
final_ledger_name=reviewed.final_ledger_name,
user_id=user_id,
)
completed+=1
return completed
def ai_assist_one(db, *, tenant_id, client_id, purchase_id, user_id):
row = db.execute(select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.id == int(purchase_id),
AccountingGSTR2BPurchase.tenant_id == int(tenant_id),
AccountingGSTR2BPurchase.client_id == int(client_id),
)).scalar_one_or_none()
if not row:
raise ValueError("Purchase record was not found.")
return run_ai_assist_purchase(db, row=row, user_id=user_id)
+50 -1
View File
@@ -11,7 +11,7 @@ from app.modules.accounting.historical_learning_service import active_natures
from app.modules.accounting.ledger_learning_service import available_tally_guids
from app.modules.accounting.purchase_review_service import (
review_queue, review_counts, return_periods, nature_maps_for_rows,
explanations_for_rows, mappings_by_nature, review_one, bulk_confirm,
explanations_for_rows, mappings_by_nature, review_one, bulk_confirm, ai_assist_one,
)
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
@@ -90,3 +90,52 @@ async def bulk_confirm_rows(request:Request):
except Exception as exc:
db.rollback(); return _redirect(client_id,filters,error=str(exc))
finally:db.close()
@router.post("/purchase/{purchase_id}/ai-assist")
def ai_assist_purchase_row(
request: Request,
purchase_id: int,
client_id: int = Form(...),
tally_guid: str = Form(""),
status: str = Form("all"),
confidence: str = Form("all"),
source: str = Form("all"),
supplier: str = Form(""),
return_period: str = Form(""),
page: int = Form(1),
per_page: int = Form(25),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
filters = {
"tally_guid": tally_guid, "status": status, "confidence": confidence,
"source": source, "supplier": supplier, "return_period": return_period,
"page": page, "per_page": per_page,
}
try:
user, response = _require_partner(request, db, "accounting.learning.manage")
if response:
return response
client, _, scope = _find_visible_client(db, request, user, client_id)
if not client:
from app.core.http_responses import ui_access_denied
return ui_access_denied()
decision = ai_assist_one(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
purchase_id=purchase_id,
user_id=user.id,
)
return _redirect(
client.id,
filters,
message=f"AI semantic fallback completed at {decision.combined_confidence}% combined confidence.",
)
except Exception as exc:
db.rollback()
return _redirect(client_id, filters, error=str(exc))
finally:
db.close()
@@ -0,0 +1,61 @@
{% 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 Intelligence · Phase 12</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Server AI Semantic Layer</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">AI is a fallback only. Confirmed mappings, historical evidence and deterministic rules remain primary. AI can select only an existing Accounting Nature; it cannot create ledgers or post to Tally.</p>
</div>
<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 Tally</a>
</div>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="grid gap-4 md:grid-cols-4">
<div><div class="text-xs uppercase text-slate-500">Provider</div><div class="mt-1 font-semibold">{{ provider_status.provider or 'Not configured' }}</div></div>
<div><div class="text-xs uppercase text-slate-500">Model</div><div class="mt-1 font-semibold">{{ provider_status.model or '—' }}</div></div>
<div><div class="text-xs uppercase text-slate-500">AI Gate</div><div class="mt-1 font-semibold">&lt; {{ provider_status.gate }}% deterministic confidence</div></div>
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="mt-1 font-semibold {% if provider_status.configured %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ 'Configured' if provider_status.configured else 'Disabled until server key/model are configured' }}</div></div>
</div>
<p class="mt-3 text-xs text-slate-500">Server environment: ACCOUNTING_AI_PROVIDER, ACCOUNTING_AI_MODEL, OPENAI_API_KEY. API keys are never displayed in the ERP UI or sent to Local Agents.</p>
</section>
<form method="get" class="rounded-2xl bg-white p-5 shadow-soft">
<label class="text-sm font-semibold">Client
<select name="client_id" class="mt-1 w-full max-w-xl rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">All visible clients</option>
{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and selected_client.id == c.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}
</select>
</label>
<button class="mt-3 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Apply</button>
</form>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-6">
{% for label,value in [('AI Calls',summary.total),('Completed',summary.completed),('Reviewed',summary.reviewed),('Accepted',summary.accepted),('Corrected',summary.corrected),('Acceptance %',summary.acceptance_rate)] %}
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold">{{ value }}</div></div>
{% endfor %}
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold">Recent AI decisions</h2><p class="text-sm text-slate-500">Input tokens {{ summary.input_tokens }} · Output tokens {{ summary.output_tokens }}</p></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-3">When</th><th class="px-3 py-3">Source</th><th class="px-3 py-3">Model</th><th class="px-3 py-3">AI</th><th class="px-3 py-3">Confidence</th><th class="px-3 py-3">Review</th><th class="px-3 py-3">Reason</th></tr></thead>
<tbody>
{% for d in decisions %}
<tr class="border-b border-slate-100 align-top">
<td class="px-3 py-3 whitespace-nowrap">{{ d.created_at_utc }}</td>
<td class="px-3 py-3">{{ d.source_type }} #{{ d.source_record_id }}</td>
<td class="px-3 py-3">{{ d.model_name }}</td>
<td class="px-3 py-3">{{ d.ai_nature_code or '—' }}</td>
<td class="px-3 py-3">{{ d.ai_confidence }}% AI · {{ d.combined_confidence }}% combined</td>
<td class="px-3 py-3">{% if d.suggestion_accepted is sameas true %}<span class="text-emerald-700">Accepted</span>{% elif d.suggestion_accepted is sameas false %}<span class="text-amber-700">Corrected</span>{% elif d.status=='failed' %}<span class="text-red-700">Failed</span>{% else %}Pending{% endif %}</td>
<td class="px-3 py-3 max-w-xl">{{ d.concise_reason or d.error_message or '—' }}</td>
</tr>
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No AI decisions yet.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% endblock %}
@@ -29,6 +29,12 @@
<div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div>
<div class="xl:col-span-6">
{% if tx.review_status != 'reviewed' %}
{% if not tx.contra_pair_id and tx.suggested_confidence < 90 %}
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/ai-assist" class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="w-full rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">AI Semantic Assist</button>
</form>
{% endif %}
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/review" class="grid gap-2 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-xs font-semibold">Voucher Type<select name="voucher_type" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">{% for v in ['Payment','Receipt','Contra'] %}<option {% if tx.suggested_voucher_type==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>
@@ -34,7 +34,14 @@
<td class="px-3 py-4 text-right whitespace-nowrap"><div class="font-semibold">₹{{ '%.2f'|format(row.taxable_value) }}</div><div class="text-xs text-slate-500">Invoice ₹{{ '%.2f'|format(row.invoice_value) }}</div></td>
<td class="px-3 py-4 min-w-[250px]">{% if suggested %}<div class="flex gap-2"><span class="font-semibold">{{ suggested.name }}</span><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.suggested_confidence>=85 %}bg-emerald-50 text-emerald-700{% elif row.suggested_confidence>=60 %}bg-blue-50 text-blue-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ row.suggested_confidence }}%</span></div><div class="text-sm text-slate-600">{{ row.suggested_ledger_name or 'No mapped Tally ledger' }}</div>{% if explanations.get(row.id) %}<details class="mt-2"><summary class="cursor-pointer text-xs font-semibold text-brand-600">Why?</summary><ul class="mt-1 text-xs text-slate-500">{% for x in explanations.get(row.id)[:5] %}<li>• {{ x }}</li>{% endfor %}</ul></details>{% endif %}{% else %}<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Review required</span>{% endif %}</td>
<td class="px-3 py-4 min-w-[220px]">{% if row.review_status=='reviewed' %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"><div class="text-xs font-semibold uppercase text-emerald-700">Reviewed</div><div class="font-semibold">{{ final.name if final else '-' }}</div><div class="text-sm text-slate-600">{{ row.final_ledger_name or 'No ledger' }}</div></div>{% else %}<span class="text-xs text-slate-500">Awaiting review</span>{% endif %}</td>
<td class="px-3 py-4 min-w-[330px]"><form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/review" class="space-y-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}<select name="final_nature_id" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm">{% for nature in natures %}<option value="{{ nature.id }}" {% if (final and final.id==nature.id) or (not final and suggested and suggested.id==nature.id) %}selected{% endif %}>{{ nature.name }}</option>{% endfor %}</select><select name="final_ledger_name" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm"><option value="">No mapped ledger yet</option>{% for nature_id, maps in mappings_by_nature.items() %}{% for m in maps %}<option value="{{ m.ledger_name }}" {% if row.final_ledger_name==m.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name==m.ledger_name) %}selected{% endif %}>{{ m.ledger_name }}</option>{% endfor %}{% endfor %}</select><button class="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ 'Update Review' if row.review_status=='reviewed' else 'Confirm / Correct' }}</button></form></td></tr>
<td class="px-3 py-4 min-w-[330px]">
{% if row.review_status!='reviewed' and row.suggested_confidence < 90 %}
<form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/ai-assist" class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}
<button class="w-full rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">AI Semantic Assist</button>
</form>
{% endif %}
<form method="post" action="/tools/accounting/purchase-review/purchase/{{ row.id }}/review" class="space-y-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">{% for k in ['tally_guid','status','confidence','source','supplier','return_period','page','per_page'] %}<input type="hidden" name="{{ k }}" value="{{ filters[k] }}">{% endfor %}<select name="final_nature_id" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm">{% for nature in natures %}<option value="{{ nature.id }}" {% if (final and final.id==nature.id) or (not final and suggested and suggested.id==nature.id) %}selected{% endif %}>{{ nature.name }}</option>{% endfor %}</select><select name="final_ledger_name" class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm"><option value="">No mapped ledger yet</option>{% for nature_id, maps in mappings_by_nature.items() %}{% for m in maps %}<option value="{{ m.ledger_name }}" {% if row.final_ledger_name==m.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name==m.ledger_name) %}selected{% endif %}>{{ m.ledger_name }}</option>{% endfor %}{% endfor %}</select><button class="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">{{ 'Update Review' if row.review_status=='reviewed' else 'Confirm / Correct' }}</button></form></td></tr>
{% else %}<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No purchases match the selected filters.</td></tr>{% endfor %}</tbody></table></div>
{% if result.pages>1 %}<div class="flex items-center justify-between border-t border-slate-200 px-5 py-4"><span class="text-sm text-slate-500">Page {{ result.page }} of {{ result.pages }}</span><div class="flex gap-2">{% if prev_url %}<a href="{{ prev_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Previous</a>{% endif %}{% if next_url %}<a href="{{ next_url }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Next</a>{% endif %}</div></div>{% endif %}</section>
</form>
@@ -21,6 +21,7 @@
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Accounting AI</a>
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
</div>
+2
View File
@@ -45,6 +45,7 @@ from app.modules.accounting.purchase_enrichment_ui import router as accounting_p
from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router
from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router
from app.modules.accounting.bank_ui import router as accounting_bank_ui_router
from app.modules.accounting.ai_ui import router as accounting_ai_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
from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -76,6 +77,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_purchase_review_ui_router)
app.include_router(accounting_purchase_posting_ui_router)
app.include_router(accounting_bank_ui_router)
app.include_router(accounting_ai_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)