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:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

@ -0,0 +1,452 @@
"""
Service for managing dispute and credit note file archival to Nextcloud.
Handles:
- Dispute attachment storage and archival
- Credit note PDF storage and archival
- File retrieval from Nextcloud
"""
import os
import uuid
import hashlib
import logging
from datetime import datetime
from typing import Tuple, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models.dispute import DisputeAttachment, CreditNote, InvoiceDispute
from models.settings import KitchenSettings
from models.supplier import Supplier
from models.invoice import Invoice
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
# Storage paths
DISPUTE_ATTACHMENTS_DIR = "/app/data/disputes"
CREDIT_NOTES_DIR = "/app/data/credit_notes"
class DisputeArchivalService:
"""Service for managing dispute and credit note 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()
def _sanitize_filename(self, name: str) -> str:
"""Sanitize filename for safe storage"""
# Remove unsafe characters
safe = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in name)
# Collapse multiple spaces/underscores
safe = '_'.join(safe.split())
# Limit length
return safe[:50]
def _generate_descriptive_filename(
self,
supplier_name: str,
date: datetime,
prefix: str,
original_filename: str
) -> str:
"""
Generate descriptive filename for Nextcloud archival.
Format: {date}-{supplier}-{prefix}-{hash}.{ext}
Example: 2026-01-22-Brakes-dispute-5-attachment-a1b2c3d4.jpg
"""
date_str = date.strftime("%Y-%m-%d")
supplier_safe = self._sanitize_filename(supplier_name)[:30]
# Get file extension
ext = os.path.splitext(original_filename)[1].lower() or ".dat"
# Generate short hash for uniqueness
hash_input = f"{supplier_name}{date}{prefix}{original_filename}".encode()
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
return f"{date_str}-{supplier_safe}-{prefix}-{short_hash}{ext}"
async def save_dispute_attachment(
self,
dispute: InvoiceDispute,
file_content: bytes,
filename: str,
file_type: str
) -> Tuple[bool, str]:
"""
Save dispute attachment locally.
Args:
dispute: The dispute
file_content: File bytes
filename: Original filename
file_type: MIME type
Returns:
(success, file_path or error)
"""
try:
# Create storage directory
dispute_dir = os.path.join(DISPUTE_ATTACHMENTS_DIR, str(self.kitchen_id), str(dispute.id))
os.makedirs(dispute_dir, exist_ok=True)
# Generate unique filename
ext = os.path.splitext(filename)[1] or ".dat"
unique_filename = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(dispute_dir, unique_filename)
# Save file
with open(file_path, 'wb') as f:
f.write(file_content)
return (True, file_path)
except Exception as e:
logger.error(f"Failed to save dispute attachment: {e}")
return (False, str(e))
async def save_credit_note(
self,
invoice: Invoice,
file_content: bytes,
filename: str
) -> Tuple[bool, str]:
"""
Save credit note PDF locally.
Args:
invoice: The invoice
file_content: PDF bytes
filename: Original filename
Returns:
(success, file_path or error)
"""
try:
# Create storage directory
cn_dir = os.path.join(CREDIT_NOTES_DIR, str(self.kitchen_id))
os.makedirs(cn_dir, exist_ok=True)
# Generate unique filename
ext = os.path.splitext(filename)[1] or ".pdf"
unique_filename = f"{uuid.uuid4()}{ext}"
file_path = os.path.join(cn_dir, unique_filename)
# Save file
with open(file_path, 'wb') as f:
f.write(file_content)
return (True, file_path)
except Exception as e:
logger.error(f"Failed to save credit note: {e}")
return (False, str(e))
async def archive_dispute_attachment(self, attachment: DisputeAttachment) -> Tuple[bool, str]:
"""
Archive dispute attachment to Nextcloud.
Args:
attachment: Attachment to archive
Returns:
(success, nextcloud_path or error)
"""
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 attachment.file_storage_location != "local":
return (False, "Attachment already archived")
if not os.path.exists(attachment.file_path):
return (False, "Local file not found")
try:
# Get dispute and invoice info
result = await self.db.execute(
select(InvoiceDispute).where(InvoiceDispute.id == attachment.dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
return (False, "Dispute not found")
result = await self.db.execute(
select(Invoice).where(Invoice.id == dispute.invoice_id)
)
invoice = result.scalar_one_or_none()
if not invoice:
return (False, "Invoice not found")
# 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(attachment.file_path, 'rb') as f:
file_content = f.read()
# Generate descriptive filename
date = attachment.uploaded_at or datetime.utcnow()
prefix = f"dispute-{dispute.id}-{attachment.attachment_type}"
descriptive_filename = self._generate_descriptive_filename(
supplier_name,
date,
prefix,
attachment.file_name
)
# Build Nextcloud path structure
base_path = (settings.nextcloud_base_path or "/Kitchen Invoices").strip('/')
year = date.strftime("%Y")
month = date.strftime("%m")
nextcloud_path = f"{base_path}/Disputes/{supplier_name}/{year}/{month}"
# Upload to Nextcloud
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # No base path, we use explicit path
)
success, result = await nc.upload_file(
file_content,
nextcloud_path,
descriptive_filename
)
await nc.close()
if not success:
return (False, f"Nextcloud upload failed: {result}")
# Update attachment record
attachment.file_storage_location = "nextcloud"
attachment.nextcloud_path = result
attachment.archived_at = datetime.utcnow()
await self.db.commit()
# Optionally delete local file
if settings.nextcloud_delete_local:
try:
os.remove(attachment.file_path)
logger.info(f"Deleted local attachment after archival: {attachment.file_path}")
except Exception as e:
logger.warning(f"Could not delete local file: {e}")
return (True, result)
except Exception as e:
logger.error(f"Failed to archive dispute attachment: {e}")
return (False, str(e))
async def archive_credit_note(self, credit_note: CreditNote) -> Tuple[bool, str]:
"""
Archive credit note to Nextcloud.
Args:
credit_note: Credit note to archive
Returns:
(success, nextcloud_path or error)
"""
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 credit_note.file_storage_location != "local":
return (False, "Credit note already archived")
if not os.path.exists(credit_note.file_path):
return (False, "Local file not found")
try:
# Get supplier name
supplier_name = "Unknown"
if credit_note.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == credit_note.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(credit_note.file_path, 'rb') as f:
file_content = f.read()
# Generate descriptive filename
date = credit_note.credit_date
date_str = date.strftime("%Y-%m-%d")
supplier_safe = self._sanitize_filename(supplier_name)[:30]
cn_number_safe = self._sanitize_filename(credit_note.credit_note_number)[:20]
amount_str = f"£{credit_note.credit_amount:.2f}".replace('.', '_')
# Short hash for uniqueness
hash_input = f"{supplier_name}{date}{credit_note.credit_note_number}".encode()
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
descriptive_filename = f"{date_str}-{supplier_safe}-CN-{cn_number_safe}-{amount_str}-{short_hash}.pdf"
# Build Nextcloud path structure
base_path = (settings.nextcloud_base_path or "/Kitchen Invoices").strip('/')
year = date.strftime("%Y")
month = date.strftime("%m")
nextcloud_path = f"{base_path}/Credit Notes/{supplier_name}/{year}/{month}"
# Upload to Nextcloud
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, result = await nc.upload_file(
file_content,
nextcloud_path,
descriptive_filename
)
await nc.close()
if not success:
return (False, f"Nextcloud upload failed: {result}")
# Update credit note record
credit_note.file_storage_location = "nextcloud"
credit_note.nextcloud_path = result
credit_note.original_local_path = credit_note.file_path
credit_note.archived_at = datetime.utcnow()
await self.db.commit()
# Optionally delete local file
if settings.nextcloud_delete_local:
try:
os.remove(credit_note.file_path)
logger.info(f"Deleted local credit note after archival: {credit_note.file_path}")
except Exception as e:
logger.warning(f"Could not delete local file: {e}")
return (True, result)
except Exception as e:
logger.error(f"Failed to archive credit note: {e}")
return (False, str(e))
async def get_attachment_content(self, attachment: DisputeAttachment) -> Tuple[bool, bytes]:
"""
Get attachment file content (from local or Nextcloud).
Args:
attachment: Attachment to retrieve
Returns:
(success, file_content or error_message)
"""
try:
# Try local file first
if attachment.file_storage_location == "local" and os.path.exists(attachment.file_path):
with open(attachment.file_path, 'rb') as f:
return (True, f.read())
# Try Nextcloud
if attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, b"Nextcloud not enabled")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, content = await nc.download_file(attachment.nextcloud_path)
await nc.close()
if success:
return (True, content)
else:
return (False, content.encode() if isinstance(content, str) else content)
return (False, b"File not found")
except Exception as e:
logger.error(f"Failed to get attachment content: {e}")
return (False, str(e).encode())
async def get_credit_note_content(self, credit_note: CreditNote) -> Tuple[bool, bytes]:
"""
Get credit note file content (from local or Nextcloud).
Args:
credit_note: Credit note to retrieve
Returns:
(success, file_content or error_message)
"""
try:
# Try local file first
if credit_note.file_storage_location == "local" and os.path.exists(credit_note.file_path):
with open(credit_note.file_path, 'rb') as f:
return (True, f.read())
# Try original local path if different
if credit_note.original_local_path and os.path.exists(credit_note.original_local_path):
with open(credit_note.original_local_path, 'rb') as f:
return (True, f.read())
# Try Nextcloud
if credit_note.file_storage_location == "nextcloud" and credit_note.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, b"Nextcloud not enabled")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
""
)
success, content = await nc.download_file(credit_note.nextcloud_path)
await nc.close()
if success:
return (True, content)
else:
return (False, content.encode() if isinstance(content, str) else content)
return (False, b"File not found")
except Exception as e:
logger.error(f"Failed to get credit note content: {e}")
return (False, str(e).encode())