- Add deployment/linux/ with deploy.sh for deploying from Claude-Agent LXC to Windows server - Add ServerLogsView.vue for viewing server logs from frontend - Add shared/routes/system.py for system health endpoints - Update CLAUDE.md with quick deploy instructions - Improve Windows deployment scripts (ROA2WEB-Console.ps1) - Fix OCR service validation and worker pool improvements - Update environment config examples - Various script permission and startup fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
538 lines
18 KiB
Python
538 lines
18 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
|
|
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,
|
|
AttachmentResponse,
|
|
AccountingEntryResponse,
|
|
WorkflowAction,
|
|
RejectRequest,
|
|
EntriesUpdateRequest,
|
|
PartnerOption,
|
|
AccountOption,
|
|
CashRegisterOption,
|
|
ExpenseTypeOption,
|
|
)
|
|
from backend.modules.data_entry.db.models.receipt import ReceiptStatus, ReceiptDirection
|
|
|
|
# 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
|
|
|
|
|
|
# ============ 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,
|
|
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."""
|
|
# 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,
|
|
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("/{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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
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()
|