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:
Claude Agent
2026-01-04 00:26:36 +00:00
parent 495790411f
commit 02a8c8682c
39 changed files with 1939 additions and 80 deletions

View File

@@ -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:

View File

@@ -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'])

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View File

@@ -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]

View File

@@ -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

View File

@@ -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