|
|
|
@@ -1,13 +1,13 @@
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sqlite3
|
|
|
|
|
from typing import Sequence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SCHEMA_VERSION = "4"
|
|
|
|
|
SCHEMA_VERSION = "5"
|
|
|
|
|
|
|
|
|
|
MASTER_TABLES = {
|
|
|
|
|
"groups": "tally_groups",
|
|
|
|
@@ -31,8 +31,9 @@ class LocalAccountingStore:
|
|
|
|
|
|
|
|
|
|
Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped
|
|
|
|
|
read-only Tally master snapshots. Phase 4 adds date-scoped vouchers and their
|
|
|
|
|
ledger, inventory, bill, cost-centre and bank allocations. Existing .act files
|
|
|
|
|
are upgraded in place.
|
|
|
|
|
ledger, inventory, bill, cost-centre and bank allocations. Phase 5 adds
|
|
|
|
|
reviewable Income-tax depreciation rate rules and draft calculations only.
|
|
|
|
|
Existing .act files are upgraded in place and no Tally write-back occurs.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, storage_root: Path):
|
|
|
|
@@ -244,6 +245,35 @@ class LocalAccountingStore:
|
|
|
|
|
FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_tally_bank_allocations_ledger_entry ON tally_bank_allocations(ledger_entry_id, line_no);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS it_depreciation_rate_rules (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
tally_guid TEXT NOT NULL, ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '',
|
|
|
|
|
ledger_name TEXT NOT NULL, rate_percent REAL NOT NULL DEFAULT 0,
|
|
|
|
|
updated_by_user_id INTEGER, updated_at_utc TEXT NOT NULL,
|
|
|
|
|
UNIQUE(tally_guid, ledger_key)
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_it_dep_rate_rules_company ON it_depreciation_rate_rules(tally_guid, ledger_name);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS it_depreciation_runs (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL,
|
|
|
|
|
mapping_id INTEGER, fy_start TEXT NOT NULL, fy_end TEXT NOT NULL,
|
|
|
|
|
depreciation_expense_ledger TEXT NOT NULL DEFAULT '', depreciation_reserve_ledger TEXT NOT NULL DEFAULT '',
|
|
|
|
|
requested_by_user_id INTEGER, status TEXT NOT NULL DEFAULT 'draft', created_at_utc TEXT NOT NULL,
|
|
|
|
|
total_depreciation REAL NOT NULL DEFAULT 0, details_json TEXT NOT NULL DEFAULT '{}'
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_it_dep_runs_company_fy ON it_depreciation_runs(tally_guid, fy_start, fy_end, id);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS it_depreciation_run_lines (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, line_no INTEGER NOT NULL,
|
|
|
|
|
ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '', ledger_name TEXT NOT NULL, parent_group TEXT NOT NULL DEFAULT '',
|
|
|
|
|
opening_wdv REAL NOT NULL DEFAULT 0, additions_full_rate REAL NOT NULL DEFAULT 0,
|
|
|
|
|
additions_half_rate REAL NOT NULL DEFAULT 0, reductions REAL NOT NULL DEFAULT 0,
|
|
|
|
|
rate_percent REAL NOT NULL DEFAULT 0, depreciation_amount REAL NOT NULL DEFAULT 0,
|
|
|
|
|
closing_wdv REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}',
|
|
|
|
|
FOREIGN KEY(run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS ix_it_dep_lines_run ON it_depreciation_run_lines(run_id, line_no);
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -517,6 +547,159 @@ class LocalAccountingStore:
|
|
|
|
|
result[key] = int(db.execute(f"SELECT COUNT(*) FROM {table} WHERE tally_guid=?", (tally_guid,)).fetchone()[0])
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _ledger_key(row: dict) -> str:
|
|
|
|
|
guid = str(row.get("master_guid") or row.get("guid") or "").strip()
|
|
|
|
|
name = str(row.get("name") or row.get("ledger_name") or "").strip()
|
|
|
|
|
return ("G:" + guid) if guid else ("N:" + name.casefold())
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _is_debit_entry(row: sqlite3.Row | dict) -> bool:
|
|
|
|
|
flag = str(row["is_deemed_positive"] if isinstance(row, sqlite3.Row) else row.get("is_deemed_positive") or "").strip().lower()
|
|
|
|
|
if flag in {"yes", "true", "1"}:
|
|
|
|
|
return True
|
|
|
|
|
if flag in {"no", "false", "0"}:
|
|
|
|
|
return False
|
|
|
|
|
amount = float(row["amount"] if isinstance(row, sqlite3.Row) else row.get("amount") or 0)
|
|
|
|
|
return amount < 0
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _half_rate_addition(voucher_date: str, fy_end: date) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
acquired = date.fromisoformat(str(voucher_date or ""))
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
# Transaction date is a review proxy for put-to-use date. Half-rate is
|
|
|
|
|
# flagged only when fewer than 180 calendar days remain including that date.
|
|
|
|
|
return ((fy_end - acquired).days + 1) < 180
|
|
|
|
|
|
|
|
|
|
def _fixed_asset_group_names(self, db: sqlite3.Connection, tally_guid: str) -> set[str]:
|
|
|
|
|
rows = db.execute("SELECT name, parent FROM tally_groups WHERE tally_guid=?", (tally_guid,)).fetchall()
|
|
|
|
|
all_groups = {str(r["name"] or "").strip(): str(r["parent"] or "").strip() for r in rows}
|
|
|
|
|
fixed = {name for name in all_groups if name.casefold() == "fixed assets"}
|
|
|
|
|
changed = True
|
|
|
|
|
while changed:
|
|
|
|
|
changed = False
|
|
|
|
|
fixed_fold = {x.casefold() for x in fixed}
|
|
|
|
|
for name, parent in all_groups.items():
|
|
|
|
|
if name not in fixed and parent.casefold() in fixed_fold:
|
|
|
|
|
fixed.add(name); changed = True
|
|
|
|
|
return fixed
|
|
|
|
|
|
|
|
|
|
def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict:
|
|
|
|
|
rows = db.execute(
|
|
|
|
|
"SELECT details_json FROM tally_sync_runs WHERE sync_type='transactions' AND tally_guid=? AND status='completed' ORDER BY id",
|
|
|
|
|
(tally_guid,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
ranges = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
try:
|
|
|
|
|
d = json.loads(row["details_json"] or "{}")
|
|
|
|
|
a = date.fromisoformat(str(d.get("date_from") or "")); b = date.fromisoformat(str(d.get("date_to") or ""))
|
|
|
|
|
if b >= fy_start and a <= fy_end:
|
|
|
|
|
ranges.append((max(a, fy_start), min(b, fy_end)))
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
ranges.sort()
|
|
|
|
|
merged = []
|
|
|
|
|
for a,b in ranges:
|
|
|
|
|
if not merged or a > merged[-1][1] + timedelta(days=1): merged.append([a,b])
|
|
|
|
|
elif b > merged[-1][1]: merged[-1][1] = b
|
|
|
|
|
complete = bool(merged and merged[0][0] <= fy_start and merged[-1][1] >= fy_end and all(merged[i][1] + timedelta(days=1) >= merged[i+1][0] for i in range(len(merged)-1)))
|
|
|
|
|
return {"complete": complete, "ranges": [{"from":a.isoformat(),"to":b.isoformat()} for a,b in merged]}
|
|
|
|
|
|
|
|
|
|
def depreciation_preview(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str) -> dict:
|
|
|
|
|
if not self.exists(client_id):
|
|
|
|
|
raise ValueError("Accounting storage is not initialized for this client.")
|
|
|
|
|
mapping = self.get_active_mapping_by_guid(client_id, tally_guid)
|
|
|
|
|
start = date.fromisoformat(str(fy_start)); end = date.fromisoformat(str(fy_end))
|
|
|
|
|
if start > end: raise ValueError("Financial-year start cannot be after end date.")
|
|
|
|
|
with self.connect(client_id) as db:
|
|
|
|
|
fixed_groups = self._fixed_asset_group_names(db, tally_guid)
|
|
|
|
|
if not fixed_groups:
|
|
|
|
|
raise ValueError("Fixed Assets group was not found in synchronized Tally masters. Synchronize Phase 3 masters first.")
|
|
|
|
|
ledgers = db.execute(
|
|
|
|
|
"SELECT master_guid, name, parent, opening_balance, closing_balance, payload_json FROM tally_ledgers WHERE tally_guid=? ORDER BY name COLLATE NOCASE",
|
|
|
|
|
(tally_guid,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
rule_rows = db.execute("SELECT ledger_key, rate_percent FROM it_depreciation_rate_rules WHERE tally_guid=?", (tally_guid,)).fetchall()
|
|
|
|
|
rules = {str(r["ledger_key"]): float(r["rate_percent"] or 0) for r in rule_rows}
|
|
|
|
|
coverage = self._transaction_coverage(db, tally_guid, start, end)
|
|
|
|
|
fixed_fold = {x.casefold() for x in fixed_groups}
|
|
|
|
|
result=[]
|
|
|
|
|
for row in ledgers:
|
|
|
|
|
if str(row["parent"] or "").strip().casefold() not in fixed_fold: continue
|
|
|
|
|
item=dict(row); key=self._ledger_key(item); name=str(row["name"] or "").strip()
|
|
|
|
|
entries=db.execute(
|
|
|
|
|
"""SELECT v.voucher_date, e.amount, e.is_deemed_positive
|
|
|
|
|
FROM tally_voucher_ledger_entries e JOIN tally_vouchers v ON v.id=e.voucher_id
|
|
|
|
|
WHERE v.tally_guid=? AND e.ledger_name=? AND v.voucher_date>=? AND v.voucher_date<=?
|
|
|
|
|
AND lower(COALESCE(v.is_cancelled,'')) NOT IN ('yes','true','1')
|
|
|
|
|
AND lower(COALESCE(v.is_optional,'')) NOT IN ('yes','true','1')""",
|
|
|
|
|
(tally_guid,name,start.isoformat(),end.isoformat()),
|
|
|
|
|
).fetchall()
|
|
|
|
|
full=half=reductions=0.0
|
|
|
|
|
for e in entries:
|
|
|
|
|
amt=abs(float(e["amount"] or 0))
|
|
|
|
|
if self._is_debit_entry(e):
|
|
|
|
|
if self._half_rate_addition(str(e["voucher_date"] or ""), end): half += amt
|
|
|
|
|
else: full += amt
|
|
|
|
|
else: reductions += amt
|
|
|
|
|
result.append({
|
|
|
|
|
"ledger_key":key,"ledger_guid":str(row["master_guid"] or ""),"ledger_name":name,"parent_group":str(row["parent"] or ""),
|
|
|
|
|
"opening_wdv":abs(float(row["opening_balance"] or 0)),"tally_closing_balance":abs(float(row["closing_balance"] or 0)),
|
|
|
|
|
"additions_full_rate":round(full,2),"additions_half_rate":round(half,2),"reductions":round(reductions,2),
|
|
|
|
|
"rate_percent":rules.get(key,0.0),
|
|
|
|
|
})
|
|
|
|
|
all_ledgers=[str(r["name"] or "").strip() for r in ledgers if str(r["name"] or "").strip()]
|
|
|
|
|
latest=db.execute("SELECT id, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, created_at_utc FROM it_depreciation_runs WHERE tally_guid=? ORDER BY id DESC LIMIT 1",(tally_guid,)).fetchone()
|
|
|
|
|
return {"mapping":mapping,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"transaction_coverage":coverage,"fixed_asset_groups":sorted(fixed_groups,key=str.casefold),"fixed_asset_ledgers":result,"all_ledgers":all_ledgers,"latest_run":dict(latest) if latest else None}
|
|
|
|
|
|
|
|
|
|
def calculate_it_depreciation(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str, assignments: list[dict], depreciation_expense_ledger: str = "", depreciation_reserve_ledger: str = "", requested_by_user_id: int | None = None) -> dict:
|
|
|
|
|
preview=self.depreciation_preview(client_id,tally_guid=tally_guid,fy_start=fy_start,fy_end=fy_end)
|
|
|
|
|
if not preview["transaction_coverage"].get("complete"):
|
|
|
|
|
raise ValueError("Phase 4 transaction data does not completely cover the selected financial year. Synchronize the full financial year before calculating depreciation.")
|
|
|
|
|
by_key={str(x.get("ledger_key")):x for x in preview["fixed_asset_ledgers"]}
|
|
|
|
|
assignment_map={str(x.get("ledger_key") or ""): float(x.get("rate_percent") or 0) for x in assignments}
|
|
|
|
|
unknown=[k for k in assignment_map if k not in by_key]
|
|
|
|
|
if unknown: raise ValueError("One or more submitted fixed-asset ledgers are no longer available. Refresh the depreciation page.")
|
|
|
|
|
for rate in assignment_map.values():
|
|
|
|
|
if rate < 0 or rate > 100: raise ValueError("Depreciation rate must be between 0% and 100%.")
|
|
|
|
|
now=_utc_now_iso(); lines=[]; total=0.0
|
|
|
|
|
for index,row in enumerate(preview["fixed_asset_ledgers"],1):
|
|
|
|
|
rate=float(assignment_map.get(row["ledger_key"], row.get("rate_percent") or 0))
|
|
|
|
|
opening=float(row["opening_wdv"] or 0); full=float(row["additions_full_rate"] or 0); half=float(row["additions_half_rate"] or 0); reductions=float(row["reductions"] or 0)
|
|
|
|
|
closing_before=max(0.0, opening+full+half-reductions)
|
|
|
|
|
full_base=max(0.0, opening+full-reductions)
|
|
|
|
|
dep=min(closing_before, (full_base*rate/100.0)+(half*rate/200.0))
|
|
|
|
|
dep=round(dep,2); closing=round(max(0.0,closing_before-dep),2); total+=dep
|
|
|
|
|
line={**row,"rate_percent":rate,"depreciation_amount":dep,"closing_wdv":closing,"closing_before_depreciation":round(closing_before,2)}
|
|
|
|
|
lines.append(line)
|
|
|
|
|
mapping=preview["mapping"]
|
|
|
|
|
with self.connect(client_id) as db:
|
|
|
|
|
for row in lines:
|
|
|
|
|
db.execute("""INSERT INTO it_depreciation_rate_rules(tally_guid,ledger_key,ledger_guid,ledger_name,rate_percent,updated_by_user_id,updated_at_utc)
|
|
|
|
|
VALUES(?,?,?,?,?,?,?) ON CONFLICT(tally_guid,ledger_key) DO UPDATE SET ledger_guid=excluded.ledger_guid,ledger_name=excluded.ledger_name,rate_percent=excluded.rate_percent,updated_by_user_id=excluded.updated_by_user_id,updated_at_utc=excluded.updated_at_utc""",
|
|
|
|
|
(tally_guid,row["ledger_key"],row["ledger_guid"],row["ledger_name"],row["rate_percent"],requested_by_user_id,now))
|
|
|
|
|
details={"calculation_basis":"Ledger-level draft using synchronized opening balance and voucher movements; transaction date is used as the review proxy for put-to-use date for the 180-day test.","transaction_coverage":preview["transaction_coverage"],"no_tally_writeback":True}
|
|
|
|
|
cur=db.execute("""INSERT INTO it_depreciation_runs(tally_guid,company_name,mapping_id,fy_start,fy_end,depreciation_expense_ledger,depreciation_reserve_ledger,requested_by_user_id,status,created_at_utc,total_depreciation,details_json)
|
|
|
|
|
VALUES(?,?,?,?,?,?,?,?, 'draft',?,?,?)""",
|
|
|
|
|
(tally_guid,str(mapping.get("company_name") or ""),mapping.get("id"),str(fy_start),str(fy_end),str(depreciation_expense_ledger or ""),str(depreciation_reserve_ledger or ""),requested_by_user_id,now,round(total,2),json.dumps(details,ensure_ascii=False,separators=(",",":"))))
|
|
|
|
|
run_id=int(cur.lastrowid)
|
|
|
|
|
for i,row in enumerate(lines,1):
|
|
|
|
|
db.execute("""INSERT INTO it_depreciation_run_lines(run_id,line_no,ledger_key,ledger_guid,ledger_name,parent_group,opening_wdv,additions_full_rate,additions_half_rate,reductions,rate_percent,depreciation_amount,closing_wdv,payload_json)
|
|
|
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
|
|
|
(run_id,i,row["ledger_key"],row["ledger_guid"],row["ledger_name"],row["parent_group"],row["opening_wdv"],row["additions_full_rate"],row["additions_half_rate"],row["reductions"],row["rate_percent"],row["depreciation_amount"],row["closing_wdv"],json.dumps(row,ensure_ascii=False,separators=(",",":"))))
|
|
|
|
|
return {"run_id":run_id,"status":"draft","company_name":str(mapping.get("company_name") or ""),"fy_start":str(fy_start),"fy_end":str(fy_end),"total_depreciation":round(total,2),"depreciation_expense_ledger":str(depreciation_expense_ledger or ""),"depreciation_reserve_ledger":str(depreciation_reserve_ledger or ""),"lines":lines,"proposed_entry":{"debit_ledger":str(depreciation_expense_ledger or "Depreciation"),"credit_ledger":str(depreciation_reserve_ledger or "Depreciation Reserve"),"amount":round(total,2)},"no_tally_writeback":True}
|
|
|
|
|
|
|
|
|
|
def get_it_depreciation_run(self, client_id: int, run_id: int) -> dict:
|
|
|
|
|
with self.connect(client_id) as db:
|
|
|
|
|
run=db.execute("SELECT * FROM it_depreciation_runs WHERE id=?",(int(run_id),)).fetchone()
|
|
|
|
|
if not run: raise ValueError("Depreciation draft was not found.")
|
|
|
|
|
lines=db.execute("SELECT * FROM it_depreciation_run_lines WHERE run_id=? ORDER BY line_no",(int(run_id),)).fetchall()
|
|
|
|
|
result=dict(run); result["lines"]=[dict(x) for x in lines]; result["no_tally_writeback"]=True
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def snapshot(self, client_id: int) -> dict:
|
|
|
|
|
path = self.db_path(client_id)
|
|
|
|
|
if not path.is_file():
|
|
|
|
@@ -536,6 +719,7 @@ class LocalAccountingStore:
|
|
|
|
|
latest = db.execute("SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1").fetchone()
|
|
|
|
|
latest_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='masters' ORDER BY id DESC LIMIT 1").fetchone()
|
|
|
|
|
latest_transaction_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='transactions' ORDER BY id DESC LIMIT 1").fetchone()
|
|
|
|
|
latest_depreciation = db.execute("SELECT id, tally_guid, company_name, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, status, created_at_utc FROM it_depreciation_runs ORDER BY id DESC LIMIT 1").fetchone()
|
|
|
|
|
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
|
|
|
|
|
mapped = []
|
|
|
|
|
for row in mappings:
|
|
|
|
@@ -565,4 +749,5 @@ class LocalAccountingStore:
|
|
|
|
|
"mappings": mapped,
|
|
|
|
|
"latest_master_sync": latest_sync_dict,
|
|
|
|
|
"latest_transaction_sync": latest_transaction_sync_dict,
|
|
|
|
|
"latest_it_depreciation": dict(latest_depreciation) if latest_depreciation else None,
|
|
|
|
|
}
|
|
|
|
|