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,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