feat(data-entry): Bulk Receipt Upload cu Mobile UX Android Nativ
## Funcționalități Principale ### Bulk Upload & Processing - Drag & drop pentru upload bonuri multiple oriunde pe pagină - Batch processing cu job queue și worker pool - Real-time updates via SSE (Server-Sent Events) cu fallback polling - Duplicate detection via SHA-256 file hash - Auto-retry pentru job-uri failed - Cancel individual jobs sau batch complet ### Mobile UX - Android Native Style - Top bar fixă cu hamburger, titlu centrat, acțiuni (search/filter) - Bottom navigation cu 4 tab-uri (Bonuri, Upload, Rapoarte, Setări) - FAB (Floating Action Button) cu hide/show on scroll - Filter chips orizontal scrollabile - Selecție multiplă prin long-press (500ms) - Select All + Bulk Delete cu confirmare - Layout Android pentru Create/Edit/View bon (Gmail compose style) ### Bug Fixes - Refresh individual via SSE în loc de refresh total pagină - Bonurile cu eroare OCR rămân vizibile pentru editare manuală - Afișare nume fișier original pentru toate bonurile - Upload stabil pe mobil (fix race condition File API) - Păstrare ordine bonuri la refresh (nu se reordonează) ### Backend - SSE endpoint pentru status updates real-time - Bulk delete endpoint cu partial success - Auto-cleanup bonuri failed după 7 zile - Batch model cu tracking complet ### Testing - E2E tests cu Playwright - Unit tests pentru bulk upload, auto-create, cleanup ## Commits Squashed: 43 user stories (US-001 → US-043) ## Branch: ralph/bulk-receipt-upload ## Timp dezvoltare: ~3 zile (Ralph autonomous) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
212
backend/modules/data_entry/schemas/bulk.py
Normal file
212
backend/modules/data_entry/schemas/bulk.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Pydantic schemas for bulk upload endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BulkUploadResponse(BaseModel):
|
||||
"""Response schema for bulk upload endpoint."""
|
||||
|
||||
batch_id: int = Field(..., description="Unique batch identifier for tracking")
|
||||
job_ids: List[str] = Field(..., description="List of OCR job UUIDs created")
|
||||
total_files: int = Field(..., description="Number of files in the batch")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"job_ids": [
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
"550e8400-e29b-41d4-a716-446655440002",
|
||||
],
|
||||
"total_files": 2,
|
||||
"message": "2 files queued for processing"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BatchJobInfo(BaseModel):
|
||||
"""Information about a single job in a batch."""
|
||||
|
||||
job_id: str = Field(..., description="OCR job UUID")
|
||||
filename: str = Field(..., description="Original filename")
|
||||
status: str = Field(..., description="Job status: pending, processing, completed, failed")
|
||||
receipt_id: Optional[int] = Field(None, description="Created receipt ID (if completed)")
|
||||
error_message: Optional[str] = Field(None, description="Error message (if failed)")
|
||||
|
||||
|
||||
class BatchStatusResponse(BaseModel):
|
||||
"""Response schema for batch status endpoint."""
|
||||
|
||||
batch_id: int = Field(..., description="Batch identifier")
|
||||
status: str = Field(..., description="Overall batch status")
|
||||
total_files: int = Field(..., description="Total number of files in batch")
|
||||
pending_count: int = Field(..., description="Number of pending jobs")
|
||||
processing_count: int = Field(..., description="Number of processing jobs")
|
||||
completed_count: int = Field(..., description="Number of completed jobs")
|
||||
failed_count: int = Field(..., description="Number of failed jobs")
|
||||
jobs: List[BatchJobInfo] = Field(..., description="List of jobs with their status")
|
||||
total_amount: Optional[float] = Field(None, description="Sum of all receipt amounts")
|
||||
created_at: datetime = Field(..., description="Batch creation timestamp")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"status": "processing",
|
||||
"total_files": 5,
|
||||
"pending_count": 2,
|
||||
"processing_count": 1,
|
||||
"completed_count": 2,
|
||||
"failed_count": 0,
|
||||
"jobs": [
|
||||
{"job_id": "abc-123", "filename": "bon1.pdf", "status": "completed", "receipt_id": 15},
|
||||
{"job_id": "def-456", "filename": "bon2.jpg", "status": "processing", "receipt_id": None},
|
||||
],
|
||||
"total_amount": 150.50,
|
||||
"created_at": "2025-01-09T10:30:00"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DuplicateFileInfo(BaseModel):
|
||||
"""Information about a duplicate file detected during upload."""
|
||||
|
||||
filename: str = Field(..., description="Name of the duplicate file")
|
||||
error: str = Field(default="duplicate", description="Error type (always 'duplicate')")
|
||||
existing_receipt_id: int = Field(..., description="ID of the existing receipt with same file hash")
|
||||
message: str = Field(..., description="Human-readable error message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"filename": "bon_lidl.pdf",
|
||||
"error": "duplicate",
|
||||
"existing_receipt_id": 123,
|
||||
"message": "Fișier duplicat - există deja ca bon #123"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BulkUploadResponseWithDuplicates(BaseModel):
|
||||
"""Response schema for bulk upload with partial success (some duplicates)."""
|
||||
|
||||
batch_id: Optional[int] = Field(None, description="Batch ID (None if all files were duplicates)")
|
||||
job_ids: List[str] = Field(default_factory=list, description="List of OCR job UUIDs created")
|
||||
total_files: int = Field(..., description="Total number of files submitted")
|
||||
processed_files: int = Field(..., description="Number of files successfully queued")
|
||||
duplicate_files: int = Field(..., description="Number of duplicate files rejected")
|
||||
duplicates: List[DuplicateFileInfo] = Field(default_factory=list, description="List of duplicate file details")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"batch_id": 1,
|
||||
"job_ids": ["550e8400-e29b-41d4-a716-446655440001"],
|
||||
"total_files": 3,
|
||||
"processed_files": 1,
|
||||
"duplicate_files": 2,
|
||||
"duplicates": [
|
||||
{
|
||||
"filename": "bon_lidl.pdf",
|
||||
"error": "duplicate",
|
||||
"existing_receipt_id": 123,
|
||||
"message": "Fișier duplicat - există deja ca bon #123"
|
||||
}
|
||||
],
|
||||
"message": "1 fișier în procesare, 2 duplicate ignorate"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BulkUploadError(BaseModel):
|
||||
"""Error response for bulk upload validation failures."""
|
||||
|
||||
detail: str = Field(..., description="Error message")
|
||||
invalid_files: Optional[List[str]] = Field(None, description="List of invalid filenames")
|
||||
|
||||
|
||||
class RetryResponse(BaseModel):
|
||||
"""Response schema for retry endpoints."""
|
||||
|
||||
success: bool = Field(..., description="Whether the retry was successful")
|
||||
receipt_id: int = Field(..., description="Receipt ID that was retried")
|
||||
job_id: Optional[str] = Field(None, description="New OCR job ID created")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"receipt_id": 123,
|
||||
"job_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"message": "Bon reîncarcat în procesare"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BatchRetryResponse(BaseModel):
|
||||
"""Response schema for batch retry endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether any retries were successful")
|
||||
batch_id: str = Field(..., description="Batch ID that was retried")
|
||||
retried_count: int = Field(..., description="Number of receipts successfully retried")
|
||||
failed_count: int = Field(..., description="Number of receipts that couldn't be retried")
|
||||
errors: List[str] = Field(default_factory=list, description="List of error messages")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"batch_id": "abc-123",
|
||||
"retried_count": 3,
|
||||
"failed_count": 0,
|
||||
"errors": [],
|
||||
"message": "3 bonuri reîncarcate în procesare"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CancelJobResponse(BaseModel):
|
||||
"""Response schema for cancel job endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether the cancellation was successful")
|
||||
job_id: str = Field(..., description="Job ID that was cancelled")
|
||||
cancelled_at: datetime = Field(..., description="Timestamp when the job was cancelled")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"job_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"cancelled_at": "2025-01-11T15:30:00",
|
||||
"message": "Job anulat cu succes"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CancelBatchResponse(BaseModel):
|
||||
"""Response schema for cancel batch endpoint."""
|
||||
|
||||
success: bool = Field(..., description="Whether any jobs were cancelled")
|
||||
batch_id: int = Field(..., description="Batch ID that was cancelled")
|
||||
cancelled_count: int = Field(..., description="Number of jobs successfully cancelled")
|
||||
skipped_count: int = Field(..., description="Number of jobs skipped (completed/failed)")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"batch_id": 1,
|
||||
"cancelled_count": 3,
|
||||
"skipped_count": 2,
|
||||
"message": "3 job-uri anulate, 2 ignorate (deja procesate)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user