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,830 @@
# Plan 6: PDF Highlighting for Non-Stock Items in Dext Emails
## Current State
### Existing Dext Integration
**Files:**
- `backend/services/dext.py` - Dext API client
- `backend/services/email_service.py` - Email sending
- Email templates for notifications
Current email functionality:
- Sends plain text email with invoice details
- Lists non-stock items in email body
- Provides link to PDF in Nextcloud
- No highlighting or annotation on PDF itself
**Example current email:**
```
Subject: Invoice Requires Attention - Non-Stock Items Found
Invoice #12345 from ACME Suppliers has 3 non-stock items:
- Garden Peas, Frozen (2.5 kg) - €15.50
- Tomato Sauce, Organic (1 L) - €8.25
- Cleaning Spray (500 ml) - €4.99
Please add these items to your product catalog or link them to existing products.
View Invoice: [Nextcloud Link]
```
## Problem Statement
**User Request:**
> "highlight or embed in the pdf itself not just the email body text"
**Current Limitation:**
- Non-stock items only highlighted in email text
- PDF remains unchanged from Dext
- User must manually find items in PDF
- Time-consuming when PDF has 50+ line items
**Goal:**
Modify the PDF to visually highlight non-stock items before sending email, so users can immediately identify problematic items in the document itself.
## Use Cases
### Use Case 1: Large Multi-Page Invoice
**Scenario:** 100-item invoice with 5 non-stock items scattered across 3 pages
**Current Experience:**
1. Read email listing 5 items
2. Open PDF
3. Manually scan all 100 items to find the 5 mentioned
4. Switch back and forth between email and PDF
**Desired Experience:**
1. Open PDF
2. **Immediately see yellow-highlighted items** on pages 1, 2, and 3
3. Quickly identify and resolve non-stock items
### Use Case 2: Similar Product Names
**Scenario:** Invoice has "Tomatoes, Vine" (in stock) and "Tomatoes, Cherry" (not in stock)
**Current Experience:**
- Email says "Tomatoes, Cherry" not in stock
- PDF has 3 different tomato products
- User must carefully read each tomato line to find "Cherry"
**Desired Experience:**
- PDF shows "Tomatoes, Cherry" with bright yellow highlight
- Instantly distinguishable from other tomato products
## Technical Approaches
### Option A: PDF Annotation (Recommended)
**Concept:** Add highlight annotations directly to PDF using PyPDF2 or reportlab
**Pros:**
- Native PDF feature (annotations)
- Works in all PDF viewers
- Non-destructive (original content preserved)
- Can add notes/comments
**Cons:**
- Complex to position highlights accurately
- Requires text coordinate detection
- May not work with scanned PDFs (OCR needed)
**Libraries:**
- `PyPDF2` / `pypdf` - PDF manipulation
- `pdfplumber` - Text extraction with coordinates
- `reportlab` - PDF generation/modification
### Option B: Render New PDF with Highlights
**Concept:** Extract content, render new PDF with highlighted sections
**Pros:**
- Full control over appearance
- Can add colored boxes, borders, icons
- Works with any PDF structure
**Cons:**
- More complex implementation
- May lose original formatting
- Larger file sizes
**Libraries:**
- `reportlab` - PDF rendering
- `pdf2image` + PIL - Image-based approach
### Option C: Embedded Annotations + Email Summary
**Concept:** Combine PDF annotations with enhanced email (current approach++)
**Pros:**
- Best of both worlds
- Fallback for annotation failures
- Accessible via email even without PDF viewer
**Cons:**
- Most development work
- Redundant information
## Recommended Approach: Option A (PDF Annotation)
Use pdfplumber to find text coordinates and PyPDF2 to add highlight annotations.
## Implementation Plan
### Phase 1: Text Coordinate Detection
#### 1.1 Install Dependencies
**File:** `backend/requirements.txt` (UPDATE)
```txt
# Existing dependencies...
# PDF processing
pdfplumber==0.10.3
pypdf==3.17.4
```
#### 1.2 Create PDF Highlighter Service
**File:** `backend/services/pdf_highlighter.py` (NEW)
```python
import pdfplumber
from pypdf import PdfReader, PdfWriter
from pypdf.generic import DictionaryObject, ArrayObject, FloatObject, NameObject
from pathlib import Path
import logging
from typing import List, Dict, Tuple
logger = logging.getLogger(__name__)
class PDFHighlighter:
"""Service for highlighting text in PDF files"""
def __init__(self, pdf_path: str):
self.pdf_path = pdf_path
self.reader = PdfReader(pdf_path)
self.writer = PdfWriter()
def highlight_text_items(self, items_to_highlight: List[str], output_path: str) -> str:
"""
Highlight specific text items in PDF
Args:
items_to_highlight: List of text strings to find and highlight
output_path: Path to save annotated PDF
Returns:
Path to annotated PDF
"""
logger.info(f"Highlighting {len(items_to_highlight)} items in {self.pdf_path}")
try:
# Extract text with coordinates using pdfplumber
text_coordinates = self._extract_text_coordinates(items_to_highlight)
# Add pages to writer with highlights
for page_num, page in enumerate(self.reader.pages):
# Add page to writer
self.writer.add_page(page)
# Get highlights for this page
page_highlights = text_coordinates.get(page_num, [])
if page_highlights:
# Add highlight annotations to page
for coords in page_highlights:
self._add_highlight_annotation(
page_num=page_num,
coordinates=coords
)
# Write annotated PDF
with open(output_path, "wb") as output_file:
self.writer.write(output_file)
logger.info(f"Annotated PDF saved to {output_path}")
return output_path
except Exception as e:
logger.error(f"PDF highlighting failed: {e}", exc_info=True)
# Return original PDF path if highlighting fails
return self.pdf_path
def _extract_text_coordinates(self, search_texts: List[str]) -> Dict[int, List[Dict]]:
"""
Find coordinates of text items in PDF
Returns:
Dict mapping page_num -> list of coordinate dicts
"""
coordinates = {}
with pdfplumber.open(self.pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages):
page_coords = []
# Extract words with bounding boxes
words = page.extract_words(x_tolerance=3, y_tolerance=3)
# Search for each item
for search_text in search_texts:
# Normalize text for comparison
search_normalized = search_text.lower().strip()
# Find matching words
matches = []
for word in words:
word_text = word['text'].lower()
if search_normalized in word_text or word_text in search_normalized:
matches.append(word)
# Group consecutive words into phrases
if matches:
# Get bounding box for entire phrase
x0 = min(w['x0'] for w in matches)
x1 = max(w['x1'] for w in matches)
y0 = min(w['top'] for w in matches)
y1 = max(w['bottom'] for w in matches)
# Convert to PDF coordinates (bottom-left origin)
page_height = page.height
coord = {
'x0': x0,
'y0': page_height - y1, # Flip Y coordinate
'x1': x1,
'y1': page_height - y0,
'text': search_text
}
page_coords.append(coord)
logger.debug(f"Found '{search_text}' on page {page_num} at {coord}")
if page_coords:
coordinates[page_num] = page_coords
return coordinates
def _add_highlight_annotation(self, page_num: int, coordinates: Dict):
"""
Add highlight annotation to page
Args:
page_num: Page index
coordinates: Dict with x0, y0, x1, y1 coordinates
"""
page = self.writer.pages[page_num]
# Create highlight annotation
highlight = DictionaryObject()
highlight.update({
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Highlight"),
NameObject("/Rect"): ArrayObject([
FloatObject(coordinates['x0'] - 2), # Add padding
FloatObject(coordinates['y0'] - 2),
FloatObject(coordinates['x1'] + 2),
FloatObject(coordinates['y1'] + 2)
]),
NameObject("/C"): ArrayObject([
FloatObject(1.0), # Red
FloatObject(1.0), # Green
FloatObject(0.0) # Blue -> Yellow
]),
NameObject("/CA"): FloatObject(0.5), # 50% transparency
NameObject("/T"): "Kitchen Invoice Flash", # Author
NameObject("/Contents"): "Non-stock item - requires product mapping"
})
# Add to page annotations
if "/Annots" in page:
page[NameObject("/Annots")].append(highlight)
else:
page[NameObject("/Annots")] = ArrayObject([highlight])
def highlight_non_stock_items_in_pdf(
original_pdf_path: str,
non_stock_items: List[Dict],
output_path: str
) -> str:
"""
Convenience function to highlight non-stock items in invoice PDF
Args:
original_pdf_path: Path to original PDF
non_stock_items: List of dicts with 'description' key
output_path: Path to save annotated PDF
Returns:
Path to annotated PDF (or original if highlighting fails)
Example:
non_stock_items = [
{"description": "Garden Peas, Frozen", "quantity": 2.5},
{"description": "Tomato Sauce, Organic", "quantity": 1.0}
]
highlighted_path = highlight_non_stock_items_in_pdf(
"/app/pdfs/invoice_123.pdf",
non_stock_items,
"/app/pdfs/invoice_123_highlighted.pdf"
)
"""
if not non_stock_items:
logger.info("No non-stock items to highlight")
return original_pdf_path
try:
highlighter = PDFHighlighter(original_pdf_path)
# Extract item descriptions
items_to_highlight = [item['description'] for item in non_stock_items]
# Create highlighted PDF
return highlighter.highlight_text_items(items_to_highlight, output_path)
except Exception as e:
logger.error(f"Failed to create highlighted PDF: {e}", exc_info=True)
return original_pdf_path
```
### Phase 2: Integration with Dext Processing
#### 2.1 Update Dext Service
**File:** `backend/services/dext.py` (UPDATE)
Modify invoice processing to generate highlighted PDF:
```python
from services.pdf_highlighter import highlight_non_stock_items_in_pdf
async def process_invoice_with_highlighting(invoice_data: dict, pdf_path: str) -> dict:
"""
Process invoice and create highlighted PDF if non-stock items found
Returns:
dict with processing results including highlighted_pdf_path
"""
# Existing invoice processing logic...
line_items = extract_line_items(invoice_data)
# Identify non-stock items
non_stock_items = []
for item in line_items:
product = await find_matching_product(item['description'], db)
if not product:
non_stock_items.append({
'description': item['description'],
'quantity': item['quantity'],
'total': item['total']
})
# Create highlighted PDF if non-stock items exist
highlighted_pdf_path = pdf_path # Default to original
if non_stock_items:
output_path = pdf_path.replace('.pdf', '_highlighted.pdf')
highlighted_pdf_path = highlight_non_stock_items_in_pdf(
original_pdf_path=pdf_path,
non_stock_items=non_stock_items,
output_path=output_path
)
logger.info(f"Created highlighted PDF with {len(non_stock_items)} items marked")
return {
'line_items': line_items,
'non_stock_items': non_stock_items,
'original_pdf_path': pdf_path,
'highlighted_pdf_path': highlighted_pdf_path,
'has_highlights': highlighted_pdf_path != pdf_path
}
```
### Phase 3: Update Email Service
#### 3.1 Attach Highlighted PDF to Email
**File:** `backend/services/email_service.py` (UPDATE)
```python
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
async def send_non_stock_items_alert(
recipient_email: str,
invoice_number: str,
supplier_name: str,
non_stock_items: List[Dict],
highlighted_pdf_path: str,
nextcloud_link: str
):
"""
Send email alert with highlighted PDF attached
Args:
recipient_email: Recipient email address
invoice_number: Invoice number
supplier_name: Supplier name
non_stock_items: List of non-stock items
highlighted_pdf_path: Path to PDF with highlights
nextcloud_link: Link to PDF in Nextcloud
"""
# Create multipart message
msg = MIMEMultipart()
msg['From'] = os.getenv('EMAIL_FROM', 'noreply@kitchen-invoice-flash.com')
msg['To'] = recipient_email
msg['Subject'] = f"⚠️ Invoice #{invoice_number} - Non-Stock Items Highlighted"
# Email body (HTML for better formatting)
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
<h2 style="color: #d97706;">⚠️ Invoice Requires Attention</h2>
<p>
Invoice <strong>#{invoice_number}</strong> from <strong>{supplier_name}</strong>
contains <strong>{len(non_stock_items)}</strong> non-stock item(s).
</p>
<div style="background: #fef3c7; padding: 15px; border-left: 4px solid #f59e0b; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; color: #92400e;">
📄 The attached PDF has been annotated with <span style="background: yellow; padding: 2px 6px;">yellow highlights</span>
to help you quickly locate these items.
</p>
</div>
<h3>Non-Stock Items:</h3>
<ul>
{"".join(f'<li><strong>{item["description"]}</strong> - {item["quantity"]} × €{item.get("unit_price", 0):.2f} = €{item["total"]:.2f}</li>' for item in non_stock_items)}
</ul>
<h3>Next Steps:</h3>
<ol>
<li>Open the attached PDF (highlights visible in any PDF viewer)</li>
<li>Review each highlighted item</li>
<li>Add new products to catalog OR link to existing products</li>
<li>Re-process invoice once products are mapped</li>
</ol>
<p style="margin-top: 30px;">
<a href="{nextcloud_link}" style="background: #667eea; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
View Invoice in Nextcloud
</a>
</p>
<hr style="margin-top: 30px; border: none; border-top: 1px solid #e5e7eb;">
<p style="font-size: 0.9em; color: #6b7280;">
Generated by Kitchen Invoice Flash<br>
<em>Tip: Use Ctrl+F in the PDF to search for highlighted items if you have many pages.</em>
</p>
</body>
</html>
"""
msg.attach(MIMEText(html_body, 'html'))
# Attach highlighted PDF
if os.path.exists(highlighted_pdf_path):
with open(highlighted_pdf_path, 'rb') as pdf_file:
pdf_attachment = MIMEApplication(pdf_file.read(), _subtype="pdf")
pdf_attachment.add_header(
'Content-Disposition',
'attachment',
filename=f'invoice_{invoice_number}_highlighted.pdf'
)
msg.attach(pdf_attachment)
logger.info(f"Attached highlighted PDF: {highlighted_pdf_path}")
else:
logger.warning(f"Highlighted PDF not found: {highlighted_pdf_path}")
# Send email
await send_email(msg)
```
### Phase 4: Testing & Validation
#### 4.1 Unit Tests
**File:** `backend/tests/test_pdf_highlighter.py` (NEW)
```python
import pytest
from services.pdf_highlighter import PDFHighlighter, highlight_non_stock_items_in_pdf
import pdfplumber
def test_highlight_single_item(sample_pdf_path):
"""Test highlighting a single item"""
output_path = "/tmp/test_highlighted.pdf"
highlighter = PDFHighlighter(sample_pdf_path)
result_path = highlighter.highlight_text_items(
items_to_highlight=["Garden Peas"],
output_path=output_path
)
assert os.path.exists(result_path)
# Verify annotation exists
reader = PdfReader(result_path)
page = reader.pages[0]
assert "/Annots" in page
assert len(page["/Annots"]) > 0
# Check annotation type
annot = page["/Annots"][0].get_object()
assert annot["/Subtype"] == "/Highlight"
def test_highlight_multiple_items_across_pages(multi_page_pdf):
"""Test highlighting items on different pages"""
items = [
"Item on Page 1",
"Item on Page 2",
"Item on Page 3"
]
output_path = "/tmp/test_multi_page.pdf"
highlighter = PDFHighlighter(multi_page_pdf)
result_path = highlighter.highlight_text_items(items, output_path)
reader = PdfReader(result_path)
# Verify each page has annotations
for page_num in range(3):
page = reader.pages[page_num]
assert "/Annots" in page
def test_highlight_non_existent_text(sample_pdf_path):
"""Test highlighting text that doesn't exist in PDF"""
output_path = "/tmp/test_no_match.pdf"
highlighter = PDFHighlighter(sample_pdf_path)
result_path = highlighter.highlight_text_items(
items_to_highlight=["NonExistentItem12345"],
output_path=output_path
)
# Should still create output, just without highlights
assert os.path.exists(result_path)
def test_highlight_with_special_characters(sample_pdf_path):
"""Test highlighting items with special characters"""
items = [
"Tomato Sauce, Organic (1L)",
"Cheese - Parmesan, Grated"
]
output_path = "/tmp/test_special_chars.pdf"
result_path = highlight_non_stock_items_in_pdf(
original_pdf_path=sample_pdf_path,
non_stock_items=[
{"description": item, "quantity": 1.0}
for item in items
],
output_path=output_path
)
assert os.path.exists(result_path)
def test_highlight_fallback_on_error(corrupted_pdf_path):
"""Test that errors fall back to original PDF"""
output_path = "/tmp/test_fallback.pdf"
result_path = highlight_non_stock_items_in_pdf(
original_pdf_path=corrupted_pdf_path,
non_stock_items=[{"description": "Item", "quantity": 1}],
output_path=output_path
)
# Should return original path if highlighting fails
assert result_path == corrupted_pdf_path
```
#### 4.2 Integration Test
**Manual test procedure:**
1. **Prepare Test Invoice**
- Get sample invoice PDF with 10+ line items
- Identify 3-5 items to mark as "non-stock"
2. **Trigger Processing**
```bash
# Upload invoice via Dext
# Or manually trigger processing
curl -X POST http://localhost:8000/api/invoices/process \
-H "Authorization: Bearer $TOKEN" \
-d '{"invoice_id": 123}'
```
3. **Verify Highlighted PDF**
- Download highlighted PDF from email attachment
- Open in Adobe Acrobat, Preview, or Chrome
- Verify yellow highlights visible on non-stock items
- Check highlights are positioned correctly (not covering text)
4. **Test in Multiple PDF Viewers**
- Adobe Acrobat Reader
- macOS Preview
- Google Chrome (built-in PDF viewer)
- Firefox PDF viewer
- Mobile PDF viewers (iOS, Android)
5. **Test Edge Cases**
- Very long product names (>100 characters)
- Multi-line item descriptions
- Items with special characters (é, ñ, ü)
- Scanned PDFs (may not work - expected behavior)
### Phase 5: Error Handling & Fallbacks
#### 5.1 Graceful Degradation
**Scenarios where highlighting might fail:**
1. **Scanned PDF (Image-based)**
- No extractable text
- Solution: OCR first, or skip highlighting
2. **Encrypted/Protected PDF**
- Cannot modify
- Solution: Use original PDF, note in email
3. **Corrupted PDF**
- pdfplumber fails
- Solution: Fallback to original
4. **Text Not Found**
- Item description doesn't exactly match PDF text
- Solution: Try fuzzy matching, or skip specific item
**Implementation:**
```python
def highlight_with_fallback(pdf_path: str, items: List[str], output_path: str) -> Tuple[str, List[str]]:
"""
Highlight PDF with robust error handling
Returns:
(path_to_pdf, list_of_errors)
"""
errors = []
try:
# Attempt highlighting
result = highlight_non_stock_items_in_pdf(pdf_path, items, output_path)
if result == pdf_path:
errors.append("Highlighting failed, using original PDF")
return result, errors
except Exception as e:
logger.error(f"Highlighting error: {e}")
errors.append(f"Could not highlight PDF: {str(e)}")
return pdf_path, errors
```
### Phase 6: UI Enhancements
#### 6.1 Show Highlight Status in Invoice List
**File:** `frontend/src/pages/Invoices.tsx` (UPDATE)
Add indicator for invoices with highlights:
```typescript
{invoice.has_highlighted_pdf && (
<span style={styles.highlightBadge} title="PDF contains highlighted non-stock items">
🟡 Highlighted
</span>
)}
```
#### 6.2 Download Both Versions
Provide option to download original and highlighted versions:
```typescript
<div style={styles.downloadButtons}>
<a href={invoice.pdf_url} download>
📄 Download Original PDF
</a>
{invoice.highlighted_pdf_url && (
<a href={invoice.highlighted_pdf_url} download>
🟡 Download Highlighted PDF
</a>
)}
</div>
```
## Success Criteria
✅ Non-stock items highlighted in yellow in PDF
✅ Highlights visible in all major PDF viewers
✅ Email attaches highlighted PDF (not just link)
✅ Original PDF preserved (both versions available)
✅ Fallback to original PDF if highlighting fails
✅ Highlights positioned accurately on item descriptions
✅ Works with multi-page invoices
✅ Email explains highlights feature
✅ Processing time <5 seconds for highlighting
## Testing Checklist
- [ ] Single item highlighted on page 1
- [ ] Multiple items highlighted across multiple pages
- [ ] Very long product names (>80 chars)
- [ ] Special characters in item names (é, ñ, £, €)
- [ ] 50+ item invoice with 10 non-stock items
- [ ] Scanned PDF (should gracefully fail)
- [ ] PDF with complex layout (tables, multi-column)
- [ ] Email received with attachment
- [ ] Highlights visible in Adobe Acrobat
- [ ] Highlights visible in Chrome PDF viewer
- [ ] Highlights visible on mobile (iOS/Android)
## Performance Considerations
**Expected Processing Times:**
- Text extraction: 0.5-2 seconds per page
- Annotation creation: 0.1 seconds per item
- PDF writing: 0.5-1 second
**Total:** 2-5 seconds for typical invoice (3 pages, 5 non-stock items)
**Optimization:**
- Cache text extraction results
- Batch process annotations
- Async processing (don't block email)
## Limitations & Future Enhancements
### Current Limitations
1. **Text-Based PDFs Only**
- Scanned PDFs without OCR won't work
- Need OCR preprocessing for image-based invoices
2. **Exact Text Matching**
- Item description must match PDF text closely
- Punctuation/spacing differences may cause misses
3. **Fixed Highlight Color**
- Always yellow, not customizable
### Future Enhancements
1. **OCR Integration**
- Use Tesseract to OCR scanned PDFs
- Enable highlighting on image-based documents
2. **Smart Text Matching**
- Fuzzy matching with fuzzywuzzy
- Handle abbreviations and variations
3. **Customizable Highlights**
- Color coding by category (red=critical, yellow=review, green=optional)
- Different styles for different issue types
4. **Interactive PDF**
- Clickable highlights link to product catalog search
- Embedded forms for quick product mapping
5. **AI-Powered Annotation**
- Use LLM to suggest product matches
- Add sticky notes with mapping recommendations
6. **Highlight Dashboard**
- Track highlight accuracy
- Learn from user corrections
- Improve matching algorithm over time