feat: Add client extraction, amount cross-validation, and workflow fixes
OCR improvements: - Extract client data (name, CUI, address) from B2B receipts - Cross-validate amounts using payment methods and TVA entries - OCR-tolerant patterns for "TOTAL LEI" with common OCR errors - Better BON FISCAL vs CHITANTA detection Backend workflow fixes: - Fix SQLAlchemy deleted instance error in resubmit/submit workflow - Add session.refresh() after deleting accounting entries - Add unapprove endpoint (APPROVED → PENDING_REVIEW) - Add direction filter for receipt listing Frontend improvements: - Fix Vue v-else-if chain broken by Menu component - Unified OCR Preview layout with values table - Receipt list filter by direction (plati/incasari) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,9 @@ class ReceiptCRUD:
|
||||
if filters.status:
|
||||
query = query.where(Receipt.status == filters.status)
|
||||
|
||||
if filters.direction:
|
||||
query = query.where(Receipt.direction == filters.direction)
|
||||
|
||||
if filters.company_id:
|
||||
query = query.where(Receipt.company_id == filters.company_id)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.schemas.receipt import (
|
||||
CashRegisterOption,
|
||||
ExpenseTypeOption,
|
||||
)
|
||||
from app.db.models.receipt import ReceiptStatus
|
||||
from app.db.models.receipt import ReceiptStatus, ReceiptDirection
|
||||
|
||||
# Auth integration
|
||||
from auth.dependencies import get_current_user
|
||||
@@ -121,6 +121,7 @@ async def create_receipt(
|
||||
@router.get("/", response_model=ReceiptListResponse)
|
||||
async def list_receipts(
|
||||
status: Optional[ReceiptStatus] = None,
|
||||
direction: Optional[ReceiptDirection] = None,
|
||||
company_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
@@ -136,6 +137,7 @@ async def list_receipts(
|
||||
|
||||
filters = ReceiptFilter(
|
||||
status=status,
|
||||
direction=direction,
|
||||
company_id=company_id or selected_company,
|
||||
created_by=created_by,
|
||||
date_from=date_type.fromisoformat(date_from) if date_from else None,
|
||||
@@ -301,6 +303,24 @@ async def resubmit_receipt(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/unapprove", response_model=WorkflowAction)
|
||||
async def unapprove_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Unapprove receipt (APPROVED → PENDING_REVIEW). Returns to pending for corrections."""
|
||||
success, message, receipt = await ReceiptService.unapprove_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
# ============ Accounting Entries Endpoints ============
|
||||
|
||||
@router.get("/{receipt_id}/entries", response_model=List[AccountingEntryResponse])
|
||||
|
||||
@@ -42,9 +42,15 @@ class ExtractionData(BaseModel):
|
||||
payment_methods: List[PaymentMethod] = Field(default=[], description="Payment methods from receipt (CARD, NUMERAR)")
|
||||
suggested_payment_mode: Optional[str] = Field(default=None, description="Auto-suggested payment mode based on OCR (casa/banca)")
|
||||
|
||||
# Client data (for B2B receipts - buyer information)
|
||||
client_name: Optional[str] = Field(default=None, description="Client/customer company name")
|
||||
client_cui: Optional[str] = Field(default=None, description="Client CUI/CIF fiscal code")
|
||||
client_address: Optional[str] = Field(default=None, description="Client address")
|
||||
|
||||
confidence_amount: float = Field(default=0.0, ge=0, le=1, description="Amount extraction confidence")
|
||||
confidence_date: float = Field(default=0.0, ge=0, le=1, description="Date extraction confidence")
|
||||
confidence_vendor: float = Field(default=0.0, ge=0, le=1, description="Vendor extraction confidence")
|
||||
confidence_client: float = Field(default=0.0, ge=0, le=1, description="Client extraction confidence")
|
||||
overall_confidence: float = Field(default=0.0, ge=0, le=1, description="Overall confidence score")
|
||||
raw_text: str = Field(default="", description="Raw OCR text")
|
||||
ocr_engine: str = Field(default="", description="OCR engine used: paddleocr or tesseract")
|
||||
|
||||
@@ -147,6 +147,8 @@ class ReceiptResponse(ReceiptBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
# Override amount to allow zero values in response (validation is on input, not output)
|
||||
amount: Decimal
|
||||
status: ReceiptStatus
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
@@ -206,6 +208,7 @@ class ReceiptListResponse(BaseModel):
|
||||
class ReceiptFilter(BaseModel):
|
||||
"""Schema for filtering receipts."""
|
||||
status: Optional[ReceiptStatus] = None
|
||||
direction: Optional[ReceiptDirection] = None
|
||||
company_id: Optional[int] = None
|
||||
created_by: Optional[str] = None
|
||||
date_from: Optional[date] = None
|
||||
|
||||
@@ -25,9 +25,15 @@ class ExtractionResult:
|
||||
items_count: Optional[int] = None
|
||||
payment_methods: List[dict] = field(default_factory=list) # [{"method":"CARD","amount":Decimal}]
|
||||
|
||||
# Client data (for B2B receipts - buyer information)
|
||||
client_name: Optional[str] = None
|
||||
client_cui: Optional[str] = None
|
||||
client_address: Optional[str] = None
|
||||
|
||||
confidence_amount: float = 0.0
|
||||
confidence_date: float = 0.0
|
||||
confidence_vendor: float = 0.0
|
||||
confidence_client: float = 0.0
|
||||
raw_text: str = ""
|
||||
ocr_engine: str = "" # OCR engine used: paddleocr or tesseract
|
||||
processing_time_ms: int = 0 # Processing time in milliseconds
|
||||
@@ -51,8 +57,10 @@ class ReceiptExtractor:
|
||||
# Romanian receipts use various formats: TOTAL LEI, TOTAL:, TOTAL RON, etc.
|
||||
# OCR often produces errors, so patterns must be tolerant
|
||||
TOTAL_PATTERNS = [
|
||||
# Most common: TOTAL LEI followed by amount
|
||||
(r'TOTAL\s+LEI\s*([\d\s.,]+)', 0.98),
|
||||
# Most common: TOTAL LEI followed by amount (with OCR-tolerant variations)
|
||||
# Handles: TOTAL LEI, TOTAL. LE!, T0TAL LEI, TOTAL LE1, etc.
|
||||
(r'T[O0]TAL[.\s]+L[E3][I1!]\s*:?\s*([\d\s.,]+)', 0.98), # OCR-tolerant: TOTAL. LE!, T0TAL LEI
|
||||
(r'TOTAL\s+LEI\s*([\d\s.,]+)', 0.98), # Standard clean pattern
|
||||
(r'[OT]?OTAL\s+LEI\s*([\d\s.,]+)', 0.95), # OCR may miss first letter
|
||||
# Standard patterns
|
||||
(r'TOTAL\s*:?\s*([\d\s.,]+)\s*(?:RON|LEI)?', 0.95),
|
||||
@@ -228,6 +236,41 @@ class ReceiptExtractor:
|
||||
(r'(JUD\.?\s+[A-Z]+,?\s*(?:MUN\.?|OR\.?|COM\.?)?\s*[A-Z]+)', 0.85),
|
||||
]
|
||||
|
||||
# Client/Buyer patterns (for B2B receipts)
|
||||
# CLIENT, CUMPARATOR, BENEFICIAR sections
|
||||
CLIENT_SECTION_MARKERS = [
|
||||
r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:', # CIF CLIENT: (reversed format)
|
||||
r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:', # CUI CLIENT: (reversed format)
|
||||
r'CLIENT\s*:',
|
||||
r'CUMPARATOR\s*:',
|
||||
r'BENEFICIAR\s*:',
|
||||
r'CUMP[AĂ]R[AĂ]TOR\s*:',
|
||||
r'DATE\s+CLIENT',
|
||||
r'LIENT\s*:', # OCR truncation
|
||||
]
|
||||
|
||||
# Client CUI patterns (explicitly after CLIENT marker)
|
||||
CLIENT_CUI_PATTERNS = [
|
||||
# CIF CLIENT: R01879856 (reversed format - CIF before CLIENT)
|
||||
(r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:?\s*(R[O0]?\d{6,10})', 0.98),
|
||||
(r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:?\s*(R[O0]?\d{6,10})', 0.98),
|
||||
(r'C\.?\s*I\.?\s*F\.?\s+CLIENT\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98),
|
||||
(r'C\.?\s*U\.?\s*I\.?\s+CLIENT\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98),
|
||||
# CLIENT C.U.I./ C.I.F. :R01879855 (slash variant with both labels)
|
||||
(r'CLIENT\s+C\.\s*U\.\s*I\.?\s*/\s*C\.\s*[I1]\.\s*F\.?\s*:?\s*(R[O0]?\d{6,10})', 0.97),
|
||||
(r'CLIENT\s+C\.?\s*U\.?\s*I\.?(?:\s*/\s*C\.?\s*[I1]\.?\s*F\.?)?\s*:?\s*(R[O0]?\d{6,10})', 0.96),
|
||||
# CLIENT C.U.I. or CLIENT CUI or CLIENT CIF
|
||||
(r'CLIENT\s+C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98),
|
||||
(r'CLIENT\s+C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.98),
|
||||
(r'CUMPARATOR\s+C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95),
|
||||
(r'CUMPARATOR\s+C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95),
|
||||
# CUI/CIF on line immediately after CLIENT marker
|
||||
(r'CLIENT\s*:\s*\n\s*C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95),
|
||||
(r'CLIENT\s*:\s*\n\s*C\.?\s*I\.?\s*F\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.95),
|
||||
# CUI after client name: "CLIENT: COMPANY SRL\nCUI: 12345678"
|
||||
(r'CLIENT\s*:.*\n.*C\.?\s*U\.?\s*I\.?\s*:?\s*(?:R[O0])?(\d{6,10})', 0.90),
|
||||
]
|
||||
|
||||
# Vendor name indicators (lines containing these are likely vendor names)
|
||||
# These should be company type suffixes, not generic words
|
||||
# Patterns must handle OCR spaces: "S. R. L." as well as "S.R.L."
|
||||
@@ -282,6 +325,13 @@ class ReceiptExtractor:
|
||||
result.address = self._extract_address(text_upper)
|
||||
result.payment_methods = self._extract_payment_methods(text_upper)
|
||||
|
||||
# Extract client data (B2B receipts)
|
||||
client_name, client_cui, client_address, confidence_client = self._extract_client_data(text_upper, text)
|
||||
result.client_name = client_name
|
||||
result.client_cui = client_cui
|
||||
result.client_address = client_address
|
||||
result.confidence_client = confidence_client
|
||||
|
||||
# Detect receipt type
|
||||
result.receipt_type = self._detect_receipt_type(text_upper)
|
||||
|
||||
@@ -291,6 +341,19 @@ class ReceiptExtractor:
|
||||
if not is_valid:
|
||||
print(f"[TVA Reverse Validation] {msg}", flush=True)
|
||||
|
||||
# Cross-validate amount using payment methods and TVA
|
||||
validated_amount, validated_confidence, source = self._cross_validate_and_calculate_amount(
|
||||
result.amount,
|
||||
result.confidence_amount,
|
||||
result.payment_methods,
|
||||
result.tva_entries,
|
||||
result.tva_total
|
||||
)
|
||||
if validated_amount != result.amount:
|
||||
print(f"[Cross-Validation] Amount updated: {result.amount} -> {validated_amount} (source: {source})", flush=True)
|
||||
result.amount = validated_amount
|
||||
result.confidence_amount = validated_confidence
|
||||
|
||||
return result
|
||||
|
||||
def _extract_amount(self, text: str) -> Tuple[Optional[Decimal], float]:
|
||||
@@ -402,6 +465,97 @@ class ReceiptExtractor:
|
||||
|
||||
return num_str
|
||||
|
||||
def _cross_validate_and_calculate_amount(
|
||||
self,
|
||||
amount: Optional[Decimal],
|
||||
confidence_amount: float,
|
||||
payment_methods: List[dict],
|
||||
tva_entries: List[dict],
|
||||
tva_total: Optional[Decimal]
|
||||
) -> Tuple[Optional[Decimal], float, str]:
|
||||
"""
|
||||
Cross-validate and potentially calculate total from payment methods and TVA.
|
||||
|
||||
Returns: (amount, confidence, source_description)
|
||||
|
||||
Logic:
|
||||
1. If amount is valid (>0) with high confidence (>=0.8), use it directly
|
||||
2. Calculate payment_sum = CARD + NUMERAR + other methods
|
||||
3. Calculate tva_implied_total = tva_total * (100 + rate) / rate
|
||||
4. Cross-validate: if payment_sum matches extracted amount, boost confidence
|
||||
5. If amount is 0/None, use payment_sum as total
|
||||
6. If payment_sum is 0, try to calculate from TVA
|
||||
"""
|
||||
# Calculate payment methods sum
|
||||
payment_sum = Decimal('0')
|
||||
if payment_methods:
|
||||
for pm in payment_methods:
|
||||
try:
|
||||
pm_amount = pm.get('amount')
|
||||
if pm_amount:
|
||||
payment_sum += Decimal(str(pm_amount))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Calculate TVA-implied total: total = tva * (100 + rate) / rate
|
||||
tva_implied_total = None
|
||||
if tva_entries:
|
||||
# Use the main TVA entry (typically the largest or first one)
|
||||
main_entry = tva_entries[0]
|
||||
rate = main_entry.get('percent', 19)
|
||||
tva_amount = main_entry.get('amount')
|
||||
if tva_amount and rate > 0:
|
||||
try:
|
||||
tva_dec = Decimal(str(tva_amount))
|
||||
# total = tva * (100 + rate) / rate
|
||||
tva_implied_total = (tva_dec * Decimal(100 + rate) / Decimal(rate)).quantize(Decimal('0.01'))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Case 1: Amount is valid with high confidence - just validate
|
||||
if amount and amount > 0 and confidence_amount >= 0.8:
|
||||
# Cross-validate: check if it matches payment methods
|
||||
if payment_sum > 0 and abs(amount - payment_sum) <= Decimal('0.02'):
|
||||
# Perfect match - boost confidence
|
||||
return amount, min(0.98, confidence_amount + 0.05), "extracted (validated by payment methods)"
|
||||
return amount, confidence_amount, "extracted"
|
||||
|
||||
# Case 2: Amount exists but low confidence - try to validate/correct
|
||||
if amount and amount > 0:
|
||||
# Check if payment methods sum matches
|
||||
if payment_sum > 0:
|
||||
if abs(amount - payment_sum) <= Decimal('0.02'):
|
||||
# Match - boost confidence
|
||||
return amount, 0.90, "extracted (validated by payment methods)"
|
||||
else:
|
||||
# Mismatch - prefer payment_sum as it's more reliable
|
||||
print(f"[Cross-Validation] Amount mismatch: extracted={amount}, payments={payment_sum}", flush=True)
|
||||
return payment_sum, 0.85, "calculated from payment methods"
|
||||
|
||||
# Check TVA-implied total
|
||||
if tva_implied_total:
|
||||
if abs(amount - tva_implied_total) <= Decimal('0.50'):
|
||||
# Close match - use extracted amount
|
||||
return amount, 0.80, "extracted (validated by TVA)"
|
||||
else:
|
||||
print(f"[Cross-Validation] TVA mismatch: extracted={amount}, tva_implied={tva_implied_total}", flush=True)
|
||||
|
||||
# No validation possible - return as-is
|
||||
return amount, confidence_amount, "extracted (unvalidated)"
|
||||
|
||||
# Case 3: Amount is 0 or None - calculate from payment methods
|
||||
if payment_sum > 0:
|
||||
print(f"[Cross-Validation] Amount not found, using payment sum: {payment_sum}", flush=True)
|
||||
return payment_sum, 0.85, "calculated from payment methods"
|
||||
|
||||
# Case 4: Try TVA-implied total as last resort
|
||||
if tva_implied_total and tva_implied_total > 0:
|
||||
print(f"[Cross-Validation] Amount not found, using TVA-implied total: {tva_implied_total}", flush=True)
|
||||
return tva_implied_total, 0.70, "calculated from TVA"
|
||||
|
||||
# Nothing worked - return original
|
||||
return amount, confidence_amount, "not found"
|
||||
|
||||
def _extract_date(self, text: str) -> Tuple[Optional[date], float]:
|
||||
"""Extract receipt date from text."""
|
||||
# First try standard patterns (clean dates)
|
||||
@@ -650,9 +804,17 @@ class ReceiptExtractor:
|
||||
return None, 0.0
|
||||
|
||||
def _detect_receipt_type(self, text: str) -> str:
|
||||
"""Detect receipt type from text content."""
|
||||
"""Detect receipt type from text content.
|
||||
|
||||
BON FISCAL variants: "BON FISCAL", "BON FISCAL.", "BON FISCAL"
|
||||
CHITANTA variants: "CHITANTA", "CHITANȚĂ"
|
||||
"""
|
||||
# Check for explicit BON FISCAL first (handles OCR spacing variations)
|
||||
if re.search(r'BON\s+FISCAL', text):
|
||||
return 'bon_fiscal'
|
||||
if 'CHITANTA' in text or 'CHITANȚĂ' in text:
|
||||
return 'chitanta'
|
||||
# Default to bon_fiscal if neither found
|
||||
return 'bon_fiscal'
|
||||
|
||||
def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal]]:
|
||||
@@ -1237,3 +1399,103 @@ class ReceiptExtractor:
|
||||
continue
|
||||
|
||||
return payment_methods
|
||||
|
||||
def _extract_client_data(
|
||||
self, text_upper: str, original_text: str
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], float]:
|
||||
"""
|
||||
Extract client/buyer data from B2B receipts.
|
||||
|
||||
Returns (client_name, client_cui, client_address, confidence)
|
||||
"""
|
||||
client_name = None
|
||||
client_cui = None
|
||||
client_address = None
|
||||
confidence = 0.0
|
||||
|
||||
# Step 1: Find CLIENT section marker
|
||||
client_section_start = None
|
||||
for marker in self.CLIENT_SECTION_MARKERS:
|
||||
match = re.search(marker, text_upper, re.IGNORECASE)
|
||||
if match:
|
||||
client_section_start = match.start()
|
||||
break
|
||||
|
||||
if client_section_start is None:
|
||||
# No client section found
|
||||
return None, None, None, 0.0
|
||||
|
||||
# Step 2: Extract client CUI
|
||||
for pattern, conf in self.CLIENT_CUI_PATTERNS:
|
||||
match = re.search(pattern, text_upper, re.IGNORECASE | re.MULTILINE)
|
||||
if match:
|
||||
cui = match.group(1)
|
||||
if 6 <= len(cui) <= 10:
|
||||
client_cui = cui
|
||||
confidence = max(confidence, conf)
|
||||
break
|
||||
|
||||
# Step 3: Extract client name from CLIENT section
|
||||
# Look for company name after CLIENT: marker
|
||||
lines = original_text.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
line_upper = line.upper().strip()
|
||||
|
||||
# Check if this line contains CLIENT marker
|
||||
if any(re.search(marker, line_upper) for marker in self.CLIENT_SECTION_MARKERS):
|
||||
# Check if name is on same line after ":"
|
||||
if ':' in line:
|
||||
name_part = line.split(':', 1)[1].strip()
|
||||
if name_part and len(name_part) >= 3:
|
||||
# Skip if it looks like a CUI (R/RO followed by digits)
|
||||
if re.match(r'^R[O0]?\d{6,10}$', name_part.upper()):
|
||||
# This is a CUI, not a name - extract it if not already found
|
||||
if not client_cui:
|
||||
cui_digits = re.sub(r'[^0-9]', '', name_part)
|
||||
if 6 <= len(cui_digits) <= 10:
|
||||
client_cui = cui_digits
|
||||
confidence = max(confidence, 0.90)
|
||||
continue
|
||||
# Check for company indicators
|
||||
if any(re.search(ind, name_part.upper()) for ind in self.VENDOR_INDICATORS):
|
||||
client_name = self._clean_vendor_name(name_part)
|
||||
confidence = max(confidence, 0.95)
|
||||
break
|
||||
elif len(name_part) >= 5 and not name_part.isdigit():
|
||||
client_name = self._clean_vendor_name(name_part)
|
||||
confidence = max(confidence, 0.80)
|
||||
break
|
||||
|
||||
# Check next line for company name
|
||||
if i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
next_upper = next_line.upper()
|
||||
|
||||
# Skip if it's a CUI/CIF line
|
||||
if not re.search(r'C\.?\s*[UI]\.?\s*[IF]\.?', next_upper):
|
||||
if any(re.search(ind, next_upper) for ind in self.VENDOR_INDICATORS):
|
||||
client_name = self._clean_vendor_name(next_line)
|
||||
confidence = max(confidence, 0.90)
|
||||
break
|
||||
elif len(next_line) >= 5 and not next_line.isdigit():
|
||||
# Check if it looks like a company name
|
||||
if not any(kw in next_upper for kw in ['CUI', 'CIF', 'COD', 'FISCAL']):
|
||||
client_name = self._clean_vendor_name(next_line)
|
||||
confidence = max(confidence, 0.75)
|
||||
break
|
||||
|
||||
# Step 4: Extract client address (if present after client section)
|
||||
if client_section_start:
|
||||
# Look for address patterns after client section
|
||||
client_region = text_upper[client_section_start:client_section_start + 500]
|
||||
for pattern, _ in self.ADDRESS_PATTERNS:
|
||||
match = re.search(pattern, client_region)
|
||||
if match:
|
||||
client_address = match.group(1).strip()
|
||||
break
|
||||
|
||||
# Log extraction result
|
||||
if client_cui or client_name:
|
||||
print(f"[Client Extraction] Found: name={client_name}, cui={client_cui}, conf={confidence}", flush=True)
|
||||
|
||||
return client_name, client_cui, client_address, confidence
|
||||
|
||||
@@ -139,7 +139,6 @@ class ReceiptService:
|
||||
account_code=expense_type.account_code,
|
||||
account_name=expense_type.account_name,
|
||||
amount=net_amount,
|
||||
partner_id=receipt.partner_id,
|
||||
))
|
||||
|
||||
# Debit: VAT deductible
|
||||
@@ -156,7 +155,6 @@ class ReceiptService:
|
||||
account_code=expense_type.account_code,
|
||||
account_name=expense_type.account_name,
|
||||
amount=amount,
|
||||
partner_id=receipt.partner_id,
|
||||
))
|
||||
|
||||
# Credit entry - based on payment_mode (new) or cash_register (legacy)
|
||||
@@ -245,6 +243,9 @@ class ReceiptService:
|
||||
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
|
||||
await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True)
|
||||
|
||||
# Refresh receipt to clear stale relationship references after entry deletion
|
||||
await session.refresh(receipt)
|
||||
|
||||
# Update status
|
||||
updated = await ReceiptCRUD.update_status(
|
||||
session, receipt, ReceiptStatus.PENDING_REVIEW
|
||||
@@ -288,6 +289,31 @@ class ReceiptService:
|
||||
|
||||
return True, "Receipt approved", updated
|
||||
|
||||
@staticmethod
|
||||
async def unapprove_receipt(
|
||||
session: AsyncSession,
|
||||
receipt_id: int,
|
||||
username: str,
|
||||
) -> Tuple[bool, str, Optional[Receipt]]:
|
||||
"""
|
||||
Unapprove receipt (APPROVED → PENDING_REVIEW).
|
||||
Returns receipt to pending review for corrections.
|
||||
"""
|
||||
receipt = await ReceiptCRUD.get_by_id(session, receipt_id)
|
||||
|
||||
if not receipt:
|
||||
return False, "Receipt not found", None
|
||||
|
||||
if receipt.status != ReceiptStatus.APPROVED:
|
||||
return False, "Receipt is not approved", None
|
||||
|
||||
# Update status back to pending review
|
||||
updated = await ReceiptCRUD.update_status(
|
||||
session, receipt, ReceiptStatus.PENDING_REVIEW
|
||||
)
|
||||
|
||||
return True, "Receipt returned to pending review", updated
|
||||
|
||||
@staticmethod
|
||||
async def reject_receipt(
|
||||
session: AsyncSession,
|
||||
@@ -342,6 +368,9 @@ class ReceiptService:
|
||||
await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id)
|
||||
await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True)
|
||||
|
||||
# Refresh receipt to clear stale relationship references after entry deletion
|
||||
await session.refresh(receipt)
|
||||
|
||||
# Update status
|
||||
updated = await ReceiptCRUD.update_status(
|
||||
session, receipt, ReceiptStatus.PENDING_REVIEW
|
||||
|
||||
Reference in New Issue
Block a user