fix telegram
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
"""API endpoints for receipts."""
|
||||
|
||||
from typing import List, Optional, Annotated
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD
|
||||
from backend.modules.data_entry.db.crud.attachment import AttachmentCRUD
|
||||
from backend.modules.data_entry.db.crud.accounting_entry import AccountingEntryCRUD
|
||||
from backend.modules.data_entry.services.receipt_service import ReceiptService
|
||||
from backend.modules.data_entry.services.nomenclature_service import NomenclatureService
|
||||
from backend.modules.data_entry.schemas.receipt import (
|
||||
ReceiptCreate,
|
||||
ReceiptUpdate,
|
||||
ReceiptResponse,
|
||||
ReceiptListResponse,
|
||||
ReceiptFilter,
|
||||
ProcessingStats,
|
||||
AttachmentResponse,
|
||||
AccountingEntryResponse,
|
||||
WorkflowAction,
|
||||
RejectRequest,
|
||||
EntriesUpdateRequest,
|
||||
PartnerOption,
|
||||
AccountOption,
|
||||
CashRegisterOption,
|
||||
ExpenseTypeOption,
|
||||
BulkDeleteRequest,
|
||||
BulkDeleteResponse,
|
||||
BulkDeleteFailure,
|
||||
)
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptStatus, ReceiptDirection
|
||||
from backend.modules.data_entry.services import sse_service
|
||||
|
||||
# Auth integration
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.models import CurrentUser
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Helper for selected company from header ============
|
||||
|
||||
async def get_selected_company(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
x_selected_company: Annotated[Optional[str], Header()] = None
|
||||
) -> int:
|
||||
"""
|
||||
Get selected company from X-Selected-Company header.
|
||||
|
||||
Validates that the user has access to the specified company.
|
||||
Falls back to user's first company if no header is provided.
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user doesn't have access to specified company
|
||||
HTTPException 400: If user has no companies assigned
|
||||
"""
|
||||
if x_selected_company:
|
||||
try:
|
||||
company_id = int(x_selected_company)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid company ID format: {x_selected_company}"
|
||||
)
|
||||
|
||||
# Validate user has access to this company
|
||||
# Auth stores companies as strings
|
||||
if str(company_id) in current_user.companies:
|
||||
return company_id
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Nu aveți acces la firma {company_id}"
|
||||
)
|
||||
|
||||
# No header - use first company from user's list
|
||||
if current_user.companies:
|
||||
try:
|
||||
return int(current_user.companies[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nu aveți nicio firmă asignată"
|
||||
)
|
||||
|
||||
|
||||
# Dependency for injection
|
||||
SelectedCompany = Annotated[int, Depends(get_selected_company)]
|
||||
|
||||
|
||||
# Legacy function for backwards compatibility (deprecated)
|
||||
def get_current_user_company(current_user: CurrentUser) -> int:
|
||||
"""
|
||||
DEPRECATED: Use get_selected_company() dependency instead.
|
||||
This function returns the first company, ignoring X-Selected-Company header.
|
||||
"""
|
||||
if current_user.companies:
|
||||
try:
|
||||
return int(current_user.companies[0])
|
||||
except (ValueError, IndexError):
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
# ============ SSE Endpoint for Real-time Status Updates ============
|
||||
|
||||
@router.get("/sse/status")
|
||||
async def sse_status_stream(
|
||||
batch_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Optional batch_id to filter events for a specific batch"
|
||||
),
|
||||
):
|
||||
"""
|
||||
Server-Sent Events endpoint for real-time receipt status updates.
|
||||
|
||||
This endpoint provides a persistent connection that streams status change
|
||||
events as they occur. Clients receive updates for CRUD operations on receipts
|
||||
without needing to poll.
|
||||
|
||||
Query Parameters:
|
||||
batch_id: Optional filter to only receive events for a specific batch upload.
|
||||
|
||||
Event Format:
|
||||
data: {"receipt_id": 123, "status": "DRAFT", "processing_status": "completed", ...}
|
||||
|
||||
Headers:
|
||||
- Content-Type: text/event-stream
|
||||
- Cache-Control: no-cache
|
||||
- Connection: keep-alive
|
||||
|
||||
Reconnection:
|
||||
The retry: 3000 header hints clients to reconnect after 3 seconds if disconnected.
|
||||
|
||||
Example:
|
||||
curl -N http://localhost:8000/api/data-entry/receipts/sse/status
|
||||
curl -N http://localhost:8000/api/data-entry/receipts/sse/status?batch_id=abc-123
|
||||
"""
|
||||
return StreamingResponse(
|
||||
sse_service.subscribe(batch_id=batch_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============ Receipt CRUD Endpoints ============
|
||||
|
||||
@router.post("/", response_model=ReceiptResponse)
|
||||
async def create_receipt(
|
||||
data: ReceiptCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new receipt in DRAFT status."""
|
||||
receipt = await ReceiptService.create_receipt(session, data, current_user.username)
|
||||
return ReceiptResponse.model_validate(receipt)
|
||||
|
||||
|
||||
@router.get("/", response_model=ReceiptListResponse)
|
||||
async def list_receipts(
|
||||
response: Response,
|
||||
status: Optional[ReceiptStatus] = None,
|
||||
direction: Optional[ReceiptDirection] = None,
|
||||
company_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
# Bulk upload filters (US-012)
|
||||
processing_status: Optional[str] = Query(default=None, description="Filter by processing status: pending, processing, completed, failed"),
|
||||
batch_id: Optional[str] = Query(default=None, description="Filter by batch_id UUID"),
|
||||
sort_by: Optional[str] = Query(default=None, description="Sort field: processing_started_at, processing_started_at_asc"),
|
||||
# Pagination
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get paginated list of receipts with filters.
|
||||
|
||||
US-012: Extended with batch_id, processing_status filters and processing_stats.
|
||||
"""
|
||||
# Disable browser caching to always get fresh data
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
from datetime import date as date_type
|
||||
|
||||
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,
|
||||
date_to=date_type.fromisoformat(date_to) if date_to else None,
|
||||
search=search,
|
||||
processing_status=processing_status,
|
||||
batch_id=batch_id,
|
||||
sort_by=sort_by,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return await ReceiptService.get_receipts(session, filters)
|
||||
|
||||
|
||||
@router.get("/pending", response_model=List[ReceiptResponse])
|
||||
async def list_pending_receipts(
|
||||
response: Response,
|
||||
company_id: Optional[int] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get all receipts pending review (for accountant view)."""
|
||||
# Disable browser caching to always get fresh data
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
receipts = await ReceiptCRUD.get_pending_review(
|
||||
session, company_id or selected_company
|
||||
)
|
||||
return [ReceiptResponse.model_validate(r) for r in receipts]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_receipt_stats(
|
||||
response: Response,
|
||||
company_id: Optional[int] = None,
|
||||
my_receipts: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Get receipt statistics."""
|
||||
# Disable browser caching to always get fresh data
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
return await ReceiptCRUD.get_stats(
|
||||
session,
|
||||
company_id or selected_company,
|
||||
created_by=current_user.username if my_receipts else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{receipt_id}", response_model=ReceiptResponse)
|
||||
async def get_receipt(
|
||||
receipt_id: int,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get receipt details with attachments and accounting entries."""
|
||||
# Disable browser caching to always get fresh data
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
|
||||
receipt = await ReceiptService.get_receipt(session, receipt_id)
|
||||
|
||||
if not receipt:
|
||||
raise HTTPException(status_code=404, detail="Receipt not found")
|
||||
|
||||
return ReceiptResponse.model_validate(receipt)
|
||||
|
||||
|
||||
@router.put("/{receipt_id}", response_model=ReceiptResponse)
|
||||
async def update_receipt(
|
||||
receipt_id: int,
|
||||
data: ReceiptUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Update receipt (only DRAFT status, only by creator)."""
|
||||
success, message, receipt = await ReceiptService.update_receipt(
|
||||
session, receipt_id, data, current_user.username
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return ReceiptResponse.model_validate(receipt)
|
||||
|
||||
|
||||
@router.delete("/bulk", response_model=BulkDeleteResponse)
|
||||
async def bulk_delete_receipts(
|
||||
data: BulkDeleteRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Bulk delete receipts (US-024).
|
||||
|
||||
Deletes multiple receipts in a single request with partial success support.
|
||||
|
||||
Validation rules:
|
||||
- Each receipt must be in DRAFT status
|
||||
- Each receipt must be created by the current user
|
||||
- Receipts with processing_status 'pending' or 'processing' cannot be deleted
|
||||
|
||||
Returns:
|
||||
BulkDeleteResponse with deleted IDs and failed items with error messages
|
||||
"""
|
||||
deleted: List[int] = []
|
||||
failed: List[BulkDeleteFailure] = []
|
||||
|
||||
for receipt_id in data.ids:
|
||||
# Get receipt with relationships for deletion
|
||||
receipt = await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=True)
|
||||
|
||||
if not receipt:
|
||||
failed.append(BulkDeleteFailure(id=receipt_id, error="Bonul nu a fost găsit"))
|
||||
continue
|
||||
|
||||
# Check if receipt is being processed (bulk upload in progress)
|
||||
if receipt.processing_status in ["pending", "processing"]:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error="Bonul este în curs de procesare și nu poate fi șters"
|
||||
))
|
||||
continue
|
||||
|
||||
# Check status - only DRAFT can be deleted
|
||||
if receipt.status != ReceiptStatus.DRAFT:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error=f"Doar bonurile în status DRAFT pot fi șterse (status curent: {receipt.status.value})"
|
||||
))
|
||||
continue
|
||||
|
||||
# Check ownership
|
||||
if receipt.created_by != current_user.username:
|
||||
failed.append(BulkDeleteFailure(
|
||||
id=receipt_id,
|
||||
error="Doar creatorul bonului poate să-l șteargă"
|
||||
))
|
||||
continue
|
||||
|
||||
# All validations passed - delete the receipt
|
||||
# Note: Cascade delete handles attachments and accounting entries
|
||||
await ReceiptCRUD.delete(session, receipt)
|
||||
deleted.append(receipt_id)
|
||||
|
||||
return BulkDeleteResponse(deleted=deleted, failed=failed)
|
||||
|
||||
|
||||
@router.delete("/{receipt_id}")
|
||||
async def delete_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Delete receipt (only DRAFT status, only by creator)."""
|
||||
success, message = await ReceiptService.delete_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
||||
# ============ Workflow Endpoints ============
|
||||
|
||||
@router.post("/{receipt_id}/submit", response_model=WorkflowAction)
|
||||
async def submit_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Submit receipt for review (DRAFT → PENDING_REVIEW)."""
|
||||
success, message, receipt = await ReceiptService.submit_for_review(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/approve", response_model=WorkflowAction)
|
||||
async def approve_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Approve receipt (PENDING_REVIEW → APPROVED). Accountant action."""
|
||||
success, message, receipt = await ReceiptService.approve_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/reject", response_model=WorkflowAction)
|
||||
async def reject_receipt(
|
||||
receipt_id: int,
|
||||
data: RejectRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Reject receipt (PENDING_REVIEW → REJECTED). Accountant action."""
|
||||
success, message, receipt = await ReceiptService.reject_receipt(
|
||||
session, receipt_id, current_user.username, data.reason
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/resubmit", response_model=WorkflowAction)
|
||||
async def resubmit_receipt(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Resubmit rejected receipt after corrections (REJECTED → PENDING_REVIEW)."""
|
||||
success, message, receipt = await ReceiptService.resubmit_receipt(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
return WorkflowAction(
|
||||
success=success,
|
||||
message=message,
|
||||
receipt=ReceiptResponse.model_validate(receipt) if receipt else None,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
# Broadcast SSE event on success (US-030)
|
||||
if success and receipt:
|
||||
await sse_service.broadcast_status_change(
|
||||
receipt_id=receipt.id,
|
||||
status=receipt.status.value,
|
||||
processing_status=receipt.processing_status,
|
||||
batch_id=receipt.batch_id,
|
||||
)
|
||||
|
||||
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])
|
||||
async def get_receipt_entries(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get accounting entries for a receipt."""
|
||||
entries = await AccountingEntryCRUD.get_by_receipt_id(session, receipt_id)
|
||||
return [AccountingEntryResponse.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.put("/{receipt_id}/entries", response_model=List[AccountingEntryResponse])
|
||||
async def update_receipt_entries(
|
||||
receipt_id: int,
|
||||
data: EntriesUpdateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Update accounting entries for a receipt (accountant action)."""
|
||||
success, message, entries = await ReceiptService.update_entries(
|
||||
session, receipt_id, data.entries, current_user.username
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return [AccountingEntryResponse.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.post("/{receipt_id}/entries/regenerate", response_model=List[AccountingEntryResponse])
|
||||
async def regenerate_entries(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Regenerate accounting entries based on receipt data."""
|
||||
success, message, _ = await ReceiptService.regenerate_entries(
|
||||
session, receipt_id, current_user.username
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
entries = await AccountingEntryCRUD.get_by_receipt_id(session, receipt_id)
|
||||
return [AccountingEntryResponse.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
# ============ Attachment Endpoints ============
|
||||
|
||||
@router.post("/{receipt_id}/attachments", response_model=AttachmentResponse)
|
||||
async def upload_attachment(
|
||||
receipt_id: int,
|
||||
file: UploadFile = File(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Upload attachment for a receipt."""
|
||||
# Check receipt exists and user can modify it
|
||||
receipt = await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=False)
|
||||
|
||||
if not receipt:
|
||||
raise HTTPException(status_code=404, detail="Receipt not found")
|
||||
|
||||
# Only allow uploads for DRAFT and REJECTED receipts
|
||||
if receipt.status not in [ReceiptStatus.DRAFT, ReceiptStatus.REJECTED]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot upload attachments for this receipt status"
|
||||
)
|
||||
|
||||
# Only creator can upload
|
||||
if receipt.created_by != current_user.username:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the creator can upload attachments"
|
||||
)
|
||||
|
||||
try:
|
||||
attachment = await AttachmentCRUD.create(session, receipt_id, file)
|
||||
return AttachmentResponse.model_validate(attachment)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{receipt_id}/attachments", response_model=List[AttachmentResponse])
|
||||
async def list_attachments(
|
||||
receipt_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Get all attachments for a receipt."""
|
||||
attachments = await AttachmentCRUD.get_by_receipt_id(session, receipt_id)
|
||||
return [AttachmentResponse.model_validate(a) for a in attachments]
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}/download")
|
||||
async def download_attachment(
|
||||
attachment_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Download an attachment file."""
|
||||
attachment = await AttachmentCRUD.get_by_id(session, attachment_id)
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
file_path = AttachmentCRUD.get_file_path(attachment)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=attachment.filename,
|
||||
media_type=attachment.mime_type,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}")
|
||||
async def delete_attachment(
|
||||
attachment_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an attachment."""
|
||||
attachment = await AttachmentCRUD.get_by_id(session, attachment_id)
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get receipt to check permissions
|
||||
receipt = await ReceiptCRUD.get_by_id(session, attachment.receipt_id, include_relations=False)
|
||||
|
||||
if not receipt:
|
||||
raise HTTPException(status_code=404, detail="Receipt not found")
|
||||
|
||||
# Only allow deletion for DRAFT receipts by creator
|
||||
if receipt.status != ReceiptStatus.DRAFT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete attachments for this receipt status"
|
||||
)
|
||||
|
||||
if receipt.created_by != current_user.username:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the creator can delete attachments"
|
||||
)
|
||||
|
||||
await AttachmentCRUD.delete(session, attachment)
|
||||
return {"success": True, "message": "Attachment deleted"}
|
||||
|
||||
|
||||
# ============ Nomenclature Endpoints ============
|
||||
|
||||
@router.get("/nomenclature/partners", response_model=List[PartnerOption])
|
||||
async def get_partners(
|
||||
search: Optional[str] = None,
|
||||
company_id: Optional[int] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get partners (suppliers/customers) for dropdown."""
|
||||
return await NomenclatureService.get_partners(
|
||||
company_id or selected_company, search, session
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nomenclature/accounts", response_model=List[AccountOption])
|
||||
async def get_accounts(
|
||||
prefix: Optional[str] = None,
|
||||
company_id: Optional[int] = None,
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get chart of accounts for dropdown."""
|
||||
return await NomenclatureService.get_accounts(
|
||||
company_id or selected_company, prefix
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nomenclature/cash-registers", response_model=List[CashRegisterOption])
|
||||
async def get_cash_registers(
|
||||
company_id: Optional[int] = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
selected_company: SelectedCompany = None,
|
||||
):
|
||||
"""Get cash registers and bank accounts for dropdown."""
|
||||
return await NomenclatureService.get_cash_registers(company_id or selected_company, session)
|
||||
|
||||
|
||||
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])
|
||||
async def get_expense_types():
|
||||
"""Get predefined expense types for dropdown."""
|
||||
return await NomenclatureService.get_expense_types()
|
||||
Reference in New Issue
Block a user