Add Phase 8 E-Invoice and E-Way Bill purchase enrichment

This commit is contained in:
A R R R Associates
2026-08-22 15:30:46 +05:30
parent 7283f45f43
commit 510d1e12df
8 changed files with 1499 additions and 0 deletions
@@ -0,0 +1,121 @@
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 AccountingPurchaseEnrichmentBatch(CommonBase):
__tablename__ = "accounting_purchase_enrichment_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)
source_type: Mapped[str] = mapped_column(String(30), nullable=False, 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)
source_period: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="imported", index=True)
records_read: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
records_imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
records_duplicate: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
records_linked: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
records_unmatched: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
records_ambiguous: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
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
)
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AccountingPurchaseEnrichmentRecord(CommonBase):
__tablename__ = "accounting_purchase_enrichment_records"
__table_args__ = (
UniqueConstraint(
"tenant_id", "client_id", "source_type", "source_document_key",
name="uq_accounting_purchase_enrichment_source_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_purchase_enrichment_batches.id", ondelete="CASCADE"), nullable=False, index=True
)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True)
source_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
source_document_key: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
external_reference: Mapped[str] = mapped_column(String(180), 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)
recipient_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
document_number: Mapped[str] = mapped_column(String(160), nullable=False, default="", index=True)
document_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)
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)
place_of_supply: Mapped[str] = mapped_column(String(120), nullable=False, default="")
transport_mode: Mapped[str] = mapped_column(String(80), nullable=False, default="")
vehicle_number: Mapped[str] = mapped_column(String(40), nullable=False, default="")
transporter_id: Mapped[str] = mapped_column(String(40), nullable=False, default="")
raw_summary_json: Mapped[str | None] = mapped_column(Text, nullable=True)
source_row_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
gstr2b_purchase_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_gstr2b_purchases.id", ondelete="SET NULL"), nullable=True, index=True
)
match_status: Mapped[str] = mapped_column(String(30), nullable=False, default="unmatched", index=True)
match_method: Mapped[str] = mapped_column(String(80), nullable=False, default="")
match_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
manually_linked: 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,
)
class AccountingPurchaseEnrichmentItem(CommonBase):
__tablename__ = "accounting_purchase_enrichment_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
record_id: Mapped[int] = mapped_column(
ForeignKey("accounting_purchase_enrichment_records.id", ondelete="CASCADE"), nullable=False, index=True
)
line_number: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
product_name: Mapped[str] = mapped_column(String(500), nullable=False, default="")
description_text: Mapped[str | None] = mapped_column(Text, nullable=True)
hsn_code: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
unit: Mapped[str] = mapped_column(String(30), nullable=False, default="")
unit_price: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
taxable_value: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
gst_rate: 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)
@@ -0,0 +1,457 @@
from __future__ import annotations
import csv
import hashlib
import io
import json
import re
from datetime import date, datetime
from pathlib import Path
from openpyxl import load_workbook
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
def _s(value) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def _norm(value) -> str:
text = _s(value).lower().replace("\n", " ").replace("\r", " ")
text = re.sub(r"[_\-]+", " ", text)
text = re.sub(r"[^\w/(). ]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _gstin(value) -> str:
return re.sub(r"[^A-Z0-9]", "", _s(value).upper())[:15]
def _hsn(value) -> str:
return re.sub(r"\D", "", _s(value))[:8]
def _money(value) -> float:
if value in (None, ""):
return 0.0
if isinstance(value, (int, float)):
return float(value)
text = _s(value).replace(",", "").replace("₹", "")
text = re.sub(r"^\((.*)\)$", r"-\1", text)
try:
return float(text)
except Exception:
return 0.0
def _date(value) -> str:
if value in (None, ""):
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = _s(value)
for fmt in (
"%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%d-%b-%Y", "%d %b %Y",
"%d.%m.%Y", "%d/%m/%y", "%d-%m-%y",
):
try:
return datetime.strptime(text, fmt).date().isoformat()
except Exception:
pass
return text[:20]
def _doc_type(value: str, number: str = "") -> str:
text = f"{value} {number}".upper()
if "CREDIT" in text or "CR NOTE" in text:
return "credit_note"
if "DEBIT" in text or "DR NOTE" in text:
return "debit_note"
return "invoice"
def _sha(value: str) -> str:
return hashlib.sha256(value.encode("utf-8", "ignore")).hexdigest()
def file_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _record_key(source_type: str, row: dict) -> str:
external = row.get("external_reference", "")
if external:
return f"{source_type}|REF|{external}"
return "|".join([
source_type,
row.get("supplier_gstin", ""),
row.get("document_number", ""),
row.get("document_date", ""),
row.get("document_type", ""),
])
def _finalize(source_type: str, row: dict, items: list[dict]):
row["source_type"] = source_type
row["supplier_gstin"] = _gstin(row.get("supplier_gstin"))
row["recipient_gstin"] = _gstin(row.get("recipient_gstin"))
row["document_number"] = _s(row.get("document_number"))[:160]
row["document_date"] = _date(row.get("document_date"))
row["document_type"] = _doc_type(_s(row.get("document_type")), row["document_number"])
row["supplier_name"] = _s(row.get("supplier_name"))[:260]
row["external_reference"] = _s(row.get("external_reference"))[:180]
for key in ("taxable_value", "igst", "cgst", "sgst", "cess", "invoice_value"):
row[key] = _money(row.get(key))
row["place_of_supply"] = _s(row.get("place_of_supply"))[:120]
row["transport_mode"] = _s(row.get("transport_mode"))[:80]
row["vehicle_number"] = _s(row.get("vehicle_number"))[:40]
row["transporter_id"] = _s(row.get("transporter_id"))[:40]
normalized_items = []
for index, item in enumerate(items or [], start=1):
product_name = _s(item.get("product_name"))[:500]
description = _s(item.get("description_text"))[:4000]
hsn = _hsn(item.get("hsn_code"))
normalized_items.append({
"line_number": int(item.get("line_number") or index),
"product_name": product_name,
"description_text": description,
"hsn_code": hsn,
"quantity": _money(item.get("quantity")),
"unit": _s(item.get("unit"))[:30],
"unit_price": _money(item.get("unit_price")),
"taxable_value": _money(item.get("taxable_value")),
"gst_rate": _money(item.get("gst_rate")),
"igst": _money(item.get("igst")),
"cgst": _money(item.get("cgst")),
"sgst": _money(item.get("sgst")),
"cess": _money(item.get("cess")),
})
if not row["taxable_value"]:
row["taxable_value"] = sum(x["taxable_value"] for x in normalized_items)
if not row["igst"]:
row["igst"] = sum(x["igst"] for x in normalized_items)
if not row["cgst"]:
row["cgst"] = sum(x["cgst"] for x in normalized_items)
if not row["sgst"]:
row["sgst"] = sum(x["sgst"] for x in normalized_items)
if not row["cess"]:
row["cess"] = sum(x["cess"] for x in normalized_items)
if not row["invoice_value"]:
row["invoice_value"] = row["taxable_value"] + row["igst"] + row["cgst"] + row["sgst"] + row["cess"]
if not row["document_number"] or not row["document_date"]:
return None
if not row["supplier_gstin"] and not row["supplier_name"]:
return None
row["source_document_key"] = _record_key(source_type, row)
compact = json.dumps({"row": row, "items": normalized_items}, sort_keys=True, default=str, ensure_ascii=False)
row["source_row_hash"] = _sha(compact)
return row, normalized_items
def _path(data, *keys, default=None):
cur = data
for key in keys:
if not isinstance(cur, dict):
return default
candidates = (key, key.lower(), key.upper(), key.capitalize())
found = None
for cand in candidates:
if cand in cur:
found = cur[cand]
break
if found is None:
# case-insensitive fallback
kmap = {str(k).lower(): k for k in cur.keys()}
real = kmap.get(str(key).lower())
if real is None:
return default
found = cur[real]
cur = found
return cur
def _parse_einvoice_object(obj: dict):
doc = _path(obj, "DocDtls", default={}) or {}
seller = _path(obj, "SellerDtls", default={}) or {}
buyer = _path(obj, "BuyerDtls", default={}) or {}
val = _path(obj, "ValDtls", default={}) or {}
tran = _path(obj, "TranDtls", default={}) or {}
irn = _path(obj, "Irn") or _path(obj, "IRN") or _path(obj, "AckNo") or ""
row = {
"external_reference": irn,
"supplier_gstin": _path(seller, "Gstin") or _path(obj, "SellerGstin") or _path(obj, "supplier_gstin"),
"supplier_name": _path(seller, "TrdNm") or _path(seller, "LglNm") or _path(obj, "SellerName") or _path(obj, "supplier_name"),
"recipient_gstin": _path(buyer, "Gstin") or _path(obj, "BuyerGstin") or _path(obj, "recipient_gstin"),
"document_number": _path(doc, "No") or _path(obj, "DocNo") or _path(obj, "invoice_number"),
"document_date": _path(doc, "Dt") or _path(obj, "DocDt") or _path(obj, "invoice_date"),
"document_type": _path(doc, "Typ") or _path(obj, "DocTyp") or "invoice",
"taxable_value": _path(val, "AssVal") or _path(obj, "TaxableValue"),
"igst": _path(val, "IgstVal") or _path(obj, "IgstVal"),
"cgst": _path(val, "CgstVal") or _path(obj, "CgstVal"),
"sgst": _path(val, "SgstVal") or _path(obj, "SgstVal"),
"cess": _path(val, "CesVal") or _path(obj, "CessVal"),
"invoice_value": _path(val, "TotInvVal") or _path(obj, "TotInvVal") or _path(obj, "invoice_value"),
"place_of_supply": _path(buyer, "Pos") or _path(obj, "Pos"),
"transport_mode": _path(tran, "TransMode") or _path(obj, "TransMode"),
"vehicle_number": _path(obj, "VehNo") or "",
"transporter_id": _path(tran, "TransId") or _path(obj, "TransId"),
}
raw_items = _path(obj, "ItemList", default=[]) or _path(obj, "items", default=[]) or []
items = []
for i, item in enumerate(raw_items if isinstance(raw_items, list) else [], start=1):
ass = _path(item, "AssAmt") or _path(item, "TaxableValue") or 0
rate = _path(item, "GstRt") or _path(item, "GSTRate") or 0
items.append({
"line_number": _path(item, "SlNo") or i,
"product_name": _path(item, "PrdDesc") or _path(item, "ProductName") or _path(item, "Nm"),
"description_text": _path(item, "PrdDesc") or _path(item, "Desc"),
"hsn_code": _path(item, "HsnCd") or _path(item, "HSN"),
"quantity": _path(item, "Qty"),
"unit": _path(item, "Unit"),
"unit_price": _path(item, "UnitPrice"),
"taxable_value": ass,
"gst_rate": rate,
"igst": _path(item, "IgstAmt"),
"cgst": _path(item, "CgstAmt"),
"sgst": _path(item, "SgstAmt"),
"cess": _path(item, "CesAmt"),
})
return _finalize("e_invoice", row, items)
def _parse_ewaybill_object(obj: dict):
row = {
"external_reference": _path(obj, "ewbNo") or _path(obj, "EwbNo") or _path(obj, "ewayBillNo"),
"supplier_gstin": _path(obj, "fromGstin") or _path(obj, "supplierGstin") or _path(obj, "supplier_gstin"),
"supplier_name": _path(obj, "fromTrdName") or _path(obj, "fromPlace") or _path(obj, "supplierName"),
"recipient_gstin": _path(obj, "toGstin") or _path(obj, "recipientGstin") or _path(obj, "recipient_gstin"),
"document_number": _path(obj, "docNo") or _path(obj, "documentNumber") or _path(obj, "invoice_number"),
"document_date": _path(obj, "docDate") or _path(obj, "documentDate") or _path(obj, "invoice_date"),
"document_type": _path(obj, "docType") or "invoice",
"taxable_value": _path(obj, "totalValue") or _path(obj, "taxableValue"),
"igst": _path(obj, "igstValue"),
"cgst": _path(obj, "cgstValue"),
"sgst": _path(obj, "sgstValue"),
"cess": _path(obj, "cessValue"),
"invoice_value": _path(obj, "totInvValue") or _path(obj, "invoiceValue"),
"place_of_supply": _path(obj, "toStateCode") or _path(obj, "placeOfSupply"),
"transport_mode": _path(obj, "transMode") or _path(obj, "transportMode"),
"vehicle_number": _path(obj, "vehicleNo") or _path(obj, "vehNo"),
"transporter_id": _path(obj, "transporterId") or _path(obj, "transporterGstin"),
}
raw_items = _path(obj, "itemList", default=[]) or _path(obj, "items", default=[]) or []
items = []
for i, item in enumerate(raw_items if isinstance(raw_items, list) else [], start=1):
items.append({
"line_number": i,
"product_name": _path(item, "productName") or _path(item, "productDesc"),
"description_text": _path(item, "productDesc") or _path(item, "description"),
"hsn_code": _path(item, "hsnCode") or _path(item, "hsn"),
"quantity": _path(item, "quantity") or _path(item, "qty"),
"unit": _path(item, "qtyUnit") or _path(item, "unit"),
"unit_price": _path(item, "unitPrice"),
"taxable_value": _path(item, "taxableAmount") or _path(item, "taxableValue"),
"gst_rate": (
_money(_path(item, "igstRate"))
or _money(_path(item, "cgstRate")) + _money(_path(item, "sgstRate"))
),
"igst": _path(item, "igstValue") or _path(item, "igstAmount"),
"cgst": _path(item, "cgstValue") or _path(item, "cgstAmount"),
"sgst": _path(item, "sgstValue") or _path(item, "sgstAmount"),
"cess": _path(item, "cessValue") or _path(item, "cessAmount"),
})
return _finalize("e_way_bill", row, items)
HEADER_ALIASES = {
"external_reference": {"irn", "ack no", "ackno", "e way bill no", "eway bill no", "ewb no", "ewbno"},
"supplier_gstin": {"supplier gstin", "seller gstin", "from gstin", "fromgstin", "gstin of supplier"},
"supplier_name": {"supplier name", "seller name", "from trade name", "fromtrdname", "trade/legal name"},
"recipient_gstin": {"recipient gstin", "buyer gstin", "to gstin", "togstin"},
"document_number": {"document number", "doc no", "docno", "invoice number", "invoice no"},
"document_date": {"document date", "doc date", "docdate", "invoice date"},
"document_type": {"document type", "doc type", "doctype", "invoice type"},
"taxable_value": {"taxable value", "taxable amount", "total value", "totalvalue"},
"igst": {"igst", "igst value", "igst amount"},
"cgst": {"cgst", "cgst value", "cgst amount"},
"sgst": {"sgst", "sgst value", "sgst amount"},
"cess": {"cess", "cess value", "cess amount"},
"invoice_value": {"invoice value", "total invoice value", "tot inv value", "totinvvalue"},
"place_of_supply": {"place of supply", "pos", "to state code"},
"transport_mode": {"transport mode", "trans mode", "transmode"},
"vehicle_number": {"vehicle number", "vehicle no", "veh no"},
"transporter_id": {"transporter id", "trans id", "transporter gstin"},
"hsn_code": {"hsn", "hsn code", "hsn/sac"},
"product_name": {"product name", "item name", "product"},
"description_text": {"description", "product description", "item description"},
"quantity": {"quantity", "qty"},
"unit": {"unit", "qty unit", "uom"},
"unit_price": {"unit price", "rate"},
}
ALIAS = {a: k for k, vals in HEADER_ALIASES.items() for a in vals}
def _canon_header(value):
n = _norm(value)
if n in ALIAS:
return ALIAS[n]
for alias, key in ALIAS.items():
if len(alias) >= 8 and alias in n:
return key
return None
def _sheet_records(content: bytes, source_type: str):
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
out = []
try:
for ws in wb.worksheets:
rows = [list(r) for r in ws.iter_rows(values_only=True)]
best = None
for idx, vals in enumerate(rows[:40]):
mapping = {}
for c, v in enumerate(vals):
key = _canon_header(v)
if key and key not in mapping:
mapping[key] = c
score = sum(k in mapping for k in ("document_number", "document_date", "supplier_gstin"))
if score >= 2 and (best is None or score > best[0]):
best = (score, idx, mapping)
if not best:
continue
_, header_idx, mapping = best
for row_no, vals in enumerate(rows[header_idx + 1:], start=header_idx + 2):
def get(k):
c = mapping.get(k)
return vals[c] if c is not None and c < len(vals) else None
row = {k: get(k) for k in (
"external_reference", "supplier_gstin", "supplier_name", "recipient_gstin",
"document_number", "document_date", "document_type", "taxable_value", "igst",
"cgst", "sgst", "cess", "invoice_value", "place_of_supply", "transport_mode",
"vehicle_number", "transporter_id",
)}
item = {
"line_number": row_no,
"product_name": get("product_name"),
"description_text": get("description_text"),
"hsn_code": get("hsn_code"),
"quantity": get("quantity"),
"unit": get("unit"),
"unit_price": get("unit_price"),
"taxable_value": get("taxable_value"),
}
finalized = _finalize(source_type, row, [item] if any(_s(v) for v in item.values()) else [])
if finalized:
out.append(finalized)
finally:
wb.close()
return out
def _csv_records(content: bytes, source_type: str):
text = None
for enc in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
try:
text = content.decode(enc)
break
except UnicodeDecodeError:
pass
text = text or content.decode("utf-8", "replace")
try:
dialect = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|")
except Exception:
dialect = csv.excel
rows = list(csv.reader(io.StringIO(text), dialect))
best = None
for idx, vals in enumerate(rows[:40]):
mapping = {}
for c, v in enumerate(vals):
key = _canon_header(v)
if key and key not in mapping:
mapping[key] = c
score = sum(k in mapping for k in ("document_number", "document_date", "supplier_gstin"))
if score >= 2 and (best is None or score > best[0]):
best = (score, idx, mapping)
if not best:
return []
_, header_idx, mapping = best
result = []
for row_no, vals in enumerate(rows[header_idx + 1:], start=header_idx + 2):
def get(k):
c = mapping.get(k)
return vals[c] if c is not None and c < len(vals) else None
row = {k: get(k) for k in (
"external_reference", "supplier_gstin", "supplier_name", "recipient_gstin",
"document_number", "document_date", "document_type", "taxable_value", "igst",
"cgst", "sgst", "cess", "invoice_value", "place_of_supply", "transport_mode",
"vehicle_number", "transporter_id",
)}
item = {
"line_number": row_no,
"product_name": get("product_name"),
"description_text": get("description_text"),
"hsn_code": get("hsn_code"),
"quantity": get("quantity"),
"unit": get("unit"),
"unit_price": get("unit_price"),
"taxable_value": get("taxable_value"),
}
finalized = _finalize(source_type, row, [item] if any(_s(v) for v in item.values()) else [])
if finalized:
result.append(finalized)
return result
def _json_objects(payload):
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ("data", "result", "records", "invoices", "ewayBills", "ewaybills", "items"):
val = payload.get(key)
if isinstance(val, list) and val and isinstance(val[0], dict):
return val
return [payload]
def parse_enrichment(content: bytes, filename: str, source_type: str):
if source_type not in {"e_invoice", "e_way_bill"}:
raise ValueError("Source type must be E-Invoice or E-Way Bill.")
if not content:
raise ValueError("Uploaded enrichment file is empty.")
if len(content) > MAX_UPLOAD_BYTES:
raise ValueError("Enrichment upload exceeds the 25 MB limit.")
suffix = Path(filename or "").suffix.lower()
records = []
if suffix == ".json":
payload = json.loads(content.decode("utf-8-sig"))
for obj in _json_objects(payload):
if not isinstance(obj, dict):
continue
item = _parse_einvoice_object(obj) if source_type == "e_invoice" else _parse_ewaybill_object(obj)
if item:
records.append(item)
elif suffix in {".xlsx", ".xlsm"}:
records = _sheet_records(content, source_type)
elif suffix in {".csv", ".txt"}:
records = _csv_records(content, source_type)
else:
raise ValueError("Upload a .json, .xlsx, .xlsm or .csv file.")
if not records:
raise ValueError("No usable E-Invoice/E-Way Bill document records were detected.")
return records
@@ -0,0 +1,350 @@
from __future__ import annotations
import json
import re
from collections import Counter
from datetime import datetime, timezone
from sqlalchemy import func, select
from app.modules.accounting.gstr2b_models import AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_service import analyze_purchase
from app.modules.accounting.purchase_enrichment_models import (
AccountingPurchaseEnrichmentBatch,
AccountingPurchaseEnrichmentItem,
AccountingPurchaseEnrichmentRecord,
)
from app.modules.accounting.purchase_enrichment_parser import file_sha256, parse_enrichment
def _utcnow():
return datetime.now(timezone.utc)
def _norm_invoice(value: str) -> str:
return re.sub(r"[^A-Z0-9]", "", str(value or "").upper())
def _value_close(a: float, b: float) -> bool:
a = float(a or 0)
b = float(b or 0)
tolerance = max(2.0, abs(a) * 0.005)
return abs(a - b) <= tolerance
def _candidate_rows(db, record: AccountingPurchaseEnrichmentRecord):
stmt = select(AccountingGSTR2BPurchase).where(
AccountingGSTR2BPurchase.tenant_id == record.tenant_id,
AccountingGSTR2BPurchase.client_id == record.client_id,
)
if record.supplier_gstin:
stmt = stmt.where(AccountingGSTR2BPurchase.supplier_gstin == record.supplier_gstin)
return list(db.execute(stmt).scalars().all())
def match_record(db, record: AccountingPurchaseEnrichmentRecord):
rows = _candidate_rows(db, record)
inv = _norm_invoice(record.document_number)
date = record.document_date
exact = [
r for r in rows
if _norm_invoice(r.invoice_number) == inv
and r.invoice_date == date
and r.document_type == record.document_type
]
if len(exact) == 1:
record.gstr2b_purchase_id = exact[0].id
record.match_status = "linked"
record.match_method = "gstin_invoice_date_document_type"
record.match_confidence = 100
return exact[0]
if len(exact) > 1:
record.match_status = "ambiguous"
record.match_method = "multiple_exact_candidates"
record.match_confidence = 70
record.gstr2b_purchase_id = None
return None
same_invoice = [r for r in rows if _norm_invoice(r.invoice_number) == inv]
if len(same_invoice) == 1:
record.gstr2b_purchase_id = same_invoice[0].id
record.match_status = "linked"
record.match_method = "gstin_invoice_number"
record.match_confidence = 94
return same_invoice[0]
date_value = [
r for r in rows
if r.invoice_date == date and _value_close(r.invoice_value, record.invoice_value)
]
if len(date_value) == 1:
record.gstr2b_purchase_id = date_value[0].id
record.match_status = "linked"
record.match_method = "gstin_date_invoice_value"
record.match_confidence = 86
return date_value[0]
record.gstr2b_purchase_id = None
record.match_status = "ambiguous" if len(same_invoice) > 1 or len(date_value) > 1 else "unmatched"
record.match_method = "multiple_candidates" if record.match_status == "ambiguous" else "no_match"
record.match_confidence = 50 if record.match_status == "ambiguous" else 0
return None
def enriched_context(db, purchase_id: int):
records = list(db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.gstr2b_purchase_id == purchase_id,
AccountingPurchaseEnrichmentRecord.match_status == "linked",
)).scalars().all())
if not records:
return {"description": "", "hsn_code": "", "records": [], "items": []}
record_ids = [r.id for r in records]
items = list(db.execute(select(AccountingPurchaseEnrichmentItem).where(
AccountingPurchaseEnrichmentItem.record_id.in_(record_ids)
).order_by(
AccountingPurchaseEnrichmentItem.record_id,
AccountingPurchaseEnrichmentItem.line_number,
)).scalars().all())
descriptions = []
hsns = []
for item in items:
if item.product_name:
descriptions.append(item.product_name)
if item.description_text and item.description_text not in descriptions:
descriptions.append(item.description_text)
if item.hsn_code:
hsns.append(item.hsn_code)
# Only use one HSN as the Phase 6 scalar HSN input when all enriched item lines agree.
unique_hsn = sorted(set(hsns))
hsn = unique_hsn[0] if len(unique_hsn) == 1 else ""
description = " | ".join(dict.fromkeys(descriptions))[:12000]
return {
"description": description,
"hsn_code": hsn,
"records": records,
"items": items,
"all_hsn_codes": unique_hsn,
}
def reanalyze_linked_purchase(db, purchase: AccountingGSTR2BPurchase):
context = enriched_context(db, purchase.id)
original_desc = purchase.description_text or ""
original_hsn = purchase.hsn_code or ""
# Preserve the GSTR-2B source record. Temporarily enrich the classifier inputs only.
merged_desc = " | ".join(x for x in (original_desc, context["description"]) if x)
saved_desc = purchase.description_text
saved_hsn = purchase.hsn_code
try:
purchase.description_text = merged_desc[:12000] or None
if context["hsn_code"]:
purchase.hsn_code = context["hsn_code"]
analyze_purchase(db, purchase)
finally:
purchase.description_text = saved_desc
purchase.hsn_code = saved_hsn
reasons = []
try:
reasons = json.loads(purchase.suggestion_explanation_json or "[]")
except Exception:
reasons = []
if context["records"]:
srcs = sorted({r.source_type.replace("_", " ").title() for r in context["records"]})
reasons.insert(0, f"Enriched with linked {' + '.join(srcs)} source data.")
if context["all_hsn_codes"]:
reasons.insert(1, f"Enriched item HSN(s): {', '.join(context['all_hsn_codes'][:8])}.")
purchase.suggestion_explanation_json = json.dumps(reasons, ensure_ascii=False)
db.add(purchase)
return purchase
def _update_batch_counts(db, batch: AccountingPurchaseEnrichmentBatch):
rows = list(db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.batch_id == batch.id
)).scalars().all())
counter = Counter(r.match_status for r in rows)
batch.records_linked = counter.get("linked", 0)
batch.records_unmatched = counter.get("unmatched", 0)
batch.records_ambiguous = counter.get("ambiguous", 0)
batch.status = "matched"
batch.completed_at_utc = _utcnow()
db.add(batch)
def import_enrichment(
db, *,
tenant_id: int,
client_id: int,
tally_guid: str,
source_type: str,
source_period: str,
filename: str,
content: bytes,
user_id: int,
):
digest = file_sha256(content)
old = db.execute(select(AccountingPurchaseEnrichmentBatch).where(
AccountingPurchaseEnrichmentBatch.tenant_id == tenant_id,
AccountingPurchaseEnrichmentBatch.client_id == client_id,
AccountingPurchaseEnrichmentBatch.source_type == source_type,
AccountingPurchaseEnrichmentBatch.file_sha256 == digest,
)).scalar_one_or_none()
if old:
return old, True
parsed = parse_enrichment(content, filename, source_type)
batch = AccountingPurchaseEnrichmentBatch(
tenant_id=tenant_id,
client_id=client_id,
tally_guid=(tally_guid or "").strip(),
source_type=source_type,
original_filename=(filename or "")[:260],
file_sha256=digest,
source_period=(source_period or "")[:20],
status="importing",
records_read=len(parsed),
imported_by_user_id=user_id,
)
db.add(batch)
db.flush()
imported = duplicate = 0
linked_purchases = set()
for row, items in parsed:
existing = db.execute(select(AccountingPurchaseEnrichmentRecord.id).where(
AccountingPurchaseEnrichmentRecord.tenant_id == tenant_id,
AccountingPurchaseEnrichmentRecord.client_id == client_id,
AccountingPurchaseEnrichmentRecord.source_type == source_type,
AccountingPurchaseEnrichmentRecord.source_document_key == row["source_document_key"],
)).scalar_one_or_none()
if existing:
duplicate += 1
continue
raw_summary = {
"source_type": source_type,
"source_document_key": row["source_document_key"],
"item_count": len(items),
}
record = AccountingPurchaseEnrichmentRecord(
tenant_id=tenant_id,
client_id=client_id,
batch_id=batch.id,
tally_guid=(tally_guid or "").strip(),
raw_summary_json=json.dumps(raw_summary, ensure_ascii=False),
**row,
)
db.add(record)
db.flush()
for item in items:
db.add(AccountingPurchaseEnrichmentItem(record_id=record.id, **item))
purchase = match_record(db, record)
if purchase:
linked_purchases.add(purchase.id)
imported += 1
batch.records_imported = imported
batch.records_duplicate = duplicate
db.flush()
_update_batch_counts(db, batch)
for purchase_id in linked_purchases:
purchase = db.get(AccountingGSTR2BPurchase, purchase_id)
if purchase:
reanalyze_linked_purchase(db, purchase)
db.commit()
db.refresh(batch)
return batch, False
def rematch_batch(db, batch: AccountingPurchaseEnrichmentBatch):
rows = list(db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.batch_id == batch.id
)).scalars().all())
linked = set()
for record in rows:
if record.manually_linked and record.gstr2b_purchase_id:
linked.add(record.gstr2b_purchase_id)
continue
purchase = match_record(db, record)
if purchase:
linked.add(purchase.id)
_update_batch_counts(db, batch)
for purchase_id in linked:
purchase = db.get(AccountingGSTR2BPurchase, purchase_id)
if purchase:
reanalyze_linked_purchase(db, purchase)
db.commit()
return len(rows)
def manually_link_record(db, record: AccountingPurchaseEnrichmentRecord, purchase: AccountingGSTR2BPurchase):
if record.tenant_id != purchase.tenant_id or record.client_id != purchase.client_id:
raise ValueError("The enrichment record and GSTR-2B purchase belong to different client scopes.")
record.gstr2b_purchase_id = purchase.id
record.match_status = "linked"
record.match_method = "manual_user_link"
record.match_confidence = 100
record.manually_linked = True
db.add(record)
batch = db.get(AccountingPurchaseEnrichmentBatch, record.batch_id)
if batch:
_update_batch_counts(db, batch)
reanalyze_linked_purchase(db, purchase)
db.commit()
return record
def unlink_record(db, record: AccountingPurchaseEnrichmentRecord):
purchase_id = record.gstr2b_purchase_id
record.gstr2b_purchase_id = None
record.match_status = "unmatched"
record.match_method = "manual_unlink"
record.match_confidence = 0
record.manually_linked = False
db.add(record)
batch = db.get(AccountingPurchaseEnrichmentBatch, record.batch_id)
if batch:
_update_batch_counts(db, batch)
if purchase_id:
purchase = db.get(AccountingGSTR2BPurchase, purchase_id)
if purchase:
reanalyze_linked_purchase(db, purchase)
db.commit()
return record
def batches_for_client(db, tenant_id: int, client_id: int, limit: int = 30):
return list(db.execute(select(AccountingPurchaseEnrichmentBatch).where(
AccountingPurchaseEnrichmentBatch.tenant_id == tenant_id,
AccountingPurchaseEnrichmentBatch.client_id == client_id,
).order_by(AccountingPurchaseEnrichmentBatch.id.desc()).limit(limit)).scalars().all())
def records_for_batch(db, batch_id: int, limit: int = 300):
return list(db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.batch_id == batch_id
).order_by(AccountingPurchaseEnrichmentRecord.id.desc()).limit(limit)).scalars().all())
def items_for_records(db, record_ids):
ids = [int(x) for x in record_ids if x]
if not ids:
return {}
rows = list(db.execute(select(AccountingPurchaseEnrichmentItem).where(
AccountingPurchaseEnrichmentItem.record_id.in_(ids)
).order_by(
AccountingPurchaseEnrichmentItem.record_id,
AccountingPurchaseEnrichmentItem.line_number,
)).scalars().all())
result = {}
for item in rows:
result.setdefault(item.record_id, []).append(item)
return result
@@ -0,0 +1,261 @@
from __future__ import annotations
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 AccountingGSTR2BPurchase
from app.modules.accounting.gstr2b_service import purchases_for_client
from app.modules.accounting.ledger_learning_service import available_tally_guids
from app.modules.accounting.purchase_enrichment_models import (
AccountingPurchaseEnrichmentBatch,
AccountingPurchaseEnrichmentRecord,
)
from app.modules.accounting.purchase_enrichment_service import (
batches_for_client,
import_enrichment,
items_for_records,
manually_link_record,
records_for_batch,
rematch_batch,
unlink_record,
)
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/purchase-enrichment", tags=["accounting-purchase-enrichment-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[:220]
if error:
params["error"] = error[:220]
return RedirectResponse(
url="/tools/accounting/purchase-enrichment?" + urlencode(params),
status_code=303,
)
@router.get("")
def 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 = []
selected_batch = None
records = []
items = {}
gstr2b_rows = []
gstr2b_by_id = {}
companies = []
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]
if selected_batch:
records = records_for_batch(db, selected_batch.id)
items = items_for_records(db, [r.id for r in records])
gstr2b_rows = purchases_for_client(db, scope.tenant_id, selected.id, limit=500)
gstr2b_by_id = {r.id: r for r in gstr2b_rows}
companies = available_tally_guids(db, scope.tenant_id, selected.id)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/purchase_enrichment.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": "E-Invoice / E-Way Bill Enrichment",
"clients": clients,
"selected_client": selected,
"batches": batches,
"selected_batch": selected_batch,
"records": records,
"items_by_record": items,
"gstr2b_rows": gstr2b_rows,
"gstr2b_by_id": gstr2b_by_id,
"tally_companies": companies,
"message": message,
"error": error,
},
)
finally:
db.close()
@router.post("/upload")
async def upload(
request: Request,
client_id: int = Form(...),
source_type: str = Form(...),
source_period: str = Form(""),
tally_guid: 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_enrichment(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
tally_guid=tally_guid,
source_type=source_type,
source_period=source_period,
filename=upload.filename or "source.json",
content=content,
user_id=user.id,
)
if duplicate_file:
msg = f"This exact {source_type.replace('_', ' ')} file was already imported as batch #{batch.id}."
else:
msg = (
f"Imported {batch.records_imported} document(s): "
f"{batch.records_linked} linked, {batch.records_unmatched} unmatched, "
f"{batch.records_ambiguous} ambiguous, {batch.records_duplicate} duplicate(s)."
)
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}/rematch")
def rematch(
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(AccountingPurchaseEnrichmentBatch).where(
AccountingPurchaseEnrichmentBatch.id == batch_id,
AccountingPurchaseEnrichmentBatch.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentBatch.client_id == client.id,
)).scalar_one_or_none()
if not batch:
return _redirect(client_id, error="Enrichment batch was not found.")
count = rematch_batch(db, batch)
return _redirect(client_id, batch_id=batch.id, message=f"Re-matched {count} enrichment document(s).")
except Exception as exc:
db.rollback()
return _redirect(client_id, batch_id=batch_id, error=str(exc))
finally:
db.close()
@router.post("/record/{record_id}/link")
def manual_link(
request: Request,
record_id: int,
client_id: int = Form(...),
purchase_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()
record = db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.id == record_id,
AccountingPurchaseEnrichmentRecord.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentRecord.client_id == client.id,
)).scalar_one_or_none()
purchase = 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 record or not purchase:
return _redirect(client_id, error="Record or GSTR-2B purchase was not found.")
manually_link_record(db, record, purchase)
return _redirect(client_id, batch_id=record.batch_id, message=f"Linked to GSTR-2B invoice {purchase.invoice_number}.")
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()
@router.post("/record/{record_id}/unlink")
def manual_unlink(
request: Request,
record_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()
record = db.execute(select(AccountingPurchaseEnrichmentRecord).where(
AccountingPurchaseEnrichmentRecord.id == record_id,
AccountingPurchaseEnrichmentRecord.tenant_id == scope.tenant_id,
AccountingPurchaseEnrichmentRecord.client_id == client.id,
)).scalar_one_or_none()
if not record:
return _redirect(client_id, error="Enrichment record was not found.")
batch_id = record.batch_id
unlink_record(db, record)
return _redirect(client_id, batch_id=batch_id, message="Enrichment source was unlinked.")
except Exception as exc:
db.rollback()
return _redirect(client_id, error=str(exc))
finally:
db.close()
@@ -0,0 +1,195 @@
{% 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">E-Invoice / E-Way Bill Enrichment</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">Phase 8 links E-Invoice and E-Way Bill source documents to existing GSTR-2B purchases and enriches classification with item description, HSN, quantity and logistics context. Original source fields remain unchanged and nothing is posted to Tally.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/gstr2b{% 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">GSTR-2B Intelligence</a>
<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/purchase-enrichment" 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">
Enrichment 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.source_type|replace('_',' ')|title }} · {{ batch.original_filename }}</option>
{% endfor %}
</select>
</label>
{% endif %}
<div class="flex items-end"><button 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 enrichment source</h2>
<p class="mt-1 text-sm text-slate-500">Supported: JSON, XLSX/XLSM and CSV. JSON supports common GST E-Invoice and E-Way Bill field structures, including item lists.</p>
</div>
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">Enrichment only · No voucher posting</span>
</div>
<form method="post" action="/tools/accounting/purchase-enrichment/upload" enctype="multipart/form-data" class="mt-4 grid gap-4 lg:grid-cols-6">
<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">
Source
<select name="source_type" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="e_invoice">E-Invoice</option>
<option value="e_way_bill">E-Way Bill</option>
</select>
</label>
<label class="text-sm font-medium text-slate-700">
Period
<input name="source_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">
File
<input type="file" name="upload" accept=".json,.xlsx,.xlsm,.csv,.txt" required class="mt-1 block w-full text-sm text-slate-700" />
</label>
<div class="flex items-end"><button class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800">Import & Match</button></div>
</form>
</section>
{% if selected_batch %}
<section class="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Source</div><div class="mt-2 font-semibold">{{ selected_batch.source_type|replace('_',' ')|title }}</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">{{ selected_batch.records_imported }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Linked</div><div class="mt-2 text-xl font-semibold text-emerald-700">{{ selected_batch.records_linked }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Unmatched</div><div class="mt-2 text-xl font-semibold text-amber-700">{{ selected_batch.records_unmatched }}</div></div>
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Ambiguous</div><div class="mt-2 text-xl font-semibold text-red-700">{{ selected_batch.records_ambiguous }}</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">{{ selected_batch.records_duplicate }}</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">Automatic matching uses supplier GSTIN + invoice number/date first, with a conservative value/date fallback.</p>
</div>
<form method="post" action="/tools/accounting/purchase-enrichment/batch/{{ selected_batch.id }}/rematch">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Re-match Batch</button>
</form>
</div>
</section>
<section class="space-y-4">
{% for record in records %}
{% set linked = gstr2b_by_id.get(record.gstr2b_purchase_id) if record.gstr2b_purchase_id else None %}
<article class="rounded-2xl bg-white p-5 shadow-soft">
<div class="grid gap-5 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">{{ record.supplier_name or record.supplier_gstin or 'Supplier' }}</h3>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">{{ record.source_type|replace('_',' ')|title }}</span>
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if record.match_status == 'linked' %}bg-emerald-50 text-emerald-700{% elif record.match_status == 'ambiguous' %}bg-red-50 text-red-700{% else %}bg-amber-50 text-amber-700{% endif %}">{{ record.match_status|title }}</span>
</div>
<div class="mt-2 space-y-1 text-sm text-slate-600">
<div>GSTIN: <span class="font-medium text-slate-900">{{ record.supplier_gstin or '-' }}</span></div>
<div>Document: <span class="font-medium text-slate-900">{{ record.document_number }}</span> · {{ record.document_date }}</div>
<div>Reference: {{ record.external_reference or '-' }}</div>
{% if record.vehicle_number %}<div>Vehicle: {{ record.vehicle_number }}{% if record.transport_mode %} · {{ record.transport_mode }}{% endif %}</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(record.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(record.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(record.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(record.cgst + record.sgst) }}</div></div>
</div>
</div>
<div class="xl:col-span-5">
{% if linked %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3">
<div class="text-xs font-semibold uppercase text-emerald-700">Linked GSTR-2B purchase · {{ record.match_confidence }}%</div>
<div class="mt-1 font-semibold text-slate-900">{{ linked.invoice_number }} · {{ linked.invoice_date }}</div>
<div class="mt-1 text-sm text-slate-700">₹{{ '%.2f'|format(linked.taxable_value) }} taxable · {{ record.match_method|replace('_',' ') }}</div>
</div>
<form method="post" action="/tools/accounting/purchase-enrichment/record/{{ record.id }}/unlink" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
<button class="text-xs font-semibold text-red-600 hover:text-red-700">Unlink source</button>
</form>
{% else %}
<form method="post" action="/tools/accounting/purchase-enrichment/record/{{ record.id }}/link" class="rounded-xl border border-slate-200 p-3">
<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">
Manual GSTR-2B link
<select name="purchase_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-2 py-2 text-sm">
<option value="">Select invoice</option>
{% for row in gstr2b_rows %}
<option value="{{ row.id }}">{{ row.supplier_name or row.supplier_gstin }} · {{ row.invoice_number }} · {{ row.invoice_date }} · ₹{{ '%.2f'|format(row.taxable_value) }}</option>
{% endfor %}
</select>
</label>
<button class="mt-2 rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Link & Re-analyze</button>
</form>
{% endif %}
</div>
</div>
{% if items_by_record.get(record.id) %}
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><th class="px-3 py-2">Item</th><th class="px-3 py-2">HSN</th><th class="px-3 py-2 text-right">Qty</th><th class="px-3 py-2">Unit</th><th class="px-3 py-2 text-right">Taxable</th></tr></thead>
<tbody>
{% for item in items_by_record.get(record.id) %}
<tr class="border-t border-slate-100">
<td class="px-3 py-2"><div class="font-medium text-slate-900">{{ item.product_name or item.description_text or '-' }}</div>{% if item.description_text and item.description_text != item.product_name %}<div class="text-xs text-slate-500">{{ item.description_text }}</div>{% endif %}</td>
<td class="px-3 py-2">{{ item.hsn_code or '-' }}</td>
<td class="px-3 py-2 text-right">{{ item.quantity }}</td>
<td class="px-3 py-2">{{ item.unit or '-' }}</td>
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(item.taxable_value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</article>
{% else %}
<div class="rounded-2xl bg-white p-8 text-center text-sm text-slate-500 shadow-soft">No enrichment records in this batch.</div>
{% endfor %}
</section>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -17,6 +17,7 @@
{% 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/accounting/purchase-enrichment?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">E-Invoice / E-Way Bill</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>