feat: Add Linux deployment scripts and server logs view
- 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>
This commit is contained in:
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import UploadFile
|
||||
|
||||
from backend.modules.data_entry.db.models.receipt import ReceiptAttachment
|
||||
from backend.modules.data_entry.config import settings
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
class AttachmentCRUD:
|
||||
@@ -29,7 +29,7 @@ class AttachmentCRUD:
|
||||
"""Get full path for storing file, organized by year/month."""
|
||||
now = datetime.utcnow()
|
||||
relative_path = Path(str(now.year)) / f"{now.month:02d}"
|
||||
full_path = settings.upload_path_resolved / relative_path
|
||||
full_path = settings.data_entry_upload_path_resolved / relative_path
|
||||
|
||||
# Ensure directory exists
|
||||
full_path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -50,19 +50,19 @@ class AttachmentCRUD:
|
||||
relative_path = AttachmentCRUD._get_upload_path(stored_filename)
|
||||
|
||||
# Full path for saving
|
||||
full_path = settings.upload_path_resolved / relative_path
|
||||
full_path = settings.data_entry_upload_path_resolved / relative_path
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
if file_size > settings.max_upload_size_bytes:
|
||||
raise ValueError(f"File too large. Maximum size is {settings.max_upload_size_mb}MB")
|
||||
if file_size > settings.data_entry_max_upload_size_bytes:
|
||||
raise ValueError(f"File too large. Maximum size is {settings.data_entry_max_upload_size_mb}MB")
|
||||
|
||||
# Validate MIME type
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
if mime_type not in settings.allowed_mime_types:
|
||||
if mime_type not in settings.data_entry_allowed_mime_types:
|
||||
raise ValueError(f"File type not allowed: {mime_type}")
|
||||
|
||||
# Save file
|
||||
@@ -111,7 +111,7 @@ class AttachmentCRUD:
|
||||
@staticmethod
|
||||
def get_file_path(attachment: ReceiptAttachment) -> Path:
|
||||
"""Get full file path for an attachment."""
|
||||
return settings.upload_path_resolved / attachment.file_path
|
||||
return settings.data_entry_upload_path_resolved / attachment.file_path
|
||||
|
||||
@staticmethod
|
||||
async def delete(session: AsyncSession, attachment: ReceiptAttachment) -> bool:
|
||||
|
||||
@@ -193,6 +193,14 @@ class ReceiptCRUD:
|
||||
"""Update receipt fields."""
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Recalculate tva_total from tva_breakdown if breakdown is being updated
|
||||
if 'tva_breakdown' in update_data and update_data['tva_breakdown']:
|
||||
tva_total = sum(
|
||||
float(entry.get('amount', 0) if isinstance(entry, dict) else getattr(entry, 'amount', 0))
|
||||
for entry in update_data['tva_breakdown']
|
||||
)
|
||||
update_data['tva_total'] = round(tva_total, 2)
|
||||
|
||||
# Serialize tva_breakdown and payment_methods to JSON string if present
|
||||
if 'tva_breakdown' in update_data:
|
||||
update_data['tva_breakdown'] = _serialize_tva_breakdown(update_data['tva_breakdown'])
|
||||
|
||||
@@ -6,13 +6,13 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from backend.modules.data_entry.config import settings
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
# Create async engine
|
||||
# Note: echo=False to disable SQL query logging (too verbose)
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
settings.data_entry_database_url,
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
@@ -28,7 +28,7 @@ async_session_maker = sessionmaker(
|
||||
async def init_db() -> None:
|
||||
"""Initialize database - create tables if they don't exist."""
|
||||
# Ensure data directory exists
|
||||
db_path = Path(settings.sqlite_database_path)
|
||||
db_path = Path(settings.data_entry_sqlite_database_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -18,7 +18,7 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.modules.data_entry.db.database import get_session
|
||||
@@ -190,7 +190,10 @@ async def get_job_status(
|
||||
@router.get("/jobs/{job_id}/wait", response_model=OCRJobResponse)
|
||||
async def wait_for_job_status(
|
||||
job_id: str,
|
||||
response: Response,
|
||||
timeout: int = Query(default=30, ge=1, le=60, description="Max wait time in seconds"),
|
||||
wait_for_terminal: bool = Query(default=False, description="If true, only return on completed/failed"),
|
||||
_t: int = Query(default=None, description="Cache-busting timestamp (ignored)"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
@@ -198,7 +201,8 @@ async def wait_for_job_status(
|
||||
Long-poll for OCR job status change.
|
||||
|
||||
Waits until:
|
||||
- Job status changes to completed/failed
|
||||
- Job status changes (default behavior - returns on any status change)
|
||||
- Job reaches terminal state (if wait_for_terminal=true)
|
||||
- Timeout expires (returns current status)
|
||||
|
||||
Recommended client timeout: timeout + 5 seconds
|
||||
@@ -206,36 +210,53 @@ async def wait_for_job_status(
|
||||
Args:
|
||||
job_id: Job UUID from POST /extract response
|
||||
timeout: Max wait time in seconds (1-60, default 30)
|
||||
wait_for_terminal: If true, wait until completed/failed only
|
||||
|
||||
Returns:
|
||||
OCRJobResponse with status, queue_position, and result (if completed)
|
||||
"""
|
||||
# Prevent caching - critical for long-polling
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
end_time = time.time() + timeout
|
||||
start_time = time.time()
|
||||
end_time = start_time + timeout
|
||||
last_status = None
|
||||
iteration = 0
|
||||
|
||||
print(f"[OCR Wait] Starting long-poll for job {job_id}, timeout={timeout}s, wait_for_terminal={wait_for_terminal}", flush=True)
|
||||
|
||||
while time.time() < end_time:
|
||||
iteration += 1
|
||||
job = await job_queue.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
print(f"[OCR Wait] Job {job_id} not found after {iteration} iterations", flush=True)
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
# Return immediately if job completed or failed
|
||||
# Return immediately if job completed or failed (terminal states)
|
||||
if job.status in [JobStatus.completed, JobStatus.failed]:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[OCR Wait] Job {job_id} {job.status.value} after {elapsed:.1f}s ({iteration} iterations)", flush=True)
|
||||
return await get_job_status(job_id, session, current_user)
|
||||
|
||||
# Return if status changed from last check
|
||||
if last_status is not None and job.status != last_status:
|
||||
# Return on status change (unless wait_for_terminal is set)
|
||||
if not wait_for_terminal and last_status is not None and job.status != last_status:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[OCR Wait] Job {job_id} status changed {last_status.value}->{job.status.value} after {elapsed:.1f}s", flush=True)
|
||||
return await get_job_status(job_id, session, current_user)
|
||||
|
||||
last_status = job.status
|
||||
|
||||
# Wait 1 second before next internal check
|
||||
await asyncio.sleep(1)
|
||||
# Wait 500ms before next internal check (faster polling for better responsiveness)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Timeout - return current status
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[OCR Wait] Job {job_id} timeout after {elapsed:.1f}s ({iteration} iterations), status={last_status.value if last_status else 'unknown'}", flush=True)
|
||||
return await get_job_status(job_id, session, current_user)
|
||||
|
||||
|
||||
@@ -417,7 +438,7 @@ async def _apply_fuzzy_cui_matching(
|
||||
if match:
|
||||
corrected_cui, supplier_name = match
|
||||
if corrected_cui != extraction_data.cui:
|
||||
print(f"[Fuzzy Match] Corrected: {extraction_data.cui} → {corrected_cui} ({supplier_name})", flush=True)
|
||||
print(f"[Fuzzy Match] Corrected: {extraction_data.cui} -> {corrected_cui} ({supplier_name})", flush=True)
|
||||
extraction_data.cui = corrected_cui
|
||||
# Also set partner_name if not already set
|
||||
if not extraction_data.partner_name:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import List, Optional, Annotated
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -120,6 +120,7 @@ async def create_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,
|
||||
@@ -133,6 +134,10 @@ async def list_receipts(
|
||||
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(
|
||||
@@ -152,11 +157,16 @@ async def list_receipts(
|
||||
|
||||
@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
|
||||
)
|
||||
@@ -165,6 +175,7 @@ async def list_pending_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),
|
||||
@@ -172,6 +183,10 @@ async def get_receipt_stats(
|
||||
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,
|
||||
@@ -182,9 +197,14 @@ async def get_receipt_stats(
|
||||
@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:
|
||||
|
||||
@@ -75,9 +75,10 @@ class OCRWorkerPool:
|
||||
self._sync_lock = mp.Lock()
|
||||
|
||||
# Register cleanup handlers
|
||||
# NOTE: Only use atexit, NOT signal handlers!
|
||||
# Signal handlers interfere with FastAPI's shutdown handling.
|
||||
# FastAPI's shutdown event calls stop_job_worker() which calls shutdown().
|
||||
atexit.register(self._cleanup_on_exit)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("[OCRWorkerPool] Singleton instance created")
|
||||
|
||||
@@ -1004,7 +1004,7 @@ class OCRValidationEngine:
|
||||
for replacement in candidates + all_digits:
|
||||
candidate = cui_digits[:pos] + replacement + cui_digits[pos+1:]
|
||||
if CUIChecksumRule.validate_checksum(candidate):
|
||||
print(f"[CUI Repair] Fixed {cui_digits} → {candidate} (position {pos}: {original_digit}→{replacement})", flush=True)
|
||||
print(f"[CUI Repair] Fixed {cui_digits} -> {candidate} (position {pos}: {original_digit}->{replacement})", flush=True)
|
||||
return candidate
|
||||
|
||||
# No single-digit fix found
|
||||
@@ -1164,7 +1164,7 @@ class OCRValidationEngine:
|
||||
if CUIChecksumRule.validate_checksum(cui_digits):
|
||||
match = await lookup_cui_in_db(cui_digits)
|
||||
if match:
|
||||
print(f"[Fuzzy CUI] Exact match found: {cui} → {match[0]} ({match[1]})", flush=True)
|
||||
print(f"[Fuzzy CUI] Exact match found: {cui} -> {match[0]} ({match[1]})", flush=True)
|
||||
return match
|
||||
# Valid checksum but not in DB - return as-is (it might be a new supplier)
|
||||
return None
|
||||
@@ -1214,7 +1214,7 @@ class OCRValidationEngine:
|
||||
# Check if this corrected CUI exists in database
|
||||
match = await lookup_cui_in_db(candidate)
|
||||
if match:
|
||||
print(f"[Fuzzy CUI] DB match: {cui} → {match[0]} ({match[1]}) [pos {pos}: {original_digit}→{replacement}]", flush=True)
|
||||
print(f"[Fuzzy CUI] DB match: {cui} -> {match[0]} ({match[1]}) [pos {pos}: {original_digit}->{replacement}]", flush=True)
|
||||
return match
|
||||
|
||||
# No match found in database
|
||||
@@ -1269,7 +1269,7 @@ class OCRValidationEngine:
|
||||
if not name_words:
|
||||
return None
|
||||
|
||||
print(f"[Fuzzy Name] Searching for vendor: '{vendor_name}' → keywords: {name_words}", flush=True)
|
||||
print(f"[Fuzzy Name] Searching for vendor: '{vendor_name}' -> keywords: {name_words}", flush=True)
|
||||
|
||||
# Build search pattern - use first significant word
|
||||
primary_word = name_words[0]
|
||||
|
||||
@@ -1475,7 +1475,7 @@ class ReceiptExtractor:
|
||||
# e.g., from 14.921492, extract 14.92
|
||||
try:
|
||||
corrected_amount = Decimal(f"{int_part}.{dec_part[:2]}")
|
||||
print(f"[TVA Validation] Corrected concatenation error: {amount} → {corrected_amount}", flush=True)
|
||||
print(f"[TVA Validation] Corrected concatenation error: {amount} -> {corrected_amount}", flush=True)
|
||||
entry['amount'] = corrected_amount
|
||||
except InvalidOperation:
|
||||
pass
|
||||
|
||||
@@ -195,7 +195,7 @@ class OCRService:
|
||||
del images
|
||||
return True, "OCR complete (fast mode)", extraction
|
||||
else:
|
||||
print("[OCR] → Step 1 incomplete, continuing to Step 2...", flush=True)
|
||||
print("[OCR] -> Step 1 incomplete, continuing to Step 2...", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[OCR] PaddleOCR light failed: {e}", flush=True)
|
||||
extraction = ExtractionResult()
|
||||
@@ -251,7 +251,7 @@ class OCRService:
|
||||
del images
|
||||
return True, "OCR complete (paddle dual)", extraction
|
||||
else:
|
||||
print("[OCR] → Step 2 incomplete, continuing to Step 3 (Tesseract)...", flush=True)
|
||||
print("[OCR] -> Step 2 incomplete, continuing to Step 3 (Tesseract)...", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[OCR] PaddleOCR medium failed: {e}", flush=True)
|
||||
# Cleanup on error
|
||||
|
||||
Reference in New Issue
Block a user