573 lines
19 KiB
Python
573 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
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, AccountingVoucherLine
|
|
from app.modules.documents.agent_jobs import enqueue_agent_job
|
|
from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
|
|
|
|
|
|
VOUCHER_TYPES = ("Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal")
|
|
BUSINESS_NATURES = (
|
|
("PURCHASE", "Purchase"),
|
|
("DIRECT_EXPENSE", "Direct Expense"),
|
|
("INDIRECT_EXPENSE", "Indirect Expense"),
|
|
("SALES", "Sales"),
|
|
("DIRECT_INCOME", "Direct Income"),
|
|
("INDIRECT_INCOME", "Indirect / Other Income"),
|
|
("RECEIPT", "Receipt"),
|
|
("PAYMENT", "Payment"),
|
|
("CONTRA", "Contra"),
|
|
("ADJUSTMENT", "Adjustment / Journal"),
|
|
)
|
|
|
|
PREFLIGHT_ACTION = "accounting_native_voucher_preflight"
|
|
POST_ACTION = "accounting_post_native_voucher"
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _s(value):
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _loads(value, default=None):
|
|
try:
|
|
return json.loads(value or "")
|
|
except Exception:
|
|
return default if default is not None else {}
|
|
|
|
|
|
def _dumps(value):
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
|
|
|
|
def suggested_voucher_type(business_nature: str) -> str:
|
|
nature = _s(business_nature).upper()
|
|
return {
|
|
"PURCHASE": "Purchase",
|
|
"DIRECT_EXPENSE": "Purchase",
|
|
"INDIRECT_EXPENSE": "Purchase",
|
|
"SALES": "Sales",
|
|
"DIRECT_INCOME": "Sales",
|
|
"INDIRECT_INCOME": "Sales",
|
|
"RECEIPT": "Receipt",
|
|
"PAYMENT": "Payment",
|
|
"CONTRA": "Contra",
|
|
"ADJUSTMENT": "Journal",
|
|
}.get(nature, "Journal")
|
|
|
|
|
|
def chart_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str):
|
|
return list(
|
|
db.execute(
|
|
select(AccountingChartLedger)
|
|
.where(
|
|
AccountingChartLedger.tenant_id == int(tenant_id),
|
|
AccountingChartLedger.client_id == int(client_id),
|
|
AccountingChartLedger.tally_guid == _s(tally_guid),
|
|
)
|
|
.order_by(AccountingChartLedger.name)
|
|
).scalars().all()
|
|
)
|
|
|
|
|
|
def _ledger_map(db, *, tenant_id: int, client_id: int, tally_guid: str):
|
|
return {
|
|
row.name.casefold(): row
|
|
for row in chart_ledgers(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client_id=client_id,
|
|
tally_guid=tally_guid,
|
|
)
|
|
}
|
|
|
|
|
|
def _normalize_lines(
|
|
db,
|
|
*,
|
|
tenant_id: int,
|
|
client_id: int,
|
|
tally_guid: str,
|
|
ledger_names,
|
|
dr_cr_values,
|
|
amounts,
|
|
notes,
|
|
):
|
|
ledger_map = _ledger_map(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client_id=client_id,
|
|
tally_guid=tally_guid,
|
|
)
|
|
if not ledger_map:
|
|
raise ValueError(
|
|
"No Chart of Accounts is available for this mapped Tally company. "
|
|
"Refresh Chart of Accounts first."
|
|
)
|
|
|
|
ledger_names = list(ledger_names or [])
|
|
dr_cr_values = list(dr_cr_values or [])
|
|
amounts = list(amounts or [])
|
|
notes = list(notes or [])
|
|
|
|
result = []
|
|
for index, ledger_name in enumerate(ledger_names):
|
|
name = _s(ledger_name)
|
|
if not name:
|
|
continue
|
|
|
|
side = _s(dr_cr_values[index] if index < len(dr_cr_values) else "").upper()
|
|
if side not in {"DR", "CR"}:
|
|
raise ValueError(f"Line {index + 1}: debit/credit side is invalid.")
|
|
|
|
try:
|
|
amount = round(abs(float(amounts[index] if index < len(amounts) else 0)), 2)
|
|
except Exception:
|
|
amount = 0
|
|
if amount <= 0:
|
|
raise ValueError(f"Line {index + 1}: amount must be greater than zero.")
|
|
|
|
ledger = ledger_map.get(name.casefold())
|
|
if not ledger:
|
|
raise ValueError(
|
|
f"Line {index + 1}: ledger '{name}' is not in the synchronized Chart of Accounts."
|
|
)
|
|
|
|
result.append(
|
|
{
|
|
"line_no": len(result) + 1,
|
|
"ledger_name": ledger.name,
|
|
"ledger_role_code": effective_role(ledger),
|
|
"dr_cr": side,
|
|
"amount": amount,
|
|
"line_note": _s(notes[index] if index < len(notes) else ""),
|
|
}
|
|
)
|
|
|
|
if len(result) < 2:
|
|
raise ValueError("At least two voucher lines are required.")
|
|
|
|
total_dr = round(sum(row["amount"] for row in result if row["dr_cr"] == "DR"), 2)
|
|
total_cr = round(sum(row["amount"] for row in result if row["dr_cr"] == "CR"), 2)
|
|
|
|
if total_dr <= 0 or total_cr <= 0:
|
|
raise ValueError("The voucher must contain at least one Debit and one Credit line.")
|
|
if abs(total_dr - total_cr) > 0.01:
|
|
raise ValueError(
|
|
f"Voucher is not balanced. Debit ₹{total_dr:.2f} != Credit ₹{total_cr:.2f}."
|
|
)
|
|
|
|
return result, total_dr, total_cr
|
|
|
|
|
|
def _validate_voucher_semantics(voucher_type: str, rows: list[dict]):
|
|
vtype = _s(voucher_type).title()
|
|
if vtype not in VOUCHER_TYPES:
|
|
raise ValueError("Voucher type must be Purchase, Sales, Receipt, Payment, Contra or Journal.")
|
|
|
|
debit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "DR"}
|
|
credit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "CR"}
|
|
all_roles = debit_roles | credit_roles
|
|
bank_cash = {"BANK", "CASH"}
|
|
|
|
if vtype == "Receipt" and not (debit_roles & bank_cash):
|
|
raise ValueError("Receipt voucher requires Bank/Cash on the Debit side.")
|
|
|
|
if vtype == "Payment" and not (credit_roles & bank_cash):
|
|
raise ValueError("Payment voucher requires Bank/Cash on the Credit side.")
|
|
|
|
if vtype == "Contra":
|
|
if not all_roles or not all_roles.issubset(bank_cash):
|
|
raise ValueError("Contra voucher may contain only Bank/Cash ledgers.")
|
|
if not (debit_roles & bank_cash) or not (credit_roles & bank_cash):
|
|
raise ValueError("Contra voucher requires Bank/Cash on both Debit and Credit sides.")
|
|
|
|
if vtype == "Purchase":
|
|
allowed = {
|
|
"PURCHASE",
|
|
"DIRECT_EXPENSE",
|
|
"INDIRECT_EXPENSE",
|
|
"FIXED_ASSET",
|
|
"CURRENT_ASSET",
|
|
"INVENTORY",
|
|
"GST_INPUT",
|
|
}
|
|
if not (debit_roles & allowed):
|
|
raise ValueError(
|
|
"Purchase voucher requires a Purchase/Expense/Asset/Input-tax ledger on the Debit side."
|
|
)
|
|
|
|
if vtype == "Sales":
|
|
allowed = {"SALES", "DIRECT_INCOME", "OTHER_INCOME", "GST_OUTPUT", "FREIGHT_CHARGE"}
|
|
if not (credit_roles & allowed):
|
|
raise ValueError(
|
|
"Sales voucher requires a Sales/Income/Output-tax/charge ledger on the Credit side."
|
|
)
|
|
|
|
|
|
def _audit_projection(voucher_type: str, rows: list[dict]):
|
|
roles = {row["ledger_role_code"] for row in rows}
|
|
areas = []
|
|
flags = []
|
|
|
|
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 & {"FIXED_ASSET", "DEPRECIATION"}:
|
|
areas.append("Property, Plant & Equipment")
|
|
if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
|
|
areas.append("GST & Taxes")
|
|
flags.append("tax-ledger-present")
|
|
if roles & {"CUSTOMER"}:
|
|
areas.append("Trade Receivables")
|
|
if roles & {"LOAN", "CAPITAL", "RESERVE"}:
|
|
areas.append("Equity & Borrowings")
|
|
if voucher_type == "Journal":
|
|
flags.append("journal-adjustment")
|
|
|
|
if not areas:
|
|
areas.append("General Ledger")
|
|
|
|
return " · ".join(dict.fromkeys(areas)), flags
|
|
|
|
|
|
def create_draft(
|
|
db,
|
|
*,
|
|
tenant_id: int,
|
|
client_id: int,
|
|
tally_guid: str,
|
|
company_name: str,
|
|
voucher_type: str,
|
|
business_nature: str,
|
|
voucher_date: str,
|
|
reference: str,
|
|
narration: str,
|
|
source_type: str,
|
|
ledger_names,
|
|
dr_cr_values,
|
|
amounts,
|
|
notes,
|
|
user_id: int,
|
|
):
|
|
vtype = _s(voucher_type).title()
|
|
rows, total_dr, total_cr = _normalize_lines(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client_id=client_id,
|
|
tally_guid=tally_guid,
|
|
ledger_names=ledger_names,
|
|
dr_cr_values=dr_cr_values,
|
|
amounts=amounts,
|
|
notes=notes,
|
|
)
|
|
_validate_voucher_semantics(vtype, rows)
|
|
audit_area, audit_flags = _audit_projection(vtype, rows)
|
|
|
|
draft = AccountingVoucherDraft(
|
|
tenant_id=int(tenant_id),
|
|
client_id=int(client_id),
|
|
tally_guid=_s(tally_guid),
|
|
company_name=_s(company_name),
|
|
voucher_type=vtype,
|
|
business_nature=_s(business_nature).upper(),
|
|
voucher_date=_s(voucher_date),
|
|
reference=_s(reference),
|
|
narration=_s(narration),
|
|
source_type=_s(source_type) or "manual",
|
|
audit_area=audit_area,
|
|
audit_flags_json=_dumps(audit_flags),
|
|
total_debit=total_dr,
|
|
total_credit=total_cr,
|
|
status="draft",
|
|
created_by_user_id=int(user_id),
|
|
updated_at_utc=_utcnow(),
|
|
)
|
|
|
|
for row in rows:
|
|
draft.lines.append(AccountingVoucherLine(**row))
|
|
|
|
db.add(draft)
|
|
db.commit()
|
|
db.refresh(draft)
|
|
return draft
|
|
|
|
|
|
def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int):
|
|
return db.execute(
|
|
select(AccountingVoucherDraft)
|
|
.options(selectinload(AccountingVoucherDraft.lines))
|
|
.where(
|
|
AccountingVoucherDraft.id == int(draft_id),
|
|
AccountingVoucherDraft.tenant_id == int(tenant_id),
|
|
AccountingVoucherDraft.client_id == int(client_id),
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: int = 1, per_page: int = 20):
|
|
where = [
|
|
AccountingVoucherDraft.tenant_id == int(tenant_id),
|
|
AccountingVoucherDraft.client_id == int(client_id),
|
|
]
|
|
if _s(status):
|
|
where.append(AccountingVoucherDraft.status == _s(status))
|
|
|
|
total = int(db.scalar(select(func.count(AccountingVoucherDraft.id)).where(*where)) or 0)
|
|
per_page = max(10, min(100, int(per_page or 20)))
|
|
pages = max(1, (total + per_page - 1) // per_page)
|
|
page = max(1, min(int(page or 1), pages))
|
|
|
|
rows = list(
|
|
db.execute(
|
|
select(AccountingVoucherDraft)
|
|
.options(selectinload(AccountingVoucherDraft.lines))
|
|
.where(*where)
|
|
.order_by(AccountingVoucherDraft.id.desc())
|
|
.offset((page - 1) * per_page)
|
|
.limit(per_page)
|
|
).scalars().all()
|
|
)
|
|
return rows, total, page, pages
|
|
|
|
|
|
def review_draft(db, *, draft: AccountingVoucherDraft, note: str, user_id: int):
|
|
if draft.status != "draft":
|
|
raise ValueError("Only Draft vouchers can be marked Reviewed.")
|
|
draft.status = "reviewed"
|
|
draft.review_note = _s(note)
|
|
draft.reviewed_by_user_id = int(user_id)
|
|
draft.reviewed_at_utc = _utcnow()
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
return draft
|
|
|
|
|
|
def approve_draft(db, *, draft: AccountingVoucherDraft, user_id: int):
|
|
if draft.status != "reviewed":
|
|
raise ValueError("Voucher must be Reviewed before Approval.")
|
|
draft.status = "approved"
|
|
draft.approved_by_user_id = int(user_id)
|
|
draft.approved_at_utc = _utcnow()
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
return draft
|
|
|
|
|
|
def void_draft(db, *, draft: AccountingVoucherDraft):
|
|
if draft.status == "posted":
|
|
raise ValueError("A posted voucher cannot be voided from the ERP draft queue.")
|
|
if draft.status in {"preflight_queued", "preflight_claimed", "posting_queued", "posting_claimed"}:
|
|
raise ValueError("An active Local Agent job exists for this voucher.")
|
|
draft.status = "void"
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
return draft
|
|
|
|
|
|
def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None):
|
|
stmt = select(ERPWorkstationAgent).where(
|
|
ERPWorkstationAgent.tenant_id == int(tenant_id),
|
|
ERPWorkstationAgent.is_active.is_(True),
|
|
ERPWorkstationAgent.tally_connected.is_(True),
|
|
)
|
|
if branch_id is not None:
|
|
stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id))
|
|
|
|
return list(
|
|
db.execute(
|
|
stmt.order_by(
|
|
ERPWorkstationAgent.last_seen_at_utc.desc(),
|
|
ERPWorkstationAgent.id.desc(),
|
|
)
|
|
).scalars().all()
|
|
)
|
|
|
|
|
|
def _idempotency(draft: AccountingVoucherDraft):
|
|
raw = "|".join(
|
|
[
|
|
str(draft.tenant_id),
|
|
str(draft.client_id),
|
|
str(draft.id),
|
|
draft.tally_guid,
|
|
draft.voucher_type,
|
|
draft.voucher_date,
|
|
draft.reference,
|
|
f"{draft.total_debit:.2f}",
|
|
]
|
|
)
|
|
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()[:32]
|
|
|
|
|
|
def _payload(draft: AccountingVoucherDraft):
|
|
return {
|
|
"client_id": int(draft.client_id),
|
|
"tenant_id": int(draft.tenant_id),
|
|
"tally_guid": draft.tally_guid,
|
|
"company_name": draft.company_name,
|
|
"voucher_type": draft.voucher_type,
|
|
"voucher_date": draft.voucher_date,
|
|
"reference": draft.reference,
|
|
"narration": draft.narration,
|
|
"business_nature": draft.business_nature,
|
|
"erp_voucher_draft_id": int(draft.id),
|
|
"total_amount": float(draft.total_debit),
|
|
"lines": [
|
|
{
|
|
"ledger_name": line.ledger_name,
|
|
"ledger_role_code": line.ledger_role_code,
|
|
"dr_cr": line.dr_cr,
|
|
"amount": float(line.amount),
|
|
"line_note": line.line_note,
|
|
}
|
|
for line in draft.lines
|
|
],
|
|
}
|
|
|
|
|
|
def queue_preflight(db, *, draft: AccountingVoucherDraft, workstation_id: int, user_id: int):
|
|
if draft.status != "approved":
|
|
raise ValueError("Voucher must be Approved before Local Agent preflight.")
|
|
|
|
workstation = db.get(ERPWorkstationAgent, int(workstation_id))
|
|
if (
|
|
not workstation
|
|
or not workstation.is_active
|
|
or int(workstation.tenant_id) != int(draft.tenant_id)
|
|
or not workstation.tally_connected
|
|
):
|
|
raise ValueError("Selected workstation is unavailable or Tally is not connected.")
|
|
|
|
job = enqueue_agent_job(
|
|
db,
|
|
workstation_agent_id=workstation.id,
|
|
action=PREFLIGHT_ACTION,
|
|
payload=_payload(draft),
|
|
idempotency_key=f"native-voucher:{_idempotency(draft)}:preflight:{workstation.id}",
|
|
priority=9,
|
|
max_attempts=2,
|
|
created_by_user_id=user_id,
|
|
)
|
|
|
|
draft.workstation_agent_id = workstation.id
|
|
draft.preflight_job_id = job.id
|
|
draft.preflight_result_json = "{}"
|
|
draft.last_error = ""
|
|
draft.status = "preflight_queued"
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
return draft
|
|
|
|
|
|
def queue_post(db, *, draft: AccountingVoucherDraft, user_id: int):
|
|
sync_draft(db, draft)
|
|
if draft.status != "preflight_ready":
|
|
raise ValueError("Successful workstation/Tally preflight is required before posting.")
|
|
|
|
preflight = _loads(draft.preflight_result_json, {})
|
|
if preflight.get("duplicate_candidates"):
|
|
raise ValueError("Possible duplicate voucher exists in Tally. Posting is blocked.")
|
|
|
|
workstation = db.get(ERPWorkstationAgent, int(draft.workstation_agent_id or 0))
|
|
if not workstation or not workstation.is_active or not workstation.tally_connected:
|
|
raise ValueError("The preflight workstation is no longer available or connected to Tally.")
|
|
|
|
job = enqueue_agent_job(
|
|
db,
|
|
workstation_agent_id=workstation.id,
|
|
action=POST_ACTION,
|
|
payload=_payload(draft),
|
|
idempotency_key=f"native-voucher:{_idempotency(draft)}:post",
|
|
priority=10,
|
|
max_attempts=1,
|
|
created_by_user_id=user_id,
|
|
)
|
|
|
|
draft.posting_job_id = job.id
|
|
draft.status = "posting_queued"
|
|
draft.last_error = ""
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
return draft
|
|
|
|
|
|
def sync_draft(db, draft: AccountingVoucherDraft):
|
|
changed = False
|
|
|
|
if draft.preflight_job_id and draft.status.startswith("preflight"):
|
|
job = db.get(ERPAgentJob, int(draft.preflight_job_id))
|
|
if job:
|
|
if job.status == "claimed" and draft.status != "preflight_claimed":
|
|
draft.status = "preflight_claimed"
|
|
changed = True
|
|
elif job.status == "succeeded":
|
|
result = _loads(job.result_json, {})
|
|
draft.preflight_result_json = _dumps(result)
|
|
draft.company_name = _s(result.get("company_name")) or draft.company_name
|
|
draft.status = "preflight_ready"
|
|
draft.last_error = ""
|
|
changed = True
|
|
elif job.status in {"failed", "cancelled"}:
|
|
draft.status = "preflight_failed"
|
|
draft.last_error = _s(job.last_error) or f"Preflight job {job.status}."
|
|
changed = True
|
|
|
|
if draft.posting_job_id and draft.status.startswith("posting"):
|
|
job = db.get(ERPAgentJob, int(draft.posting_job_id))
|
|
if job:
|
|
if job.status == "claimed" and draft.status != "posting_claimed":
|
|
draft.status = "posting_claimed"
|
|
changed = True
|
|
elif job.status == "succeeded":
|
|
result = _loads(job.result_json, {})
|
|
tally = result.get("tally_result") or result
|
|
draft.posting_result_json = _dumps(result)
|
|
draft.tally_voucher_id = _s(
|
|
tally.get("last_voucher_id") or tally.get("voucher_id")
|
|
)[:120]
|
|
draft.tally_voucher_number = _s(
|
|
tally.get("voucher_number")
|
|
or tally.get("last_voucher_id")
|
|
or tally.get("voucher_id")
|
|
)[:160]
|
|
draft.status = "posted"
|
|
draft.last_error = ""
|
|
changed = True
|
|
elif job.status in {"failed", "cancelled"}:
|
|
draft.status = "posting_failed"
|
|
draft.last_error = _s(job.last_error) or f"Posting job {job.status}."
|
|
changed = True
|
|
|
|
if changed:
|
|
draft.updated_at_utc = _utcnow()
|
|
db.add(draft)
|
|
db.commit()
|
|
|
|
return draft
|
|
|
|
|
|
def sync_drafts(db, drafts):
|
|
for draft in drafts:
|
|
if draft.status.startswith("preflight") or draft.status.startswith("posting"):
|
|
sync_draft(db, draft)
|