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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
363
backend/services/file_archival_service.py
Normal file
363
backend/services/file_archival_service.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"""
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue