Add Phase 18 automatic stock item voucher preparation
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.accounting.chart_models import AccountingChartLedger
|
||||
from app.modules.accounting.chart_service import effective_role
|
||||
from app.modules.accounting.voucher_engine_models import (
|
||||
AccountingVoucherDraft,
|
||||
AccountingVoucherItemLine,
|
||||
AccountingVoucherLine,
|
||||
)
|
||||
|
||||
|
||||
def _s(value):
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _ledger_candidates(db, *, tenant_id: int, client_id: int, tally_guid: str):
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(AccountingChartLedger).where(
|
||||
AccountingChartLedger.tenant_id == int(tenant_id),
|
||||
AccountingChartLedger.client_id == int(client_id),
|
||||
AccountingChartLedger.tally_guid == _s(tally_guid),
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _match_by_role(rows, roles, name_hint=""):
|
||||
roles = set(roles)
|
||||
hint = _s(name_hint).casefold()
|
||||
|
||||
ranked = []
|
||||
for row in rows:
|
||||
role = effective_role(row)
|
||||
if role not in roles:
|
||||
continue
|
||||
score = 100
|
||||
if hint:
|
||||
name = row.name.casefold()
|
||||
if hint == name:
|
||||
score += 100
|
||||
elif hint in name or name in hint:
|
||||
score += 40
|
||||
for token in [t for t in hint.replace("-", " ").split() if len(t) >= 4]:
|
||||
if token in name:
|
||||
score += 5
|
||||
ranked.append((score, row))
|
||||
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
return ranked[0][1] if ranked else None
|
||||
|
||||
|
||||
def _name_match(rows, text, allowed_roles=None):
|
||||
target = _s(text).casefold()
|
||||
if not target:
|
||||
return None
|
||||
|
||||
ranked = []
|
||||
for row in rows:
|
||||
role = effective_role(row)
|
||||
if allowed_roles and role not in set(allowed_roles):
|
||||
continue
|
||||
name = row.name.casefold()
|
||||
score = 0
|
||||
if target == name:
|
||||
score = 300
|
||||
elif target in name or name in target:
|
||||
score = 180
|
||||
else:
|
||||
tokens = [t for t in target.replace("-", " ").replace(".", " ").split() if len(t) >= 4]
|
||||
score = sum(12 for token in tokens if token in name)
|
||||
if score:
|
||||
ranked.append((score, row))
|
||||
|
||||
ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
return ranked[0][1] if ranked else None
|
||||
|
||||
|
||||
def _append_line(draft, line_no, ledger, side, amount, note=""):
|
||||
if amount <= 0 or ledger is None:
|
||||
return line_no
|
||||
draft.lines.append(
|
||||
AccountingVoucherLine(
|
||||
line_no=line_no,
|
||||
ledger_name=ledger.name,
|
||||
ledger_role_code=effective_role(ledger),
|
||||
dr_cr=side,
|
||||
amount=round(float(amount), 2),
|
||||
line_note=note,
|
||||
)
|
||||
)
|
||||
return line_no + 1
|
||||
|
||||
|
||||
def _audit_area(voucher_type, roles):
|
||||
areas = []
|
||||
if roles & {"SALES", "DIRECT_INCOME", "OTHER_INCOME"}:
|
||||
areas.append("Revenue & Other Income")
|
||||
if roles & {"PURCHASE", "DIRECT_EXPENSE", "INDIRECT_EXPENSE", "SUPPLIER"}:
|
||||
areas.append("Purchases & Expenses")
|
||||
if roles & {"BANK", "CASH"}:
|
||||
areas.append("Cash & Bank")
|
||||
if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
|
||||
areas.append("GST & Taxes")
|
||||
if roles & {"CUSTOMER"}:
|
||||
areas.append("Trade Receivables")
|
||||
if not areas:
|
||||
areas.append("General Ledger")
|
||||
return " · ".join(dict.fromkeys(areas))
|
||||
|
||||
|
||||
def create_bank_draft(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
tally_guid: str,
|
||||
company_name: str,
|
||||
source_row: dict,
|
||||
bank_ledger_name: str,
|
||||
user_id: int,
|
||||
):
|
||||
rows = _ledger_candidates(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
tally_guid=tally_guid,
|
||||
)
|
||||
|
||||
bank = _name_match(rows, bank_ledger_name, {"BANK", "CASH"})
|
||||
if bank is None:
|
||||
raise ValueError(
|
||||
f"Bank/Cash ledger '{bank_ledger_name}' was not found in the client's Chart of Accounts."
|
||||
)
|
||||
|
||||
direction = _s(source_row.get("direction")).upper()
|
||||
amount = round(abs(float(source_row.get("amount") or 0)), 2)
|
||||
party_hint = source_row.get("party") or source_row.get("narration") or ""
|
||||
category = _s(source_row.get("category")).lower()
|
||||
narration = _s(source_row.get("narration"))
|
||||
|
||||
contra_hint = _s(source_row.get("contra_hint"))
|
||||
counter = None
|
||||
voucher_type = ""
|
||||
|
||||
if contra_hint:
|
||||
counter = _name_match(rows, party_hint, {"BANK", "CASH"})
|
||||
if counter:
|
||||
voucher_type = "Contra"
|
||||
|
||||
if not counter:
|
||||
if direction == "DEBIT":
|
||||
voucher_type = "Payment"
|
||||
role_order = (
|
||||
{"SUPPLIER"},
|
||||
{"DIRECT_EXPENSE", "INDIRECT_EXPENSE"},
|
||||
{"FIXED_ASSET", "CURRENT_ASSET"},
|
||||
{"LOAN"},
|
||||
{"CAPITAL"},
|
||||
)
|
||||
else:
|
||||
voucher_type = "Receipt"
|
||||
role_order = (
|
||||
{"CUSTOMER"},
|
||||
{"DIRECT_INCOME", "OTHER_INCOME", "SALES"},
|
||||
{"LOAN"},
|
||||
{"CAPITAL"},
|
||||
)
|
||||
|
||||
for roles in role_order:
|
||||
counter = _name_match(rows, party_hint, roles)
|
||||
if counter:
|
||||
break
|
||||
|
||||
if not counter and category:
|
||||
if direction == "DEBIT":
|
||||
counter = _name_match(rows, category, {"DIRECT_EXPENSE", "INDIRECT_EXPENSE", "PURCHASE"})
|
||||
else:
|
||||
counter = _name_match(rows, category, {"DIRECT_INCOME", "OTHER_INCOME", "SALES"})
|
||||
|
||||
if not counter:
|
||||
raise ValueError(
|
||||
"Counter-ledger could not be resolved confidently from the bank transaction. "
|
||||
"Review the Chart of Accounts / historical learning before auto-creating this voucher."
|
||||
)
|
||||
|
||||
draft = AccountingVoucherDraft(
|
||||
tenant_id=int(tenant_id),
|
||||
client_id=int(client_id),
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
voucher_type=voucher_type,
|
||||
business_nature=voucher_type.upper(),
|
||||
voucher_date=source_row.get("date") or datetime.now().date().isoformat(),
|
||||
reference=source_row.get("reference") or "",
|
||||
narration=narration,
|
||||
source_type="bank",
|
||||
source_record_type=source_row.get("source_table") or "bank",
|
||||
source_record_id=source_row.get("source_id") or "",
|
||||
source_evidence_json=json.dumps(source_row, ensure_ascii=False, default=str),
|
||||
total_debit=amount,
|
||||
total_credit=amount,
|
||||
status="draft",
|
||||
created_by_user_id=int(user_id),
|
||||
updated_at_utc=_utcnow(),
|
||||
)
|
||||
|
||||
if voucher_type == "Contra":
|
||||
if direction == "DEBIT":
|
||||
_append_line(draft, 1, counter, "DR", amount, "Matched own-bank transfer")
|
||||
_append_line(draft, 2, bank, "CR", amount, narration)
|
||||
else:
|
||||
_append_line(draft, 1, bank, "DR", amount, narration)
|
||||
_append_line(draft, 2, counter, "CR", amount, "Matched own-bank transfer")
|
||||
elif voucher_type == "Payment":
|
||||
_append_line(draft, 1, counter, "DR", amount, narration)
|
||||
_append_line(draft, 2, bank, "CR", amount, narration)
|
||||
else:
|
||||
_append_line(draft, 1, bank, "DR", amount, narration)
|
||||
_append_line(draft, 2, counter, "CR", amount, narration)
|
||||
|
||||
roles = {line.ledger_role_code for line in draft.lines}
|
||||
draft.audit_area = _audit_area(voucher_type, roles)
|
||||
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
db.refresh(draft)
|
||||
return draft
|
||||
|
||||
|
||||
def create_eway_invoice_draft(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
tally_guid: str,
|
||||
company_name: str,
|
||||
invoice: dict,
|
||||
user_id: int,
|
||||
):
|
||||
rows = _ledger_candidates(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
tally_guid=tally_guid,
|
||||
)
|
||||
|
||||
direction = _s(invoice.get("direction")).upper()
|
||||
if direction not in {"PURCHASE", "SALES"}:
|
||||
raise ValueError("E-Way Bill direction could not be identified as Purchase or Sales.")
|
||||
|
||||
party_name = invoice.get("party_name") or ""
|
||||
if direction == "PURCHASE":
|
||||
party = _name_match(rows, party_name, {"SUPPLIER"})
|
||||
main = _match_by_role(rows, {"PURCHASE"})
|
||||
input_cgst = _name_match(rows, "input cgst", {"GST_INPUT"})
|
||||
input_sgst = _name_match(rows, "input sgst", {"GST_INPUT"})
|
||||
input_igst = _name_match(rows, "input igst", {"GST_INPUT"})
|
||||
cess_ledger = _name_match(rows, "input cess", {"GST_INPUT", "TAX_OTHER"})
|
||||
voucher_type = "Purchase"
|
||||
item_side = "DR"
|
||||
party_side = "CR"
|
||||
else:
|
||||
party = _name_match(rows, party_name, {"CUSTOMER"})
|
||||
main = _match_by_role(rows, {"SALES"})
|
||||
input_cgst = _name_match(rows, "output cgst", {"GST_OUTPUT"})
|
||||
input_sgst = _name_match(rows, "output sgst", {"GST_OUTPUT"})
|
||||
input_igst = _name_match(rows, "output igst", {"GST_OUTPUT"})
|
||||
cess_ledger = _name_match(rows, "output cess", {"GST_OUTPUT", "TAX_OTHER"})
|
||||
voucher_type = "Sales"
|
||||
item_side = "CR"
|
||||
party_side = "DR"
|
||||
|
||||
if party is None:
|
||||
raise ValueError(
|
||||
f"{'Supplier' if direction == 'PURCHASE' else 'Customer'} ledger for "
|
||||
f"'{party_name}' could not be resolved confidently."
|
||||
)
|
||||
if main is None:
|
||||
raise ValueError(
|
||||
f"No {'Purchase' if direction == 'PURCHASE' else 'Sales'} ledger is classified "
|
||||
"in the client's Chart of Accounts."
|
||||
)
|
||||
|
||||
taxable = round(float(invoice.get("taxable_value") or 0), 2)
|
||||
cgst = round(float(invoice.get("cgst") or 0), 2)
|
||||
sgst = round(float(invoice.get("sgst") or 0), 2)
|
||||
igst = round(float(invoice.get("igst") or 0), 2)
|
||||
cess = round(float(invoice.get("cess") or 0), 2)
|
||||
gross = round(taxable + cgst + sgst + igst + cess, 2)
|
||||
|
||||
draft = AccountingVoucherDraft(
|
||||
tenant_id=int(tenant_id),
|
||||
client_id=int(client_id),
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
voucher_type=voucher_type,
|
||||
business_nature=direction,
|
||||
voucher_date=invoice.get("invoice_date") or datetime.now().date().isoformat(),
|
||||
reference=invoice.get("invoice_no") or invoice.get("eway_bill_no") or "",
|
||||
narration=(
|
||||
f"E-Way Bill {invoice.get('eway_bill_no') or '-'}"
|
||||
f" · {party_name}"
|
||||
),
|
||||
source_type="ewaybill",
|
||||
source_record_type=invoice.get("source_table") or "ewaybill",
|
||||
source_record_id=",".join([x for x in invoice.get("source_ids", []) if x])[:120],
|
||||
source_evidence_json=json.dumps(invoice, ensure_ascii=False, default=str),
|
||||
total_debit=gross,
|
||||
total_credit=gross,
|
||||
status="draft",
|
||||
created_by_user_id=int(user_id),
|
||||
updated_at_utc=_utcnow(),
|
||||
)
|
||||
|
||||
line_no = 1
|
||||
# Accounting lines remain required for semantic/audit classification.
|
||||
if direction == "PURCHASE":
|
||||
line_no = _append_line(draft, line_no, main, "DR", taxable, "Stock item taxable value")
|
||||
line_no = _append_line(draft, line_no, input_cgst, "DR", cgst, "Input CGST")
|
||||
line_no = _append_line(draft, line_no, input_sgst, "DR", sgst, "Input SGST")
|
||||
line_no = _append_line(draft, line_no, input_igst, "DR", igst, "Input IGST")
|
||||
line_no = _append_line(draft, line_no, cess_ledger, "DR", cess, "Input Cess")
|
||||
_append_line(draft, line_no, party, "CR", gross, party_name)
|
||||
else:
|
||||
line_no = _append_line(draft, line_no, party, "DR", gross, party_name)
|
||||
line_no = _append_line(draft, line_no, main, "CR", taxable, "Stock item taxable value")
|
||||
line_no = _append_line(draft, line_no, input_cgst, "CR", cgst, "Output CGST")
|
||||
line_no = _append_line(draft, line_no, input_sgst, "CR", sgst, "Output SGST")
|
||||
line_no = _append_line(draft, line_no, input_igst, "CR", igst, "Output IGST")
|
||||
_append_line(draft, line_no, cess_ledger, "CR", cess, "Output Cess")
|
||||
|
||||
for index, item in enumerate(invoice.get("items") or [], 1):
|
||||
item_amount = round(float(item.get("taxable_value") or 0), 2)
|
||||
qty = float(item.get("quantity") or 0)
|
||||
rate = float(item.get("rate") or 0)
|
||||
if not rate and qty:
|
||||
rate = item_amount / qty
|
||||
|
||||
draft.items.append(
|
||||
AccountingVoucherItemLine(
|
||||
line_no=index,
|
||||
stock_item_name=_s(item.get("item_name")),
|
||||
hsn_sac=_s(item.get("hsn")),
|
||||
quantity=qty,
|
||||
unit=_s(item.get("unit")),
|
||||
rate=round(rate, 4),
|
||||
amount=item_amount,
|
||||
dr_cr=item_side,
|
||||
accounting_ledger_name=main.name,
|
||||
godown_name="",
|
||||
source_item_json=json.dumps(item, ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
|
||||
roles = {line.ledger_role_code for line in draft.lines}
|
||||
draft.audit_area = _audit_area(voucher_type, roles)
|
||||
draft.audit_flags_json = json.dumps(
|
||||
["stock-item-wise", "eway-bill-source", "external-evidence"],
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
db.add(draft)
|
||||
db.commit()
|
||||
db.refresh(draft)
|
||||
return draft
|
||||
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
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.automatic_voucher_service import (
|
||||
create_bank_draft,
|
||||
create_eway_invoice_draft,
|
||||
)
|
||||
from app.modules.accounting.chart_models import AccountingChartLedger
|
||||
from app.modules.accounting.chart_service import effective_role
|
||||
from app.modules.accounting.source_adapter_service import (
|
||||
bank_rows,
|
||||
discover_sources,
|
||||
eway_rows,
|
||||
group_eway_invoices,
|
||||
)
|
||||
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/voucher-engine/automatic",
|
||||
tags=["accounting-automatic-voucher-source-ui"],
|
||||
)
|
||||
|
||||
|
||||
def _go(client_id=0, message="", error="", **extra):
|
||||
params = {}
|
||||
if client_id:
|
||||
params["client_id"] = client_id
|
||||
if message:
|
||||
params["message"] = message[:350]
|
||||
if error:
|
||||
params["error"] = error[:350]
|
||||
for key, value in extra.items():
|
||||
if value not in ("", None):
|
||||
params[key] = value
|
||||
return RedirectResponse(
|
||||
"/tools/accounting/voucher-engine/automatic"
|
||||
+ ("?" + urlencode(params) if params else ""),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
def _company_options(db, tenant_id, client_id):
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name)
|
||||
.where(
|
||||
AccountingChartLedger.tenant_id == int(tenant_id),
|
||||
AccountingChartLedger.client_id == int(client_id),
|
||||
)
|
||||
.distinct()
|
||||
.order_by(AccountingChartLedger.company_name)
|
||||
).all()
|
||||
)
|
||||
return [{"guid": guid, "name": name or guid} for guid, name in rows if guid]
|
||||
|
||||
|
||||
def _company_name(options, tally_guid):
|
||||
return next((row["name"] for row in options if row["guid"] == tally_guid), "")
|
||||
|
||||
|
||||
def _source_table(valid, requested):
|
||||
names = {row.table_name for row in valid}
|
||||
if requested in names:
|
||||
return requested
|
||||
return valid[0].table_name if valid else ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def page(
|
||||
request: Request,
|
||||
client_id: int | None = None,
|
||||
tally_guid: str = "",
|
||||
bank_source: str = "",
|
||||
eway_source: str = "",
|
||||
direction: str = "",
|
||||
from_date: str = "",
|
||||
to_date: str = "",
|
||||
message: str = "",
|
||||
error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.view")
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
company_options = []
|
||||
sources = {"bank": [], "eway": []}
|
||||
selected_bank_source = ""
|
||||
selected_eway_source = ""
|
||||
banks = []
|
||||
eway_invoices = []
|
||||
bank_ledgers = []
|
||||
|
||||
if selected:
|
||||
company_options = _company_options(db, scope.tenant_id, selected.id)
|
||||
if not tally_guid and len(company_options) == 1:
|
||||
tally_guid = company_options[0]["guid"]
|
||||
if tally_guid not in {row["guid"] for row in company_options}:
|
||||
tally_guid = ""
|
||||
|
||||
if tally_guid:
|
||||
bank_ledgers = list(
|
||||
db.execute(
|
||||
select(AccountingChartLedger)
|
||||
.where(
|
||||
AccountingChartLedger.tenant_id == int(scope.tenant_id),
|
||||
AccountingChartLedger.client_id == int(selected.id),
|
||||
AccountingChartLedger.tally_guid == tally_guid,
|
||||
)
|
||||
.order_by(AccountingChartLedger.name)
|
||||
).scalars().all()
|
||||
)
|
||||
bank_ledgers = [
|
||||
row for row in bank_ledgers
|
||||
if effective_role(row) in {"BANK", "CASH"}
|
||||
]
|
||||
|
||||
sources = discover_sources(db)
|
||||
selected_bank_source = _source_table(sources["bank"], bank_source)
|
||||
selected_eway_source = _source_table(sources["eway"], eway_source)
|
||||
|
||||
if selected_bank_source:
|
||||
banks = bank_rows(
|
||||
db,
|
||||
table_name=selected_bank_source,
|
||||
client_id=selected.id,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
limit=300,
|
||||
)
|
||||
|
||||
if selected_eway_source:
|
||||
items = eway_rows(
|
||||
db,
|
||||
table_name=selected_eway_source,
|
||||
client_id=selected.id,
|
||||
direction=direction,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
limit=5000,
|
||||
)
|
||||
eway_invoices = group_eway_invoices(items)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/accounting/templates/accounting/automatic_voucher_sources.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": "Automatic Accounting Preparation",
|
||||
"clients": clients,
|
||||
"selected_client": selected,
|
||||
"selected_tally_guid": tally_guid,
|
||||
"company_options": company_options,
|
||||
"sources": sources,
|
||||
"selected_bank_source": selected_bank_source,
|
||||
"selected_eway_source": selected_eway_source,
|
||||
"bank_rows": banks,
|
||||
"eway_invoices": eway_invoices,
|
||||
"bank_ledgers": bank_ledgers,
|
||||
"direction": direction,
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"message": message,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/bank/draft")
|
||||
def bank_draft(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
tally_guid: str = Form(...),
|
||||
source_table: str = Form(...),
|
||||
source_id: str = Form(...),
|
||||
bank_ledger_name: str = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.manage")
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
raise ValueError("Client is not visible.")
|
||||
|
||||
sources = discover_sources(db)["bank"]
|
||||
if source_table not in {row.table_name for row in sources}:
|
||||
raise ValueError("Selected bank source is not an approved discovered source.")
|
||||
|
||||
source = next(
|
||||
(
|
||||
row
|
||||
for row in bank_rows(
|
||||
db,
|
||||
table_name=source_table,
|
||||
client_id=client.id,
|
||||
limit=2000,
|
||||
)
|
||||
if str(row.get("source_id") or "") == str(source_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if source is None:
|
||||
raise ValueError("Bank transaction could not be reloaded from the source.")
|
||||
|
||||
options = _company_options(db, scope.tenant_id, client.id)
|
||||
company_name = _company_name(options, tally_guid)
|
||||
if not company_name:
|
||||
raise ValueError("Selected Tally company is not available in Chart of Accounts.")
|
||||
|
||||
draft = create_bank_draft(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
source_row=source,
|
||||
bank_ledger_name=bank_ledger_name,
|
||||
user_id=user.id,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
tally_guid=tally_guid,
|
||||
bank_source=source_table,
|
||||
message=f"{draft.voucher_type} draft #{draft.id} created automatically from bank transaction.",
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(
|
||||
client_id,
|
||||
tally_guid=tally_guid,
|
||||
bank_source=source_table,
|
||||
error=str(exc),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/eway/draft")
|
||||
def eway_draft(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
tally_guid: str = Form(...),
|
||||
source_table: str = Form(...),
|
||||
direction: str = Form(...),
|
||||
eway_bill_no: str = Form(""),
|
||||
invoice_no: str = Form(""),
|
||||
invoice_date: str = Form(""),
|
||||
party_gstin: str = Form(""),
|
||||
party_name: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, denied = _require_partner(request, db, "accounting.learning.manage")
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
raise ValueError("Client is not visible.")
|
||||
|
||||
sources = discover_sources(db)["eway"]
|
||||
if source_table not in {row.table_name for row in sources}:
|
||||
raise ValueError("Selected E-Way Bill source is not an approved discovered source.")
|
||||
|
||||
invoices = group_eway_invoices(
|
||||
eway_rows(
|
||||
db,
|
||||
table_name=source_table,
|
||||
client_id=client.id,
|
||||
direction=direction,
|
||||
limit=10000,
|
||||
)
|
||||
)
|
||||
invoice = next(
|
||||
(
|
||||
row
|
||||
for row in invoices
|
||||
if (row.get("eway_bill_no") or "") == eway_bill_no
|
||||
and (row.get("invoice_no") or "") == invoice_no
|
||||
and (row.get("invoice_date") or "") == invoice_date
|
||||
and (row.get("party_gstin") or "") == party_gstin
|
||||
and (row.get("party_name") or "") == party_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if invoice is None:
|
||||
raise ValueError("E-Way Bill invoice/items could not be reloaded from the source.")
|
||||
|
||||
options = _company_options(db, scope.tenant_id, client.id)
|
||||
company_name = _company_name(options, tally_guid)
|
||||
if not company_name:
|
||||
raise ValueError("Selected Tally company is not available in Chart of Accounts.")
|
||||
|
||||
draft = create_eway_invoice_draft(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
invoice=invoice,
|
||||
user_id=user.id,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
tally_guid=tally_guid,
|
||||
eway_source=source_table,
|
||||
direction=direction,
|
||||
message=(
|
||||
f"{draft.voucher_type} draft #{draft.id} created automatically "
|
||||
f"with {len(draft.items)} stock-item line(s)."
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(
|
||||
client_id,
|
||||
tally_guid=tally_guid,
|
||||
eway_source=source_table,
|
||||
direction=direction,
|
||||
error=str(exc),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting Engine · Phase 18</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Automatic Accounting Preparation</h1>
|
||||
<p class="mt-1 max-w-5xl text-sm text-slate-500">
|
||||
Create native Tally voucher drafts automatically from existing Bank Analyzer and E-Way Bill source data. Purchase and Sales drafts retain stock-item level quantity, unit, rate, HSN and taxable value for native Tally inventory vouchers.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/tools/accounting/voucher-engine{% 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">Voucher Review Queue</a>
|
||||
<a href="/tools/accounting/chart-of-accounts{% 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">Chart of Accounts</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
|
||||
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<label class="text-xs font-semibold xl:col-span-2">ERP Client
|
||||
<select name="client_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">Select client</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if selected_client and selected_client.id==c.id %}selected{% endif %}>{{ c.client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="text-xs font-semibold xl:col-span-2">Tally Company / Chart of Accounts
|
||||
<select name="tally_guid" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">Select company</option>
|
||||
{% for c in company_options %}
|
||||
<option value="{{ c.guid }}" {% if selected_tally_guid==c.guid %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="text-xs font-semibold">From
|
||||
<input type="date" name="from_date" value="{{ from_date }}" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
|
||||
<label class="text-xs font-semibold">To
|
||||
<input type="date" name="to_date" value="{{ to_date }}" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
|
||||
<div class="xl:col-span-6 flex justify-end">
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Automatic Sources</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client and selected_tally_guid %}
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/50 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-700">Bank Statements</p>
|
||||
<h2 class="mt-1 font-semibold">Receipt / Payment / Contra</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Bank Analyzer rows are converted into native Receipt, Payment or Contra voucher drafts. The bank ledger is chosen from the client's classified Chart of Accounts.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-blue-200 bg-blue-50/50 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-blue-700">E-Way Bill · Inward</p>
|
||||
<h2 class="mt-1 font-semibold">Stock-item Purchase</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Each invoice retains every item, HSN, quantity, unit, rate, taxable value and tax amounts and creates a native Purchase voucher draft.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-200 bg-violet-50/50 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-violet-700">E-Way Bill · Outward</p>
|
||||
<h2 class="mt-1 font-semibold">Stock-item Sales</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Outward invoices create native Sales voucher drafts with item-wise inventory allocations and output-tax ledger lines.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||
<div>
|
||||
<h2 class="font-semibold">Bank Analyzer Transactions</h2>
|
||||
<p class="text-sm text-slate-500">One click creates the proposed Receipt / Payment / Contra draft. Posting remains subject to the common review and Tally preflight controls.</p>
|
||||
</div>
|
||||
<form method="get" class="flex flex-wrap gap-2">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="from_date" value="{{ from_date }}">
|
||||
<input type="hidden" name="to_date" value="{{ to_date }}">
|
||||
<select name="bank_source" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
{% for source in sources.bank %}
|
||||
<option value="{{ source.table_name }}" {% if selected_bank_source==source.table_name %}selected{% endif %}>{{ source.table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Change Source</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not sources.bank %}
|
||||
<div class="p-8 text-center text-slate-500">
|
||||
No existing Bank Analyzer transaction table containing client/date/narration/amount fields was discovered in the current ERP schema. Existing Bank Analyzer data has not been changed.
|
||||
</div>
|
||||
{% elif not bank_rows %}
|
||||
<div class="p-8 text-center text-slate-500">No Bank Analyzer transactions were found for the selected client/period.</div>
|
||||
{% else %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Narration / Party</th>
|
||||
<th class="px-4 py-3">Direction</th>
|
||||
<th class="px-4 py-3 text-right">Amount</th>
|
||||
<th class="px-4 py-3">Bank/Cash Ledger</th>
|
||||
<th class="px-4 py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in bank_rows %}
|
||||
<tr class="border-t border-slate-100 align-top">
|
||||
<td class="px-4 py-3 whitespace-nowrap">{{ row.date or '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-semibold">{{ row.party or row.narration or '-' }}</div>
|
||||
{% if row.party and row.narration %}<div class="mt-1 max-w-xl text-xs text-slate-500">{{ row.narration }}</div>{% endif %}
|
||||
{% if row.category %}<div class="mt-1 text-xs text-slate-500">Category: {{ row.category }}</div>{% endif %}
|
||||
{% if row.reference %}<div class="text-xs text-slate-500">Ref: {{ row.reference }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.direction=='DEBIT' %}bg-amber-100 text-amber-800{% else %}bg-emerald-100 text-emerald-800{% endif %}">{{ row.direction }}</span></td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹{{ '%.2f'|format(row.amount) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="post" action="/tools/accounting/voucher-engine/automatic/bank/draft" class="flex min-w-[320px] gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="source_table" value="{{ row.source_table }}">
|
||||
<input type="hidden" name="source_id" value="{{ row.source_id }}">
|
||||
<select name="bank_ledger_name" required class="min-w-[190px] flex-1 rounded-lg border border-slate-300 bg-white px-2 py-2 text-xs">
|
||||
<option value="">Select Bank/Cash ledger</option>
|
||||
{% for ledger in bank_ledgers %}
|
||||
<option value="{{ ledger.name }}">{{ ledger.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Create Draft</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{% if row.contra_hint %}Contra hint detected{% else %}Auto ledger matching from party/category{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
|
||||
<div>
|
||||
<h2 class="font-semibold">E-Way Bill Purchase / Sales Invoices</h2>
|
||||
<p class="text-sm text-slate-500">Invoices are grouped by E-Way Bill + invoice + party. Every underlying item remains separate in the voucher draft.</p>
|
||||
</div>
|
||||
<form method="get" class="flex flex-wrap gap-2">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="from_date" value="{{ from_date }}">
|
||||
<input type="hidden" name="to_date" value="{{ to_date }}">
|
||||
<select name="eway_source" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
{% for source in sources.eway %}
|
||||
<option value="{{ source.table_name }}" {% if selected_eway_source==source.table_name %}selected{% endif %}>{{ source.table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="direction" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<option value="" {% if not direction %}selected{% endif %}>Purchase + Sales</option>
|
||||
<option value="PURCHASE" {% if direction=='PURCHASE' %}selected{% endif %}>Purchase / Inward</option>
|
||||
<option value="SALES" {% if direction=='SALES' %}selected{% endif %}>Sales / Outward</option>
|
||||
</select>
|
||||
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not sources.eway %}
|
||||
<div class="p-8 text-center text-slate-500">
|
||||
No existing E-Way Bill item table containing E-Way Bill number, item description and taxable value was discovered in the current ERP schema. Existing E-Way Bill features have not been modified.
|
||||
</div>
|
||||
{% elif not eway_invoices %}
|
||||
<div class="p-8 text-center text-slate-500">No E-Way Bill item invoices were found for this client/period/filter.</div>
|
||||
{% else %}
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for inv in eway_invoices %}
|
||||
<article class="p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if inv.direction=='PURCHASE' %}bg-blue-100 text-blue-800{% elif inv.direction=='SALES' %}bg-violet-100 text-violet-800{% else %}bg-slate-100{% endif %}">{{ inv.direction }}</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ inv.items|length }} stock item(s)</span>
|
||||
</div>
|
||||
<h3 class="mt-2 font-semibold">{{ inv.party_name or 'Party not available' }}</h3>
|
||||
<p class="text-sm text-slate-500">GSTIN {{ inv.party_gstin or '-' }} · Invoice {{ inv.invoice_no or '-' }} · {{ inv.invoice_date or '-' }} · EWB {{ inv.eway_bill_no or '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-semibold">Taxable ₹{{ '%.2f'|format(inv.taxable_value) }}</div>
|
||||
<div class="text-xs text-slate-500">CGST {{ '%.2f'|format(inv.cgst) }} · SGST {{ '%.2f'|format(inv.sgst) }} · IGST {{ '%.2f'|format(inv.igst) }} · Cess {{ '%.2f'|format(inv.cess) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Stock Item / Description</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">Rate</th><th class="px-3 py-2 text-right">Taxable</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in inv.items %}
|
||||
<tr class="border-t border-slate-100">
|
||||
<td class="px-3 py-2 font-semibold">{{ item.item_name or '-' }}</td>
|
||||
<td class="px-3 py-2">{{ item.hsn 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">{{ '%.4f'|format(item.rate) }}</td>
|
||||
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(item.taxable_value) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/voucher-engine/automatic/eway/draft" class="mt-4 flex justify-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
<input type="hidden" name="source_table" value="{{ inv.source_table }}">
|
||||
<input type="hidden" name="direction" value="{{ inv.direction }}">
|
||||
<input type="hidden" name="eway_bill_no" value="{{ inv.eway_bill_no }}">
|
||||
<input type="hidden" name="invoice_no" value="{{ inv.invoice_no }}">
|
||||
<input type="hidden" name="invoice_date" value="{{ inv.invoice_date }}">
|
||||
<input type="hidden" name="party_gstin" value="{{ inv.party_gstin }}">
|
||||
<input type="hidden" name="party_name" value="{{ inv.party_name }}">
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Create Stock-item {{ inv.direction|title }} Draft</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||
<h2 class="font-semibold">Manual entry remains only as a fallback</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Manual / adjustment voucher creation has not been removed. It remains useful for provisions, accruals, depreciation, reclassification, audit adjustments and source transactions that cannot yet be resolved automatically.</p>
|
||||
<a href="/tools/accounting/voucher-engine?client_id={{ selected_client.id }}" class="mt-3 inline-flex rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Open Manual / Review Voucher Engine</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,6 +10,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/tools/accounting/voucher-engine/automatic{% 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">Automatic Preparation</a>
|
||||
<a href="/tools/accounting/chart-of-accounts{% 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">Chart of Accounts</a>
|
||||
<a href="/tools/tally{% 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">Back to Accounting</a>
|
||||
</div>
|
||||
@@ -57,8 +58,8 @@
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="mb-4">
|
||||
<h2 class="font-semibold text-slate-900">Create Voucher Draft</h2>
|
||||
<p class="text-sm text-slate-500">Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.</p>
|
||||
<h2 class="font-semibold text-slate-900">Manual / Adjustment Voucher Draft</h2>
|
||||
<p class="text-sm text-slate-500">Automatic Bank/E-Way Bill preparation is available above. Use this manual form for adjustments, provisions, accruals, depreciation, reclassification, audit adjustments or unresolved source transactions. Every ledger must exist in the Chart of Accounts and Debit must equal Credit.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/voucher-engine/draft" id="voucherDraftForm" class="space-y-5">
|
||||
@@ -201,6 +202,28 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if draft.items %}
|
||||
<div class="mt-3 overflow-x-auto rounded-xl border border-blue-200">
|
||||
<div class="border-b border-blue-100 bg-blue-50 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-blue-800">Stock-item allocation</div>
|
||||
<table class="min-w-full text-xs">
|
||||
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Stock 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">Rate</th><th class="px-3 py-2 text-right">Amount</th><th class="px-3 py-2">Accounting Ledger</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in draft.items %}
|
||||
<tr class="border-t border-slate-100">
|
||||
<td class="px-3 py-2 font-semibold">{{ item.stock_item_name }}</td>
|
||||
<td class="px-3 py-2">{{ item.hsn_sac 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">{{ '%.4f'|format(item.rate) }}</td>
|
||||
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(item.amount) }}</td>
|
||||
<td class="px-3 py-2">{{ item.accounting_ledger_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if draft.last_error %}<div class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ draft.last_error }}</div>{% endif %}
|
||||
{% if draft.status == 'preflight_ready' %}<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">Tally preflight completed. Ledger existence, balancing and duplicate checks completed.</div>{% endif %}
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ class AccountingVoucherDraft(CommonBase):
|
||||
order_by="AccountingVoucherLine.line_no",
|
||||
)
|
||||
|
||||
items: Mapped[list["AccountingVoucherItemLine"]] = relationship(
|
||||
"AccountingVoucherItemLine",
|
||||
back_populates="draft",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AccountingVoucherItemLine.line_no",
|
||||
)
|
||||
|
||||
|
||||
class AccountingVoucherLine(CommonBase):
|
||||
__tablename__ = "accounting_voucher_lines"
|
||||
@@ -79,3 +86,30 @@ class AccountingVoucherLine(CommonBase):
|
||||
line_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
draft: Mapped[AccountingVoucherDraft] = relationship("AccountingVoucherDraft", back_populates="lines")
|
||||
|
||||
|
||||
class AccountingVoucherItemLine(CommonBase):
|
||||
__tablename__ = "accounting_voucher_item_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
draft_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
line_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
stock_item_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
hsn_sac: Mapped[str] = mapped_column(String(30), nullable=False, default="", index=True)
|
||||
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
unit: Mapped[str] = mapped_column(String(40), nullable=False, default="")
|
||||
rate: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
dr_cr: Mapped[str] = mapped_column(String(2), nullable=False)
|
||||
accounting_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
godown_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
source_item_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
|
||||
draft: Mapped[AccountingVoucherDraft] = relationship(
|
||||
"AccountingVoucherDraft",
|
||||
back_populates="items",
|
||||
)
|
||||
|
||||
@@ -310,7 +310,7 @@ def create_draft(
|
||||
def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int):
|
||||
return db.execute(
|
||||
select(AccountingVoucherDraft)
|
||||
.options(selectinload(AccountingVoucherDraft.lines))
|
||||
.options(selectinload(AccountingVoucherDraft.lines), selectinload(AccountingVoucherDraft.items))
|
||||
.where(
|
||||
AccountingVoucherDraft.id == int(draft_id),
|
||||
AccountingVoucherDraft.tenant_id == int(tenant_id),
|
||||
@@ -335,7 +335,7 @@ def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: i
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(AccountingVoucherDraft)
|
||||
.options(selectinload(AccountingVoucherDraft.lines))
|
||||
.options(selectinload(AccountingVoucherDraft.lines), selectinload(AccountingVoucherDraft.items))
|
||||
.where(*where)
|
||||
.order_by(AccountingVoucherDraft.id.desc())
|
||||
.offset((page - 1) * per_page)
|
||||
@@ -440,6 +440,21 @@ def _payload(draft: AccountingVoucherDraft):
|
||||
}
|
||||
for line in draft.lines
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"stock_item_name": item.stock_item_name,
|
||||
"hsn_sac": item.hsn_sac,
|
||||
"quantity": float(item.quantity),
|
||||
"unit": item.unit,
|
||||
"rate": float(item.rate),
|
||||
"amount": float(item.amount),
|
||||
"dr_cr": item.dr_cr,
|
||||
"accounting_ledger_name": item.accounting_ledger_name,
|
||||
"godown_name": item.godown_name,
|
||||
"source_item_json": item.source_item_json,
|
||||
}
|
||||
for item in draft.items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.17.0"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.18.0"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.17.0"
|
||||
__version__ = "1.18.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -83,7 +83,7 @@ class AgentCommandProcessor:
|
||||
return {
|
||||
"name": "ERP Local Agent", "version": __version__,
|
||||
"tally_capability": True, "accounting_act_capability": True,
|
||||
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True,
|
||||
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True,
|
||||
"tally_transaction_sync_capability": True,
|
||||
"historical_learning_read_capability": True,
|
||||
"purchase_posting_preflight_capability": True,
|
||||
@@ -571,6 +571,7 @@ class AgentCommandProcessor:
|
||||
reference=str(payload.get("reference") or ""),
|
||||
total_amount=float(payload.get("total_amount") or 0),
|
||||
lines=list(payload.get("lines") or []),
|
||||
items=list(payload.get("items") or []),
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
@@ -590,6 +591,7 @@ class AgentCommandProcessor:
|
||||
reference=str(payload.get("reference") or ""),
|
||||
total_amount=float(payload.get("total_amount") or 0),
|
||||
lines=list(payload.get("lines") or []),
|
||||
items=list(payload.get("items") or []),
|
||||
)
|
||||
if preflight.get("duplicate_candidates"):
|
||||
raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.")
|
||||
@@ -601,6 +603,7 @@ class AgentCommandProcessor:
|
||||
reference=str(payload.get("reference") or ""),
|
||||
narration=str(payload.get("narration") or ""),
|
||||
lines=list(payload.get("lines") or []),
|
||||
items=list(payload.get("items") or []),
|
||||
)
|
||||
safe_result = {
|
||||
key: value
|
||||
|
||||
+224
-24
@@ -40,11 +40,10 @@ def _tally_date(value):
|
||||
|
||||
|
||||
class NativeVoucherEngine:
|
||||
"""Native Tally accounting-voucher writer layered over the existing connector.
|
||||
"""Native Tally accounting-voucher writer.
|
||||
|
||||
It intentionally reuses the current Tally connector's XML transport,
|
||||
ledger discovery and voucher export functions so no existing posting
|
||||
behaviour is replaced.
|
||||
Phase 18 preserves the Phase 17 ledger-voucher behaviour and adds true
|
||||
stock-item inventory allocations for Purchase/Sales vouchers.
|
||||
"""
|
||||
|
||||
def __init__(self, tally_connector):
|
||||
@@ -78,9 +77,7 @@ class NativeVoucherEngine:
|
||||
if amount <= 0:
|
||||
raise ValueError(f"Voucher line {index + 1}: amount must be greater than zero.")
|
||||
|
||||
actual_name = str(
|
||||
available[ledger_name.casefold()].get("name") or ledger_name
|
||||
).strip()
|
||||
actual_name = str(available[ledger_name.casefold()].get("name") or ledger_name).strip()
|
||||
|
||||
if side == "DR":
|
||||
total_dr += amount
|
||||
@@ -90,6 +87,7 @@ class NativeVoucherEngine:
|
||||
normalized.append(
|
||||
{
|
||||
"ledger_name": actual_name,
|
||||
"ledger_role_code": str(line.get("ledger_role_code") or "").strip(),
|
||||
"dr_cr": side,
|
||||
"amount": amount,
|
||||
}
|
||||
@@ -109,6 +107,131 @@ class NativeVoucherEngine:
|
||||
|
||||
return normalized, total_dr, total_cr
|
||||
|
||||
def _stock_item_names(self, company_name: str):
|
||||
request_xml = f"""<ENVELOPE>
|
||||
<HEADER>
|
||||
<VERSION>1</VERSION>
|
||||
<TALLYREQUEST>Export</TALLYREQUEST>
|
||||
<TYPE>Collection</TYPE>
|
||||
<ID>ERPStockItemCollection</ID>
|
||||
</HEADER>
|
||||
<BODY>
|
||||
<DESC>
|
||||
<STATICVARIABLES>
|
||||
<SVCURRENTCOMPANY>{self.tally._xml_escape(company_name)}</SVCURRENTCOMPANY>
|
||||
<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>
|
||||
</STATICVARIABLES>
|
||||
<TDL>
|
||||
<TDLMESSAGE>
|
||||
<COLLECTION NAME="ERPStockItemCollection" ISMODIFY="No">
|
||||
<TYPE>Stock Item</TYPE>
|
||||
<FETCH>Name,BaseUnits,GSTApplicable,GSTTypeOfSupply,HSNCode</FETCH>
|
||||
</COLLECTION>
|
||||
</TDLMESSAGE>
|
||||
</TDL>
|
||||
</DESC>
|
||||
</BODY>
|
||||
</ENVELOPE>"""
|
||||
response = self.tally._post_xml(request_xml)
|
||||
root = ET.fromstring(_clean_xml_response(response).encode("utf-8"))
|
||||
names = {}
|
||||
for element in root.iter():
|
||||
if _tag(element) in {"STOCKITEM", "STOCKITEM.LIST"}:
|
||||
name = ""
|
||||
unit = ""
|
||||
for child in element.iter():
|
||||
ctag = _tag(child)
|
||||
value = (child.text or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if ctag in {"NAME", "STOCKITEMNAME"} and not name:
|
||||
name = value
|
||||
elif ctag in {"BASEUNITS", "BASEUNIT"} and not unit:
|
||||
unit = value
|
||||
if name:
|
||||
names[name.casefold()] = {"name": name, "unit": unit}
|
||||
return names
|
||||
|
||||
def _normalize_items(self, company_name: str, voucher_type: str, items: list[dict]):
|
||||
if not items:
|
||||
return []
|
||||
|
||||
vtype = str(voucher_type or "").strip().title()
|
||||
if vtype not in {"Purchase", "Sales"}:
|
||||
raise ValueError("Stock-item lines are permitted only in Purchase or Sales vouchers.")
|
||||
|
||||
available = self._stock_item_names(company_name)
|
||||
normalized = []
|
||||
|
||||
for index, item in enumerate(items or []):
|
||||
source_name = str(item.get("stock_item_name") or "").strip()
|
||||
if not source_name:
|
||||
raise ValueError(f"Stock item line {index + 1}: item name is required.")
|
||||
|
||||
matched = available.get(source_name.casefold())
|
||||
if matched is None:
|
||||
# Conservative token matching only when it produces exactly one result.
|
||||
source_tokens = {
|
||||
token for token in re.split(r"[^A-Z0-9]+", source_name.upper())
|
||||
if len(token) >= 4
|
||||
}
|
||||
candidates = []
|
||||
for key, row in available.items():
|
||||
name_tokens = {
|
||||
token for token in re.split(r"[^A-Z0-9]+", row["name"].upper())
|
||||
if len(token) >= 4
|
||||
}
|
||||
common = source_tokens & name_tokens
|
||||
if common:
|
||||
candidates.append((len(common), row))
|
||||
candidates.sort(key=lambda pair: (-pair[0], pair[1]["name"]))
|
||||
if candidates:
|
||||
top_score = candidates[0][0]
|
||||
top = [row for score, row in candidates if score == top_score]
|
||||
if len(top) == 1:
|
||||
matched = top[0]
|
||||
|
||||
if matched is None:
|
||||
raise ValueError(
|
||||
f"Stock item '{source_name}' does not exist in the open Tally company. "
|
||||
"Create/map the stock item before posting; Phase 18 will not create masters automatically."
|
||||
)
|
||||
|
||||
qty = abs(float(item.get("quantity") or 0))
|
||||
rate = abs(float(item.get("rate") or 0))
|
||||
amount = round(abs(float(item.get("amount") or 0)), 2)
|
||||
unit = str(item.get("unit") or matched.get("unit") or "").strip()
|
||||
accounting_ledger = str(item.get("accounting_ledger_name") or "").strip()
|
||||
godown = str(item.get("godown_name") or "").strip()
|
||||
side = str(item.get("dr_cr") or ("DR" if vtype == "Purchase" else "CR")).upper()
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError(f"Stock item line {index + 1}: amount must be greater than zero.")
|
||||
if qty < 0:
|
||||
raise ValueError(f"Stock item line {index + 1}: quantity is invalid.")
|
||||
if not accounting_ledger:
|
||||
raise ValueError(f"Stock item line {index + 1}: accounting ledger is required.")
|
||||
if side not in {"DR", "CR"}:
|
||||
raise ValueError(f"Stock item line {index + 1}: DR/CR is invalid.")
|
||||
|
||||
if not rate and qty:
|
||||
rate = amount / qty
|
||||
|
||||
normalized.append(
|
||||
{
|
||||
"stock_item_name": matched["name"],
|
||||
"quantity": qty,
|
||||
"unit": unit,
|
||||
"rate": rate,
|
||||
"amount": amount,
|
||||
"dr_cr": side,
|
||||
"accounting_ledger_name": accounting_ledger,
|
||||
"godown_name": godown,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
def find_duplicates(
|
||||
self,
|
||||
company_name: str,
|
||||
@@ -160,12 +283,14 @@ class NativeVoucherEngine:
|
||||
reference: str,
|
||||
total_amount: float,
|
||||
lines: list[dict],
|
||||
items: list[dict] | None = None,
|
||||
):
|
||||
vtype = str(voucher_type or "").strip().title()
|
||||
if vtype not in VALID_VOUCHER_TYPES:
|
||||
raise ValueError("Unsupported native Tally voucher type.")
|
||||
|
||||
normalized, total_dr, total_cr = self._normalize_lines(company_name, lines)
|
||||
normalized_items = self._normalize_items(company_name, vtype, list(items or []))
|
||||
duplicates = self.find_duplicates(
|
||||
company_name,
|
||||
voucher_type=vtype,
|
||||
@@ -177,12 +302,80 @@ class NativeVoucherEngine:
|
||||
return {
|
||||
"voucher_type": vtype,
|
||||
"line_count": len(normalized),
|
||||
"stock_item_count": len(normalized_items),
|
||||
"total_debit": total_dr,
|
||||
"total_credit": total_cr,
|
||||
"duplicate_candidates": duplicates,
|
||||
"validated_ledgers": [row["ledger_name"] for row in normalized],
|
||||
"validated_stock_items": [row["stock_item_name"] for row in normalized_items],
|
||||
}
|
||||
|
||||
def _ledger_xml(self, row):
|
||||
deemed_positive = "Yes" if row["dr_cr"] == "DR" else "No"
|
||||
signed_amount = (
|
||||
f"-{row['amount']:.2f}"
|
||||
if row["dr_cr"] == "DR"
|
||||
else f"{row['amount']:.2f}"
|
||||
)
|
||||
return (
|
||||
"<ALLLEDGERENTRIES.LIST>"
|
||||
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
|
||||
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
|
||||
f"<AMOUNT>{signed_amount}</AMOUNT>"
|
||||
"</ALLLEDGERENTRIES.LIST>"
|
||||
)
|
||||
|
||||
def _inventory_xml(self, item):
|
||||
deemed_positive = "Yes" if item["dr_cr"] == "DR" else "No"
|
||||
signed_amount = (
|
||||
f"-{item['amount']:.2f}"
|
||||
if item["dr_cr"] == "DR"
|
||||
else f"{item['amount']:.2f}"
|
||||
)
|
||||
|
||||
quantity_xml = ""
|
||||
rate_xml = ""
|
||||
if item["quantity"] and item["unit"]:
|
||||
qty_text = f"{item['quantity']:g} {self.tally._xml_escape(item['unit'])}"
|
||||
quantity_xml = (
|
||||
f"<ACTUALQTY>{qty_text}</ACTUALQTY>"
|
||||
f"<BILLEDQTY>{qty_text}</BILLEDQTY>"
|
||||
)
|
||||
if item["rate"] and item["unit"]:
|
||||
rate_xml = (
|
||||
f"<RATE>{item['rate']:g}/{self.tally._xml_escape(item['unit'])}</RATE>"
|
||||
)
|
||||
|
||||
godown_xml = ""
|
||||
if item["godown_name"]:
|
||||
godown_xml = (
|
||||
"<BATCHALLOCATIONS.LIST>"
|
||||
f"<GODOWNNAME>{self.tally._xml_escape(item['godown_name'])}</GODOWNNAME>"
|
||||
f"<AMOUNT>{signed_amount}</AMOUNT>"
|
||||
f"{quantity_xml}"
|
||||
"</BATCHALLOCATIONS.LIST>"
|
||||
)
|
||||
|
||||
accounting_xml = (
|
||||
"<ACCOUNTINGALLOCATIONS.LIST>"
|
||||
f"<LEDGERNAME>{self.tally._xml_escape(item['accounting_ledger_name'])}</LEDGERNAME>"
|
||||
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
|
||||
f"<AMOUNT>{signed_amount}</AMOUNT>"
|
||||
"</ACCOUNTINGALLOCATIONS.LIST>"
|
||||
)
|
||||
|
||||
return (
|
||||
"<ALLINVENTORYENTRIES.LIST>"
|
||||
f"<STOCKITEMNAME>{self.tally._xml_escape(item['stock_item_name'])}</STOCKITEMNAME>"
|
||||
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
|
||||
f"{rate_xml}"
|
||||
f"<AMOUNT>{signed_amount}</AMOUNT>"
|
||||
f"{quantity_xml}"
|
||||
f"{godown_xml}"
|
||||
f"{accounting_xml}"
|
||||
"</ALLINVENTORYENTRIES.LIST>"
|
||||
)
|
||||
|
||||
def build_xml(
|
||||
self,
|
||||
company_name: str,
|
||||
@@ -192,6 +385,7 @@ class NativeVoucherEngine:
|
||||
reference: str,
|
||||
narration: str,
|
||||
lines: list[dict],
|
||||
items: list[dict] | None = None,
|
||||
):
|
||||
vtype = str(voucher_type or "").strip().title()
|
||||
if vtype not in VALID_VOUCHER_TYPES:
|
||||
@@ -202,29 +396,31 @@ class NativeVoucherEngine:
|
||||
raise ValueError("Tally company name is required.")
|
||||
|
||||
normalized, total_dr, total_cr = self._normalize_lines(company, lines)
|
||||
normalized_items = self._normalize_items(company, vtype, list(items or []))
|
||||
|
||||
entries = []
|
||||
# When stock items are present, their purchase/sales accounting ledger
|
||||
# allocation is carried inside ALLINVENTORYENTRIES.LIST. Do not duplicate
|
||||
# that same accounting ledger as an ALLLEDGERENTRIES amount.
|
||||
item_ledger_names = {
|
||||
item["accounting_ledger_name"].casefold()
|
||||
for item in normalized_items
|
||||
if item["accounting_ledger_name"]
|
||||
}
|
||||
ledger_entries = []
|
||||
for row in normalized:
|
||||
if row["dr_cr"] == "DR":
|
||||
deemed_positive = "Yes"
|
||||
signed_amount = f"-{row['amount']:.2f}"
|
||||
else:
|
||||
deemed_positive = "No"
|
||||
signed_amount = f"{row['amount']:.2f}"
|
||||
if normalized_items and row["ledger_name"].casefold() in item_ledger_names:
|
||||
continue
|
||||
ledger_entries.append(self._ledger_xml(row))
|
||||
|
||||
entries.append(
|
||||
"<ALLLEDGERENTRIES.LIST>"
|
||||
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
|
||||
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
|
||||
f"<AMOUNT>{signed_amount}</AMOUNT>"
|
||||
"</ALLLEDGERENTRIES.LIST>"
|
||||
)
|
||||
inventory_entries = [self._inventory_xml(item) for item in normalized_items]
|
||||
|
||||
ref_xml = (
|
||||
f"<REFERENCE>{self.tally._xml_escape(reference)}</REFERENCE>"
|
||||
if str(reference or "").strip()
|
||||
else ""
|
||||
)
|
||||
voucher_view = "Invoice Voucher View" if normalized_items else "Accounting Voucher View"
|
||||
invoice_xml = "<ISINVOICE>Yes</ISINVOICE>" if normalized_items else ""
|
||||
|
||||
return f"""<ENVELOPE>
|
||||
<HEADER>
|
||||
@@ -241,13 +437,15 @@ class NativeVoucherEngine:
|
||||
</DESC>
|
||||
<DATA>
|
||||
<TALLYMESSAGE xmlns:UDF="TallyUDF">
|
||||
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
|
||||
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="{voucher_view}">
|
||||
<DATE>{_tally_date(voucher_date)}</DATE>
|
||||
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
|
||||
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
|
||||
<PERSISTEDVIEW>{voucher_view}</PERSISTEDVIEW>
|
||||
{invoice_xml}
|
||||
{ref_xml}
|
||||
<NARRATION>{self.tally._xml_escape(narration)}</NARRATION>
|
||||
{''.join(entries)}
|
||||
{''.join(ledger_entries)}
|
||||
{''.join(inventory_entries)}
|
||||
</VOUCHER>
|
||||
</TALLYMESSAGE>
|
||||
</DATA>
|
||||
@@ -263,6 +461,7 @@ class NativeVoucherEngine:
|
||||
reference: str,
|
||||
narration: str,
|
||||
lines: list[dict],
|
||||
items: list[dict] | None = None,
|
||||
):
|
||||
request_xml = self.build_xml(
|
||||
company_name,
|
||||
@@ -271,6 +470,7 @@ class NativeVoucherEngine:
|
||||
reference=reference,
|
||||
narration=narration,
|
||||
lines=lines,
|
||||
items=items,
|
||||
)
|
||||
response_xml = self.tally._post_xml(request_xml)
|
||||
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
|
||||
|
||||
Reference in New Issue
Block a user