Add Phase 7 GSTR-2B purchase intelligence

This commit is contained in:
A R R R Associates
2026-08-22 14:31:24 +05:30
parent 7e1b9c24ef
commit 7283f45f43
8 changed files with 1199 additions and 0 deletions
@@ -0,0 +1,99 @@
"""Phase 7 GSTR-2B purchase intelligence.
Revision ID: 20260822_gstr2b_purchase_p7
Revises: 20260822_ledger_learning_p6
"""
from alembic import op
import sqlalchemy as sa
revision = "20260822_gstr2b_purchase_p7"
down_revision = "20260822_ledger_learning_p6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"accounting_gstr2b_import_batches",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""),
sa.Column("original_filename", sa.String(260), nullable=False, server_default=""),
sa.Column("file_sha256", sa.String(64), nullable=False),
sa.Column("return_period", sa.String(20), nullable=False, server_default=""),
sa.Column("source_kind", sa.String(30), nullable=False, server_default="gstr2b_upload"),
sa.Column("status", sa.String(30), nullable=False, server_default="imported"),
sa.Column("rows_read", sa.Integer(), nullable=False, server_default="0"),
sa.Column("rows_imported", sa.Integer(), nullable=False, server_default="0"),
sa.Column("rows_skipped_duplicate", sa.Integer(), nullable=False, server_default="0"),
sa.Column("rows_skipped_invalid", sa.Integer(), nullable=False, server_default="0"),
sa.Column("analyzed_rows", sa.Integer(), nullable=False, server_default="0"),
sa.Column("reviewed_rows", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("imported_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("analyzed_at_utc", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
)
for col in ("tenant_id", "client_id", "tally_guid", "file_sha256", "return_period", "source_kind", "status", "created_at_utc"):
op.create_index(f"ix_accounting_gstr2b_import_batches_{col}", "accounting_gstr2b_import_batches", [col])
op.create_table(
"accounting_gstr2b_purchases",
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("batch_id", sa.Integer(), sa.ForeignKey("accounting_gstr2b_import_batches.id", ondelete="CASCADE"), nullable=False),
sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""),
sa.Column("return_period", sa.String(20), nullable=False, server_default=""),
sa.Column("supplier_gstin", sa.String(20), nullable=False, server_default=""),
sa.Column("supplier_name", sa.String(260), nullable=False, server_default=""),
sa.Column("invoice_number", sa.String(160), nullable=False, server_default=""),
sa.Column("invoice_date", sa.String(20), nullable=False, server_default=""),
sa.Column("document_type", sa.String(40), nullable=False, server_default="invoice"),
sa.Column("invoice_type", sa.String(80), nullable=False, server_default=""),
sa.Column("place_of_supply", sa.String(120), nullable=False, server_default=""),
sa.Column("reverse_charge", sa.String(20), nullable=False, server_default=""),
sa.Column("itc_availability", sa.String(80), nullable=False, server_default=""),
sa.Column("taxable_value", sa.Float(), nullable=False, server_default="0"),
sa.Column("igst", sa.Float(), nullable=False, server_default="0"),
sa.Column("cgst", sa.Float(), nullable=False, server_default="0"),
sa.Column("sgst", sa.Float(), nullable=False, server_default="0"),
sa.Column("cess", sa.Float(), nullable=False, server_default="0"),
sa.Column("invoice_value", sa.Float(), nullable=False, server_default="0"),
sa.Column("hsn_code", sa.String(20), nullable=False, server_default=""),
sa.Column("description_text", sa.Text(), nullable=True),
sa.Column("source_sheet", sa.String(160), nullable=False, server_default=""),
sa.Column("source_row_number", sa.Integer(), nullable=False, server_default="0"),
sa.Column("source_row_hash", sa.String(64), nullable=False, server_default=""),
sa.Column("review_status", sa.String(30), nullable=False, server_default="pending_analysis"),
sa.Column("suggested_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True),
sa.Column("suggested_ledger_name", sa.String(240), nullable=False, server_default=""),
sa.Column("suggested_confidence", sa.Integer(), nullable=False, server_default="0"),
sa.Column("suggestion_explanation_json", 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(240), nullable=False, server_default=""),
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("posting_status", sa.String(30), nullable=False, server_default="not_enabled"),
sa.Column("is_duplicate_source", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint(
"tenant_id", "client_id", "supplier_gstin", "invoice_number", "invoice_date", "document_type",
name="uq_accounting_gstr2b_purchase_document",
),
)
for col in (
"tenant_id", "client_id", "batch_id", "tally_guid", "return_period", "supplier_gstin",
"supplier_name", "invoice_number", "invoice_date", "document_type", "hsn_code",
"source_row_hash", "review_status", "suggested_nature_id", "final_nature_id",
"posting_status", "created_at_utc",
):
op.create_index(f"ix_accounting_gstr2b_purchases_{col}", "accounting_gstr2b_purchases", [col])
def downgrade():
op.drop_table("accounting_gstr2b_purchases")
op.drop_table("accounting_gstr2b_import_batches")
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class AccountingGSTR2BImportBatch(CommonBase):
__tablename__ = "accounting_gstr2b_import_batches"
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, default="", index=True)
original_filename: Mapped[str] = mapped_column(String(260), nullable=False, default="")
file_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
return_period: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
source_kind: Mapped[str] = mapped_column(String(30), nullable=False, default="gstr2b_upload", index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="imported", index=True)
rows_read: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rows_imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rows_skipped_duplicate: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
rows_skipped_invalid: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
analyzed_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reviewed_rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
imported_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), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
)
analyzed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AccountingGSTR2BPurchase(CommonBase):
__tablename__ = "accounting_gstr2b_purchases"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"client_id",
"supplier_gstin",
"invoice_number",
"invoice_date",
"document_type",
name="uq_accounting_gstr2b_purchase_document",
),
)
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)
batch_id: Mapped[int] = mapped_column(
ForeignKey("accounting_gstr2b_import_batches.id", ondelete="CASCADE"), nullable=False, index=True
)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True)
return_period: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
supplier_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
supplier_name: Mapped[str] = mapped_column(String(260), nullable=False, default="", index=True)
invoice_number: Mapped[str] = mapped_column(String(160), nullable=False, default="", index=True)
invoice_date: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
document_type: Mapped[str] = mapped_column(String(40), nullable=False, default="invoice", index=True)
invoice_type: Mapped[str] = mapped_column(String(80), nullable=False, default="")
place_of_supply: Mapped[str] = mapped_column(String(120), nullable=False, default="")
reverse_charge: Mapped[str] = mapped_column(String(20), nullable=False, default="")
itc_availability: Mapped[str] = mapped_column(String(80), nullable=False, default="")
taxable_value: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
igst: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
cgst: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
sgst: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
cess: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
invoice_value: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
hsn_code: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
description_text: Mapped[str | None] = mapped_column(Text, nullable=True)
source_sheet: Mapped[str] = mapped_column(String(160), nullable=False, default="")
source_row_number: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
source_row_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending_analysis", index=True)
suggested_nature_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True
)
suggested_ledger_name: Mapped[str] = mapped_column(String(240), nullable=False, default="")
suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
suggestion_explanation_json: 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(240), nullable=False, default="")
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)
posting_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_enabled", index=True)
is_duplicate_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False, index=True
)
updated_at_utc: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
+307
View File
@@ -0,0 +1,307 @@
from __future__ import annotations
import csv
import hashlib
import io
import re
from datetime import date, datetime
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
HEADER_ALIASES = {
"supplier_gstin": {
"gstin of supplier", "supplier gstin", "gstin", "gstin/uin of supplier",
"gstin / uin of supplier", "ctin",
},
"supplier_name": {
"trade/legal name", "trade legal name", "supplier name", "legal name",
"trade name", "name of supplier", "supplier trade name",
},
"invoice_number": {
"invoice number", "invoice no", "invoice no.", "document number",
"document no", "doc no", "inum",
},
"invoice_date": {
"invoice date", "document date", "doc date", "invoice dt", "idt",
},
"invoice_type": {
"invoice type", "document type", "type", "inv type",
},
"invoice_value": {
"invoice value", "document value", "invoice amount", "total invoice value",
"val",
},
"place_of_supply": {
"place of supply", "pos", "place of supply (state/ut)", "place of supply state/ut",
},
"reverse_charge": {
"supply attract reverse charge", "reverse charge", "rcm", "reverse charge applicable",
},
"taxable_value": {
"taxable value", "taxable amount", "txval",
},
"igst": {
"integrated tax", "integrated tax amount", "igst", "igst amount", "iamt",
},
"cgst": {
"central tax", "central tax amount", "cgst", "cgst amount", "camt",
},
"sgst": {
"state/ut tax", "state / ut tax", "state tax", "sgst", "utgst",
"state/ut tax amount", "sgst amount", "samt",
},
"cess": {
"cess", "cess amount", "csamt",
},
"itc_availability": {
"itc availability", "itc available", "itc available for the tax period",
"itc eligibility", "eligibility for itc",
},
"hsn_code": {
"hsn", "hsn code", "hsn/sac", "hsn / sac", "hsn/sac code",
},
"description_text": {
"description", "item description", "product description", "goods/service description",
"description of goods/services", "description of goods / services",
},
}
def _norm_header(value) -> str:
text = str(value or "").strip().lower()
text = text.replace("\n", " ").replace("\r", " ")
text = re.sub(r"[_\-]+", " ", text)
text = re.sub(r"[^\w/(). ]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
ALIAS_LOOKUP = {
alias: key
for key, aliases in HEADER_ALIASES.items()
for alias in aliases
}
def _canonical_header(value) -> str | None:
norm = _norm_header(value)
if norm in ALIAS_LOOKUP:
return ALIAS_LOOKUP[norm]
# Conservative contains matching only for longer aliases.
for alias, key in ALIAS_LOOKUP.items():
if len(alias) >= 10 and (norm.startswith(alias) or alias in norm):
return key
return None
def _text(value) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _money(value) -> float:
if value in (None, ""):
return 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "").replace("₹", "")
text = re.sub(r"^\((.*)\)$", r"-\1", text)
try:
return float(text)
except Exception:
return 0.0
def _date_text(value) -> str:
if value in (None, ""):
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = str(value).strip()
candidates = (
"%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%d", "%d-%b-%Y", "%d %b %Y",
"%d.%m.%Y", "%m/%d/%Y",
)
for fmt in candidates:
try:
return datetime.strptime(text, fmt).date().isoformat()
except Exception:
pass
# Preserve original if portal format is unfamiliar; duplicate identity remains stable.
return text[:20]
def _gstin(value) -> str:
return re.sub(r"[^A-Z0-9]", "", str(value or "").upper())[:15]
def _hsn(value) -> str:
text = re.sub(r"\D", "", str(value or ""))
return text[:8]
def _document_type(invoice_type: str, invoice_number: str) -> str:
text = f"{invoice_type} {invoice_number}".upper()
if "CREDIT" in text or "CR NOTE" in text or "CREDIT NOTE" in text:
return "credit_note"
if "DEBIT" in text or "DR NOTE" in text or "DEBIT NOTE" in text:
return "debit_note"
return "invoice"
def _row_hash(row: dict) -> str:
raw = "|".join([
row.get("supplier_gstin", ""),
row.get("supplier_name", ""),
row.get("invoice_number", ""),
row.get("invoice_date", ""),
row.get("document_type", ""),
f"{row.get('taxable_value', 0):.2f}",
f"{row.get('igst', 0):.2f}",
f"{row.get('cgst', 0):.2f}",
f"{row.get('sgst', 0):.2f}",
row.get("hsn_code", ""),
row.get("description_text", ""),
])
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()
def _find_header(rows: list[list], max_scan: int = 40):
best = None
for index, values in enumerate(rows[:max_scan]):
mapping = {}
for col, value in enumerate(values):
key = _canonical_header(value)
if key and key not in mapping:
mapping[key] = col
score = sum(1 for key in ("supplier_gstin", "invoice_number", "invoice_date", "taxable_value") if key in mapping)
if score >= 3 and ("supplier_gstin" in mapping or "supplier_name" in mapping):
if best is None or score > best[0]:
best = (score, index, mapping)
return best
def _normalized_record(values: list, mapping: dict[str, int], *, sheet: str, row_number: int):
def get(key):
idx = mapping.get(key)
return values[idx] if idx is not None and idx < len(values) else None
supplier_gstin = _gstin(get("supplier_gstin"))
supplier_name = _text(get("supplier_name"))
invoice_number = _text(get("invoice_number"))
invoice_date = _date_text(get("invoice_date"))
invoice_type = _text(get("invoice_type"))
if not supplier_gstin and not supplier_name:
return None
if not invoice_number or not invoice_date:
return None
row = {
"supplier_gstin": supplier_gstin,
"supplier_name": supplier_name,
"invoice_number": invoice_number[:160],
"invoice_date": invoice_date,
"document_type": _document_type(invoice_type, invoice_number),
"invoice_type": invoice_type[:80],
"invoice_value": _money(get("invoice_value")),
"place_of_supply": _text(get("place_of_supply"))[:120],
"reverse_charge": _text(get("reverse_charge"))[:20],
"taxable_value": _money(get("taxable_value")),
"igst": _money(get("igst")),
"cgst": _money(get("cgst")),
"sgst": _money(get("sgst")),
"cess": _money(get("cess")),
"itc_availability": _text(get("itc_availability"))[:80],
"hsn_code": _hsn(get("hsn_code")),
"description_text": _text(get("description_text"))[:4000],
"source_sheet": sheet[:160],
"source_row_number": int(row_number),
}
if not row["invoice_value"]:
row["invoice_value"] = (
row["taxable_value"] + row["igst"] + row["cgst"] + row["sgst"] + row["cess"]
)
row["source_row_hash"] = _row_hash(row)
return row
def _iter_xlsx(content: bytes):
workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
try:
for ws in workbook.worksheets:
# Summary sheets usually won't pass header detection.
rows = [list(row) for row in ws.iter_rows(values_only=True)]
if not rows:
continue
found = _find_header(rows)
if not found:
continue
_, header_index, mapping = found
for idx, values in enumerate(rows[header_index + 1:], start=header_index + 2):
record = _normalized_record(values, mapping, sheet=ws.title, row_number=idx)
if record:
yield record
finally:
workbook.close()
def _decode_csv(content: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
pass
return content.decode("utf-8", "replace")
def _iter_csv(content: bytes):
text = _decode_csv(content)
sample = text[:8192]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
except Exception:
dialect = csv.excel
rows = [list(row) for row in csv.reader(io.StringIO(text), dialect)]
found = _find_header(rows)
if not found:
return
_, header_index, mapping = found
for idx, values in enumerate(rows[header_index + 1:], start=header_index + 2):
record = _normalized_record(values, mapping, sheet="CSV", row_number=idx)
if record:
yield record
def parse_gstr2b(content: bytes, filename: str):
if not content:
raise ValueError("Uploaded GSTR-2B file is empty.")
if len(content) > MAX_UPLOAD_BYTES:
raise ValueError("GSTR-2B upload exceeds the 25 MB limit.")
suffix = Path(filename or "").suffix.lower()
if suffix in {".xlsx", ".xlsm"}:
records = list(_iter_xlsx(content))
elif suffix in {".csv", ".txt"}:
records = list(_iter_csv(content))
else:
raise ValueError("Upload an .xlsx, .xlsm or .csv GSTR-2B file.")
if not records:
raise ValueError(
"No GSTR-2B invoice rows were detected. The file must contain supplier GSTIN/name, "
"invoice number, invoice date and taxable value columns."
)
return records
def file_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.modules.accounting.gstr2b_models import AccountingGSTR2BImportBatch, AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_parser import file_sha256, parse_gstr2b
from app.modules.accounting.ledger_learning_service import rank_suggestions, record_review
from app.modules.accounting.taxonomy_models import AccountingNature
def _utcnow():
return datetime.now(timezone.utc)
def import_gstr2b(
db, *,
tenant_id: int,
client_id: int,
tally_guid: str,
return_period: str,
filename: str,
content: bytes,
user_id: int,
):
digest = file_sha256(content)
existing_batch = db.execute(select(AccountingGSTR2BImportBatch).where(
AccountingGSTR2BImportBatch.tenant_id == tenant_id,
AccountingGSTR2BImportBatch.client_id == client_id,
AccountingGSTR2BImportBatch.file_sha256 == digest,
)).scalar_one_or_none()
if existing_batch:
return existing_batch, True
parsed = parse_gstr2b(content, filename)
batch = AccountingGSTR2BImportBatch(
tenant_id=tenant_id,
client_id=client_id,
tally_guid=(tally_guid or "").strip(),
original_filename=(filename or "gstr2b.xlsx")[:260],
file_sha256=digest,
return_period=(return_period or "").strip()[:20],
source_kind="gstr2b_upload",
status="importing",
rows_read=len(parsed),
imported_by_user_id=user_id,
)
db.add(batch)
db.flush()
imported = duplicate = invalid = 0
for row in parsed:
if not row.get("invoice_number") or not row.get("invoice_date"):
invalid += 1
continue
exists = db.execute(select(AccountingGSTR2BPurchase.id).where(
AccountingGSTR2BPurchase.tenant_id == tenant_id,
AccountingGSTR2BPurchase.client_id == client_id,
AccountingGSTR2BPurchase.supplier_gstin == row.get("supplier_gstin", ""),
AccountingGSTR2BPurchase.invoice_number == row["invoice_number"],
AccountingGSTR2BPurchase.invoice_date == row["invoice_date"],
AccountingGSTR2BPurchase.document_type == row["document_type"],
)).scalar_one_or_none()
if exists:
duplicate += 1
continue
db.add(AccountingGSTR2BPurchase(
tenant_id=tenant_id,
client_id=client_id,
batch_id=batch.id,
tally_guid=(tally_guid or "").strip(),
return_period=(return_period or "").strip()[:20],
**row,
))
imported += 1
batch.rows_imported = imported
batch.rows_skipped_duplicate = duplicate
batch.rows_skipped_invalid = invalid
batch.status = "imported"
batch.completed_at_utc = _utcnow()
db.commit()
db.refresh(batch)
return batch, False
def batches_for_client(db, tenant_id: int, client_id: int, limit: int = 20):
return list(db.execute(select(AccountingGSTR2BImportBatch).where(
AccountingGSTR2BImportBatch.tenant_id == tenant_id,
AccountingGSTR2BImportBatch.client_id == client_id,
).order_by(AccountingGSTR2BImportBatch.id.desc()).limit(limit)).scalars().all())
def purchases_for_client(db, tenant_id: int, client_id: int, *, batch_id: int | None = None, limit: int = 300):
stmt = select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.tenant_id == tenant_id,
AccountingGSTR2BPurchase.client_id == client_id,
)
if batch_id:
stmt = stmt.where(AccountingGSTR2BPurchase.batch_id == batch_id)
return list(db.execute(stmt.order_by(
AccountingGSTR2BPurchase.invoice_date.desc(),
AccountingGSTR2BPurchase.id.desc(),
).limit(limit)).scalars().all())
def nature_lookup(db, ids):
ids = {int(x) for x in ids if x}
if not ids:
return {}
return {row.id: row for row in db.execute(
select(AccountingNature).where(AccountingNature.id.in_(ids))
).scalars().all()}
def analyze_purchase(db, row: AccountingGSTR2BPurchase):
description_parts = [
row.description_text or "",
f"Invoice type {row.invoice_type}" if row.invoice_type else "",
f"Place of supply {row.place_of_supply}" if row.place_of_supply else "",
]
suggestions = rank_suggestions(
db,
tenant_id=row.tenant_id,
client_id=row.client_id,
tally_guid=row.tally_guid,
supplier_name=row.supplier_name,
supplier_gstin=row.supplier_gstin,
hsn_code=row.hsn_code,
description=" | ".join(x for x in description_parts if x),
amount=row.taxable_value,
)
if suggestions:
top = suggestions[0]
row.suggested_nature_id = top["nature"].id
row.suggested_ledger_name = top.get("suggested_ledger") or ""
row.suggested_confidence = int(top.get("confidence") or 0)
row.suggestion_explanation_json = json.dumps(top.get("reasons") or [], ensure_ascii=False)
row.review_status = "suggested"
else:
row.suggested_nature_id = None
row.suggested_ledger_name = ""
row.suggested_confidence = 0
row.suggestion_explanation_json = json.dumps(
["No reliable classification evidence is available yet."], ensure_ascii=False
)
row.review_status = "review_required"
return suggestions
def analyze_batch(db, batch: AccountingGSTR2BImportBatch):
rows = list(db.execute(select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.batch_id == batch.id
)).scalars().all())
analyzed = 0
for row in rows:
analyze_purchase(db, row)
analyzed += 1
batch.analyzed_rows = analyzed
batch.analyzed_at_utc = _utcnow()
batch.status = "analyzed"
db.commit()
return analyzed
def review_purchase(
db, *,
row: AccountingGSTR2BPurchase,
final_nature_id: int,
final_ledger_name: str,
user_id: int,
):
try:
explanation = json.loads(row.suggestion_explanation_json or "[]")
except Exception:
explanation = []
record_review(
db,
tenant_id=row.tenant_id,
client_id=row.client_id,
tally_guid=row.tally_guid,
supplier_name=row.supplier_name,
supplier_gstin=row.supplier_gstin,
hsn_code=row.hsn_code,
description=row.description_text or "",
amount=row.taxable_value,
suggested_nature_id=row.suggested_nature_id,
suggested_ledger_name=row.suggested_ledger_name,
suggested_confidence=row.suggested_confidence,
final_nature_id=final_nature_id,
final_ledger_name=final_ledger_name,
user_id=user_id,
explanation=explanation,
)
# record_review commits its learning event; refresh the purchase in this session.
row.final_nature_id = final_nature_id
row.final_ledger_name = (final_ledger_name or "").strip()
row.review_status = "reviewed"
row.reviewed_by_user_id = user_id
row.reviewed_at_utc = _utcnow()
db.add(row)
batch = db.get(AccountingGSTR2BImportBatch, row.batch_id)
if batch:
reviewed_count = db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(
AccountingGSTR2BPurchase.batch_id == batch.id,
AccountingGSTR2BPurchase.review_status == "reviewed",
)).scalar_one()
# Include the current row if the database count was evaluated before flush.
if row.review_status == "reviewed":
db.flush()
reviewed_count = db.execute(select(func.count(AccountingGSTR2BPurchase.id)).where(
AccountingGSTR2BPurchase.batch_id == batch.id,
AccountingGSTR2BPurchase.review_status == "reviewed",
)).scalar_one()
batch.reviewed_rows = int(reviewed_count or 0)
db.commit()
db.refresh(row)
return row
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import json
from urllib.parse import urlencode
from fastapi import APIRouter, File, Form, Request, UploadFile
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.gstr2b_models import AccountingGSTR2BImportBatch, AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_service import (
analyze_batch,
batches_for_client,
import_gstr2b,
nature_lookup,
purchases_for_client,
review_purchase,
)
from app.modules.accounting.historical_learning_service import active_natures, ledger_mappings
from app.modules.accounting.ledger_learning_service import available_tally_guids
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
router = APIRouter(prefix="/tools/accounting/gstr2b", tags=["accounting-gstr2b-ui"])
def _redirect(client_id: int, *, batch_id: int | None = None, message: str = "", error: str = ""):
params = {"client_id": client_id}
if batch_id:
params["batch_id"] = batch_id
if message:
params["message"] = message
if error:
params["error"] = error[:180]
return RedirectResponse(url="/tools/accounting/gstr2b?" + urlencode(params), status_code=303)
@router.get("")
def gstr2b_page(
request: Request,
client_id: int | None = None,
batch_id: int | None = None,
message: str = "",
error: str = "",
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.learning.view")
if response:
return response
clients, scope = _visible_clients(db, request, user)
selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None)
batches = []
purchases = []
mappings = []
companies = []
natures = []
nature_by_id = {}
selected_batch = None
if selected:
batches = batches_for_client(db, scope.tenant_id, selected.id)
selected_batch = next((b for b in batches if batch_id and b.id == batch_id), None)
if not selected_batch and batches:
selected_batch = batches[0]
purchases = purchases_for_client(
db, scope.tenant_id, selected.id,
batch_id=selected_batch.id if selected_batch else None
)
mappings = ledger_mappings(
db, scope.tenant_id, selected.id,
selected_batch.tally_guid if selected_batch else ""
)
companies = available_tally_guids(db, scope.tenant_id, selected.id)
natures = active_natures(db, scope.tenant_id)
ids = []
for row in purchases:
ids.extend([row.suggested_nature_id, row.final_nature_id])
nature_by_id = nature_lookup(db, ids)
explanations = {}
for row in purchases:
try:
explanations[row.id] = json.loads(row.suggestion_explanation_json or "[]")
except Exception:
explanations[row.id] = []
return templates.TemplateResponse("modules/accounting/templates/accounting/gstr2b.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": "GSTR-2B Purchase Intelligence",
"clients": clients,
"selected_client": selected,
"batches": batches,
"selected_batch": selected_batch,
"purchases": purchases,
"mappings": mappings,
"tally_companies": companies,
"natures": natures,
"nature_by_id": nature_by_id,
"explanations": explanations,
"message": message,
"error": error,
})
finally:
db.close()
@router.post("/upload")
async def upload_gstr2b(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(""),
return_period: str = Form(""),
upload: UploadFile = File(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
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()
content = await upload.read()
batch, duplicate_file = import_gstr2b(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
tally_guid=tally_guid,
return_period=return_period,
filename=upload.filename or "gstr2b.xlsx",
content=content,
user_id=user.id,
)
if duplicate_file:
msg = f"This exact GSTR-2B file was already imported as batch #{batch.id}."
else:
msg = (
f"Imported {batch.rows_imported} purchase document(s); "
f"{batch.rows_skipped_duplicate} duplicate(s) and "
f"{batch.rows_skipped_invalid} invalid row(s) skipped."
)
return _redirect(client.id, batch_id=batch.id, message=msg)
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()
@router.post("/batch/{batch_id}/analyze")
def analyze(
request: Request,
batch_id: int,
client_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
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()
batch = db.execute(select(AccountingGSTR2BImportBatch).where(
AccountingGSTR2BImportBatch.id == batch_id,
AccountingGSTR2BImportBatch.tenant_id == scope.tenant_id,
AccountingGSTR2BImportBatch.client_id == client.id,
)).scalar_one_or_none()
if not batch:
return _redirect(client_id, error="GSTR-2B import batch was not found.")
count = analyze_batch(db, batch)
return _redirect(client_id, batch_id=batch.id, message=f"Analyzed {count} purchase document(s).")
except Exception as exc:
db.rollback()
return _redirect(client_id, batch_id=batch_id, error=str(exc))
finally:
db.close()
@router.post("/purchase/{purchase_id}/review")
def review(
request: Request,
purchase_id: int,
client_id: int = Form(...),
final_nature_id: int = Form(...),
final_ledger_name: str = Form(""),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
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()
row = db.execute(select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.id == purchase_id,
AccountingGSTR2BPurchase.tenant_id == scope.tenant_id,
AccountingGSTR2BPurchase.client_id == client.id,
)).scalar_one_or_none()
if not row:
return _redirect(client_id, error="GSTR-2B purchase record was not found.")
# If a ledger is selected, ensure it is one of the Phase 5 mappings for this nature/company.
if final_ledger_name.strip():
allowed = ledger_mappings(db, scope.tenant_id, client.id, row.tally_guid)
valid = any(
m.ledger_name == final_ledger_name.strip() and int(m.nature_id) == int(final_nature_id)
for m in allowed
)
if not valid:
return _redirect(
client_id, batch_id=row.batch_id,
error="Selected Tally ledger is not mapped to the selected accounting nature for this client/company."
)
review_purchase(
db,
row=row,
final_nature_id=final_nature_id,
final_ledger_name=final_ledger_name,
user_id=user.id,
)
return _redirect(client_id, batch_id=row.batch_id, message=f"Invoice {row.invoice_number} reviewed and learned.")
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()
@@ -0,0 +1,202 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting Intelligence</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">GSTR-2B Purchase Intelligence</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">Import GSTR-2B purchase documents, classify them through the Phase 6 learning engine, and confirm the accounting nature and mapped Tally ledger. Phase 7 does not post vouchers to Tally.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/ledger-learning{% 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 text-slate-700 hover:bg-slate-50">Ledger Learning</a>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Back to Tally</a>
</div>
</div>
{% if message %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">{{ message }}</div>
{% endif %}
{% if error %}
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ error }}</div>
{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" action="/tools/accounting/gstr2b" class="grid gap-4 lg:grid-cols-5">
<label class="text-sm font-medium text-slate-700 lg:col-span-2">
ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<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_name }}</option>
{% endfor %}
</select>
</label>
{% if selected_client %}
<label class="text-sm font-medium text-slate-700 lg:col-span-2">
Import Batch
<select name="batch_id" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Latest batch</option>
{% for batch in batches %}
<option value="{{ batch.id }}" {% if selected_batch and selected_batch.id == batch.id %}selected{% endif %}>#{{ batch.id }} · {{ batch.return_period or 'No period' }} · {{ batch.original_filename }}</option>
{% endfor %}
</select>
</label>
{% endif %}
<div class="flex items-end">
<button type="submit" class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Load</button>
</div>
</form>
</section>
{% if selected_client %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-slate-900">Import GSTR-2B</h2>
<p class="mt-1 text-sm text-slate-500">Supported: .xlsx, .xlsm and .csv. Summary sheets are ignored automatically; invoice sheets are detected by column headers.</p>
</div>
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Suggestion & review only · No Tally posting</span>
</div>
<form method="post" action="/tools/accounting/gstr2b/upload" enctype="multipart/form-data" class="mt-4 grid gap-4 lg:grid-cols-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<label class="text-sm font-medium text-slate-700">
Return Period
<input name="return_period" maxlength="20" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="e.g. 072026" />
</label>
<label class="text-sm font-medium text-slate-700 lg:col-span-2">
Tally Company Context
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">No specific Tally company</option>
{% for guid, company in tally_companies %}
<option value="{{ guid }}">{{ company or guid }} · {{ guid }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-slate-700">
GSTR-2B File
<input type="file" name="upload" accept=".xlsx,.xlsm,.csv,.txt" required class="mt-1 block w-full text-sm text-slate-700" />
</label>
<div class="flex items-end">
<button type="submit" class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Import</button>
</div>
</form>
</section>
{% if selected_batch %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Batch</div><div class="mt-2 text-xl font-semibold">#{{ selected_batch.id }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Rows Read</div><div class="mt-2 text-xl font-semibold">{{ selected_batch.rows_read }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Imported</div><div class="mt-2 text-xl font-semibold text-emerald-700">{{ selected_batch.rows_imported }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Duplicates</div><div class="mt-2 text-xl font-semibold text-amber-700">{{ selected_batch.rows_skipped_duplicate }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Analyzed</div><div class="mt-2 text-xl font-semibold">{{ selected_batch.analyzed_rows }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Reviewed</div><div class="mt-2 text-xl font-semibold">{{ selected_batch.reviewed_rows }}</div></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-slate-900">{{ selected_batch.original_filename }}</h2>
<p class="mt-1 text-sm text-slate-500">Return period {{ selected_batch.return_period or '-' }} · Status {{ selected_batch.status|replace('_',' ')|title }}</p>
</div>
<form method="post" action="/tools/accounting/gstr2b/batch/{{ selected_batch.id }}/analyze">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Analyze / Refresh Suggestions</button>
</form>
</div>
</section>
<section class="space-y-4">
{% for row in purchases %}
{% set suggested = nature_by_id.get(row.suggested_nature_id) if row.suggested_nature_id else None %}
{% set final = nature_by_id.get(row.final_nature_id) if row.final_nature_id else None %}
<article class="rounded-2xl bg-white p-5 shadow-soft">
<div class="grid gap-4 xl:grid-cols-12">
<div class="xl:col-span-4">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold text-slate-900">{{ row.supplier_name or row.supplier_gstin or 'Supplier' }}</h3>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">{{ row.document_type|replace('_',' ')|title }}</span>
{% if row.review_status == 'reviewed' %}
<span class="rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700">Reviewed</span>
{% elif row.review_status == 'suggested' %}
<span class="rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700">Suggested</span>
{% else %}
<span class="rounded-full bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-700">Review required</span>
{% endif %}
</div>
<div class="mt-2 space-y-1 text-sm text-slate-600">
<div>GSTIN: <span class="font-medium text-slate-900">{{ row.supplier_gstin or '-' }}</span></div>
<div>Invoice: <span class="font-medium text-slate-900">{{ row.invoice_number }}</span> · {{ row.invoice_date }}</div>
<div>HSN: {{ row.hsn_code or '-' }} · POS: {{ row.place_of_supply or '-' }}</div>
{% if row.description_text %}<div class="text-xs text-slate-500">{{ row.description_text }}</div>{% endif %}
</div>
</div>
<div class="xl:col-span-3">
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="rounded-xl bg-slate-50 p-2"><div class="text-xs text-slate-500">Taxable</div><div class="font-semibold">₹{{ '%.2f'|format(row.taxable_value) }}</div></div>
<div class="rounded-xl bg-slate-50 p-2"><div class="text-xs text-slate-500">Invoice Value</div><div class="font-semibold">₹{{ '%.2f'|format(row.invoice_value) }}</div></div>
<div class="rounded-xl bg-slate-50 p-2"><div class="text-xs text-slate-500">IGST</div><div class="font-semibold">₹{{ '%.2f'|format(row.igst) }}</div></div>
<div class="rounded-xl bg-slate-50 p-2"><div class="text-xs text-slate-500">CGST + SGST</div><div class="font-semibold">₹{{ '%.2f'|format(row.cgst + row.sgst) }}</div></div>
</div>
</div>
<div class="xl:col-span-5">
{% if suggested %}
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-xs font-semibold uppercase text-slate-500">Suggested Nature</div>
<div class="mt-1 font-semibold text-slate-900">{{ suggested.name }}</div>
</div>
<span class="rounded-full px-2.5 py-1 text-xs font-semibold {% if row.suggested_confidence >= 85 %}bg-emerald-100 text-emerald-700{% elif row.suggested_confidence >= 60 %}bg-blue-100 text-blue-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ row.suggested_confidence }}%</span>
</div>
{% if row.suggested_ledger_name %}<div class="mt-2 text-sm text-slate-700">Ledger: <span class="font-semibold">{{ row.suggested_ledger_name }}</span></div>{% endif %}
{% if explanations.get(row.id) %}
<ul class="mt-2 space-y-1 text-xs text-slate-600">
{% for reason in explanations.get(row.id)[:4] %}<li>• {{ reason }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
{% if row.review_status != 'pending_analysis' %}
<form method="post" action="/tools/accounting/gstr2b/purchase/{{ row.id }}/review" class="mt-3 grid gap-3 md:grid-cols-5">
<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 text-slate-600 md:col-span-2">
Final Nature
<select name="final_nature_id" required class="mt-1 w-full rounded-xl 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>
</label>
<label class="text-xs font-semibold text-slate-600 md:col-span-2">
Final Tally Ledger
<select name="final_ledger_name" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-2 py-2 text-sm">
<option value="">No ledger selected yet</option>
{% for mapping in mappings %}
<option value="{{ mapping.ledger_name }}" {% if row.final_ledger_name == mapping.ledger_name or (not row.final_ledger_name and row.suggested_ledger_name == mapping.ledger_name) %}selected{% endif %}>{{ mapping.ledger_name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end">
<button type="submit" class="w-full rounded-xl bg-slate-900 px-3 py-2 text-sm font-semibold text-white hover:bg-slate-800">{{ 'Update Review' if row.review_status == 'reviewed' else 'Confirm / Correct' }}</button>
</div>
</form>
{% else %}
<p class="mt-3 text-sm text-amber-700">Run Analyze / Refresh Suggestions before reviewing this invoice.</p>
{% endif %}
</div>
</div>
</article>
{% else %}
<div class="rounded-2xl bg-white p-8 text-center text-sm text-slate-500 shadow-soft">This batch has no imported purchase documents.</div>
{% endfor %}
</section>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -16,6 +16,7 @@
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
{% if selected_client %}<a href="/tools/accounting/historical-learning?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">Historical Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/ledger-learning?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">Ledger Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/gstr2b?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">GSTR-2B Intelligence</a>{% endif %}
{% 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
@@ -40,6 +40,7 @@ from app.modules.accounting.ui import router as accounting_ui_router
from app.modules.accounting.taxonomy_ui import router as accounting_taxonomy_ui_router
from app.modules.accounting.historical_learning_ui import router as accounting_historical_learning_ui_router
from app.modules.accounting.ledger_learning_ui import router as accounting_ledger_learning_ui_router
from app.modules.accounting.gstr2b_ui import router as accounting_gstr2b_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
@@ -66,6 +67,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_taxonomy_ui_router)
app.include_router(accounting_historical_learning_ui_router)
app.include_router(accounting_ledger_learning_ui_router)
app.include_router(accounting_gstr2b_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)