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:
@@ -147,13 +147,33 @@ class ReceiptCRUD:
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk upload filters (US-012)
|
||||
# US-005: Support comma-separated values for processing_status filter (e.g., "pending,processing")
|
||||
if filters.processing_status:
|
||||
statuses = [s.strip() for s in filters.processing_status.split(",")]
|
||||
if len(statuses) == 1:
|
||||
query = query.where(Receipt.processing_status == statuses[0])
|
||||
else:
|
||||
query = query.where(Receipt.processing_status.in_(statuses))
|
||||
|
||||
if filters.batch_id:
|
||||
query = query.where(Receipt.batch_id == filters.batch_id)
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(Receipt.created_at.desc())
|
||||
# Apply ordering based on sort_by parameter (US-012)
|
||||
if filters.sort_by == "processing_started_at":
|
||||
query = query.order_by(Receipt.processing_started_at.desc())
|
||||
elif filters.sort_by == "processing_started_at_asc":
|
||||
query = query.order_by(Receipt.processing_started_at.asc())
|
||||
else:
|
||||
# Default ordering
|
||||
query = query.order_by(Receipt.created_at.desc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (filters.page - 1) * filters.page_size
|
||||
query = query.offset(offset).limit(filters.page_size)
|
||||
|
||||
@@ -163,6 +183,61 @@ class ReceiptCRUD:
|
||||
|
||||
return list(receipts), total
|
||||
|
||||
@staticmethod
|
||||
async def get_processing_stats(
|
||||
session: AsyncSession,
|
||||
company_id: Optional[int] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Get processing status counts for bulk uploaded receipts (US-012)."""
|
||||
# Build base query for counting by processing_status
|
||||
base_conditions = []
|
||||
|
||||
if company_id:
|
||||
base_conditions.append(Receipt.company_id == company_id)
|
||||
|
||||
if batch_id:
|
||||
base_conditions.append(Receipt.batch_id == batch_id)
|
||||
|
||||
# Only count receipts that have a processing_status (bulk uploads)
|
||||
base_conditions.append(Receipt.processing_status.isnot(None))
|
||||
|
||||
query = select(
|
||||
Receipt.processing_status,
|
||||
func.count(Receipt.id).label("count")
|
||||
)
|
||||
|
||||
for condition in base_conditions:
|
||||
query = query.where(condition)
|
||||
|
||||
query = query.group_by(Receipt.processing_status)
|
||||
|
||||
result = await session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Initialize stats
|
||||
stats = {
|
||||
"pending_count": 0,
|
||||
"processing_count": 0,
|
||||
"completed_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
|
||||
# Map results
|
||||
for row in rows:
|
||||
status = row.processing_status
|
||||
count = row.count
|
||||
if status == "pending":
|
||||
stats["pending_count"] = count
|
||||
elif status == "processing":
|
||||
stats["processing_count"] = count
|
||||
elif status == "completed":
|
||||
stats["completed_count"] = count
|
||||
elif status == "failed":
|
||||
stats["failed_count"] = count
|
||||
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
async def get_pending_review(
|
||||
session: AsyncSession,
|
||||
|
||||
Reference in New Issue
Block a user