Add full Tally ODBC export with SQLite progress
This commit is contained in:
@@ -218,6 +218,42 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if accounting and accounting.exists %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft" id="full-accounting-export">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900">Full Accounting Export · ODBC → SQLite</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Export the selected mapped Tally company into the client Accounting Mirror .act database in local storage. This refreshes ledgers, vouchers, voucher ledger entries, stock items, stock movements, HSN and GST rates, then refreshes the existing client .act snapshots used by older accounting tools.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"><strong>Before export:</strong> open the mapped company in TallyPrime and set Tally's active period (Alt+F2) to cover the full period you want exported. The ODBC mirror reads Tally's active period; the ERP does not change Tally's period automatically.</div>
|
||||
<form method="post" action="/tools/tally/full-export" class="mt-5 grid gap-4 lg:grid-cols-4 lg:items-end">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}" />
|
||||
<label class="text-sm font-medium text-slate-700">Mapped Tally Company
|
||||
<select name="tally_guid" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
|
||||
<option value="">Select mapped company</option>
|
||||
{% for mapping in accounting.mappings or [] %}
|
||||
{% if mapping.tally_guid and mapping.is_active %}
|
||||
<option value="{{ mapping.tally_guid }}">{{ mapping.company_name }}{% if mapping.gstin %} · {{ mapping.gstin }}{% endif %}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">From Date<input type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
|
||||
<label class="text-sm font-medium text-slate-700">To Date<input type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
|
||||
<button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Export All to SQLite</button>
|
||||
</form>
|
||||
<div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between gap-3"><div><div id="fullExportStage" class="font-semibold text-slate-900">Waiting…</div><div id="fullExportMessage" class="mt-1 text-xs text-slate-500"></div></div><div id="fullExportPct" class="text-lg font-bold text-slate-900">0%</div></div>
|
||||
<div class="mt-3 h-3 overflow-hidden rounded-full bg-slate-200"><div id="fullExportBar" class="h-full bg-emerald-600 transition-all duration-300" style="width:0%"></div></div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Ledgers</div><div id="fullExportLedgers" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Vouchers</div><div id="fullExportVouchers" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Voucher Lines</div><div id="fullExportLines" class="mt-1 text-lg font-semibold">0</div></div><div class="rounded-lg bg-white p-3"><div class="text-xs uppercase text-slate-500">Stock Movements</div><div id="fullExportInventory" class="mt-1 text-lg font-semibold">0</div></div></div>
|
||||
<div id="fullExportPaths" class="mt-3 break-all text-xs text-slate-500"></div><div id="fullExportError" class="mt-3 hidden rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700"></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if accounting and accounting.exists and tally and tally.connected %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -418,4 +454,29 @@
|
||||
Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const clientId = {{ selected_client.id if selected_client else 'null' }};
|
||||
if (!clientId) return;
|
||||
const box=document.getElementById('fullExportProgress'); if(!box) return;
|
||||
let completedReloaded=false;
|
||||
function setText(id,v){const e=document.getElementById(id);if(e)e.textContent=String(v??'');}
|
||||
async function pollFullExport(){
|
||||
try{
|
||||
const r=await fetch('/tools/tally/full-export/status?client_id='+encodeURIComponent(clientId),{cache:'no-store'});
|
||||
const d=await r.json(); if(!r.ok||!d.ok||!d.job)return;
|
||||
const j=d.job||{}, p=Math.max(0,Math.min(100,Number(j.percent)||0)); box.classList.remove('hidden');
|
||||
setText('fullExportStage',j.stage||j.status||'Working…'); setText('fullExportMessage',j.message||''); setText('fullExportPct',Math.round(p)+'%');
|
||||
document.getElementById('fullExportBar').style.width=p+'%';
|
||||
const rows=j.rows||((j.mirror||{}).counts)||{}; setText('fullExportLedgers',rows.ledger_master||0); setText('fullExportVouchers',rows.voucher||rows.voucher_header||0); setText('fullExportLines',rows.voucher_ledger_entry||rows.voucher_ledger||0); setText('fullExportInventory',rows.inventory_movement||rows.inventory||0);
|
||||
const paths=[]; if(j.mirror_db_path)paths.push('Mirror: '+j.mirror_db_path); if(j.accounting_db_path)paths.push('Client DB: '+j.accounting_db_path); setText('fullExportPaths',paths.join(' • '));
|
||||
const er=document.getElementById('fullExportError'); if(j.error){er.textContent=j.error;er.classList.remove('hidden')}else{er.classList.add('hidden')}
|
||||
const btn=document.getElementById('fullExportButton'); if(btn)btn.disabled=['queued','running'].includes(String(j.status||'').toLowerCase());
|
||||
if(String(j.status||'').toLowerCase()==='completed'&&!completedReloaded){completedReloaded=true;setTimeout(()=>location.href='/tools/tally?client_id='+clientId+'&refresh=1',1800)}
|
||||
}catch(e){}
|
||||
}
|
||||
pollFullExport(); setInterval(pollFullExport,1500);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
@@ -197,6 +197,7 @@ def tally_tool(
|
||||
unmapped: int = 0,
|
||||
synced: int = 0,
|
||||
transactions_synced: int = 0,
|
||||
export_started: int = 0,
|
||||
error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
@@ -547,6 +548,75 @@ def sync_tally_transactions(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/full-export")
|
||||
def start_full_accounting_export(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
tally_guid: str = Form(...),
|
||||
date_from: str = Form(...),
|
||||
date_to: str = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.tally.sync_transactions")
|
||||
if response:
|
||||
return response
|
||||
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return _denied()
|
||||
try:
|
||||
start = date.fromisoformat(str(date_from or ""))
|
||||
end = date.fromisoformat(str(date_to or ""))
|
||||
if start > end:
|
||||
raise ValueError("From Date cannot be after To Date.")
|
||||
except Exception as exc:
|
||||
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303)
|
||||
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||
if not node or not _node_online(node):
|
||||
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
|
||||
result = request_agent_command(
|
||||
node.node_code, "accounting_full_export_start",
|
||||
{
|
||||
**_accounting_storage_payload(client, _financial_year_for_date(start)),
|
||||
"tally_guid": str(tally_guid or "").strip(),
|
||||
"date_from": start.isoformat(), "date_to": end.isoformat(),
|
||||
"requested_by_user_id": int(user.id), "timeout_seconds": 1800,
|
||||
}, timeout_seconds=20,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise RuntimeError(str(result.get("error") or "Full Accounting Export could not be started."))
|
||||
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&export_started=1", status_code=303)
|
||||
except Exception as exc:
|
||||
return RedirectResponse(url=f"/tools/tally?client_id={client_id}&error={quote(str(exc))}", status_code=303)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/full-export/status")
|
||||
def full_accounting_export_status(request: Request, client_id: int):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user, response = _require_partner(request, db, "accounting.tally.view")
|
||||
if response:
|
||||
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
|
||||
client, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return JSONResponse({"ok": False, "error": "Client is not available."}, status_code=404)
|
||||
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
|
||||
if not node or not _node_online(node):
|
||||
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=503)
|
||||
result = request_agent_command(
|
||||
node.node_code, "accounting_full_export_status", _accounting_storage_payload(client), timeout_seconds=10
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500)
|
||||
return JSONResponse({"ok": True, **(result.get("result") or {})})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/depreciation")
|
||||
def depreciation_it_tool(
|
||||
request: Request,
|
||||
|
||||
Reference in New Issue
Block a user