From 953d4436e0d6cd67fe6b050c7f4edcfc03bc99d3 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Mon, 20 Jul 2026 09:49:58 +0000 Subject: [PATCH] Prevent duplicate import --- brewman/brewman/routers/transaction_import.py | 88 +++++++++++++++++-- .../confirm-dialog.component.html | 2 +- .../transaction-import.component.ts | 5 +- .../transaction-import.service.ts | 5 +- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/brewman/brewman/routers/transaction_import.py b/brewman/brewman/routers/transaction_import.py index 73c497b0..ae1b5698 100644 --- a/brewman/brewman/routers/transaction_import.py +++ b/brewman/brewman/routers/transaction_import.py @@ -3,22 +3,25 @@ import logging import uuid from datetime import date, datetime +from decimal import Decimal from typing import Annotated, Any import pandas as pd from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from ..core.security import get_current_active_user as get_user from ..db.session import SessionDep from ..models.account_base import AccountBase from ..models.journal import Journal +from ..models.validations import check_journals_are_valid from ..models.voucher import Voucher from ..models.voucher_type import VoucherType from ..schemas.user import UserToken +from . import get_lock_info router = APIRouter() @@ -158,7 +161,7 @@ async def preview_import( if mapping.narration_col and mapping.narration_col in row: val = row.get(mapping.narration_col) if not pd.isna(val): - narration = str(val) + narration = str(val).strip() # 4. Amount / Debit / Credit debit = 0.0 @@ -211,13 +214,18 @@ async def execute_import( raise HTTPException(status_code=400, detail="Primary account not found.") created_vouchers = [] + processed_rows = [] for row in request.rows: if not row.is_valid or not row.date_ or not row.account_id: + processed_rows.append(row) continue other_acc = db.execute(select(AccountBase).where(AccountBase.id == row.account_id)).scalar_one_or_none() if not other_acc: + row.is_valid = False + row.validation_error = "Other account not found" + processed_rows.append(row) continue voucher = Voucher( @@ -227,11 +235,50 @@ async def execute_import( user_id=user.id_, voucher_type=VoucherType.JOURNAL, ) - db.add(voucher) + + # Check for duplicate + existing_vouchers = ( + db.execute( + select(Voucher).where(Voucher.date_ == row.date_, func.trim(Voucher.narration) == row.narration.strip()) + ) + .scalars() + .unique() + .all() + ) + + is_duplicate = False + expected_amount = Decimal(str(row.debit if row.debit > 0 else row.credit)).quantize(Decimal("0.01")) + expected_p_debit = 1 if row.debit > 0 else -1 + expected_o_debit = -1 if row.debit > 0 else 1 + + for v in existing_vouchers: + if len(v.journals) != 2: + continue + + p_jnl = next((j for j in v.journals if str(j.account_id) == str(primary_acc.id)), None) + o_jnl = next((j for j in v.journals if str(j.account_id) == str(other_acc.id)), None) + + if not p_jnl or not o_jnl: + continue + + if ( + p_jnl.amount.quantize(Decimal("0.01")) == expected_amount + and o_jnl.amount.quantize(Decimal("0.01")) == expected_amount + and p_jnl.debit == expected_p_debit + and o_jnl.debit == expected_o_debit + ): + is_duplicate = True + break + + if is_duplicate: + row.is_valid = False + row.validation_error = "Duplicate" + processed_rows.append(row) + continue # Determine signs for journals + amount = expected_amount if row.debit > 0: - amount = row.debit # Primary gets Debit (1) j_primary = Journal( amount=amount, @@ -247,7 +294,6 @@ async def execute_import( cost_centre_id=other_acc.cost_centre_id, ) else: - amount = row.credit # Primary gets Credit (-1) j_primary = Journal( amount=amount, @@ -265,9 +311,39 @@ async def execute_import( voucher.journals.append(j_primary) voucher.journals.append(j_other) + + # Validate Lockout Dates + account_types = [primary_acc.type_id, other_acc.type_id] + allowed, message = get_lock_info([row.date_], VoucherType.JOURNAL, account_types, db) + if not allowed: + row.is_valid = False + row.validation_error = f"Locked: {message}" + processed_rows.append(row) + continue + + # Validate Journal integrity + try: + check_journals_are_valid(voucher) + except HTTPException as e: + row.is_valid = False + row.validation_error = f"Validation Failed: {e.detail}" + processed_rows.append(row) + continue + + db.add(voucher) db.add(j_primary) db.add(j_other) created_vouchers.append(voucher) + row.is_valid = True + row.validation_error = "Imported" + processed_rows.append(row) + db.commit() - return {"message": f"Successfully imported {len(created_vouchers)} entries."} + msg = f"Successfully imported {len(created_vouchers)} entries." + + skipped_count = len([r for r in processed_rows if not r.is_valid]) + if skipped_count > 0: + msg += f" Skipped {skipped_count} entries." + + return {"message": msg, "processed_rows": [r.model_dump() for r in processed_rows]} diff --git a/overlord/src/app/shared/confirm-dialog/confirm-dialog.component.html b/overlord/src/app/shared/confirm-dialog/confirm-dialog.component.html index b1c7db7c..8c19d24e 100644 --- a/overlord/src/app/shared/confirm-dialog/confirm-dialog.component.html +++ b/overlord/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -1,5 +1,5 @@

{{ data.title }}

-
+
{{ data.content }}
diff --git a/overlord/src/app/transaction-import/transaction-import.component.ts b/overlord/src/app/transaction-import/transaction-import.component.ts index e5c60d5f..b39552e1 100644 --- a/overlord/src/app/transaction-import/transaction-import.component.ts +++ b/overlord/src/app/transaction-import/transaction-import.component.ts @@ -262,8 +262,9 @@ export class TransactionImportComponent { this.importService.execute(req).subscribe({ next: (res) => { - this.snackBar.open(res.message, 'Close', { duration: 3000 }); - this.router.navigate(['/daybook']); + this.snackBar.open(res.message, 'Close', { duration: 5000 }); + this.previewRows.set(res.processed_rows || []); + this.selection.clear(); }, error: (err) => { this.snackBar.open('Error importing transactions: ' + err.message, 'Close', { duration: 3000 }); diff --git a/overlord/src/app/transaction-import/transaction-import.service.ts b/overlord/src/app/transaction-import/transaction-import.service.ts index e5b0b1d3..920aa291 100644 --- a/overlord/src/app/transaction-import/transaction-import.service.ts +++ b/overlord/src/app/transaction-import/transaction-import.service.ts @@ -62,11 +62,12 @@ export class TransactionImportService { }>; } - execute(request: ImportExecutionRequest): Observable<{ message: string }> { + execute(request: ImportExecutionRequest): Observable<{ message: string; processed_rows?: PreviewRow[] }> { return this.http - .post<{ message: string }>(`${url}/execute`, request) + .post<{ message: string; processed_rows?: PreviewRow[] }>(`${url}/execute`, request) .pipe(catchError(this.log.handleError('TransactionImportService', 'execute'))) as Observable<{ message: string; + processed_rows?: PreviewRow[]; }>; } }