kitchen/backend/services/file_archival_service.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

363 lines
13 KiB
Python

"""
Service for managing invoice file archival to Nextcloud.
Handles:
- Automatic archival after invoice confirmation + Dext send
- File retrieval from Nextcloud for re-processing
- Deleted file handling
"""
import os
import hashlib
import logging
from datetime import datetime
from typing import Tuple, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.invoice import Invoice, InvoiceStatus
from models.settings import KitchenSettings
from models.supplier import Supplier
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
class FileArchivalService:
"""Service for managing invoice file archival"""
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
async def get_settings(self) -> Optional[KitchenSettings]:
"""Get kitchen settings"""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
return result.scalar_one_or_none()
async def is_ready_for_archival(self, invoice: Invoice) -> bool:
"""
Check if invoice is ready to be archived to Nextcloud.
Conditions:
- Status is CONFIRMED
- If Dext is enabled and auto-send is on, must have been sent
- File is still local
"""
if invoice.status != InvoiceStatus.CONFIRMED:
return False
if invoice.file_storage_location != "local":
return False
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return False
# Check Dext requirement - only if auto-send is enabled
if settings.dext_email and settings.dext_auto_send_enabled:
if not invoice.dext_sent_at:
return False
return True
async def archive_invoice_file(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Archive invoice file to Nextcloud.
Args:
invoice: Invoice to archive
Returns:
(success, message or path)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not enabled")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud not configured")
if invoice.file_storage_location != "local":
return (False, "File already archived")
if not os.path.exists(invoice.image_path):
return (False, "Local file not found")
try:
# Get supplier name
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(invoice.image_path, 'rb') as f:
file_content = f.read()
# Generate hash from file content
file_hash = hashlib.md5(file_content).hexdigest()
# Initialize Nextcloud service
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Generate path and filename
dir_path = nc.generate_path(supplier_name, invoice.invoice_date)
filename = nc.generate_filename(
invoice.invoice_date,
supplier_name,
invoice.invoice_number,
float(invoice.total) if invoice.total else 0,
os.path.basename(invoice.image_path),
file_hash
)
# Upload to Nextcloud
success, result = await nc.upload_file(file_content, dir_path, filename)
await nc.close()
if not success:
return (False, f"Upload failed: {result}")
# Update invoice record
invoice.original_local_path = invoice.image_path
invoice.nextcloud_path = result
invoice.file_storage_location = "nextcloud"
invoice.archived_at = datetime.utcnow()
await self.db.commit()
logger.info(f"Archived invoice {invoice.id} to Nextcloud: {result}")
# Delete local file if setting enabled
if settings.nextcloud_delete_local and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
logger.info(f"Deleted local file for invoice {invoice.id}: {invoice.image_path}")
except Exception as e:
logger.warning(f"Failed to delete local file {invoice.image_path}: {e}")
return (True, result)
except Exception as e:
logger.error(f"Archival failed for invoice {invoice.id}: {e}")
return (False, str(e))
async def get_file_content(self, invoice: Invoice) -> Tuple[bool, bytes | str]:
"""
Get file content, downloading from Nextcloud if needed.
Used for OCR re-processing or file serving.
Args:
invoice: Invoice to get file for
Returns:
(success, file_bytes or error_message)
"""
# Try local file first (image_path)
if os.path.exists(invoice.image_path):
with open(invoice.image_path, 'rb') as f:
return (True, f.read())
# Try original local path (if file was archived but local copy exists)
if invoice.original_local_path and os.path.exists(invoice.original_local_path):
with open(invoice.original_local_path, 'rb') as f:
return (True, f.read())
# Download from Nextcloud
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not configured")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud credentials not set")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # Path already includes base
)
success, result = await nc.download_file(invoice.nextcloud_path)
await nc.close()
if success and isinstance(result, bytes):
# Cache locally for future use
try:
os.makedirs(os.path.dirname(invoice.image_path), exist_ok=True)
with open(invoice.image_path, 'wb') as f:
f.write(result)
except Exception as e:
logger.warning(f"Failed to cache file locally: {e}")
return (success, result)
return (False, "File not found")
async def handle_invoice_deletion(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Handle file when invoice is being deleted.
If on Nextcloud, copy to deleted folder.
Args:
invoice: Invoice being deleted
Returns:
(success, message)
"""
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if settings and settings.nextcloud_enabled:
try:
# Get supplier name for deleted folder
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Copy to deleted folder
original_filename = os.path.basename(invoice.nextcloud_path)
success, msg = await nc.copy_to_deleted(
invoice.nextcloud_path,
supplier_name,
original_filename
)
await nc.close()
if not success:
logger.warning(f"Failed to copy to deleted folder: {msg}")
return (True, "File moved to deleted folder")
except Exception as e:
logger.error(f"Error handling file deletion: {e}")
# Local file deletion
if invoice.image_path and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
except Exception as e:
logger.warning(f"Failed to delete local file: {e}")
# Also try to delete original local path if different
if invoice.original_local_path and invoice.original_local_path != invoice.image_path:
if os.path.exists(invoice.original_local_path):
try:
os.remove(invoice.original_local_path)
except Exception as e:
logger.warning(f"Failed to delete original local file: {e}")
return (True, "Local file deleted")
async def get_archive_stats(self) -> dict:
"""
Get archival statistics for this kitchen.
Returns:
dict with pending_count, archived_count, etc.
"""
settings = await self.get_settings()
# Count invoices ready for archival (confirmed + local)
pending_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
pending_result = await self.db.execute(pending_query)
pending_count = pending_result.scalar() or 0
# Count already archived invoices
archived_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "nextcloud"
)
archived_result = await self.db.execute(archived_query)
archived_count = archived_result.scalar() or 0
# Count all invoices with local files (any status)
local_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "local"
)
local_result = await self.db.execute(local_query)
local_count = local_result.scalar() or 0
return {
"pending_count": pending_count,
"archived_count": archived_count,
"local_count": local_count,
"nextcloud_enabled": settings.nextcloud_enabled if settings else False,
"nextcloud_configured": bool(
settings and settings.nextcloud_host and
settings.nextcloud_username and settings.nextcloud_password
) if settings else False
}
async def archive_all_pending(self) -> Tuple[int, int, list]:
"""
Archive all pending invoices to Nextcloud.
Returns:
(success_count, failed_count, error_messages)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (0, 0, ["Nextcloud not enabled"])
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (0, 0, ["Nextcloud not configured"])
# Get all invoices ready for archival
query = select(Invoice).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
result = await self.db.execute(query)
invoices = result.scalars().all()
success_count = 0
failed_count = 0
errors = []
for invoice in invoices:
if await self.is_ready_for_archival(invoice):
try:
success, msg = await self.archive_invoice_file(invoice)
if success:
success_count += 1
else:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {msg}")
except Exception as e:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {str(e)}")
return (success_count, failed_count, errors)