Prevent duplicate import
This commit is contained in:
1 parent
81619f8569
commit
953d4436e0
4 files changed
+89
-11
No files matched your search
@@ -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[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user