Prevent duplicate import

This commit is contained in:
2026-07-20 09:49:58 +00:00
parent 81619f8569
commit 953d4436e0
4 changed files with 89 additions and 11 deletions

View File

@ -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]}

View File

@ -1,5 +1,5 @@
<h1 mat-dialog-title>{{ data.title }}</h1>
<div mat-dialog-content>
<div mat-dialog-content style="white-space: pre-wrap">
{{ data.content }}
</div>
<div mat-dialog-actions>

View File

@ -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 });

View File

@ -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[];
}>;
}
}