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>
270 lines
9.6 KiB
Python
270 lines
9.6 KiB
Python
"""Pydantic schemas for receipts API."""
|
|
|
|
import json
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
from typing import Optional, List, Any, Union
|
|
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
|
|
|
from app.db.models.receipt import ReceiptType, ReceiptDirection, ReceiptStatus
|
|
from app.db.models.accounting_entry import EntryType
|
|
|
|
|
|
# ============ Accounting Entry Schemas ============
|
|
|
|
class AccountingEntryBase(BaseModel):
|
|
"""Base schema for accounting entry."""
|
|
entry_type: EntryType
|
|
account_code: str = Field(max_length=20)
|
|
account_name: Optional[str] = Field(default=None, max_length=200)
|
|
amount: Decimal
|
|
partner_id: Optional[int] = None
|
|
cost_center_id: Optional[int] = None
|
|
|
|
|
|
class AccountingEntryCreate(AccountingEntryBase):
|
|
"""Schema for creating an accounting entry."""
|
|
pass
|
|
|
|
|
|
class AccountingEntryUpdate(BaseModel):
|
|
"""Schema for updating an accounting entry."""
|
|
entry_type: Optional[EntryType] = None
|
|
account_code: Optional[str] = Field(default=None, max_length=20)
|
|
account_name: Optional[str] = Field(default=None, max_length=200)
|
|
amount: Optional[Decimal] = None
|
|
partner_id: Optional[int] = None
|
|
cost_center_id: Optional[int] = None
|
|
|
|
|
|
class AccountingEntryResponse(AccountingEntryBase):
|
|
"""Schema for accounting entry response."""
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
receipt_id: int
|
|
is_auto_generated: bool
|
|
modified_by: Optional[str] = None
|
|
modified_at: Optional[datetime] = None
|
|
sort_order: int
|
|
|
|
|
|
# ============ Attachment Schemas ============
|
|
|
|
class AttachmentResponse(BaseModel):
|
|
"""Schema for attachment response."""
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
receipt_id: int
|
|
filename: str
|
|
stored_filename: str
|
|
file_path: str
|
|
file_size: int
|
|
mime_type: str
|
|
uploaded_at: datetime
|
|
|
|
|
|
# ============ TVA Schema ============
|
|
|
|
class TvaEntrySchema(BaseModel):
|
|
"""Single TVA entry with code, percentage and amount."""
|
|
code: Optional[str] = Field(default=None, description="TVA code: A, B, C, D")
|
|
percent: int = Field(description="TVA percentage: 0, 5, 9, 19, 21")
|
|
amount: Decimal = Field(description="TVA amount for this rate")
|
|
|
|
|
|
class PaymentMethodSchema(BaseModel):
|
|
"""Payment method entry (CARD/NUMERAR)."""
|
|
method: str = Field(description="Payment method: CARD or NUMERAR")
|
|
amount: Decimal = Field(description="Amount paid with this method")
|
|
|
|
|
|
# ============ Receipt Schemas ============
|
|
|
|
class ReceiptBase(BaseModel):
|
|
"""Base schema for receipt."""
|
|
receipt_type: ReceiptType = ReceiptType.BON_FISCAL
|
|
direction: ReceiptDirection = ReceiptDirection.CHELTUIALA
|
|
receipt_number: Optional[str] = Field(default=None, max_length=50)
|
|
receipt_series: Optional[str] = Field(default=None, max_length=20)
|
|
receipt_date: date
|
|
amount: Decimal = Field(gt=0)
|
|
description: Optional[str] = Field(default=None, max_length=500)
|
|
# TVA info (multiple entries support)
|
|
tva_breakdown: Optional[List[TvaEntrySchema]] = Field(default=None, description="List of TVA entries")
|
|
tva_total: Optional[Decimal] = Field(default=None, description="Total TVA amount")
|
|
items_count: Optional[int] = Field(default=None, description="Number of items")
|
|
vendor_address: Optional[str] = Field(default=None, max_length=500, description="Vendor address")
|
|
# Other fields
|
|
expense_type_code: Optional[str] = Field(default=None, max_length=20)
|
|
company_id: int
|
|
# partner_id removed - supplier data is text-only (partner_name, cui)
|
|
partner_name: Optional[str] = Field(default=None, max_length=200)
|
|
cui: Optional[str] = Field(default=None, max_length=20, description="Fiscal code (CUI) from OCR")
|
|
ocr_raw_text: Optional[str] = Field(default=None, description="Raw OCR text for debugging")
|
|
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR")
|
|
cash_register_id: Optional[int] = None
|
|
cash_register_name: Optional[str] = Field(default=None, max_length=100)
|
|
cash_register_account: Optional[str] = Field(default=None, max_length=20)
|
|
payment_mode: Optional[str] = Field(default=None, description="Payment mode: casa/banca/avans_decontare")
|
|
|
|
|
|
class ReceiptCreate(ReceiptBase):
|
|
"""Schema for creating a receipt."""
|
|
pass
|
|
|
|
|
|
class ReceiptUpdate(BaseModel):
|
|
"""Schema for updating a receipt (DRAFT only)."""
|
|
receipt_type: Optional[ReceiptType] = None
|
|
direction: Optional[ReceiptDirection] = None
|
|
receipt_number: Optional[str] = Field(default=None, max_length=50)
|
|
receipt_series: Optional[str] = Field(default=None, max_length=20)
|
|
receipt_date: Optional[date] = None
|
|
amount: Optional[Decimal] = Field(default=None, gt=0)
|
|
description: Optional[str] = Field(default=None, max_length=500)
|
|
# TVA info (multiple entries support)
|
|
tva_breakdown: Optional[List[TvaEntrySchema]] = Field(default=None, description="List of TVA entries")
|
|
tva_total: Optional[Decimal] = Field(default=None, description="Total TVA amount")
|
|
items_count: Optional[int] = Field(default=None, description="Number of items")
|
|
vendor_address: Optional[str] = Field(default=None, max_length=500, description="Vendor address")
|
|
# Other fields
|
|
expense_type_code: Optional[str] = Field(default=None, max_length=20)
|
|
# partner_id removed - supplier data is text-only (partner_name, cui)
|
|
partner_name: Optional[str] = Field(default=None, max_length=200)
|
|
cui: Optional[str] = Field(default=None, max_length=20, description="Fiscal code (CUI) from OCR")
|
|
ocr_raw_text: Optional[str] = Field(default=None, description="Raw OCR text for debugging")
|
|
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR")
|
|
cash_register_id: Optional[int] = None
|
|
cash_register_name: Optional[str] = Field(default=None, max_length=100)
|
|
cash_register_account: Optional[str] = Field(default=None, max_length=20)
|
|
payment_mode: Optional[str] = Field(default=None, description="Payment mode: casa/banca/avans_decontare")
|
|
|
|
|
|
class ReceiptResponse(ReceiptBase):
|
|
"""Schema for receipt response with all fields."""
|
|
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
|
|
updated_at: datetime
|
|
submitted_at: Optional[datetime] = None
|
|
reviewed_by: Optional[str] = None
|
|
reviewed_at: Optional[datetime] = None
|
|
rejection_reason: Optional[str] = None
|
|
oracle_synced_at: Optional[datetime] = None
|
|
oracle_act_id: Optional[int] = None
|
|
oracle_error: Optional[str] = None
|
|
|
|
# Relationships (optional, loaded when needed)
|
|
attachments: List[AttachmentResponse] = []
|
|
entries: List[AccountingEntryResponse] = []
|
|
|
|
@field_validator('tva_breakdown', mode='before')
|
|
@classmethod
|
|
def parse_tva_breakdown(cls, v: Any) -> Optional[List[dict]]:
|
|
"""Deserialize tva_breakdown from JSON string if needed."""
|
|
if v is None:
|
|
return None
|
|
if isinstance(v, str):
|
|
try:
|
|
return json.loads(v)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if isinstance(v, list):
|
|
return v
|
|
return None
|
|
|
|
@field_validator('payment_methods', mode='before')
|
|
@classmethod
|
|
def parse_payment_methods(cls, v: Any) -> Optional[List[dict]]:
|
|
"""Deserialize payment_methods from JSON string if needed."""
|
|
if v is None:
|
|
return None
|
|
if isinstance(v, str):
|
|
try:
|
|
return json.loads(v)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if isinstance(v, list):
|
|
return v
|
|
return None
|
|
|
|
|
|
class ReceiptListResponse(BaseModel):
|
|
"""Schema for paginated receipt list response."""
|
|
items: List[ReceiptResponse]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
pages: int
|
|
|
|
|
|
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
|
|
date_to: Optional[date] = None
|
|
search: Optional[str] = None # Search in description, partner_name
|
|
page: int = Field(default=1, ge=1)
|
|
page_size: int = Field(default=20, ge=1, le=100)
|
|
|
|
|
|
# ============ Workflow Schemas ============
|
|
|
|
class WorkflowAction(BaseModel):
|
|
"""Schema for workflow action response."""
|
|
success: bool
|
|
message: str
|
|
receipt: Optional[ReceiptResponse] = None
|
|
|
|
|
|
class RejectRequest(BaseModel):
|
|
"""Schema for rejection request."""
|
|
reason: str = Field(min_length=5, max_length=500)
|
|
|
|
|
|
class EntriesUpdateRequest(BaseModel):
|
|
"""Schema for bulk updating accounting entries."""
|
|
entries: List[AccountingEntryCreate]
|
|
|
|
|
|
# ============ Nomenclature Schemas ============
|
|
|
|
class PartnerOption(BaseModel):
|
|
"""Schema for partner dropdown option (used for autocomplete assistance)."""
|
|
name: str
|
|
fiscal_code: Optional[str] = None
|
|
address: Optional[str] = None
|
|
source: str = "oracle" # 'oracle' (synced) or 'local'
|
|
|
|
|
|
class AccountOption(BaseModel):
|
|
"""Schema for account dropdown option."""
|
|
code: str
|
|
name: str
|
|
|
|
|
|
class CashRegisterOption(BaseModel):
|
|
"""Schema for cash register dropdown option."""
|
|
id: int
|
|
name: str
|
|
account_code: str # 5311, 5121, etc.
|
|
|
|
|
|
class ExpenseTypeOption(BaseModel):
|
|
"""Schema for expense type dropdown option."""
|
|
code: str
|
|
name: str
|
|
account_code: str
|
|
has_vat: bool
|
|
vat_percent: Decimal = Decimal("19")
|