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

327
backend/api/credit_notes.py Normal file
View file

@ -0,0 +1,327 @@
"""
API endpoints for credit note management.
Handles:
- Credit note upload
- Credit note download
- Credit note CRUD operations
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from datetime import date as date_type
from typing import Optional
from pydantic import BaseModel
from decimal import Decimal
from auth import get_current_user, require_cap
from database import get_db
from models.user import User
from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity
from models.invoice import Invoice
from models.supplier import Supplier
from services.dispute_archival_service import DisputeArchivalService
router = APIRouter()
# Pydantic Schemas
class CreditNoteResponse(BaseModel):
id: int
invoice_id: int
supplier_id: int
supplier_name: str
credit_note_number: str
credit_date: str
credit_amount: float
reason: Optional[str]
notes: Optional[str]
file_storage_location: str
created_at: str
created_by: str
class Config:
from_attributes = True
# Endpoints
@router.post("/upload")
async def upload_credit_note(
file: UploadFile = File(...),
invoice_id: int = Form(...),
credit_note_number: str = Form(...),
credit_date: str = Form(...),
credit_amount: float = Form(...),
dispute_id: Optional[int] = Form(None),
reason: Optional[str] = Form(None),
notes: Optional[str] = Form(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Upload credit note PDF"""
# Verify invoice exists and belongs to kitchen
result = await db.execute(
select(Invoice).where(
and_(
Invoice.id == invoice_id,
Invoice.kitchen_id == current_user.kitchen_id
)
)
)
invoice = result.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Parse credit date
try:
credit_date_obj = date_type.fromisoformat(credit_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
# Read file content
file_content = await file.read()
# Validate file type (should be PDF)
if file.content_type and "pdf" not in file.content_type.lower():
raise HTTPException(status_code=400, detail="Only PDF files are supported for credit notes")
# Save file
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
success, file_path = await archival_service.save_credit_note(
invoice,
file_content,
file.filename or "credit_note.pdf"
)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}")
# Create credit note record
credit_note = CreditNote(
kitchen_id=current_user.kitchen_id,
invoice_id=invoice_id,
supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle)
credit_note_number=credit_note_number,
credit_date=credit_date_obj,
credit_amount=Decimal(str(credit_amount)),
reason=reason,
notes=notes,
file_path=file_path,
file_storage_location="local",
created_by=current_user.id
)
db.add(credit_note)
await db.flush() # Get credit_note.id
# If linked to dispute, update dispute
if dispute_id:
result = await db.execute(
select(InvoiceDispute).where(
and_(
InvoiceDispute.id == dispute_id,
InvoiceDispute.kitchen_id == current_user.kitchen_id
)
)
)
dispute = result.scalar_one_or_none()
if dispute:
# Link credit note to dispute
dispute.credit_note_id = credit_note.id
dispute.resolved_amount = credit_note.credit_amount
# Update dispute status if still open/in_progress
if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]:
dispute.status = DisputeStatus.AWAITING_CREDIT
# Log activity
activity = DisputeActivity(
dispute_id=dispute_id,
activity_type="credit_note_added",
description=f"Credit note {credit_note_number}{credit_amount:.2f}) linked to dispute",
created_by=current_user.id
)
db.add(activity)
await db.commit()
await db.refresh(credit_note)
# Archive to Nextcloud if enabled
success, result = await archival_service.archive_credit_note(credit_note)
if success:
await db.commit() # Update archived status
return {
"id": credit_note.id,
"credit_note_number": credit_note_number,
"credit_amount": credit_amount,
"archived": success
}
@router.get("/{credit_note_id}")
async def get_credit_note(
credit_note_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> CreditNoteResponse:
"""Get credit note details"""
result = await db.execute(
select(CreditNote).options(
selectinload(CreditNote.supplier),
selectinload(CreditNote.created_by_user)
).where(
and_(
CreditNote.id == credit_note_id,
CreditNote.kitchen_id == current_user.kitchen_id
)
)
)
credit_note = result.scalar_one_or_none()
if not credit_note:
raise HTTPException(status_code=404, detail="Credit note not found")
return CreditNoteResponse(
id=credit_note.id,
invoice_id=credit_note.invoice_id,
supplier_id=credit_note.supplier_id,
supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown",
credit_note_number=credit_note.credit_note_number,
credit_date=credit_note.credit_date.isoformat(),
credit_amount=float(credit_note.credit_amount),
reason=credit_note.reason,
notes=credit_note.notes,
file_storage_location=credit_note.file_storage_location,
created_at=credit_note.created_at.isoformat(),
created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown"
)
@router.get("/{credit_note_id}/download")
async def download_credit_note(
credit_note_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Download credit note PDF"""
result = await db.execute(
select(CreditNote).where(
and_(
CreditNote.id == credit_note_id,
CreditNote.kitchen_id == current_user.kitchen_id
)
)
)
credit_note = result.scalar_one_or_none()
if not credit_note:
raise HTTPException(status_code=404, detail="Credit note not found")
# Get file content
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
success, content = await archival_service.get_credit_note_content(credit_note)
if not success:
raise HTTPException(status_code=404, detail="File not found")
return Response(
content=content,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'}
)
@router.patch("/{credit_note_id}")
async def update_credit_note(
credit_note_id: int,
credit_note_number: Optional[str] = None,
credit_date: Optional[str] = None,
credit_amount: Optional[float] = None,
reason: Optional[str] = None,
notes: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update credit note details"""
result = await db.execute(
select(CreditNote).where(
and_(
CreditNote.id == credit_note_id,
CreditNote.kitchen_id == current_user.kitchen_id
)
)
)
credit_note = result.scalar_one_or_none()
if not credit_note:
raise HTTPException(status_code=404, detail="Credit note not found")
if credit_note_number:
credit_note.credit_note_number = credit_note_number
if credit_date:
try:
credit_note.credit_date = date_type.fromisoformat(credit_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if credit_amount is not None:
credit_note.credit_amount = Decimal(str(credit_amount))
if reason is not None:
credit_note.reason = reason
if notes is not None:
credit_note.notes = notes
await db.commit()
return {"status": "updated"}
@router.delete("/{credit_note_id}")
async def delete_credit_note(
credit_note_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete credit note"""
result = await db.execute(
select(CreditNote).where(
and_(
CreditNote.id == credit_note_id,
CreditNote.kitchen_id == current_user.kitchen_id
)
)
)
credit_note = result.scalar_one_or_none()
if not credit_note:
raise HTTPException(status_code=404, detail="Credit note not found")
# Check if linked to dispute
result = await db.execute(
select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id)
)
dispute = result.scalar_one_or_none()
if dispute:
# Unlink from dispute
dispute.credit_note_id = None
dispute.resolved_amount = None
await db.delete(credit_note)
await db.commit()
return {"status": "deleted"}