kitchen/docs/archive/PLAN_7_invoice_dispute_tracking.md
jtricerolph 8d688b459d 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>
2026-07-12 12:15:39 +00:00

1006 lines
36 KiB
Markdown

# Plan 7: Invoice Dispute Tracking System
## Current State
### Existing Invoice Management
**Files:**
- `backend/models/invoices.py` - Invoice database model
- `backend/api/invoices.py` - Invoice CRUD endpoints
- `frontend/src/pages/Invoices.tsx` - Invoice list view
Current invoice fields:
- `invoice_number`, `supplier_id`, `invoice_date`
- `total_amount`, `tax_amount`
- `status` (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:**
1. **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
2. **Short Delivery:**
- Ordered 20kg flour, received 15kg
- Invoice charges for 20kg
- Need to: Document shortage, request credit, track resolution
3. **Wrong Product:**
- Ordered "Chicken Breast, Free Range"
- Received "Chicken Breast, Standard"
- Different price point, quality issue
4. **Quality Issue:**
- Spoiled produce delivered
- Return required, credit needed
- Document photos, correspondence
5. **Calculation Error:**
- Line item totals incorrect
- Tax calculated wrong
- Invoice total doesn't match order
**Goals:**
1. **Log Disputes:** Record issues with invoices/line items
2. **Track Status:** Open, In Progress, Resolved, Closed
3. **Document Evidence:** Attach emails, photos, delivery notes
4. **Link Credit Notes:** Connect disputes to resolutions
5. **Dashboard Widget:** Show open disputes at a glance
6. **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)
```python
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)
```python
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)
```python
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)
```typescript
// 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)
```typescript
// 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:
```typescript
<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
1. **Email Integration** - Send dispute emails to suppliers from app
2. **Recurring Issue Detection** - Flag suppliers with high dispute rates
3. **Dispute Templates** - Quick dispute creation for common issues
4. **Batch Disputes** - Log multiple disputes across invoices
5. **Analytics Dashboard** - Dispute trends, supplier scorecards
6. **Automated Reminders** - Follow-up notifications for open disputes
7. **Contract Integration** - Link to price agreements for validation