Add FY-wise Tally data paths to engagement workspace
This commit is contained in:
@@ -435,6 +435,32 @@ def build_year_wise_relative_path(db: Session, engagement: ClientServiceSubscrip
|
||||
return Path(fy) / "Clients" / letter / client_folder / service_category / period / doc_type / stored_filename
|
||||
|
||||
|
||||
def build_tally_data_relative_path(
|
||||
db: Session,
|
||||
engagement: ClientServiceSubscription,
|
||||
document: EngagementDocument,
|
||||
version_no: int,
|
||||
original_filename: str,
|
||||
financial_year: str,
|
||||
) -> Path:
|
||||
"""Build the local-storage target for an uploaded Tally dataset.
|
||||
|
||||
Tally data is engagement-level reference data, but it is deliberately stored
|
||||
under the selected financial year so current-year and prior-year datasets can
|
||||
coexist and be compared without changing the engagement/task workflow.
|
||||
"""
|
||||
client = getattr(engagement, "client", None) or db.get(Client, engagement.client_id)
|
||||
fy = sanitize_segment(f"FY{financial_year}", "FY")
|
||||
letter, client_folder = client_folder_parts(client, engagement.client_id)
|
||||
stored_filename = build_versioned_filename(
|
||||
original_filename,
|
||||
document_id=document.id,
|
||||
version_no=version_no,
|
||||
engagement_code=sanitize_segment(f"ENG-{engagement.id}-TALLY", "ENGAGEMENT"),
|
||||
)
|
||||
return Path(fy) / "Clients" / letter / client_folder / "Accounting" / "Tally Data" / stored_filename
|
||||
|
||||
|
||||
def build_permanent_relative_path(db: Session, client: Client, document: PermanentClientDocument, version_no: int, original_filename: str) -> Path:
|
||||
letter, client_folder = client_folder_parts(client, document.client_id)
|
||||
category = sanitize_segment(document.category, "Other Permanent Documents")
|
||||
@@ -1150,6 +1176,7 @@ def save_uploaded_revision(
|
||||
user,
|
||||
existing_document_id: int | None = None,
|
||||
udin_required: bool | str | None = None,
|
||||
tally_financial_year: str | None = None,
|
||||
) -> EngagementDocument:
|
||||
original_filename = Path(upload_file.filename or "document.bin").name
|
||||
document_type = document_type if document_type in DOCUMENT_TYPES else "GENERAL"
|
||||
@@ -1164,6 +1191,8 @@ def save_uploaded_revision(
|
||||
if description is not None:
|
||||
document.description = description.strip() or None
|
||||
document.document_type = document_type
|
||||
if tally_financial_year:
|
||||
document.financial_year = tally_financial_year
|
||||
document.udin_required = resolved_udin_required
|
||||
if resolved_udin_required and (document.udin_status or "not_required") == "not_required":
|
||||
document.udin_status = "pending"
|
||||
@@ -1175,7 +1204,7 @@ def save_uploaded_revision(
|
||||
branch_id=engagement.branch_id,
|
||||
client_id=engagement.client_id,
|
||||
engagement_id=engagement.id,
|
||||
financial_year=engagement.financial_year,
|
||||
financial_year=tally_financial_year or engagement.financial_year,
|
||||
assessment_year=engagement.assessment_year,
|
||||
document_code="PENDING",
|
||||
document_type=document_type,
|
||||
@@ -1192,7 +1221,12 @@ def save_uploaded_revision(
|
||||
document.document_code = create_document_code(db, engagement.tenant_id, engagement.id, document.id)
|
||||
|
||||
next_version_no = int(document.current_version_no or 0) + 1
|
||||
rel_path = build_year_wise_relative_path(db, engagement, document, next_version_no, original_filename)
|
||||
if tally_financial_year:
|
||||
rel_path = build_tally_data_relative_path(
|
||||
db, engagement, document, next_version_no, original_filename, tally_financial_year
|
||||
)
|
||||
else:
|
||||
rel_path = build_year_wise_relative_path(db, engagement, document, next_version_no, original_filename)
|
||||
abs_path = DEFAULT_STORAGE_ROOT / rel_path
|
||||
try:
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -101,6 +101,20 @@ from app.modules.services.execution import recalculate_task_aqmm_status
|
||||
router = APIRouter(prefix="/documents", tags=["documents-ui"])
|
||||
logger = logging.getLogger("audit_storage_agent.documents_ui")
|
||||
|
||||
_TALLY_MARKER_PREFIX = "ERP_TALLY_DATA_FY:"
|
||||
|
||||
def _previous_financial_year(financial_year: str) -> str:
|
||||
match = re.match(r"^(\d{4})-(\d{2}|\d{4})$", (financial_year or "").strip())
|
||||
if not match:
|
||||
return ""
|
||||
start = int(match.group(1))
|
||||
return f"{start - 1:04d}-{start % 100:02d}"
|
||||
|
||||
def _allowed_tally_financial_years(engagement) -> set[str]:
|
||||
current = str(getattr(engagement, "financial_year", "") or "").strip()
|
||||
previous = _previous_financial_year(current)
|
||||
return {year for year in (current, previous) if year}
|
||||
|
||||
|
||||
def _normalise_document_text(value) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
||||
@@ -359,6 +373,8 @@ def upload_engagement_document(
|
||||
remarks: str | None = Form(None),
|
||||
existing_document_id: str | None = Form(None),
|
||||
udin_required: str | None = Form(None),
|
||||
upload_target: str | None = Form(None),
|
||||
tally_financial_year: str | None = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
return_to: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
@@ -381,6 +397,24 @@ def upload_engagement_document(
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement.id}?year_locked=1", status_code=303)
|
||||
if not file or not file.filename:
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=missing_file", status_code=303)
|
||||
|
||||
is_tally_upload = (upload_target or "").strip().lower() == "tally"
|
||||
selected_tally_fy = (tally_financial_year or "").strip() if is_tally_upload else ""
|
||||
if is_tally_upload:
|
||||
if selected_tally_fy not in _allowed_tally_financial_years(engagement):
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=invalid_tally_year", status_code=303)
|
||||
title = f"Tally Data - FY {selected_tally_fy}"
|
||||
description = f"{_TALLY_MARKER_PREFIX}{selected_tally_fy}"
|
||||
document_type = "GENERAL"
|
||||
if existing_document_id:
|
||||
existing_doc = get_document(db, int(existing_document_id))
|
||||
existing_marker = str(getattr(existing_doc, "description", "") or "") if existing_doc else ""
|
||||
if (
|
||||
not existing_doc
|
||||
or int(getattr(existing_doc, "engagement_id", 0) or 0) != int(engagement.id)
|
||||
or existing_marker.strip() != f"{_TALLY_MARKER_PREFIX}{selected_tally_fy}"
|
||||
):
|
||||
return RedirectResponse(url=return_target if return_to else f"/documents/engagements/{engagement_id}?error=invalid_tally_document", status_code=303)
|
||||
try:
|
||||
doc = save_uploaded_revision(
|
||||
db,
|
||||
@@ -393,6 +427,7 @@ def upload_engagement_document(
|
||||
user=user,
|
||||
existing_document_id=int(existing_document_id) if existing_document_id else None,
|
||||
udin_required=_bool_from_form(udin_required),
|
||||
tally_financial_year=selected_tally_fy or None,
|
||||
)
|
||||
log_document_access(db, action="upload", result="success", user=user, request=request, document=doc)
|
||||
db.commit()
|
||||
|
||||
@@ -10,6 +10,28 @@ from app.modules.documents.services import client_folder_parts, get_active_stora
|
||||
from app.modules.services.models import FirmServiceTaskTemplate, ServiceTaskCategory
|
||||
|
||||
|
||||
|
||||
|
||||
_TALLY_MARKER_PREFIX = "ERP_TALLY_DATA_FY:"
|
||||
|
||||
def _previous_financial_year(financial_year: str) -> str:
|
||||
match = re.match(r"^(\d{4})-(\d{2}|\d{4})$", (financial_year or "").strip())
|
||||
if not match:
|
||||
return ""
|
||||
start = int(match.group(1))
|
||||
prev_start = start - 1
|
||||
return f"{prev_start:04d}-{start % 100:02d}"
|
||||
|
||||
def _tally_document_year(doc: Any) -> str:
|
||||
description = str(getattr(doc, "description", "") or "").strip()
|
||||
if description.startswith(_TALLY_MARKER_PREFIX):
|
||||
return description[len(_TALLY_MARKER_PREFIX):].strip()
|
||||
title = str(getattr(doc, "title", "") or "").strip()
|
||||
match = re.match(r"^Tally Data\s*-\s*FY\s*(\d{4}-\d{2,4})$", title, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
def _join_local_path(root: str, relative: str) -> str:
|
||||
root = (root or "").strip().rstrip("\\/")
|
||||
if not root:
|
||||
@@ -148,6 +170,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
|
||||
client = getattr(engagement, "client", None)
|
||||
client_id = int(getattr(engagement, "client_id", 0) or 0)
|
||||
fy = str(getattr(engagement, "financial_year", "") or "").strip()
|
||||
prior_fy = _previous_financial_year(fy)
|
||||
tally_year_options = [year for year in (fy, prior_fy) if year]
|
||||
|
||||
accounting_relative_dir = ""
|
||||
accounting_relative_path = ""
|
||||
@@ -174,19 +198,32 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
|
||||
default_upload_task_id = next((row["task_id"] for row in categories if row.get("task_id")), None)
|
||||
|
||||
document_options = []
|
||||
tally_data_files: list[dict[str, Any]] = []
|
||||
for doc in documents:
|
||||
versions = list(getattr(doc, "versions", None) or [])
|
||||
latest = versions[0] if versions else None
|
||||
document_options.append({
|
||||
latest_relative_path = str(getattr(latest, "local_relative_path", "") or "") if latest else ""
|
||||
tally_year = _tally_document_year(doc)
|
||||
row = {
|
||||
"id": int(getattr(doc, "id", 0) or 0),
|
||||
"title": str(getattr(doc, "title", "") or "Document"),
|
||||
"tally_financial_year": tally_year,
|
||||
"document_type": str(getattr(doc, "document_type", "GENERAL") or "GENERAL"),
|
||||
"version_no": int(getattr(doc, "current_version_no", 0) or 0),
|
||||
"task_instance_id": getattr(doc, "task_instance_id", None),
|
||||
"latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "",
|
||||
"latest_relative_path": str(getattr(latest, "local_relative_path", "") or "") if latest else "",
|
||||
"latest_relative_path": latest_relative_path,
|
||||
"storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "",
|
||||
})
|
||||
}
|
||||
document_options.append(row)
|
||||
if tally_year:
|
||||
tally_data_files.append({
|
||||
**row,
|
||||
"financial_year": tally_year,
|
||||
"local_path": _join_local_path(storage_root_path, latest_relative_path) if latest_relative_path else "",
|
||||
})
|
||||
|
||||
tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True)
|
||||
|
||||
return {
|
||||
"accounting_relative_dir": accounting_relative_dir,
|
||||
@@ -194,6 +231,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
|
||||
"accounting_local_path": accounting_local_path,
|
||||
"storage_root_path": storage_root_path,
|
||||
"storage_node_name": storage_node_name,
|
||||
"tally_year_options": tally_year_options,
|
||||
"tally_data_files": tally_data_files,
|
||||
"task_categories": categories,
|
||||
"default_upload_task_id": default_upload_task_id,
|
||||
"document_options": document_options,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Tally / Accounting Data</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Accounting Intelligence</div>
|
||||
<div class="mt-1 font-semibold text-indigo-950">Client accounting .act path</div>
|
||||
</div>
|
||||
{% if resources.storage_node_name %}<span class="rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-indigo-700">{{ resources.storage_node_name }}</span>{% endif %}
|
||||
@@ -25,7 +25,7 @@
|
||||
<input id="engagement-accounting-path" readonly value="{{ resources.accounting_local_path }}" class="min-w-0 flex-1 rounded-xl border border-indigo-200 bg-white px-3 py-2 font-mono text-xs text-slate-700">
|
||||
<button type="button" data-copy-target="engagement-accounting-path" class="rounded-xl bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Copy Path</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-indigo-700">This follows the existing branch Storage Node → FY → Client → Accounting convention used by the Local Agent.</p>
|
||||
<p class="mt-2 text-xs text-indigo-700">This is the existing .act accounting/intelligence database path used by the Local Agent. Uploaded Tally data paths are shown separately on the right.</p>
|
||||
{% elif resources.accounting_relative_path %}
|
||||
<div class="mt-3 rounded-xl border border-indigo-200 bg-white px-3 py-2 font-mono text-xs text-slate-700">{{ resources.accounting_relative_path }}</div>
|
||||
<p class="mt-2 text-xs text-amber-700">The relative accounting path is known, but this branch does not currently expose a configured local storage root.</p>
|
||||
@@ -59,11 +59,37 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
{% if resources.tally_data_files %}
|
||||
<div class="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div class="text-sm font-semibold text-emerald-950">Uploaded Tally Data Paths</div>
|
||||
<p class="mt-1 text-xs text-emerald-700">Copy the required year path for opening-balance comparison or local Tally work.</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
{% for tally in resources.tally_data_files %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-white p-2.5">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-semibold text-emerald-900">FY {{ tally.financial_year }}</span>
|
||||
<span class="text-[11px] text-slate-500">v{{ tally.version_no }}{% if tally.latest_filename %} · {{ tally.latest_filename }}{% endif %}</span>
|
||||
</div>
|
||||
{% if tally.local_path %}
|
||||
<div class="flex gap-2">
|
||||
<input id="tally-data-path-{{ loop.index }}" readonly value="{{ tally.local_path }}" class="min-w-0 flex-1 rounded-lg border border-emerald-200 bg-white px-2.5 py-2 font-mono text-[11px] text-slate-700">
|
||||
<button type="button" data-copy-target="tally-data-path-{{ loop.index }}" class="rounded-lg bg-emerald-600 px-2.5 py-2 text-xs font-semibold text-white hover:bg-emerald-700">Copy Path</button>
|
||||
</div>
|
||||
{% elif tally.latest_relative_path %}
|
||||
<div class="break-all rounded-lg border border-emerald-200 bg-white px-2.5 py-2 font-mono text-[11px] text-slate-700">{{ tally.latest_relative_path }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-sm font-semibold text-slate-900">Upload from Engagement Page</div>
|
||||
<p class="mt-1 text-xs text-slate-500">Select Tally Data or a service task category. Category uploads stay linked to the existing task/evidence workflow.</p>
|
||||
<form method="post" enctype="multipart/form-data" action="/documents/engagements/{{ engagement_id }}/upload" class="mt-4 space-y-3" data-engagement-upload-form data-engagement-id="{{ engagement_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="return_to" value="{{ return_to }}">
|
||||
<input type="hidden" name="upload_target" value="tally" data-upload-target-field>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Upload To</label>
|
||||
<select class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-upload-target-select>
|
||||
@@ -78,6 +104,13 @@
|
||||
<p class="mt-1 text-[11px] text-slate-500">Categories come from this service's Task Category master; older engagements are mapped through their task templates where needed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div data-tally-year-wrap>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Tally Financial Year</label>
|
||||
<select name="tally_financial_year" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-tally-year-select>
|
||||
{% for year in resources.tally_year_options %}<option value="{{ year }}">FY {{ year }}{% if loop.first %} — engagement year{% else %} — previous year{% endif %}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-[11px] text-slate-500">Previous-year Tally data is kept separately so opening balances can be compared without replacing the current-year dataset.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Upload Action</label>
|
||||
<select class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-upload-mode>
|
||||
@@ -90,7 +123,7 @@
|
||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Existing Document</label>
|
||||
<select name="existing_document_id" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" data-existing-document-select>
|
||||
<option value="">Select document</option>
|
||||
{% for doc in resources.document_options %}<option value="{{ doc.id }}">{{ doc.title }} · v{{ doc.version_no }}</option>{% endfor %}
|
||||
{% for doc in resources.document_options %}<option value="{{ doc.id }}" data-task-id="{{ doc.task_instance_id or '' }}" data-tally-fy="{{ doc.tally_financial_year or '' }}">{{ doc.title }} · v{{ doc.version_no }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-[11px] text-slate-500">Replacement/version uploads use the existing document container and retain the ERP audit/version history.</p>
|
||||
</div>
|
||||
@@ -130,19 +163,43 @@
|
||||
const wrap = form.querySelector('[data-existing-document-wrap]');
|
||||
const existing = form.querySelector('[data-existing-document-select]');
|
||||
const note = form.querySelector('[data-upload-target-note]');
|
||||
const uploadTargetField = form.querySelector('[data-upload-target-field]');
|
||||
const tallyYearWrap = form.querySelector('[data-tally-year-wrap]');
|
||||
const tallyYear = form.querySelector('[data-tally-year-select]');
|
||||
const engagementId = form.getAttribute('data-engagement-id');
|
||||
|
||||
const filterExistingDocuments = function () {
|
||||
const selected = targetSelect.options[targetSelect.selectedIndex];
|
||||
const kind = selected ? selected.getAttribute('data-kind') : 'tally';
|
||||
const taskId = selected ? (selected.getAttribute('data-task-id') || '') : '';
|
||||
const fy = tallyYear ? tallyYear.value : '';
|
||||
Array.from(existing.options).forEach(function (opt, idx) {
|
||||
if (idx === 0) return;
|
||||
const optionTask = opt.getAttribute('data-task-id') || '';
|
||||
const optionTallyFy = opt.getAttribute('data-tally-fy') || '';
|
||||
const visible = kind === 'tally' ? optionTallyFy === fy : optionTask === taskId && !optionTallyFy;
|
||||
opt.hidden = !visible;
|
||||
opt.disabled = !visible;
|
||||
});
|
||||
if (existing.selectedOptions.length && existing.selectedOptions[0].disabled) existing.value = '';
|
||||
};
|
||||
|
||||
const updateAction = function () {
|
||||
const option = targetSelect.options[targetSelect.selectedIndex];
|
||||
const kind = option ? option.getAttribute('data-kind') : 'tally';
|
||||
const taskId = option ? option.getAttribute('data-task-id') : '';
|
||||
if (kind === 'category' && taskId) {
|
||||
form.action = '/documents/tasks/' + taskId + '/upload';
|
||||
uploadTargetField.value = 'category';
|
||||
tallyYearWrap.classList.add('hidden');
|
||||
note.textContent = 'This file will be linked to the selected category through its existing engagement task, preserving evidence and AQMM behaviour.';
|
||||
} else {
|
||||
form.action = '/documents/engagements/' + engagementId + '/upload';
|
||||
note.textContent = 'Tally Data is stored at engagement level and remains separate from task-category evidence.';
|
||||
uploadTargetField.value = 'tally';
|
||||
tallyYearWrap.classList.remove('hidden');
|
||||
note.textContent = 'Tally Data is stored under the selected financial year. Current-year and previous-year datasets remain separate for opening-balance comparison.';
|
||||
}
|
||||
filterExistingDocuments();
|
||||
};
|
||||
const updateMode = function () {
|
||||
const useExisting = mode.value !== 'new';
|
||||
@@ -151,6 +208,7 @@
|
||||
if (!useExisting) existing.value = '';
|
||||
};
|
||||
targetSelect.addEventListener('change', updateAction);
|
||||
if (tallyYear) tallyYear.addEventListener('change', filterExistingDocuments);
|
||||
mode.addEventListener('change', updateMode);
|
||||
updateAction();
|
||||
updateMode();
|
||||
|
||||
Reference in New Issue
Block a user