diff --git a/alembic/versions/20260822_bank_client_archive_phase11b.py b/alembic/versions/20260822_bank_client_archive_phase11b.py new file mode 100644 index 0000000..9dd249a --- /dev/null +++ b/alembic/versions/20260822_bank_client_archive_phase11b.py @@ -0,0 +1,80 @@ +"""Phase 11B client-aware Bank Analyzer ownership and engagement archive. + +Revision ID: 20260822_bank_client_archive_p11b +Revises: 20260822_bank_to_tally_p11 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260822_bank_client_archive_p11b" +down_revision = "20260822_bank_to_tally_p11" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("engagement_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("ownership_confirmation", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("ownership_status", sa.String(30), nullable=False, server_default="not_checked"), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("ownership_validation_json", sa.Text(), nullable=True), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("engagement_archive_status", sa.String(30), nullable=False, server_default="not_requested"), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("engagement_archive_json", sa.Text(), nullable=True), + ) + + op.create_index( + "ix_bank_statement_analysis_jobs_client_id", + "bank_statement_analysis_jobs", + ["client_id"], + ) + op.create_index( + "ix_bank_statement_analysis_jobs_engagement_id", + "bank_statement_analysis_jobs", + ["engagement_id"], + ) + op.create_index( + "ix_bank_statement_analysis_jobs_ownership_status", + "bank_statement_analysis_jobs", + ["ownership_status"], + ) + op.create_index( + "ix_bank_statement_analysis_jobs_engagement_archive_status", + "bank_statement_analysis_jobs", + ["engagement_archive_status"], + ) + + +def downgrade(): + op.drop_index("ix_bank_statement_analysis_jobs_engagement_archive_status", table_name="bank_statement_analysis_jobs") + op.drop_index("ix_bank_statement_analysis_jobs_ownership_status", table_name="bank_statement_analysis_jobs") + op.drop_index("ix_bank_statement_analysis_jobs_engagement_id", table_name="bank_statement_analysis_jobs") + op.drop_index("ix_bank_statement_analysis_jobs_client_id", table_name="bank_statement_analysis_jobs") + + op.drop_column("bank_statement_analysis_jobs", "engagement_archive_json") + op.drop_column("bank_statement_analysis_jobs", "engagement_archive_status") + op.drop_column("bank_statement_analysis_jobs", "ownership_validation_json") + op.drop_column("bank_statement_analysis_jobs", "ownership_status") + op.drop_column("bank_statement_analysis_jobs", "ownership_confirmation") + op.drop_column("bank_statement_analysis_jobs", "engagement_id") + op.drop_column("bank_statement_analysis_jobs", "client_id") diff --git a/app/modules/accounting/bank_service.py b/app/modules/accounting/bank_service.py index 15bba97..905f727 100644 --- a/app/modules/accounting/bank_service.py +++ b/app/modules/accounting/bank_service.py @@ -79,6 +79,13 @@ def import_completed_job(db, *, tenant_id, client_id, job_id, user_id): raise ValueError("Completed Bank Analyzer workbook was not found.") if job.tenant_id not in (None, tenant_id): raise ValueError("Bank Analyzer job belongs to a different firm.") + if getattr(job, "client_id", None) and int(job.client_id) != int(client_id): + raise ValueError( + "This Bank Analyzer job is already bound to a different ERP client. " + "Import it using the client confirmed during statement ownership validation." + ) + if getattr(job, "client_id", None) and getattr(job, "ownership_status", "") != "confirmed": + raise ValueError("Client-bound Bank Analyzer job has not passed ownership validation.") frame = pd.read_excel(job.output_file, sheet_name="Transaction Classification") tally_options = available_tally_guids(db, tenant_id, client_id) diff --git a/app/modules/accounting/templates/accounting/bank_posting.html b/app/modules/accounting/templates/accounting/bank_posting.html index 3cd83a9..37e5587 100644 --- a/app/modules/accounting/templates/accounting/bank_posting.html +++ b/app/modules/accounting/templates/accounting/bank_posting.html @@ -12,7 +12,7 @@
@@ -73,4 +73,20 @@ {% endif %} + + + {% endblock %} diff --git a/app/modules/bank_statement_analyzer/analyzer.py b/app/modules/bank_statement_analyzer/analyzer.py index 0feb7af..a9e969d 100644 --- a/app/modules/bank_statement_analyzer/analyzer.py +++ b/app/modules/bank_statement_analyzer/analyzer.py @@ -615,6 +615,10 @@ def analyze_files(paths, customer_override="", account_override="", bank_hint="a frames = [] for path in paths: meta, df = parse_pdf(path, bank_hint=bank_hint) + # Preserve the bank-extracted owner before an optional manual correction. + # Phase 11B ownership validation always prefers this original value. + meta.extracted_customer_name = getattr(meta, "customer_name", "") or "" + df["extracted_customer_name"] = meta.extracted_customer_name if customer_override: meta.customer_name = customer_override df["customer_name"] = customer_override diff --git a/app/modules/bank_statement_analyzer/client_context.py b/app/modules/bank_statement_analyzer/client_context.py new file mode 100644 index 0000000..32cc872 --- /dev/null +++ b/app/modules/bank_statement_analyzer/client_context.py @@ -0,0 +1,438 @@ +from __future__ import annotations + +import json +import re +from difflib import SequenceMatcher +from pathlib import Path +from types import SimpleNamespace + +from sqlalchemy import or_, select + +from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit +from app.modules.consultants.models import ClientConsultantLink +from app.modules.consultants.service import get_consultant_by_user +from app.modules.documents.services import ( + build_document_scope, + list_visible_engagements, + save_uploaded_revision, + user_can_upload_to_engagement, +) +from app.modules.registrations.models import ClientRegistration +from app.modules.services.models import ClientServiceSubscription + + +_LEGAL_NOISE = { + "M", "S", "MS", "MR", "MRS", "MISS", + "PRIVATE", "PVT", "LIMITED", "LTD", "LLP", + "PROPRIETOR", "PROPRIETORSHIP", "PROP", + "THE", "INDIA", +} + + +def _text(value) -> str: + return str(value or "").strip() + + +def _normalise_name(value) -> str: + raw = re.sub(r"[^A-Z0-9]+", " ", _text(value).upper()).strip() + tokens = [token for token in raw.split() if token and token not in _LEGAL_NOISE] + return " ".join(tokens) + + +def _name_match(extracted: str, alias: str) -> tuple[bool, int, str]: + left = _normalise_name(extracted) + right = _normalise_name(alias) + if not left or not right: + return False, 0, "" + if left == right: + return True, 100, "exact normalized account-holder match" + + # Conservative containment is allowed only for reasonably descriptive names. + if min(len(left), len(right)) >= 8 and (left in right or right in left): + score = int(round(100 * min(len(left), len(right)) / max(len(left), len(right)))) + if score >= 82: + return True, max(90, score), "legal/trade-name containment match" + + ratio = int(round(SequenceMatcher(None, left, right).ratio() * 100)) + if ratio >= 92: + return True, ratio, "high-confidence legal/trade-name similarity match" + return False, ratio, "" + + +def client_owner_aliases(db, *, tenant_id: int, client_id: int) -> list[str]: + client = db.get(Client, int(client_id)) + if not client or client.tenant_id != int(tenant_id) or client.is_archived: + raise ValueError("Selected client was not found.") + + values = [client.client_name, client.trade_name] + + for unit in db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.client_id == client.id, + ClientBusinessUnit.is_active.is_(True), + ) + ).scalars().all(): + values.extend([unit.business_name, unit.trade_name]) + + for branch in db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.client_id == client.id, + ClientBranch.is_active.is_(True), + ) + ).scalars().all(): + values.append(branch.branch_name) + + for registration in db.execute( + select(ClientRegistration).where( + ClientRegistration.tenant_id == tenant_id, + ClientRegistration.client_id == client.id, + ClientRegistration.status.in_(["active", "valid", "registered"]), + ) + ).scalars().all(): + values.extend([registration.legal_name, registration.trade_name]) + + result = [] + seen = set() + for value in values: + value = _text(value) + key = _normalise_name(value) + if value and key and key not in seen: + seen.add(key) + result.append(value) + return result + + +def _consultant_context(db, user, tenant_id: int): + consultant = get_consultant_by_user( + db, + tenant_id=tenant_id, + user_id=int(user.id), + ) + return consultant + + +def analyzer_client_context(db, *, request, user, roles: list[str]) -> dict: + """Return role-scoped clients and archive-capable engagements. + + Partner/Manager/Staff reuse the existing Document module visibility rules. + Consultant reuses the existing ClientConsultantLink permissions. + """ + tenant_id = int( + request.session.get("active_tenant_id") + or getattr(user, "tenant_id", 0) + or 0 + ) + role_set = set(roles or []) + clients: dict[int, Client] = {} + engagements: dict[int, ClientServiceSubscription] = {} + + if "Consultant" in role_set: + consultant = _consultant_context(db, user, tenant_id) + if not consultant: + return {"clients": [], "engagements": [], "consultant": None} + + links = list(db.execute( + select(ClientConsultantLink).where( + ClientConsultantLink.tenant_id == tenant_id, + ClientConsultantLink.consultant_id == consultant.id, + ClientConsultantLink.is_active.is_(True), + ClientConsultantLink.can_view_client.is_(True), + ) + ).scalars().all()) + + client_ids = {int(link.client_id) for link in links} + upload_ids = { + int(link.client_id) + for link in links + if bool(link.can_upload_documents) + } + + if client_ids: + for client in db.execute( + select(Client).where( + Client.tenant_id == tenant_id, + Client.id.in_(client_ids), + Client.is_archived.is_(False), + Client.is_active.is_(True), + ).order_by(Client.client_name.asc()) + ).scalars().all(): + clients[int(client.id)] = client + + if upload_ids: + for engagement in db.execute( + select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.client_id.in_(upload_ids), + ClientServiceSubscription.is_active.is_(True), + ).order_by( + ClientServiceSubscription.financial_year.desc(), + ClientServiceSubscription.id.desc(), + ) + ).scalars().all(): + engagements[int(engagement.id)] = engagement + + return { + "clients": list(clients.values()), + "engagements": list(engagements.values()), + "consultant": consultant, + } + + # Firm Partner / Manager / Staff use the existing engagement-document scope. + scope = build_document_scope(request, db, user) + visible = list_visible_engagements(db, user, scope, limit=1000) + for engagement in visible: + client = getattr(engagement, "client", None) or db.get(Client, engagement.client_id) + if client and client.is_active and not client.is_archived: + clients[int(client.id)] = client + if user_can_upload_to_engagement(db, user, engagement, scope): + engagements[int(engagement.id)] = engagement + + return { + "clients": sorted(clients.values(), key=lambda c: (c.client_name or "").casefold()), + "engagements": list(engagements.values()), + "consultant": None, + } + + +def validate_selected_client_and_engagement( + db, + *, + request, + user, + roles: list[str], + client_id: int | None, + engagement_id: int | None, +): + if not client_id: + if engagement_id: + raise ValueError("Select a client before selecting an engagement.") + return None, None + + context = analyzer_client_context(db, request=request, user=user, roles=roles) + clients = {int(row.id): row for row in context["clients"]} + engagements = {int(row.id): row for row in context["engagements"]} + + client = clients.get(int(client_id)) + if not client: + raise ValueError("Selected client is not available in your Bank Analyzer scope.") + + if not engagement_id: + raise ValueError( + "Select an engagement document destination when a client is selected." + ) + + engagement = engagements.get(int(engagement_id)) + if not engagement or int(engagement.client_id) != int(client.id): + raise ValueError( + "Selected engagement is not available for document upload for this client." + ) + return client, engagement + + +def validate_statement_ownership( + db, + *, + tenant_id: int, + client_id: int | None, + metas: list, + owner_override: str = "", + ownership_confirmation: bool = False, +) -> dict: + """Validate that all statements belong to one ERP client/legal/trade-name family. + + With an ERP client selected the check is strict. Every statement must expose + an owner name or use a manually confirmed owner override, and that name must + match one of the ERP legal/trade aliases. + """ + extracted = [] + for position, meta in enumerate(metas, start=1): + raw = _text( + getattr(meta, "extracted_customer_name", None) + or getattr(meta, "customer_name", None) + ) + extracted.append({ + "statement_id": getattr(meta, "statement_id", f"STMT-{position:03d}"), + "source_file": _text(getattr(meta, "source_file", "")), + "owner_name": raw, + }) + + if not client_id: + # Preserve standalone behavior. We record the owner names for review but + # do not change current storage or reject mixed standalone analyses. + return { + "status": "standalone", + "client_id": None, + "aliases": [], + "statements": extracted, + "message": "Standalone analysis; no ERP client ownership binding requested.", + } + + aliases = client_owner_aliases( + db, + tenant_id=int(tenant_id), + client_id=int(client_id), + ) + if not aliases: + raise ValueError( + "Selected client does not have a usable legal/trade name for bank ownership validation." + ) + + override = _text(owner_override) + if override: + matched_override = any(_name_match(override, alias)[0] for alias in aliases) + if not matched_override: + raise ValueError( + f"Account-holder override '{override}' does not match the selected client's " + "legal/trade names." + ) + + results = [] + failures = [] + + for statement in extracted: + owner = statement["owner_name"] or override + if not owner: + failures.append( + f"{statement['statement_id']}: account-holder name could not be extracted. " + "Use Account holder override only after verifying the statement manually." + ) + results.append({**statement, "matched": False, "score": 0, "matched_alias": ""}) + continue + + best = (False, 0, "", "") + for alias in aliases: + matched, score, reason = _name_match(owner, alias) + if score > best[1]: + best = (matched, score, alias, reason) + + matched, score, alias, reason = best + results.append({ + **statement, + "effective_owner_name": owner, + "matched": bool(matched), + "score": int(score), + "matched_alias": alias, + "reason": reason, + }) + if not matched: + failures.append( + f"{statement['statement_id']}: '{owner}' does not match the selected client's " + "legal/trade names." + ) + + if failures: + raise ValueError( + "Bank account ownership validation failed. " + " ".join(failures) + ) + + if len(metas) > 1 and not ownership_confirmation: + raise ValueError( + "Confirm that all uploaded bank statements belong to the selected client " + "or its approved trade/business names." + ) + + return { + "status": "confirmed", + "client_id": int(client_id), + "aliases": aliases, + "statements": results, + "message": f"Ownership confirmed for {len(results)} statement(s).", + } + + +def _upload_proxy(path: Path, content_type: str): + handle = path.open("rb") + return SimpleNamespace( + filename=path.name, + content_type=content_type, + file=handle, + ) + + +def archive_analysis_to_engagement( + db, + *, + job, + engagement: ClientServiceSubscription | None, + metas: list, + source_paths: list[Path], + output_path: Path, + user, +) -> dict: + if not job.client_id or not job.engagement_id or engagement is None: + return {"status": "not_requested", "documents": []} + + archived = [] + meta_by_source = { + Path(_text(getattr(meta, "source_file", ""))).name: meta + for meta in metas + } + + for source in source_paths: + upload = _upload_proxy(source, "application/pdf") + try: + meta = meta_by_source.get(source.name) + bank = _text(getattr(meta, "bank_name", "")) if meta else "" + account = _text(getattr(meta, "account_number", "")) if meta else "" + title_bits = ["Bank Statement"] + if bank: + title_bits.append(bank) + if account: + title_bits.append(account[-6:]) + document = save_uploaded_revision( + db, + engagement=engagement, + upload_file=upload, + title=" - ".join(title_bits), + document_type="BANK_STATEMENT", + description=( + f"Bank Statement Analyzer source. Job {job.id}. " + f"Ownership status: {job.ownership_status}." + ), + remarks="Automatically archived from Bank Statement Analyzer.", + user=user, + udin_required=False, + ) + archived.append({ + "document_id": int(document.id), + "filename": source.name, + "type": "BANK_STATEMENT", + }) + finally: + upload.file.close() + + workbook_upload = _upload_proxy( + output_path, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + try: + document = save_uploaded_revision( + db, + engagement=engagement, + upload_file=workbook_upload, + title="Bank Statement Analyzer - Analysis Workbook", + document_type="WORKING_PAPER", + description=( + f"Generated Bank Statement Analyzer workbook for job {job.id}; " + f"includes multi-bank contra review where applicable." + ), + remarks="Automatically archived from Bank Statement Analyzer.", + user=user, + udin_required=False, + ) + archived.append({ + "document_id": int(document.id), + "filename": output_path.name, + "type": "WORKING_PAPER", + }) + finally: + workbook_upload.file.close() + + return { + "status": "archived", + "client_id": int(job.client_id), + "engagement_id": int(job.engagement_id), + "documents": archived, + } diff --git a/app/modules/bank_statement_analyzer/models.py b/app/modules/bank_statement_analyzer/models.py index 93a9917..90d35a6 100644 --- a/app/modules/bank_statement_analyzer/models.py +++ b/app/modules/bank_statement_analyzer/models.py @@ -17,6 +17,16 @@ class BankStatementAnalysisJob(CommonBase): user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) role_bucket: Mapped[str] = mapped_column(String(30), nullable=False) + # Phase 11B: optional client-aware analysis. Null preserves the existing + # standalone Work-folder behavior exactly. + client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True) + engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True) + ownership_confirmation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + ownership_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_checked", index=True) + ownership_validation_json: Mapped[str | None] = mapped_column(Text, nullable=True) + engagement_archive_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True) + engagement_archive_json: Mapped[str | None] = mapped_column(Text, nullable=True) + selected_bank: Mapped[str] = mapped_column(String(40), nullable=False, default="auto") financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True) customer_override: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/app/modules/bank_statement_analyzer/service.py b/app/modules/bank_statement_analyzer/service.py index 982fd03..cb19fef 100644 --- a/app/modules/bank_statement_analyzer/service.py +++ b/app/modules/bank_statement_analyzer/service.py @@ -20,9 +20,16 @@ from app.core.db.common import CommonSessionLocal from .analyzer import analyze_files, export_excel from .models import BankStatementAnalysisJob +from .client_context import ( + archive_analysis_to_engagement, + validate_statement_ownership, +) +from app.modules.core.iam.models import User +from app.modules.services.models import ClientServiceSubscription ALLOWED_ROLES = {"Partner", "Manager", "Branch Manager", "Staff", "Employee", "Consultant"} -MAX_FILES = int(os.getenv("BANK_ANALYZER_MAX_FILES", "24")) +# 0 means no fixed statement-count ceiling; per-file size and queue controls remain. +MAX_FILES = int(os.getenv("BANK_ANALYZER_MAX_FILES", "0")) MAX_FILE_BYTES = int(os.getenv("BANK_ANALYZER_MAX_FILE_MB", "50")) * 1024 * 1024 MAX_GLOBAL_PROCESSING = 3 MAX_PENDING_PER_USER = 3 @@ -117,7 +124,7 @@ async def save_uploads(files: list[UploadFile], input_dir: Path) -> list[Path]: usable = [item for item in files if item and (item.filename or "").strip()] if not usable: raise ValueError("Please select at least one PDF bank statement.") - if len(usable) > MAX_FILES: + if MAX_FILES > 0 and len(usable) > MAX_FILES: raise ValueError(f"A maximum of {MAX_FILES} PDF files can be analyzed in one job.") saved: list[Path] = [] try: @@ -155,7 +162,7 @@ def pending_count_for_user(user_id: int) -> int: db.close() -def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool) -> BankStatementAnalysisJob: +def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool, client_id: int | None = None, engagement_id: int | None = None, ownership_confirmation: bool = False) -> BankStatementAnalysisJob: if pending_count_for_user(int(user.id)) >= MAX_PENDING_PER_USER: shutil.rmtree(job_dir, ignore_errors=True) raise ValueError("You already have three queued or processing analyses. Please wait for one to complete before submitting another.") @@ -169,6 +176,11 @@ def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], j branch_id=getattr(user, "branch_id", None), user_id=int(user.id), role_bucket=bucket, + client_id=int(client_id) if client_id else None, + engagement_id=int(engagement_id) if engagement_id else None, + ownership_confirmation=bool(ownership_confirmation), + ownership_status="pending" if client_id else "not_checked", + engagement_archive_status="pending" if engagement_id else "not_requested", selected_bank=bank_selection, financial_year=(financial_year or "").strip() or None, customer_override=(customer_override or "").strip() or None, @@ -245,6 +257,18 @@ def _process_job(job_id: str) -> None: bank_hint=job.selected_bank, classification_enabled=job.classification_enabled, ) + + ownership = validate_statement_ownership( + db, + tenant_id=int(job.tenant_id or 0), + client_id=job.client_id, + metas=metas, + owner_override=job.customer_override or "", + ownership_confirmation=bool(job.ownership_confirmation), + ) + job.ownership_status = str(ownership.get("status") or "not_checked") + job.ownership_validation_json = json.dumps(ownership, ensure_ascii=False) + job.progress_percent = 75 db.commit() output = output_dir / _workbook_filename(metas) @@ -257,6 +281,28 @@ def _process_job(job_id: str) -> None: selected_bank=job.selected_bank, classification_enabled=job.classification_enabled, ) + + archive_result = {"status": "not_requested", "documents": []} + if job.client_id and job.engagement_id: + engagement = db.get(ClientServiceSubscription, int(job.engagement_id)) + uploader = db.get(User, int(job.user_id)) + if not engagement or int(engagement.client_id) != int(job.client_id): + raise ValueError("Selected engagement is no longer valid for the selected client.") + if not uploader: + raise ValueError("Bank Analyzer uploader could not be resolved for document archive.") + archive_result = archive_analysis_to_engagement( + db, + job=job, + engagement=engagement, + metas=metas, + source_paths=paths, + output_path=output, + user=uploader, + ) + job.engagement_archive_status = str(archive_result.get("status") or "failed") + job.engagement_archive_json = json.dumps(archive_result, ensure_ascii=False) + db.commit() + summary = { "job_id": job.id, "statement_count": len(metas), @@ -272,6 +318,10 @@ def _process_job(job_id: str) -> None: "account_number": next((meta.account_number for meta in metas if meta.account_number), ""), "financial_year": job.financial_year or "", "classification_enabled": job.classification_enabled, + "client_id": job.client_id, + "engagement_id": job.engagement_id, + "ownership_status": job.ownership_status, + "engagement_archive_status": job.engagement_archive_status, } # Keep the original uploaded statements until the job expiry time. # This applies equally to completed and failed jobs and allows the diff --git a/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html b/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html index 66a679f..f436a3f 100644 --- a/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html +++ b/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html @@ -13,13 +13,44 @@ @@ -39,4 +70,29 @@ {% if recent_jobs %}| Submitted | Bank | Files | Status | Action |
|---|---|---|---|---|
| {{ item.submitted_at }} | {{ item.selected_bank|replace('_',' ')|title }} | {{ item.file_count }} | {{ item.status|title }}{% if item.queue_position %} · Position {{ item.queue_position }}{% endif %} | View |