Refine Tally depreciation storage rates and additions

This commit is contained in:
A R R R Associates
2026-08-20 11:24:36 +05:30
parent 4ab86f4023
commit f23da90dec
6 changed files with 161 additions and 23 deletions
@@ -36,7 +36,11 @@
{% if preview %} {% if preview %}
{% set coverage = preview.transaction_coverage %} {% set coverage = preview.transaction_coverage %}
<div class="rounded-xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-800' if coverage.complete else 'border-amber-200 bg-amber-50 text-amber-900' }} px-4 py-3 text-sm"> <div class="rounded-xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-800' if coverage.complete else 'border-amber-200 bg-amber-50 text-amber-900' }} px-4 py-3 text-sm">
{% if coverage.complete %}Phase 4 transaction data completely covers {{ preview.fy_start }} to {{ preview.fy_end }}.{% else %}Phase 4 transaction data does not fully cover this financial year. Synchronize the missing period before calculation.{% endif %} {% if coverage.complete %}
Phase 4 transaction data covers the required period {{ coverage.required_from }} to {{ coverage.required_to }}{% if coverage.provisional %}. This is a provisional current-FY calculation; future dates are not required yet{% endif %}.
{% else %}
Phase 4 transaction data does not yet cover {{ coverage.required_from }} to {{ coverage.required_to }}. You may save a draft, but approval/write-back will remain blocked until the missing period is synchronized.
{% endif %}
</div> </div>
<form method="post" action="/tools/tally/depreciation/calculate" class="space-y-5"> <form method="post" action="/tools/tally/depreciation/calculate" class="space-y-5">
@@ -50,11 +54,29 @@
</section> </section>
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"> <section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Fixed Asset Ledgers</h2><p class="mt-1 text-sm text-slate-500">Rate is selected by the reviewer. Transaction date is used only as a review proxy for the 180-day put-to-use test; verify where actual put-to-use differs.</p></div> <div class="border-b border-slate-200 px-5 py-4">
<div class="overflow-x-auto"><table class="min-w-full text-sm"><thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Ledger</th><th class="px-4 py-3 text-right">Opening WDV</th><th class="px-4 py-3 text-right">Additions ≥180 days</th><th class="px-4 py-3 text-right">Additions &lt;180 days</th><th class="px-4 py-3 text-right">Reductions</th><th class="px-4 py-3">Rate</th></tr></thead><tbody class="divide-y divide-slate-100"> <h2 class="font-semibold text-slate-900">Fixed Asset Ledgers</h2>
{% for row in preview.fixed_asset_ledgers %}<tr><td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.ledger_name }}</div><div class="text-xs text-slate-500">{{ row.parent_group }}</div></td><td class="px-4 py-3 text-right">{{ '%.2f'|format(row.opening_wdv) }}</td><td class="px-4 py-3 text-right">{{ '%.2f'|format(row.additions_full_rate) }}</td><td class="px-4 py-3 text-right">{{ '%.2f'|format(row.additions_half_rate) }}</td><td class="px-4 py-3 text-right">{{ '%.2f'|format(row.reductions) }}</td><td class="px-4 py-3"><select name="rate__{{ row.ledger_key }}" class="rounded-lg border border-slate-300 px-2 py-1.5"><option value="0" {% if row.rate_percent == 0 %}selected{% endif %}>0%</option>{% for rate in [5,10,15,20,25,30,40,45,60,80,100] %}<option value="{{ rate }}" {% if row.rate_percent == rate %}selected{% endif %}>{{ rate }}%</option>{% endfor %}</select></td></tr>{% else %}<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No ledgers under Fixed Assets were found in the synchronized master snapshot.</td></tr>{% endfor %} <p class="mt-1 text-sm text-slate-500">Zero-opening ledgers with no current-period addition are omitted. Effective date is preferred for the 180-day test; voucher date is used when effective date is unavailable.</p>
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<label class="flex items-center gap-2 text-sm font-medium text-slate-700"><input id="select-all-assets" type="checkbox" class="rounded border-slate-300"> Select all displayed ledgers</label>
<div><label class="block text-xs font-semibold uppercase tracking-wide text-slate-500">Bulk rate</label><select id="bulk-rate" class="mt-1 rounded-lg border border-slate-300 px-3 py-2 text-sm"><option value="">Select rate</option><option value="0">0%</option>{% for rate in [5,10,15,20,25,30,40,45,60,80,100] %}<option value="{{ rate }}">{{ rate }}%</option>{% endfor %}</select></div>
<button type="button" id="apply-bulk-rate" class="rounded-lg border border-brand-300 bg-white px-3 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Apply rate to selected</button>
</div>
</div>
<div class="overflow-x-auto"><table class="min-w-full text-sm"><thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3 w-10">Select</th><th class="px-4 py-3">Ledger</th><th class="px-4 py-3 text-right">Opening WDV</th><th class="px-4 py-3 text-right">Additions ≥180 days</th><th class="px-4 py-3 text-right">Additions &lt;180 days</th><th class="px-4 py-3 text-right">Reductions</th><th class="px-4 py-3">Rate</th></tr></thead><tbody class="divide-y divide-slate-100">
{% for row in preview.fixed_asset_ledgers %}
<tr>
<td class="px-4 py-3"><input type="checkbox" class="asset-select rounded border-slate-300" data-ledger-key="{{ row.ledger_key }}"></td>
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ row.ledger_name }}</div><div class="text-xs text-slate-500">{{ row.parent_group }}</div></td>
<td class="px-4 py-3 text-right">{{ '%.2f'|format(row.opening_wdv) }}</td>
<td class="px-4 py-3 text-right">{{ '%.2f'|format(row.additions_full_rate) }}</td>
<td class="px-4 py-3 text-right">{{ '%.2f'|format(row.additions_half_rate) }}</td>
<td class="px-4 py-3 text-right">{{ '%.2f'|format(row.reductions) }}</td>
<td class="px-4 py-3"><select id="rate-{{ loop.index0 }}" data-ledger-key="{{ row.ledger_key }}" name="rate__{{ row.ledger_key }}" class="asset-rate rounded-lg border border-slate-300 px-2 py-1.5"><option value="0" {% if row.rate_percent == 0 %}selected{% endif %}>0%</option>{% for rate in [5,10,15,20,25,30,40,45,60,80,100] %}<option value="{{ rate }}" {% if row.rate_percent == rate %}selected{% endif %}>{{ rate }}%</option>{% endfor %}</select></td>
</tr>
{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No active Fixed Asset ledgers were found. Zero-opening ledgers without additions are intentionally omitted.</td></tr>{% endfor %}
</tbody></table></div> </tbody></table></div>
<div class="flex justify-end border-t border-slate-200 px-5 py-4"><button type="submit" {% if not coverage.complete or not preview.fixed_asset_ledgers %}disabled{% endif %} class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">Calculate & Save Draft</button></div> <div class="flex justify-end border-t border-slate-200 px-5 py-4"><button type="submit" {% if not preview.fixed_asset_ledgers %}disabled{% endif %} class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">Calculate & Save Draft</button></div>
</section> </section>
</form> </form>
{% endif %} {% endif %}
@@ -100,4 +122,30 @@
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 6 enables only controlled depreciation Journal write-back. All master and transaction synchronization remain read-only; no other Tally create/alter/delete operation is enabled.</div> <div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 6 enables only controlled depreciation Journal write-back. All master and transaction synchronization remain read-only; no other Tally create/alter/delete operation is enabled.</div>
</div> </div>
<script>
(() => {
const selectAll = document.getElementById('select-all-assets');
const bulkRate = document.getElementById('bulk-rate');
const applyButton = document.getElementById('apply-bulk-rate');
const selections = () => Array.from(document.querySelectorAll('.asset-select'));
if (selectAll) {
selectAll.addEventListener('change', () => {
selections().forEach(cb => { cb.checked = selectAll.checked; });
});
}
if (applyButton) {
applyButton.addEventListener('click', () => {
const value = bulkRate ? bulkRate.value : '';
if (value === '') return;
selections().filter(cb => cb.checked).forEach(cb => {
const key = cb.dataset.ledgerKey;
const rate = Array.from(document.querySelectorAll('.asset-rate')).find(el => el.dataset.ledgerKey === key);
if (rate) rate.value = value;
});
});
}
})();
</script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.7.1" ERP_LOCAL_AGENT_VERSION = "1.8.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,4 +1,4 @@
ERP Local Agent 1.7.1 ERP Local Agent 1.8.0
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
@@ -27,4 +27,11 @@ Phase 5 adds draft-only Income-tax depreciation calculations from synchronized .
Phase 6: approved Income-tax depreciation Journal write-back is available only after explicit Partner approval and POST confirmation. All other Tally operations remain read-only. Phase 6: approved Income-tax depreciation Journal write-back is available only after explicit Partner approval and POST confirmation. All other Tally operations remain read-only.
Hotfix 1.7.1: accepts phase6_status as a status alias; no accounting behavior changed. Depreciation refinement 1.8.0:
- Accounting .act files default to <Local Agent>\data\accounting, separate from document STORAGE_ROOT.
- Existing legacy <STORAGE_ROOT>\Accounting client databases are copied automatically on first use and left in place as recovery copies.
- Current-FY transaction coverage is required only through today, not through a future FY-end date.
- Draft calculation is available with incomplete coverage; approval/write-back remains blocked until required coverage is complete.
- Empty Fixed Asset ledgers are omitted unless they have current-period additions.
- Bulk checkbox + rate assignment is available.
- Nested Tally accounting allocations and case-insensitive ledger matching improve fixed-asset additions detection.
@@ -1,2 +1,2 @@
__version__ = "1.7.1" __version__ = "1.8.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -2,7 +2,9 @@ from __future__ import annotations
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import json import json
import os
from pathlib import Path from pathlib import Path
import shutil
import sqlite3 import sqlite3
from typing import Sequence from typing import Sequence
@@ -37,9 +39,32 @@ class LocalAccountingStore:
""" """
def __init__(self, storage_root: Path): def __init__(self, storage_root: Path):
self.root = Path(storage_root).resolve() / "Accounting" # Accounting databases are application data, not document-storage payloads.
# Keep them under the Local Agent data directory by default. An explicit
# ACCOUNTING_ROOT may override this without changing document STORAGE_ROOT.
configured = str(os.getenv("ACCOUNTING_ROOT", "") or "").strip()
install_root = Path(__file__).resolve().parents[1]
self.root = Path(configured).expanduser().resolve() if configured else (install_root / "data" / "accounting").resolve()
self.legacy_root = Path(storage_root).resolve() / "Accounting"
self.root.mkdir(parents=True, exist_ok=True) self.root.mkdir(parents=True, exist_ok=True)
def _migrate_legacy_client_if_needed(self, client_id: int) -> None:
key = self._client_key(client_id)
new_dir = self.root / key
new_db = new_dir / f"{key}.act"
old_dir = self.legacy_root / key
old_db = old_dir / f"{key}.act"
if new_db.exists() or not old_db.exists():
return
new_dir.mkdir(parents=True, exist_ok=True)
# Copy, do not delete, the legacy database. The old copy remains a recovery
# fallback while all new reads/writes switch to Local Agent data/accounting.
shutil.copy2(old_db, new_db)
for suffix in ("-wal", "-shm"):
src = Path(str(old_db) + suffix)
if src.exists():
shutil.copy2(src, Path(str(new_db) + suffix))
@staticmethod @staticmethod
def _client_key(client_id: int) -> str: def _client_key(client_id: int) -> str:
return f"client_{int(client_id):08d}" return f"client_{int(client_id):08d}"
@@ -48,13 +73,16 @@ class LocalAccountingStore:
return self.root / self._client_key(client_id) return self.root / self._client_key(client_id)
def db_path(self, client_id: int) -> Path: def db_path(self, client_id: int) -> Path:
self._migrate_legacy_client_if_needed(client_id)
key = self._client_key(client_id) key = self._client_key(client_id)
return self.client_dir(client_id) / f"{key}.act" return self.client_dir(client_id) / f"{key}.act"
def exists(self, client_id: int) -> bool: def exists(self, client_id: int) -> bool:
self._migrate_legacy_client_if_needed(client_id)
return self.db_path(client_id).is_file() return self.db_path(client_id).is_file()
def connect(self, client_id: int): def connect(self, client_id: int):
self._migrate_legacy_client_if_needed(client_id)
path = self.db_path(client_id) path = self.db_path(client_id)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(path, timeout=60) db = sqlite3.connect(path, timeout=60)
@@ -633,6 +661,8 @@ class LocalAccountingStore:
return fixed return fixed
def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict: def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict:
today = date.today()
required_end = min(fy_end, today)
rows = db.execute( rows = db.execute(
"SELECT details_json FROM tally_sync_runs WHERE sync_type='transactions' AND tally_guid=? AND status='completed' ORDER BY id", "SELECT details_json FROM tally_sync_runs WHERE sync_type='transactions' AND tally_guid=? AND status='completed' ORDER BY id",
(tally_guid,), (tally_guid,),
@@ -642,8 +672,8 @@ class LocalAccountingStore:
try: try:
d = json.loads(row["details_json"] or "{}") 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 "")) 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: if b >= fy_start and a <= required_end:
ranges.append((max(a, fy_start), min(b, fy_end))) ranges.append((max(a, fy_start), min(b, required_end)))
except Exception: except Exception:
continue continue
ranges.sort() ranges.sort()
@@ -651,8 +681,23 @@ class LocalAccountingStore:
for a,b in ranges: for a,b in ranges:
if not merged or a > merged[-1][1] + timedelta(days=1): merged.append([a,b]) if not merged or a > merged[-1][1] + timedelta(days=1): merged.append([a,b])
elif b > merged[-1][1]: merged[-1][1] = 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))) complete = bool(
return {"complete": complete, "ranges": [{"from":a.isoformat(),"to":b.isoformat()} for a,b in merged]} required_end < fy_start
or (
merged
and merged[0][0] <= fy_start
and merged[-1][1] >= required_end
and all(merged[i][1] + timedelta(days=1) >= merged[i+1][0] for i in range(len(merged)-1))
)
)
return {
"complete": bool(complete),
"ranges": [{"from":a.isoformat(),"to":b.isoformat()} for a,b in merged],
"required_from": fy_start.isoformat(),
"required_to": required_end.isoformat(),
"financial_year_end": fy_end.isoformat(),
"provisional": fy_end > today,
}
def depreciation_preview(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str) -> dict: def depreciation_preview(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str) -> dict:
if not self.exists(client_id): if not self.exists(client_id):
@@ -676,26 +721,42 @@ class LocalAccountingStore:
for row in ledgers: for row in ledgers:
if str(row["parent"] or "").strip().casefold() not in fixed_fold: continue 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() item=dict(row); key=self._ledger_key(item); name=str(row["name"] or "").strip()
movement_end = min(end, date.today())
entries=db.execute( entries=db.execute(
"""SELECT v.voucher_date, e.amount, e.is_deemed_positive """SELECT COALESCE(NULLIF(v.effective_date,''), v.voucher_date) AS movement_date,
e.amount, e.is_deemed_positive
FROM tally_voucher_ledger_entries e JOIN tally_vouchers v ON v.id=e.voucher_id 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<=? WHERE v.tally_guid=?
AND lower(trim(COALESCE(e.ledger_name,'')))=lower(trim(?))
AND COALESCE(NULLIF(v.effective_date,''), v.voucher_date)>=?
AND COALESCE(NULLIF(v.effective_date,''), v.voucher_date)<=?
AND lower(COALESCE(v.is_cancelled,'')) NOT IN ('yes','true','1') AND lower(COALESCE(v.is_cancelled,'')) NOT IN ('yes','true','1')
AND lower(COALESCE(v.is_optional,'')) NOT IN ('yes','true','1')""", AND lower(COALESCE(v.is_optional,'')) NOT IN ('yes','true','1')""",
(tally_guid,name,start.isoformat(),end.isoformat()), (tally_guid,name,start.isoformat(),movement_end.isoformat()),
).fetchall() ).fetchall()
full=half=reductions=0.0 full=half=reductions=0.0
for e in entries: for e in entries:
amt=abs(float(e["amount"] or 0)) amt=abs(float(e["amount"] or 0))
if self._is_debit_entry(e): if self._is_debit_entry(e):
if self._half_rate_addition(str(e["voucher_date"] or ""), end): half += amt if self._half_rate_addition(str(e["movement_date"] or ""), end): half += amt
else: full += amt else: full += amt
else: reductions += amt else: reductions += amt
opening_wdv = abs(float(row["opening_balance"] or 0))
tally_closing = abs(float(row["closing_balance"] or 0))
additions_total = round(full + half, 2)
# Do not clutter the depreciation schedule with empty Fixed Asset
# ledgers. A zero-opening ledger remains visible only when a current-
# period addition has actually been identified.
if opening_wdv == 0 and additions_total == 0:
continue
result.append({ result.append({
"ledger_key":key,"ledger_guid":str(row["master_guid"] or ""),"ledger_name":name,"parent_group":str(row["parent"] or ""), "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)), "opening_wdv":opening_wdv,"tally_closing_balance":tally_closing,
"additions_full_rate":round(full,2),"additions_half_rate":round(half,2),"reductions":round(reductions,2), "additions_full_rate":round(full,2),"additions_half_rate":round(half,2),"reductions":round(reductions,2),
"rate_percent":rules.get(key,0.0), "rate_percent":rules.get(key,0.0),
"movement_entries":len(entries),
}) })
all_ledgers=[str(r["name"] or "").strip() for r in ledgers if str(r["name"] or "").strip()] 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() 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()
@@ -703,8 +764,9 @@ class LocalAccountingStore:
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: 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) 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"): # A draft may be calculated even when transaction coverage is incomplete so
raise ValueError("Phase 4 transaction data does not completely cover the selected financial year. Synchronize the full financial year before calculating depreciation.") # the reviewer can work progressively. Approval/write-back remains blocked
# until Phase 4 covers the required period.
by_key={str(x.get("ledger_key")):x for x in preview["fixed_asset_ledgers"]} 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} 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] unknown=[k for k in assignment_map if k not in by_key]
@@ -727,7 +789,7 @@ class LocalAccountingStore:
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) 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""", 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)) (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} details={"calculation_basis":"Ledger-level draft using synchronized opening balance and voucher movements; effective date is preferred, otherwise voucher date is used as the review proxy for the 180-day put-to-use test.","transaction_coverage":preview["transaction_coverage"],"provisional":bool(preview["transaction_coverage"].get("provisional")),"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) 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',?,?,?)""", 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=(",",":")))) (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=(",",":"))))
@@ -762,6 +824,14 @@ class LocalAccountingStore:
raise ValueError(f"Depreciation draft cannot be approved from status '{status}'.") raise ValueError(f"Depreciation draft cannot be approved from status '{status}'.")
if float(run["total_depreciation"] or 0) <= 0: if float(run["total_depreciation"] or 0) <= 0:
raise ValueError("Depreciation amount must be greater than zero before approval.") raise ValueError("Depreciation amount must be greater than zero before approval.")
try:
run_details = json.loads(run["details_json"] or "{}")
except Exception:
run_details = {}
coverage = run_details.get("transaction_coverage") or {}
if not coverage.get("complete"):
required_to = str(coverage.get("required_to") or "the required period")
raise ValueError(f"Phase 4 transactions must be synchronized through {required_to} before this draft can be approved.")
if not str(run["depreciation_expense_ledger"] or "").strip() or not str(run["depreciation_reserve_ledger"] or "").strip(): if not str(run["depreciation_expense_ledger"] or "").strip() or not str(run["depreciation_reserve_ledger"] or "").strip():
raise ValueError("Select both the depreciation expense ledger and the depreciation reserve/credit ledger before approval.") raise ValueError("Select both the depreciation expense ledger and the depreciation reserve/credit ledger before approval.")
db.execute( db.execute(
@@ -382,7 +382,20 @@ class TallyLiveConnector:
ledger_entries: list[dict] = [] ledger_entries: list[dict] = []
inventory_entries: list[dict] = [] inventory_entries: list[dict] = []
for entry in _direct_children(voucher, ledger_tags): # Tally can emit ledger allocations directly under VOUCHER or nested
# inside invoice/inventory structures. Read both forms once.
ledger_nodes = []
seen_ledger_nodes = set()
for entry in voucher.iter():
if entry is voucher or _tag(entry) not in (ledger_tags | {"ACCOUNTINGALLOCATIONS.LIST"}):
continue
marker = id(entry)
if marker in seen_ledger_nodes:
continue
seen_ledger_nodes.add(marker)
ledger_nodes.append(entry)
for entry in ledger_nodes:
bills = [] bills = []
banks = [] banks = []
cost_centres = [] cost_centres = []