227 lines
7.8 KiB
Python
227 lines
7.8 KiB
Python
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
|