706 lines
24 KiB
Python
706 lines
24 KiB
Python
"""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()
|