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>
36 KiB
Plan 7: Invoice Dispute Tracking System
Current State
Existing Invoice Management
Files:
backend/models/invoices.py- Invoice database modelbackend/api/invoices.py- Invoice CRUD endpointsfrontend/src/pages/Invoices.tsx- Invoice list view
Current invoice fields:
invoice_number,supplier_id,invoice_datetotal_amount,tax_amountstatus(pending, approved, processed)- Line items with products and quantities
What's Missing:
- No dispute/issue tracking
- No price dispute logging
- No shortage documentation
- No error correction workflow
- No credit note linkage
Problem Statement
User Request:
"log disputed invoices for tracking price queries, shorts, errors, resolution status, dashboard widget for it"
Real-World Scenarios:
-
Price Discrepancy:
- Invoice shows €3.50/kg for tomatoes
- Contract price is €2.80/kg
- Need to: Flag issue, email supplier, track response, link credit note
-
Short Delivery:
- Ordered 20kg flour, received 15kg
- Invoice charges for 20kg
- Need to: Document shortage, request credit, track resolution
-
Wrong Product:
- Ordered "Chicken Breast, Free Range"
- Received "Chicken Breast, Standard"
- Different price point, quality issue
-
Quality Issue:
- Spoiled produce delivered
- Return required, credit needed
- Document photos, correspondence
-
Calculation Error:
- Line item totals incorrect
- Tax calculated wrong
- Invoice total doesn't match order
Goals:
- Log Disputes: Record issues with invoices/line items
- Track Status: Open, In Progress, Resolved, Closed
- Document Evidence: Attach emails, photos, delivery notes
- Link Credit Notes: Connect disputes to resolutions
- Dashboard Widget: Show open disputes at a glance
- Reporting: Analyze dispute patterns by supplier
Architecture
Data Model
┌─────────────────────────────────────────────────────┐
│ Invoice │
│ - id, invoice_number, supplier_id │
│ - total_amount, status │
└─────────────────────────────────────────────────────┘
│
│ 1:many
▼
┌─────────────────────────────────────────────────────┐
│ InvoiceDispute │
│ - id, invoice_id, dispute_type │
│ - status, priority │
│ - disputed_amount, expected_amount │
│ - description, resolution_notes │
│ - opened_by, opened_at │
│ - resolved_at, resolved_by │
└─────────────────────────────────────────────────────┘
│ │
│ 1:many │ 1:many
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ DisputeLineItem │ │ DisputeAttachment │
│ - line_item_id │ │ - file_path │
│ - quantity_ordered │ │ - file_type │
│ - quantity_received │ │ - description │
│ - price_quoted │ │ - uploaded_at │
│ - price_charged │ └──────────────────────┘
└──────────────────────┘
│
│ many:1
▼
┌──────────────────────┐
│ CreditNote │
│ - id, invoice_id │
│ - dispute_id │
│ - credit_amount │
│ - credit_date │
│ - reference_number │
└──────────────────────┘
Dispute Workflow
┌──────────────────┐
│ Create Invoice │
│ & Review │
└────────┬─────────┘
│
▼
┌─────────────┐
│ Issue Found?│
└─────┬───────┘
│ Yes
▼
┌──────────────────┐ ┌──────────────────┐
│ Log Dispute │──────┤ Add Evidence │
│ - Type │ │ - Photos │
│ - Amount │ │ - Emails │
│ - Description │ │ - Delivery Note │
└────────┬─────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ Contact Supplier │
│ - Email │
│ - Phone │
│ - Portal │
└────────┬─────────┘
│
▼
┌───────────────┐
│ Supplier │
│ Responds? │
└───┬───────────┘
│
├─────────Yes─────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Credit Note │ │ Partial │
│ Issued │ │ Resolution │
└──────┬───────┘ └──────┬───────┘
│ │
└──────────┬──────────┘
│
▼
┌──────────────┐
│ Link Credit │
│ to Dispute │
└──────┬───────┘
│
▼
┌──────────────┐
│ Close Dispute│
└──────────────┘
Implementation Plan
Phase 1: Database Schema
1.1 Create Dispute Models
File: backend/models/disputes.py (NEW)
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Boolean, Text, Integer, Enum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
import enum
class DisputeType(str, enum.Enum):
PRICE_DISCREPANCY = "price_discrepancy"
SHORT_DELIVERY = "short_delivery"
WRONG_PRODUCT = "wrong_product"
QUALITY_ISSUE = "quality_issue"
CALCULATION_ERROR = "calculation_error"
MISSING_ITEMS = "missing_items"
DAMAGED_GOODS = "damaged_goods"
OTHER = "other"
class DisputeStatus(str, enum.Enum):
OPEN = "open"
CONTACTED = "contacted"
IN_PROGRESS = "in_progress"
AWAITING_CREDIT = "awaiting_credit"
RESOLVED = "resolved"
CLOSED = "closed"
ESCALATED = "escalated"
class DisputePriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class InvoiceDispute(Base):
"""Disputes/issues raised against invoices"""
__tablename__ = "invoice_disputes"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)
# Dispute classification
dispute_type: Mapped[DisputeType] = mapped_column(Enum(DisputeType), nullable=False, index=True)
status: Mapped[DisputeStatus] = mapped_column(Enum(DisputeStatus), default=DisputeStatus.OPEN, index=True)
priority: Mapped[DisputePriority] = mapped_column(Enum(DisputePriority), default=DisputePriority.MEDIUM)
# Financial impact
disputed_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # What was charged
expected_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # What should be charged
difference_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # Disputed - Expected
# Description
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
resolution_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Supplier communication
supplier_contacted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
supplier_response: Mapped[str | None] = mapped_column(Text, nullable=True)
supplier_contact_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Resolution
credit_note_id: Mapped[int | None] = mapped_column(ForeignKey("credit_notes.id"), nullable=True)
resolved_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # Actual credit received
# Audit trail
opened_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
resolved_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
closed_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Metadata
reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Internal tracking number
tags: Mapped[list | None] = mapped_column(JSONB, nullable=True) # ["urgent", "recurring_issue", etc.]
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="invoice_disputes")
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="disputes")
opened_by_user: Mapped["User"] = relationship("User", foreign_keys=[opened_by])
resolved_by_user: Mapped["User"] = relationship("User", foreign_keys=[resolved_by])
closed_by_user: Mapped["User"] = relationship("User", foreign_keys=[closed_by])
credit_note: Mapped["CreditNote"] = relationship("CreditNote", back_populates="dispute")
line_items: Mapped[list["DisputeLineItem"]] = relationship(
"DisputeLineItem", back_populates="dispute", cascade="all, delete-orphan"
)
attachments: Mapped[list["DisputeAttachment"]] = relationship(
"DisputeAttachment", back_populates="dispute", cascade="all, delete-orphan"
)
activity_log: Mapped[list["DisputeActivity"]] = relationship(
"DisputeActivity", back_populates="dispute", cascade="all, delete-orphan"
)
class DisputeLineItem(Base):
"""Specific line items that are disputed"""
__tablename__ = "dispute_line_items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
invoice_line_item_id: Mapped[int | None] = mapped_column(ForeignKey("invoice_line_items.id"), nullable=True)
# Product details
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
product_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Quantity dispute
quantity_ordered: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
quantity_received: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
quantity_difference: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True)
# Price dispute
unit_price_quoted: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
unit_price_charged: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
price_difference: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
# Line total
total_charged: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
total_expected: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="line_items")
invoice_line_item: Mapped["InvoiceLineItem"] = relationship("InvoiceLineItem")
class DisputeAttachment(Base):
"""Supporting documents for disputes (photos, emails, delivery notes)"""
__tablename__ = "dispute_attachments"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# File details
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
file_type: Mapped[str] = mapped_column(String(50), nullable=False) # image/jpeg, application/pdf, message/rfc822
file_size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
# Categorization
attachment_type: Mapped[str] = mapped_column(String(50), nullable=False) # photo, email, delivery_note, credit_note, other
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
uploaded_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="attachments")
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
uploaded_by_user: Mapped["User"] = relationship("User")
class DisputeActivity(Base):
"""Activity log for dispute tracking (timeline of actions)"""
__tablename__ = "dispute_activity"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("invoice_disputes.id"), nullable=False, index=True)
# Activity details
activity_type: Mapped[str] = mapped_column(String(50), nullable=False) # created, status_change, contacted_supplier, note_added, etc.
description: Mapped[str] = mapped_column(Text, nullable=False)
# Old/new values for changes
old_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
new_value: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="activity_log")
created_by_user: Mapped["User"] = relationship("User")
class CreditNote(Base):
"""Credit notes issued by suppliers (resolves disputes)"""
__tablename__ = "credit_notes"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
invoice_id: Mapped[int] = mapped_column(ForeignKey("invoices.id"), nullable=False, index=True)
supplier_id: Mapped[int] = mapped_column(ForeignKey("suppliers.id"), nullable=False)
# Credit note details
credit_note_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
credit_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
credit_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Document
pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Audit
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="credit_notes")
supplier: Mapped["Supplier"] = relationship("Supplier")
created_by_user: Mapped["User"] = relationship("User")
dispute: Mapped["InvoiceDispute"] = relationship("InvoiceDispute", back_populates="credit_note", uselist=False)
# Add relationships to existing models:
# In backend/models/invoices.py - Invoice class:
# disputes: Mapped[list["InvoiceDispute"]] = relationship("InvoiceDispute", back_populates="invoice")
# credit_notes: Mapped[list["CreditNote"]] = relationship("CreditNote", back_populates="invoice")
# In backend/models/user.py - Kitchen class:
# invoice_disputes: Mapped[list["InvoiceDispute"]] = relationship("InvoiceDispute", back_populates="kitchen")
1.2 Create Migration
File: backend/migrations/add_invoice_disputes.py (NEW)
import asyncio
import logging
from sqlalchemy import text
from database import engine
logger = logging.getLogger(__name__)
async def run_migration():
"""Create invoice dispute tracking tables"""
# Enums created by SQLAlchemy
# Indexes
create_indexes_sql = [
"""
CREATE INDEX IF NOT EXISTS idx_disputes_kitchen_status
ON invoice_disputes(kitchen_id, status, opened_at DESC);
""",
"""
CREATE INDEX IF NOT EXISTS idx_disputes_invoice
ON invoice_disputes(invoice_id);
""",
"""
CREATE INDEX IF NOT EXISTS idx_disputes_priority
ON invoice_disputes(kitchen_id, priority, status);
""",
"""
CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice
ON credit_notes(invoice_id);
"""
]
try:
async with engine.begin() as conn:
for sql in create_indexes_sql:
await conn.execute(text(sql))
logger.info("Created invoice dispute indexes")
except Exception as e:
if "already exists" not in str(e).lower():
raise
logger.warning(f"Invoice disputes migration: {e}")
if __name__ == "__main__":
asyncio.run(run_migration())
Phase 2: Backend API
2.1 Disputes API
File: backend/api/disputes.py (NEW)
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
from decimal import Decimal
from auth.jwt import get_current_user
from database import get_db
from models.user import User
from models.disputes import (
InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity,
CreditNote, DisputeType, DisputeStatus, DisputePriority
)
router = APIRouter(prefix="/disputes", tags=["Disputes"])
# Pydantic Schemas
class DisputeLineItemInput(BaseModel):
invoice_line_item_id: Optional[int] = None
product_name: str
quantity_ordered: Optional[float] = None
quantity_received: Optional[float] = None
unit_price_quoted: Optional[float] = None
unit_price_charged: Optional[float] = None
total_charged: float
total_expected: Optional[float] = None
notes: Optional[str] = None
class CreateDisputeInput(BaseModel):
invoice_id: int
dispute_type: DisputeType
priority: DisputePriority = DisputePriority.MEDIUM
title: str
description: str
disputed_amount: float
expected_amount: Optional[float] = None
line_items: List[DisputeLineItemInput] = []
tags: Optional[List[str]] = None
class UpdateDisputeInput(BaseModel):
status: Optional[DisputeStatus] = None
priority: Optional[DisputePriority] = None
resolution_notes: Optional[str] = None
supplier_response: Optional[str] = None
supplier_contact_name: Optional[str] = None
class DisputeResponse(BaseModel):
id: int
invoice_id: int
invoice_number: str
supplier_name: str
dispute_type: str
status: str
priority: str
title: str
description: str
disputed_amount: float
expected_amount: Optional[float]
difference_amount: float
opened_at: str
opened_by: str
resolved_at: Optional[str]
line_items: List[dict]
attachments: List[dict]
activity_log: List[dict]
# Endpoints
@router.get("")
async def get_disputes(
status: Optional[DisputeStatus] = None,
priority: Optional[DisputePriority] = None,
invoice_id: Optional[int] = None,
limit: int = 50,
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> List[DisputeResponse]:
"""Get disputes with filters"""
query = select(InvoiceDispute).where(
InvoiceDispute.kitchen_id == current_user.kitchen_id
)
if status:
query = query.where(InvoiceDispute.status == status)
if priority:
query = query.where(InvoiceDispute.priority == priority)
if invoice_id:
query = query.where(InvoiceDispute.invoice_id == invoice_id)
query = query.order_by(InvoiceDispute.opened_at.desc())
query = query.limit(limit).offset(offset)
result = await db.execute(query)
disputes = result.scalars().all()
return [await _format_dispute(dispute, db) for dispute in disputes]
@router.post("")
async def create_dispute(
dispute_input: CreateDisputeInput,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> DisputeResponse:
"""Create new invoice dispute"""
# Calculate difference
expected = Decimal(str(dispute_input.expected_amount)) if dispute_input.expected_amount else Decimal(0)
disputed = Decimal(str(dispute_input.disputed_amount))
difference = disputed - expected
# Create dispute
dispute = InvoiceDispute(
kitchen_id=current_user.kitchen_id,
invoice_id=dispute_input.invoice_id,
dispute_type=dispute_input.dispute_type,
priority=dispute_input.priority,
status=DisputeStatus.OPEN,
title=dispute_input.title,
description=dispute_input.description,
disputed_amount=disputed,
expected_amount=expected if dispute_input.expected_amount else None,
difference_amount=difference,
opened_by=current_user.id,
tags=dispute_input.tags
)
db.add(dispute)
await db.flush() # Get dispute.id
# Add line items
for item_input in dispute_input.line_items:
line_item = DisputeLineItem(
dispute_id=dispute.id,
invoice_line_item_id=item_input.invoice_line_item_id,
product_name=item_input.product_name,
quantity_ordered=Decimal(str(item_input.quantity_ordered)) if item_input.quantity_ordered else None,
quantity_received=Decimal(str(item_input.quantity_received)) if item_input.quantity_received else None,
quantity_difference=(
Decimal(str(item_input.quantity_ordered or 0)) - Decimal(str(item_input.quantity_received or 0))
if item_input.quantity_ordered and item_input.quantity_received else None
),
unit_price_quoted=Decimal(str(item_input.unit_price_quoted)) if item_input.unit_price_quoted else None,
unit_price_charged=Decimal(str(item_input.unit_price_charged)) if item_input.unit_price_charged else None,
price_difference=(
Decimal(str(item_input.unit_price_charged or 0)) - Decimal(str(item_input.unit_price_quoted or 0))
if item_input.unit_price_quoted and item_input.unit_price_charged else None
),
total_charged=Decimal(str(item_input.total_charged)),
total_expected=Decimal(str(item_input.total_expected)) if item_input.total_expected else None,
notes=item_input.notes
)
db.add(line_item)
# Log activity
activity = DisputeActivity(
dispute_id=dispute.id,
activity_type="created",
description=f"Dispute created: {dispute.title}",
created_by=current_user.id
)
db.add(activity)
await db.commit()
await db.refresh(dispute)
return await _format_dispute(dispute, db)
@router.put("/{dispute_id}")
async def update_dispute(
dispute_id: int,
update_input: UpdateDisputeInput,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update dispute status or details"""
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 not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Track changes for activity log
changes = []
if update_input.status and update_input.status != dispute.status:
old_status = dispute.status.value
dispute.status = update_input.status
changes.append(f"Status changed from {old_status} to {update_input.status.value}")
# Auto-set resolved_at when status changes to resolved
if update_input.status == DisputeStatus.RESOLVED and not dispute.resolved_at:
dispute.resolved_at = datetime.utcnow()
dispute.resolved_by = current_user.id
if update_input.priority and update_input.priority != dispute.priority:
old_priority = dispute.priority.value
dispute.priority = update_input.priority
changes.append(f"Priority changed from {old_priority} to {update_input.priority.value}")
if update_input.resolution_notes:
dispute.resolution_notes = update_input.resolution_notes
changes.append("Resolution notes updated")
if update_input.supplier_response:
dispute.supplier_response = update_input.supplier_response
dispute.supplier_contacted_at = datetime.utcnow()
changes.append("Supplier response recorded")
if update_input.supplier_contact_name:
dispute.supplier_contact_name = update_input.supplier_contact_name
dispute.updated_at = datetime.utcnow()
# Log activity
for change in changes:
activity = DisputeActivity(
dispute_id=dispute.id,
activity_type="updated",
description=change,
created_by=current_user.id
)
db.add(activity)
await db.commit()
return {"status": "updated", "changes": changes}
@router.post("/{dispute_id}/attachments")
async def upload_dispute_attachment(
dispute_id: int,
file: UploadFile = File(...),
attachment_type: str = "other",
description: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Upload supporting document (photo, email, delivery note)"""
# Verify dispute exists
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 not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Save file
import os
upload_dir = f"/app/attachments/disputes/kitchen_{current_user.kitchen_id}"
os.makedirs(upload_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_extension = os.path.splitext(file.filename)[1]
file_name = f"dispute_{dispute_id}_{timestamp}{file_extension}"
file_path = f"{upload_dir}/{file_name}"
with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
# Create attachment record
attachment = DisputeAttachment(
dispute_id=dispute_id,
kitchen_id=current_user.kitchen_id,
file_name=file.filename,
file_path=file_path,
file_type=file.content_type,
file_size_bytes=len(content),
attachment_type=attachment_type,
description=description,
uploaded_by=current_user.id
)
db.add(attachment)
# Log activity
activity = DisputeActivity(
dispute_id=dispute_id,
activity_type="attachment_added",
description=f"Attachment added: {file.filename}",
created_by=current_user.id
)
db.add(activity)
await db.commit()
return {"id": attachment.id, "file_name": file_name}
@router.get("/stats/summary")
async def get_dispute_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get dispute statistics for dashboard widget"""
# Count by status
result = await db.execute(
select(
InvoiceDispute.status,
func.count(InvoiceDispute.id).label("count"),
func.sum(InvoiceDispute.difference_amount).label("total_amount")
).where(
InvoiceDispute.kitchen_id == current_user.kitchen_id
).group_by(InvoiceDispute.status)
)
status_counts = {row.status.value: {"count": row.count, "amount": float(row.total_amount or 0)} for row in result}
# Recent disputes
result = await db.execute(
select(InvoiceDispute).where(
InvoiceDispute.kitchen_id == current_user.kitchen_id
).order_by(InvoiceDispute.opened_at.desc()).limit(5)
)
recent_disputes = result.scalars().all()
return {
"status_counts": status_counts,
"open_count": status_counts.get("open", {}).get("count", 0),
"total_disputed_amount": sum(s.get("amount", 0) for s in status_counts.values()),
"recent_disputes": [
{
"id": d.id,
"title": d.title,
"dispute_type": d.dispute_type.value,
"amount": float(d.difference_amount),
"opened_at": d.opened_at.isoformat()
}
for d in recent_disputes
]
}
async def _format_dispute(dispute: InvoiceDispute, db: AsyncSession) -> DisputeResponse:
"""Format dispute for API response"""
# Get invoice details
from models.invoices import Invoice
invoice = await db.get(Invoice, dispute.invoice_id)
return DisputeResponse(
id=dispute.id,
invoice_id=dispute.invoice_id,
invoice_number=invoice.invoice_number,
supplier_name=invoice.supplier.name,
dispute_type=dispute.dispute_type.value,
status=dispute.status.value,
priority=dispute.priority.value,
title=dispute.title,
description=dispute.description,
disputed_amount=float(dispute.disputed_amount),
expected_amount=float(dispute.expected_amount) if dispute.expected_amount else None,
difference_amount=float(dispute.difference_amount),
opened_at=dispute.opened_at.isoformat(),
opened_by=dispute.opened_by_user.name,
resolved_at=dispute.resolved_at.isoformat() if dispute.resolved_at else None,
line_items=[
{
"id": item.id,
"product_name": item.product_name,
"quantity_ordered": float(item.quantity_ordered) if item.quantity_ordered else None,
"quantity_received": float(item.quantity_received) if item.quantity_received else None,
"total_charged": float(item.total_charged),
"total_expected": float(item.total_expected) if item.total_expected else None
}
for item in dispute.line_items
],
attachments=[
{
"id": att.id,
"file_name": att.file_name,
"attachment_type": att.attachment_type,
"description": att.description
}
for att in dispute.attachments
],
activity_log=[
{
"activity_type": act.activity_type,
"description": act.description,
"created_at": act.created_at.isoformat(),
"created_by": act.created_by_user.name
}
for act in dispute.activity_log
]
)
Phase 3: Frontend Implementation
3.1 Dashboard Widget
File: frontend/src/pages/Dashboard.tsx (UPDATE)
// Add disputes widget
const { data: disputeStats } = useQuery({
queryKey: ['dispute-stats'],
queryFn: async () => {
const res = await fetch('/api/disputes/stats/summary', {
headers: { Authorization: `Bearer ${token}` }
})
return res.json()
}
})
// Render widget
<div style={styles.widget}>
<h3>⚠️ Invoice Disputes</h3>
{disputeStats && (
<>
<div style={styles.statRow}>
<span>Open Disputes:</span>
<strong style={{ color: disputeStats.open_count > 0 ? '#d97706' : 'green' }}>
{disputeStats.open_count}
</strong>
</div>
<div style={styles.statRow}>
<span>Total Disputed Amount:</span>
<strong style={{ color: '#dc2626' }}>
€{disputeStats.total_disputed_amount.toFixed(2)}
</strong>
</div>
{disputeStats.recent_disputes.length > 0 && (
<>
<h4 style={{ marginTop: '1rem', fontSize: '0.9rem' }}>Recent:</h4>
<ul style={{ fontSize: '0.85rem', paddingLeft: '1.2rem', margin: 0 }}>
{disputeStats.recent_disputes.slice(0, 3).map((dispute) => (
<li key={dispute.id}>
<a href={`/disputes/${dispute.id}`}>
{dispute.title} (€{dispute.amount.toFixed(2)})
</a>
</li>
))}
</ul>
</>
)}
<button
onClick={() => navigate('/disputes')}
style={styles.viewAllButton}
>
View All Disputes →
</button>
</>
)}
</div>
3.2 Disputes List Page
File: frontend/src/pages/Disputes.tsx (NEW)
// Full disputes management page with:
// - Filterable list
// - Status badges
// - Create dispute modal
// - Bulk actions
export default function Disputes() {
// Similar structure to Invoices page
// Table showing: ID, Invoice #, Supplier, Type, Amount, Status, Opened Date
// Click row to see detail modal
// Filters: Status, Priority, Date Range, Supplier
}
3.3 Add Dispute Button on Invoice Detail
File: frontend/src/pages/InvoiceDetail.tsx (UPDATE)
Add "Log Dispute" button to invoice detail page:
<button
onClick={() => setShowDisputeModal(true)}
style={styles.disputeButton}
>
⚠️ Log Dispute
</button>
{showDisputeModal && (
<CreateDisputeModal
invoice={invoice}
onClose={() => setShowDisputeModal(false)}
onSuccess={() => {
setShowDisputeModal(false)
queryClient.invalidateQueries(['disputes'])
}}
/>
)}
Success Criteria
✅ Users can log disputes from invoice detail page ✅ Disputes tracked by type, status, priority ✅ Financial impact calculated and visible ✅ Photos, emails, delivery notes attachable ✅ Activity timeline shows full dispute history ✅ Dashboard widget shows open dispute count ✅ Credit notes linkable to disputes ✅ Disputes filterable and searchable ✅ Reports show dispute patterns by supplier
Future Enhancements
- Email Integration - Send dispute emails to suppliers from app
- Recurring Issue Detection - Flag suppliers with high dispute rates
- Dispute Templates - Quick dispute creation for common issues
- Batch Disputes - Log multiple disputes across invoices
- Analytics Dashboard - Dispute trends, supplier scorecards
- Automated Reminders - Follow-up notifications for open disputes
- Contract Integration - Link to price agreements for validation