Add Phase 18 automatic stock item voucher preparation

This commit is contained in:
A R R R Associates
2026-08-23 18:45:21 +05:30
parent 1111a52917
commit 67ebd587e9
13 changed files with 1705 additions and 31 deletions
@@ -0,0 +1,370 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import MetaData, Table, and_, inspect, select
@dataclass(frozen=True)
class SourceDescriptor:
kind: str
table_name: str
score: int
columns: tuple[str, ...]
BANK_TABLE_HINTS = (
"bank_statement_transaction",
"bank_transactions",
"bank_analyzer_transaction",
"bank_statement_rows",
"bank_entries",
)
EWAY_TABLE_HINTS = (
"ewaybill",
"eway_bill",
"e_way_bill",
"accounting_purchase_source",
"purchase_source",
"sales_source",
)
BANK_COLUMNS = {
"id": ("id", "transaction_id", "row_id"),
"client_id": ("client_id",),
"date": ("transaction_date", "txn_date", "date", "value_date"),
"narration": ("narration", "description", "particulars", "remarks"),
"debit": ("debit", "withdrawal", "debit_amount"),
"credit": ("credit", "deposit", "credit_amount"),
"amount": ("amount",),
"dr_cr": ("dr_cr", "type", "nature"),
"party": ("final_party", "manual_party", "auto_party", "party", "counterparty"),
"category": ("category", "final_category", "nature"),
"account_no": ("account_number", "bank_account_number", "account_no"),
"bank": ("bank_name", "bank"),
"reference": ("reference", "ref_no", "utr", "transaction_reference"),
"contra": ("contra_match_id", "contra_pair_id", "is_contra"),
}
EWAY_COLUMNS = {
"id": ("id", "item_id", "row_id"),
"client_id": ("client_id",),
"direction": ("direction", "supply_type", "transaction_type", "inward_outward"),
"ewb_no": ("eway_bill_no", "ewb_no", "ewaybill_no"),
"invoice_no": ("invoice_no", "doc_no", "document_number", "bill_no"),
"invoice_date": ("invoice_date", "doc_date", "document_date", "date"),
"party_name": ("supplier_name", "customer_name", "party_name", "trade_name", "legal_name"),
"party_gstin": ("supplier_gstin", "customer_gstin", "party_gstin", "gstin"),
"item_name": ("product_name", "item_name", "description", "product_description"),
"hsn": ("hsn", "hsn_code", "hsn_sac"),
"quantity": ("quantity", "qty"),
"unit": ("unit", "uqc"),
"rate": ("rate", "unit_rate"),
"taxable_value": ("taxable_value", "taxable_amount", "assessable_value", "amount"),
"cgst": ("cgst", "cgst_amount"),
"sgst": ("sgst", "sgst_amount"),
"igst": ("igst", "igst_amount"),
"cess": ("cess", "cess_amount"),
"tax_rate": ("tax_rate", "gst_rate", "rate_percent"),
"vehicle_no": ("vehicle_no", "vehicle_number"),
}
def _norm(value: Any) -> str:
return str(value or "").strip()
def _float(value: Any) -> float:
if value in (None, ""):
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(str(value).replace(",", ""))
except Exception:
return 0.0
def _filter_date(column, value: str):
text = _norm(value)
if not text:
return None
type_name = column.type.__class__.__name__.lower()
if "date" in type_name or "time" in type_name:
try:
return date.fromisoformat(text)
except Exception:
return text
return text
def _date_text(value: Any) -> str:
if value in (None, ""):
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = _norm(value)
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%Y%m%d"):
try:
return datetime.strptime(text, fmt).date().isoformat()
except Exception:
pass
return text[:10]
def _resolve(columns: set[str], choices: tuple[str, ...]) -> str:
lower = {c.lower(): c for c in columns}
for candidate in choices:
if candidate.lower() in lower:
return lower[candidate.lower()]
return ""
def _table_score(name: str, cols: set[str], hints: tuple[str, ...], mapping: dict) -> int:
score = 0
lname = name.lower()
if any(h in lname for h in hints):
score += 12
for choices in mapping.values():
if _resolve(cols, choices):
score += 1
return score
def discover_sources(db) -> dict[str, list[SourceDescriptor]]:
inspector = inspect(db.get_bind())
result = {"bank": [], "eway": []}
for table_name in inspector.get_table_names():
try:
cols = {row["name"] for row in inspector.get_columns(table_name)}
except Exception:
continue
bank_score = _table_score(table_name, cols, BANK_TABLE_HINTS, BANK_COLUMNS)
if bank_score >= 18 and (
_resolve(cols, BANK_COLUMNS["narration"])
and (
_resolve(cols, BANK_COLUMNS["amount"])
or _resolve(cols, BANK_COLUMNS["debit"])
or _resolve(cols, BANK_COLUMNS["credit"])
)
):
result["bank"].append(
SourceDescriptor("bank", table_name, bank_score, tuple(sorted(cols)))
)
eway_score = _table_score(table_name, cols, EWAY_TABLE_HINTS, EWAY_COLUMNS)
if eway_score >= 17 and (
_resolve(cols, EWAY_COLUMNS["ewb_no"])
and _resolve(cols, EWAY_COLUMNS["item_name"])
and _resolve(cols, EWAY_COLUMNS["taxable_value"])
):
result["eway"].append(
SourceDescriptor("eway", table_name, eway_score, tuple(sorted(cols)))
)
for key in result:
result[key] = sorted(result[key], key=lambda row: (-row.score, row.table_name))
return result
def _table(db, table_name: str):
metadata = MetaData()
return Table(table_name, metadata, autoload_with=db.get_bind())
def bank_rows(
db,
*,
table_name: str,
client_id: int,
from_date: str = "",
to_date: str = "",
limit: int = 500,
):
table = _table(db, table_name)
cols = set(table.c.keys())
cmap = {key: _resolve(cols, choices) for key, choices in BANK_COLUMNS.items()}
conditions = []
if cmap["client_id"]:
conditions.append(table.c[cmap["client_id"]] == int(client_id))
if from_date and cmap["date"]:
conditions.append(table.c[cmap["date"]] >= _filter_date(table.c[cmap["date"]], from_date))
if to_date and cmap["date"]:
conditions.append(table.c[cmap["date"]] <= _filter_date(table.c[cmap["date"]], to_date))
stmt = select(table)
if conditions:
stmt = stmt.where(and_(*conditions))
if cmap["date"]:
stmt = stmt.order_by(table.c[cmap["date"]].desc())
stmt = stmt.limit(max(1, min(2000, int(limit))))
rows = []
for raw in db.execute(stmt).mappings():
debit = _float(raw.get(cmap["debit"])) if cmap["debit"] else 0.0
credit = _float(raw.get(cmap["credit"])) if cmap["credit"] else 0.0
amount = _float(raw.get(cmap["amount"])) if cmap["amount"] else 0.0
dr_cr = _norm(raw.get(cmap["dr_cr"])).upper() if cmap["dr_cr"] else ""
if debit > 0:
direction = "DEBIT"
value = debit
elif credit > 0:
direction = "CREDIT"
value = credit
elif dr_cr.startswith("D"):
direction = "DEBIT"
value = abs(amount)
elif dr_cr.startswith("C"):
direction = "CREDIT"
value = abs(amount)
elif amount < 0:
direction = "DEBIT"
value = abs(amount)
else:
direction = "CREDIT"
value = abs(amount)
rows.append(
{
"source_table": table_name,
"source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
"date": _date_text(raw.get(cmap["date"])) if cmap["date"] else "",
"narration": _norm(raw.get(cmap["narration"])) if cmap["narration"] else "",
"party": _norm(raw.get(cmap["party"])) if cmap["party"] else "",
"category": _norm(raw.get(cmap["category"])) if cmap["category"] else "",
"bank_name": _norm(raw.get(cmap["bank"])) if cmap["bank"] else "",
"account_number": _norm(raw.get(cmap["account_no"])) if cmap["account_no"] else "",
"reference": _norm(raw.get(cmap["reference"])) if cmap["reference"] else "",
"direction": direction,
"amount": round(value, 2),
"contra_hint": _norm(raw.get(cmap["contra"])) if cmap["contra"] else "",
}
)
return rows
def eway_rows(
db,
*,
table_name: str,
client_id: int,
direction: str = "",
from_date: str = "",
to_date: str = "",
limit: int = 2000,
):
table = _table(db, table_name)
cols = set(table.c.keys())
cmap = {key: _resolve(cols, choices) for key, choices in EWAY_COLUMNS.items()}
conditions = []
if cmap["client_id"]:
conditions.append(table.c[cmap["client_id"]] == int(client_id))
if from_date and cmap["invoice_date"]:
conditions.append(table.c[cmap["invoice_date"]] >= _filter_date(table.c[cmap["invoice_date"]], from_date))
if to_date and cmap["invoice_date"]:
conditions.append(table.c[cmap["invoice_date"]] <= _filter_date(table.c[cmap["invoice_date"]], to_date))
stmt = select(table)
if conditions:
stmt = stmt.where(and_(*conditions))
if cmap["invoice_date"]:
stmt = stmt.order_by(table.c[cmap["invoice_date"]].desc())
stmt = stmt.limit(max(1, min(10000, int(limit))))
normalized_direction = _norm(direction).upper()
rows = []
for raw in db.execute(stmt).mappings():
raw_direction = _norm(raw.get(cmap["direction"])).upper() if cmap["direction"] else ""
detected = ""
if raw_direction in {"I", "IN", "INWARD", "PURCHASE"} or "INWARD" in raw_direction or "PURCHASE" in raw_direction:
detected = "PURCHASE"
elif raw_direction in {"O", "OUT", "OUTWARD", "SALES"} or "OUTWARD" in raw_direction or "SALES" in raw_direction:
detected = "SALES"
if normalized_direction and detected and detected != normalized_direction:
continue
taxable = round(abs(_float(raw.get(cmap["taxable_value"]))), 2)
qty = _float(raw.get(cmap["quantity"])) if cmap["quantity"] else 0.0
rate = _float(raw.get(cmap["rate"])) if cmap["rate"] else 0.0
if not rate and qty:
rate = taxable / qty
rows.append(
{
"source_table": table_name,
"source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
"direction": detected or normalized_direction or "UNKNOWN",
"eway_bill_no": _norm(raw.get(cmap["ewb_no"])) if cmap["ewb_no"] else "",
"invoice_no": _norm(raw.get(cmap["invoice_no"])) if cmap["invoice_no"] else "",
"invoice_date": _date_text(raw.get(cmap["invoice_date"])) if cmap["invoice_date"] else "",
"party_name": _norm(raw.get(cmap["party_name"])) if cmap["party_name"] else "",
"party_gstin": _norm(raw.get(cmap["party_gstin"])) if cmap["party_gstin"] else "",
"item_name": _norm(raw.get(cmap["item_name"])) if cmap["item_name"] else "",
"hsn": _norm(raw.get(cmap["hsn"])) if cmap["hsn"] else "",
"quantity": qty,
"unit": _norm(raw.get(cmap["unit"])) if cmap["unit"] else "",
"rate": round(rate, 4),
"taxable_value": taxable,
"cgst": round(abs(_float(raw.get(cmap["cgst"]))), 2) if cmap["cgst"] else 0.0,
"sgst": round(abs(_float(raw.get(cmap["sgst"]))), 2) if cmap["sgst"] else 0.0,
"igst": round(abs(_float(raw.get(cmap["igst"]))), 2) if cmap["igst"] else 0.0,
"cess": round(abs(_float(raw.get(cmap["cess"]))), 2) if cmap["cess"] else 0.0,
"tax_rate": _float(raw.get(cmap["tax_rate"])) if cmap["tax_rate"] else 0.0,
"vehicle_no": _norm(raw.get(cmap["vehicle_no"])) if cmap["vehicle_no"] else "",
}
)
return rows
def group_eway_invoices(rows: list[dict]) -> list[dict]:
grouped: dict[tuple, dict] = {}
for row in rows:
key = (
row.get("direction") or "",
row.get("eway_bill_no") or "",
row.get("invoice_no") or "",
row.get("invoice_date") or "",
row.get("party_gstin") or "",
row.get("party_name") or "",
)
group = grouped.setdefault(
key,
{
"direction": row.get("direction") or "",
"eway_bill_no": row.get("eway_bill_no") or "",
"invoice_no": row.get("invoice_no") or "",
"invoice_date": row.get("invoice_date") or "",
"party_name": row.get("party_name") or "",
"party_gstin": row.get("party_gstin") or "",
"source_table": row.get("source_table") or "",
"source_ids": [],
"items": [],
"taxable_value": 0.0,
"cgst": 0.0,
"sgst": 0.0,
"igst": 0.0,
"cess": 0.0,
},
)
group["source_ids"].append(row.get("source_id") or "")
group["items"].append(row)
for field in ("taxable_value", "cgst", "sgst", "igst", "cess"):
group[field] = round(group[field] + float(row.get(field) or 0), 2)
return sorted(
grouped.values(),
key=lambda x: (x["invoice_date"], x["invoice_no"], x["eway_bill_no"]),
reverse=True,
)