Prevent duplicate import

This commit is contained in:
tanshu committed 2026-07-20 10:09:32 +00:00
1 parent 81619f8569
commit 953d4436e0
4 files changed
+89 -11

No files matched your search

+82 -6
View File
@@ -3,22 +3,25 @@ import logging
import uuid import uuid
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal
from typing import Annotated, Any from typing import Annotated, Any
import pandas as pd import pandas as pd
from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.security import get_current_active_user as get_user from ..core.security import get_current_active_user as get_user
from ..db.session import SessionDep from ..db.session import SessionDep
from ..models.account_base import AccountBase from ..models.account_base import AccountBase
from ..models.journal import Journal from ..models.journal import Journal
from ..models.validations import check_journals_are_valid
from ..models.voucher import Voucher from ..models.voucher import Voucher
from ..models.voucher_type import VoucherType from ..models.voucher_type import VoucherType
from ..schemas.user import UserToken from ..schemas.user import UserToken
from . import get_lock_info
router = APIRouter() router = APIRouter()
@@ -158,7 +161,7 @@ async def preview_import(
if mapping.narration_col and mapping.narration_col in row: if mapping.narration_col and mapping.narration_col in row:
val = row.get(mapping.narration_col) val = row.get(mapping.narration_col)
if not pd.isna(val): if not pd.isna(val):
narration = str(val) narration = str(val).strip()
# 4. Amount / Debit / Credit # 4. Amount / Debit / Credit
debit = 0.0 debit = 0.0
@@ -211,13 +214,18 @@ async def execute_import(
raise HTTPException(status_code=400, detail="Primary account not found.") raise HTTPException(status_code=400, detail="Primary account not found.")
created_vouchers = [] created_vouchers = []
processed_rows = []
for row in request.rows: for row in request.rows:
if not row.is_valid or not row.date_ or not row.account_id: if not row.is_valid or not row.date_ or not row.account_id:
processed_rows.append(row)
continue continue
other_acc = db.execute(select(AccountBase).where(AccountBase.id == row.account_id)).scalar_one_or_none() other_acc = db.execute(select(AccountBase).where(AccountBase.id == row.account_id)).scalar_one_or_none()
if not other_acc: if not other_acc:
row.is_valid = False
row.validation_error = "Other account not found"
processed_rows.append(row)
continue continue
voucher = Voucher( voucher = Voucher(
@@ -227,11 +235,50 @@ async def execute_import(
user_id=user.id_, user_id=user.id_,
voucher_type=VoucherType.JOURNAL, 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 # Determine signs for journals
amount = expected_amount
if row.debit > 0: if row.debit > 0:
amount = row.debit
# Primary gets Debit (1) # Primary gets Debit (1)
j_primary = Journal( j_primary = Journal(
amount=amount, amount=amount,
@@ -247,7 +294,6 @@ async def execute_import(
cost_centre_id=other_acc.cost_centre_id, cost_centre_id=other_acc.cost_centre_id,
) )
else: else:
amount = row.credit
# Primary gets Credit (-1) # Primary gets Credit (-1)
j_primary = Journal( j_primary = Journal(
amount=amount, amount=amount,
@@ -265,9 +311,39 @@ async def execute_import(
voucher.journals.append(j_primary) voucher.journals.append(j_primary)
voucher.journals.append(j_other) 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_primary)
db.add(j_other) db.add(j_other)
created_vouchers.append(voucher) created_vouchers.append(voucher)
row.is_valid = True
row.validation_error = "Imported"
processed_rows.append(row)
db.commit() 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]}
@@ -1,5 +1,5 @@
<h1 mat-dialog-title>{{ data.title }}</h1> <h1 mat-dialog-title>{{ data.title }}</h1>
<div mat-dialog-content> <div mat-dialog-content style="white-space: pre-wrap">
{{ data.content }} {{ data.content }}
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
@@ -262,8 +262,9 @@ export class TransactionImportComponent {
this.importService.execute(req).subscribe({ this.importService.execute(req).subscribe({
next: (res) => { next: (res) => {
this.snackBar.open(res.message, 'Close', { duration: 3000 }); this.snackBar.open(res.message, 'Close', { duration: 5000 });
this.router.navigate(['/daybook']); this.previewRows.set(res.processed_rows || []);
this.selection.clear();
}, },
error: (err) => { error: (err) => {
this.snackBar.open('Error importing transactions: ' + err.message, 'Close', { duration: 3000 }); this.snackBar.open('Error importing transactions: ' + err.message, 'Close', { duration: 3000 });
@@ -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 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<{ .pipe(catchError(this.log.handleError('TransactionImportService', 'execute'))) as Observable<{
message: string; message: string;
processed_rows?: PreviewRow[];
}>; }>;
} }
} }